# antics — multiplayer for AI-generated web games Deploy a web game — one HTML file or a multi-file project — and get a multiplayer link with a leaderboard. No backend, no accounts for players. This file is the complete integration reference — if you are an LLM generating a game, you can hold the whole API in view at once. ## Import // ES module (recommended), served same-origin when your game is deployed on antics: import { joinRoom, getLeaderboard, interpolate } from "/sdk/v1.js"; // or from npm: import { joinRoom } from "antics-sdk"; // or classic then use window.antics.joinRoom When your game is deployed on antics, call joinRoom({}) with NO arguments: the SDK auto-detects the server (same origin) and the room code (from the ?room= the page provides). That is the whole integration for a hosted game. ## Core rules (read these) - Two state surfaces. `room.state` is SHARED and only the HOST may write it (world, NPCs, game phase, scores). Each player writes only their OWN slice via `room.me.setState(...)` (their position, input, cosmetics). Do not route every player's movement through the host. - Latency: act on your OWN client at once, then sync — never make another client detect your action and echo the result back (that stacks two network hops and feels laggy). A shooter should detect its OWN hits and drop the target's health bar immediately, then `send` a `hit` event the victim applies as authoritative damage. (This is "favor-the-shooter": responsive, and it trusts each client about its own shots — fine for casual games, not cheat-proof.) IT IS ABOUT THE EFFECT, NOT ABOUT POSITIONS. Trusting a client's claim about damage it dealt is fine. Trusting its claim about where a SHARED object is — the ball, the puck, the bomb — is not the same trick: that client's copy of the object is behind by definition, so adopting its position rewinds the object for everyone else, most visibly for whoever has the best connection. Take the impulse; clamp the position to a plausible drift from your own. - STAMP ANYTHING THAT MOVES, AND REPLAY IT FORWARD BY ITS AGE. This is the single biggest netcode mistake on the classic tier and it does not look like a mistake — the game runs fine next to you and badly across an ocean. A patch you receive was written one leg ago and the sender has moved on since; if you ease toward its raw contents you are easing toward the past, and THE CORRECTION IS THE LAG. Smoothing harder only spreads it out. So: // sender — one extra number, on the SHARED clock (never Date.now(): that is // per-device and comparing two of them across the wire is meaningless) room.setState({ ball: { x, y, vx, vy, at: room.now() } }) // receiver — advance it to NOW before you use it, then ease toward THAT const age = Math.min(400, room.now() - snap.at) // clamp: never extrapolate wildly const target = advance(snap, age / 1000) // your own integrator, ideally the // real one so it respects walls/floors Three things that bite even after you do this: (1) Adopt the sender's VELOCITY once per snapshot, not once per frame — re-applying it every frame pins velocity to that packet's value for the whole gap between packets, so gravity and drag stop accumulating and the object flies flat, then jerks on arrival. Gate on a sequence number. (2) Extrapolate remote players too. Their velocity is already in the slice; using it only for animation leaves them metres behind at any real running speed. And scale your smoothing by dt — a fixed per-frame lerp constant smooths twice as slowly in wall time at 30fps as at 60, so phones get double the lag. (3) Do not difference room.now() across devices without a MINIMUM filter. It is only as good as an RTT sampled by a timer sitting behind your render loop, and a stalled main thread inflates it — two clients on ONE MACHINE can read apparent ages of 400ms+. Every error source only ADDS delay, so keep the running minimum per sender (leaking upward slowly so a genuinely worsening link is still tracked) and add locally-measured time since arrival. Over-extrapolating is worse than under: it puts things where they have never been. VERIFY IT: `verify_game { simulateLatencyMs: 150 }` (or 250) works on BOTH tiers and is the only way you will ever see this — you are testing on loopback, which is the one network condition your players will never have. `interpolate`/`interpolateGroup` below handle the SMOOTHING half of this well; they do not handle the age half, so stamp regardless. CLEAR HELD-KEY STATE ON WINDOW BLUR (`addEventListener("blur", () => keys.clear())` — and send a neutral input if you forward input state to a server): when a player switches windows mid-keypress, the keyup fires in the NEWLY focused window and yours never sees it, so the key stays "held" forever. In a multiplayer game the stuck player keeps walking — off ledges, into hazards — while their window sits unattended (found by a human playtest: an unattended fighter death-looped off the stage and haunted the other player's screen as a shaking ghost at the respawn point). RECONCILE the sync: after you predict that drop, the victim's next state still shows FULL hp (they haven't applied your hit yet), so blindly copying their authoritative hp every frame snaps the bar back up for a frame, then "really" updates a round-trip later. Render `hp = min(predicted, authoritative)`. To allow respawns/heals, snap the prediction back up ONLY when the authoritative value RISES above its OWN previous value (track last-seen authoritative hp) — NOT whenever authoritative > predicted. That second test is true the instant you predict a hit, so re-snapping there reintroduces the exact lag. Same idea for any value you predict locally then also receive over the network. Other clients REPLAY your events — they don't run your logic — so anything you detect locally (a projectile impact, a melee hit, a trap trigger) needs its OWN visual on each client. E.g. a bullet others spawned from your `shoot` event flies THROUGH the victim to a wall on their screens unless every client gives it collision (stop + spark on overlap with any non-owner player); keep authority single-sourced — only the owner `send`s the hit. - Presence is immediate; there is no lobby. The instant `joinRoom` resolves you are in `room.players` for everyone, and any state you write is visible to all of them at once. If your game has a start / ready / menu screen, gate spawning AND rendering on your OWN flag — don't mark a player alive or draw them until they actually enter. Otherwise a player sitting on the menu shows up to others as a frozen "ghost" at their initial position. EVEN A SINGLE-PLAYER GAME shares its room: the share link puts every visitor in ONE room, with the player count in the chrome bar — two people staring at unrelated boards with no explanation reads as broken. Acknowledge co-presence: render other players as translucent ghosts running their own attempt (mirror your position into room.me.setState and draw room.players — a dozen lines), or show "N racing this course" from room.players.length. The leaderboard is already shared either way. - State is a flat key/value map, last-writer-wins. A value of `null` DELETES that key. - Write every frame if you want — the SDK coalesces writes to ~20 Hz automatically. - Host can change AT ANY TIME — not only when the host leaves. The SDK hands host over when a page is backgrounded, and when its rAF loop stops for 5s (a tab that threw, lost its renderer, or got starved); the server re-elects too if the host stops writing shared state. So authority can move while that player is still connected and still "playing". Use `room.isHost` / `room.onHostChange(...)`, re-check `isHost` before every host-only write (the relay rejects the rest with `NOT_HOST`), and keep host-owned state in `room.state` so whoever inherits it can carry on — never in a host-only local variable. - No key needed to start. `joinRoom({})` works instantly (keyless). A key (`pk_...`) makes rooms + leaderboards persistent. - Deployed games run in a sandboxed iframe (opaque origin): localStorage, sessionStorage, and cookies are UNAVAILABLE there — accessing them THROWS; if you must touch them, wrap in try/catch. NO FEATURE TEST WORKS, so do not write one: `typeof localStorage` THROWS (it is an accessor, and typeof only spares you an UNDECLARED name), and `'localStorage' in window` returns true and tells you nothing. Uncaught at module scope this is a blank canvas on production only, while every local test passes — the game runs same-origin at the top of a tab everywhere except production. `try` is the only guard. A preference you cannot remember is one you do not remember: degrade, never throw. Player id + name are stable for the SESSION (including reconnects within the grace window) but do NOT survive a reload or a later visit — never key durable data on them. Cross-session storage today = the project leaderboard (per player-NAME in public reads), nothing else; design "remember my best" features around that honestly. - The hosting page already shows the room code, an invite link + QR, and the live player count in a bar above your game — don't re-render those (it just duplicates the chrome). It does NOT draw a leaderboard or any in-canvas UI. If your game wants a leaderboard, render it yourself from the data (see Example 3) — it's optional, many games don't need one. ## API (the entire surface) const room = await joinRoom({ game?: string, // project id (keyed mode); omit for keyless room?: string, // room code to join; omit to create one (auto-read from ?room= if hosted) key?: string, // publishable key pk_...; omit for keyless name?: string, // display name; default "Guest N" (auto-read from ?name= in the // page URL when omitted — so a verify capture can name its player // via urlParams: { name: "TestBot" } and a shared link can carry one) }); PLAYER NAMES ARE JOIN-TIME ONLY (no rename message), and your game runs in a sandboxed iframe with NO localStorage — but the hosting room page owns a persistent per-browser name and passes it in as ?name= automatically. To let players set their name from inside your game (recommended: a lobby input), post it up to the page: parent.postMessage({ antics: "set_name", name: "Ada" }, "*"); // optional: { params: { autoready: "1" } } — comes back as ?autoready=1 The page saves the name (shared by every game the player opens there) and reloads your iframe with the new ?name= — you rejoin the same room renamed, so treat it as a navigation, not an in-place update. Wire any "ready" button to re-arm across that reload via the autoready passthrough, and never commit a rename from a blur handler (the reload eats the click that caused the blur). Empty name reverts to "Guest N". THE FRONT DOOR (`antics: "mint"`) — ASK FOR A ROOM INSTEAD OF CREATING ONE. `/p/` boots your game with NO room (`?menu=1` in the frame's query): arriving is not playing, so the page shows YOUR menu and the address bar stays on the permanent link. When the player picks a door, ask the page for a room: parent.postMessage({ antics: "mint", entry: "multi" }, "*"); // play with strangers parent.postMessage({ antics: "mint", entry: "private" }, "*"); // a room of your own // optional: { params: { mode: "arena" } } — rides into the reloaded frame's query // refusals come back DOWN: { antics: "mint_failed", reason: "..." } — show the sentence parent.postMessage({ antics: "home" }, "*"); // take the TOP page back to /p/ The page mints server-side and reloads your frame into `?room=&entry=`; it never navigates the top page, so what the player copies is still your permanent link. On a project with `roomTarget` set, `entry:"multi"` resolves the POOLED room — strangers who clicked the same link meet — while `"private"` always mints fresh. Read `?entry=` on boot if the two doors should play differently. YOU GET THIS FOR FREE — the SDK asks on your behalf. If you call `joinRoom({})` on a `/p/` page with no room, the SDK posts the mint itself (`entry: "multi"`) and joins the room the page grants, WITHOUT reloading your game. So a game that never speaks the contract still gets a project-scoped room: scores on your real leaderboard, 16 players, and a working `/r/` invite. If the page refuses or does not answer, the SDK falls back to an unkeyed room and says so in the console. Speak the contract YOURSELF when you want to choose: a front door with two buttons (play with strangers vs a room of your own), a menu whose state should ride into the room via `params`, or anything else the SDK cannot decide for you. The SDK only asks when nothing else has — an explicit `room`, or a `?room=` already in your URL, means somebody decided. The SDK also reports the room it joined UP to the page automatically (`antics: "room"`) — you never send this one; it is what fills the header's code and player count. WHAT THE PAGE TELLS YOU BACK, in your frame's query — read these on boot: ?room= the room you are in ?entry=multi|private WHICH DOOR the player pressed (only after a mint) ?pool=1 THIS ROOM IS SHARED WITH STRANGERS — set however they arrived: a mint that resolved the pool, a copied /r/ invite into it, or /world ?menu=1 no room yet; show your front door ?fresh=1 a room nobody chose (a /g/ mint landing) — fine to show your menu; an INVITE link never carries it, and must never show one ?name= the player's saved name (joinRoom reads this for you) `entry` and `pool` are different questions and you almost always want `pool`. `entry=multi` means the player pressed MULTIPLAYER, which on a project with no roomTarget still mints them a private room. `pool=1` means the room is actually shared. Anything that must hold for strangers — a mode nobody may change for others, no auto-start on a full lobby, seating a late arrival instead of making them wait — keys off `pool`. A POOLED ROOM IS A ROLLING ROOM, and this is more work than the menu. It never empties, and players arrive and leave mid-match. A game built around "everybody readies, then we start" stalls in one. Seat late arrivals (take a bot's place if you have bots), never block the round on somebody who has not pressed anything, and treat a parked tab as absent. room.pooled // true when this room is SHARED WITH STRANGERS — the project's pool, // however you arrived (a granted front-door room, a copied /r/ invite, // /world). Gate anything that must hold for strangers on THIS: a mode // nobody may change for everyone else, seating a late arrival instead // of making them wait. Not the same as which door was pressed. room.code // "KX7P2M" — the room code (7 chars, e.g. "SKX7P2M", when the room // lives on a regional relay; the SDK follows the relay's redirect itself) room.link // shareable URL (open it on another device to join this room) room.game // project id when the room is keyed (e.g. deployed into a project); null in keyless rooms room.me // your Player room.isHost // boolean; room.host = the host Player room.players // Player[] including you room.player(id) // look up a Player by id (e.g. the `from` an event gave you), or null room.onJoin(p => {}) room.onLeave(p => {}) room.onHostChange(host => {}) A LEAVER IS REMOVED FROM `room.players`, AND `room.player(id)` THEN RETURNS NULL. So anything your game still talks about after somebody goes — a final scoreboard, a kill feed, a results list, a replay — must not resolve its labels at DISPLAY time. Record the name where it ARRIVES and keep it; the match outlives the connection. `onLeave` hands you the departing Player, which is the last moment the name exists: const names = new Map(); // id -> name, never deleted room.onJoin(p => names.set(p.id, p.name)); room.onLeave(p => names.set(p.id, p.name)); // keep it, do not forget it room.players.forEach(p => names.set(p.id, p.name)); And never let the fallback name a ROLE. A shipped game used `?? "WARDEN"`, so an unresolved id did not read as blank — it impersonated the player who held the part, and the scoreboard showed two of them. A placeholder must not be mistakable for an answer. // shared state (host only) room.setState({ phase: "play", score: 0 }) // throws if you are not the host room.state // readonly snapshot room.onState((state, from) => {}) // fires with the FULL merged state // per-player state (you only) room.me.setState({ x: 10, y: 4 }) player.state // readonly room.onPlayerState((player, state) => {}) // fires with that player's FULL state // events (reliable, ordered) room.send("shoot", { dmg: 5 }) // broadcast to everyone else room.on("shoot", (payload, from) => {}) // `from` is the sender's player id STRING room.send("hit", { dmg: 5 }, from) // reply to just that sender (3rd arg = a player id) room.player(from) // → the full Player {id,name,state} for that id, or null // leaderboard await room.submitScore(1234) // → { rank, best }; optional 2nd arg: board name await getLeaderboard({ game: "proj_id", board?, limit? }) // → [{ rank, name, score }] // in a deployed game, use game: room.game — non-null means the board is persistent // misc room.ping // smoothed round-trip ms — SHOW IT somewhere: "it felt laggy" is // unactionable and indistinguishable from a bad framerate room.now() // server-synced ms (Date.now()-comparable, agreed across devices). // TWO jobs: deadlines/timers (endsAt, below) AND stamping what you // send so the receiver can tell how stale it is (see "STAMP ANYTHING // THAT MOVES" above — that second job is the one people miss) room.onHostTick((dtMs) => {...}) // host-only game loop: fires ~30 Hz ONLY while you're host, // auto-starts/stops on host change. Advance the sim + setState here. room.leave() // smoothing remote motion (optional). Use interpolateGroup for MANY entities (the usual case): const interp = interpolateGroup({ renderDelayMs: 100, angleKeys: ["angle"] }) // one buffer per id room.onPlayerState((p, s) => interp.push(p.id, { x: s.x, y: s.y, angle: s.angle })) room.onLeave((p) => interp.forget(p.id)) const s = interp.sample(playerId) // lerp + snap-on-teleport; angleKeys lerp the short way // (interpolate({...}) is the single-entity primitive — one buffer; don't feed it every player.) A Player is { id, name, avatar?, state }. ## Example 1 — shared state (host-authoritative counter) import { joinRoom } from "/sdk/v1.js"; const room = await joinRoom({}); const render = (s) => { document.body.textContent = "Count: " + (s.count ?? 0); }; render(room.state); // paint the state you joined with (a late joiner sees it now) room.onState(render); // then keep rendering on every change document.onclick = () => { if (room.isHost) room.setState({ count: (room.state.count ?? 0) + 1 }); }; ## Example 2 — per-player movement (everyone moves their own dot) import { joinRoom } from "/sdk/v1.js"; const room = await joinRoom({}); addEventListener("pointermove", (e) => { room.me.setState({ x: e.clientX / innerWidth, y: e.clientY / innerHeight }); }); function draw() { // render every player from their state slice for (const p of room.players) { const s = p.state; // includes yourself — me.setState applies locally at once if (typeof s.x === "number") drawDot(s.x * innerWidth, s.y * innerHeight, p.name); } requestAnimationFrame(draw); } requestAnimationFrame(draw); ## Example 3 — leaderboard (optional — only if your game has scores) import { joinRoom, getLeaderboard } from "/sdk/v1.js"; const room = await joinRoom({ key: "pk_...", game: "proj_id" }); // keyed = persistent // ...play past the server's minimum session age first (10s in production; the // SCORE_REJECTED hint names the LIVE value): sooner submits reject as anti-spoof. That age is WALL-clock — inside a // fast-forwarded verify capture, real seconds lag game seconds, so a win-time submit // can fire "too early". Retry rejected submits with backoff (correct production code // anyway) — but note your backoff TIMER is virtual too (setTimeout fast-forwards with // the page), so under verify a "2s" backoff burns in wall-milliseconds: keep retrying // across the whole advance window rather than counting a few attempts, or verify // score flows with sandbox:true captures, which waive the age check entirely. const { rank, best } = await room.submitScore(myScore); // Rejections carry err.code (e.g. "SCORE_REJECTED") and err.hint on the error OBJECT // — key retries on err.code, not on parsing the message. // Boards keep each player's HIGHEST score (a resubmit never lowers it); rank 1 is the // highest score. For fewest-X metrics (moves, seconds), submit an inverted score // (e.g. 100000 - moves) so "fewer" ranks higher. const top = await getLeaderboard({ game: "proj_id", limit: 10 }); // ...then render `top` (and rank/best) however fits your game — the platform draws NO // leaderboard for you, so style your own in-canvas if you want one. ## Example 4 — a synced round timer + host game loop (no per-tick writes) import { joinRoom } from "/sdk/v1.js"; const room = await joinRoom({}); const ROUND_MS = 30_000; // The HOST owns the sim. onHostTick fires ONLY while you're host and auto-moves to whoever // becomes host next, so a countdown survives host migration with zero special-casing. room.onHostTick(() => { if (room.state.endsAt == null) room.setState({ endsAt: room.now() + ROUND_MS }); // start once if (room.now() >= room.state.endsAt) room.setState({ endsAt: room.now() + ROUND_MS }); // next round }); // EVERY client (host or not) renders remaining time from the shared deadline in SERVER time — // no cross-device Date.now() skew, and the host never writes the countdown every frame. function draw() { const left = Math.max(0, ((room.state.endsAt ?? room.now()) - room.now()) / 1000); document.title = "⏱ " + left.toFixed(1) + "s"; requestAnimationFrame(draw); } requestAnimationFrame(draw); ## Errors (every error has code, message, and an actionable hint) NOT_HOST Only the host may write room.state; write player state with room.me.setState(...). NOT_JOINED Await joinRoom({...}) before calling room.* methods. PATCH_TOO_LARGE Split the update; default limit is 32 KB per patch. STATE_TOO_LARGE Delete stale keys (set them to null); default 256 KB per surface. RATE_LIMITED Throttle writes; the SDK already coalesces to ~20 Hz — don't bypass it. ROOM_NOT_FOUND Check the 6-char code (no 0/O/1/I), or joinRoom({}) to create a new room. ROOM_FULL Keyless rooms cap at 8 players; keyed default 16. ROOM_EXPIRED Keyless rooms expire after 24h; deploy with a key for persistence. INVALID_KEY Use a pk_ key, or omit `key` for keyless mode. ORIGIN_NOT_ALLOWED Add this origin to the project's allowlist, or use keyless mode. SCORE_REJECTED Below min session age, above max, or too frequent (anti-spoof, not anti-cheat). IP_RATE_LIMITED Keyless limits: 10 rooms/hour, 100 joins/hour per IP. ACCOUNT_AT_CAPACITY The project owner hit their plan's concurrent-room limit. Existing rooms keep working; a slot frees as games end, or the owner upgrades. Surface this to the player, then retry. AT_CAPACITY The service is momentarily full. Transient — retry in a few seconds; the SDK reconnects automatically. PLAN_REQUIRED (HTTP 402) The server-authoritative SIM tier needs a Pro plan — that covers live sim rooms, probe_sim, and verify_game against a sim deploy. NOT transient: never retry it (unlike ACCOUNT_AT_CAPACITY/AT_CAPACITY). Deploying a sim.js is never blocked; the classic tier on this page — rooms, sync, presence, events, leaderboards, verify_game — is unlimited on every plan. Errors on rejected writes carry a `snapshot` of the authoritative state; the SDK resyncs your local copy automatically. On the SDK, `room.onError(e => {})` surfaces them. ## Deploy A deploy is one self-contained HTML file OR a multi-file project (HTML + JS + CSS + assets). Each deployed file may be up to 3 MB; a multi-file bundle may be up to 12 MB total. Single file: curl -F file=@game.html https:///api/deploy # → { playUrl } Multi-file project — POST a JSON path→content map (entry is index.html unless you set `entry`; binary assets as data: URIs). Relative imports resolve against the bundle root: curl -H 'content-type: application/json' -d '{ "files": { "index.html": "", "game.js": "import { joinRoom } from \"/sdk/v1.js\"; /* ... */", "style.css": "body{margin:0}" } }' https:///api/deploy # → { playUrl, files } # open playUrl → it redirects to /r/; share THAT link to play together. WHICH LINK TO SHARE. `playUrl` is `/g/` — content-addressed WITHIN an owner scope (the same bytes deployed keyless and under your project are two deployments with two hashes, so one account's copy can never affect another's), pinned to exactly the bytes you deployed. That is right for "play this build", and wrong for a link you post publicly: ship an update and everyone holding it stays on the old build forever, because the update has a different hash. Deploy under a project and the response also carries `shareUrl` (`/p/`), which serves the project's LATEST deploy — post that one, keep improving the game, and the link follows. A keyless deploy has no project to follow, so it gets no `shareUrl`: redeploying gives a genuinely new link. WHO SHARES A ROOM. By default `/p/` gives every visitor their OWN room, which is right for "send this to three friends" and wrong for a link posted publicly — everyone who clicks lands alone. Set `roomTarget` on the project (players per room — MCP: `set_room_target`; REST: `PUT /api/projects/:id/meta {"roomTarget": 8}`) and the MULTIPLAYER door POOLS instead: arrivals fill one room up to that number, then the next opens, and `/p//private` stays there for anyone who wants a room of their own. Pick roomTarget to match the game's roster — the platform cap is 16 and will seat spectators past your roster if you leave it unset; pass null to go back to a fresh room per visitor. (`/world/` is the third shape: ONE permanent shared room, served directly for games with no front door of their own.) Pooling is resolved by the DOOR, not by the page: it needs your game to ask for a room with `antics: "mint"` + `entry: "multi"` (see THE FRONT DOOR above). Set roomTarget on a game that never mints and nothing changes — a page view is not a player, and the platform will not seat somebody who has not pressed anything. PROJECTS (REST): `POST /api/projects {"name": "..."}` with `Authorization: Bearer ` creates a project (returns id + pk_/sk_ keys); `GET /api/projects` lists yours; add `"project": ""` to the /api/deploy JSON body to deploy under it (that is what makes `shareUrl` appear). `PUT /api/projects//meta` (owner) sets the share-preview branding — {"name","description","ogImageUrl"} — the REST face of set_share_preview. `GET /api/projects//leaderboard?board=` (owner) lists the board WITH playerIds; `DELETE /api/projects//leaderboard/?board=` (owner) removes one leaderboard row — the cleanup path for bogus scores a buggy build submitted (best-score keeping means they can never be resubmitted over). Tokens come from `npx antics-cli login`. Or via MCP: `deploy_game` takes `html` (single file) or `files` (a project map) and returns a playable URL (keyless, before any login). CLI: `antics deploy `. After deploying under a project, `set_share_preview` (MCP) brands how its `/r/` links unfurl — a title, description, and image (https URL or uploaded bytes). Uploaded share-preview images are capped separately at 2 MB (PNG/JPEG/WebP). Give every project an `about` and `rules` too — they render on its permanent public world page (/world/) and in the room's "How to play" panel, so players understand the game and search engines see real content. Via MCP: `set_share_preview` (`about`, `rules`); via REST (owner token): PUT /api/projects//meta { "about": "What the game is and what makes it fun (≤ 1200 chars)", "rules": "Controls, goal, rules (≤ 2000 chars)" } Write them yourself from the game you just built — you know its mechanics best. Plain text, line breaks preserved; pass null (or "") to clear. ## 3D assets (do NOT hand-write geometry on the page) If your game is 3D, build the models AT DEV TIME with `antics-modelkit` and ship the .glb. Assembling primitives at page load is what makes games look like a pile of boxes and spheres, and it costs startup time on every join. FIRST, CHECK WHETHER ONE ALREADY EXISTS. There is a catalogue of free models — each one published with the recipe that generates it, so you can change the numbers and rebuild rather than settling for what it happens to be: GET https://antics.gg/api/models?q= search names, summaries and PART names https://antics.gg/models/llms.txt how to search, use and publish; read this one Search matches part names too, so `?q=wheel` finds assets that HAVE a wheel, not just ones called one. Each model has a .glb, an MIT recipe and its own llms.txt. Assets are CC0 or CC-BY; attribution is per model and stated on its page. If you build something reusable, publish it back — same file says how. A human reviews every submission, so nothing appears until it has been looked at. npm i antics-modelkit three # three is a peer dependency Write a models module exporting one function per asset, then build: // models.mjs — each export is an asset; `parts` become named nodes in the .glb import { box, lathe, sweep, spine, merge, sit, facet, tint } from "antics-modelkit"; export function crate() { const body = tint(facet(box(1, 1, 1)), 0x8a6b45); return { category: "prop", parts: [{ geometry: sit(merge([body])), name: "crate" }] }; } npx modelkit build # → dist/*.glb + manifest.json (tris, colliders, findings) npx modelkit preview crate # → a PNG, so you can LOOK before shipping Anything turned — a pot, a column, a wheel rim, a lampshade — is `lathe(profile, { axis, segments })`, where `profile` is `[radius, along]` pairs. Write it in whichever direction the shape reads in: it measures the result and rebuilds the other way round if the shell came out inside out, so there is no winding convention to get wrong. Every file is checked against the Khronos glTF validator at build time and an invalid one is a hard failure, so what builds will open anywhere. manifest.json also carries a `footprint` radius per asset — use it for colliders instead of guessing. TO SHIP IT, run one command and put three things in the deploy files map: npx modelkit vendor . # copies glbload.js + three.module.min.js next to your page That copy step is not optional and not cosmetic. `import { loadGLB } from "antics-modelkit/glbload"` resolves in NODE and cannot in a browser — a page has no node_modules — and glbload imports `three` by name for the same reason the package does not ship its own copy. `vendor` rewrites that specifier to the file it just placed, so the page needs no importmap. Then the files map holds: - `dist/*.glb` your assets, as data: URIs (binary) - `glbload.js` from vendor - `three.module.min.js` from vendor (skip with --three=false if you serve your own) `scatter(parts, matrices)` places one asset many times as instances; `pivotAt(part, anchor)` hangs a part off a joint so it rotates about that joint rather than the asset's origin (every part shares the asset origin, so `arm.rotation.x` without it swings the arm about the feet). If you skip `vendor` and copy the files by hand, keep `three` bare and declare the map yourself — `npx modelkit vendor . --bare` does that. three's own GLTFLoader also works; the output is validator-clean either way. Keep the budget in mind: 3 MB per file, 12 MB per bundle, and a data: URI is ~33% larger than the bytes it carries. The kit quantises by default, so assets land in the low hundreds of KB; if one is megabytes, that is a bug in the recipe, not the format. ## Verify (see your game without a browser) After deploying, `verify_game` (MCP; REST: POST /api/verify with the deploy `hash`) runs the game headlessly in a real room and returns a screenshot, console output, and live numeric probes. The full request shape — copy it, unknown fields are rejected with the valid list: { "hash": "", // or "room": "" for a live room "settleMs": 3000, // virtual ms for boot/join before probing "advanceSeconds": 10, // fast-forward on a virtual clock (NOT "seconds") "urlParams": { "seed": "42" }, // extra query params for the page — deterministic runs "readState": ["state.score", "player.self.x", "state.players.*.x"], "traceSeries": true, // optional: also return the sampled {t, v} series per path "screenshotAtMs": [8000], // optional: mid-capture action shots at VIRTUAL ms stamps // (same clock as the response's virtualMs — settle counts; // <=8, each lands within a frame or two, under // players[i].screenshots). "screenshot" governs the END shot. "players": 2, // a NUMBER: 1 or 2 browser pages. Not a list. "screenshot": false, "sandbox": true, // optional: scores rank in-room only, never on the project board "input": { "sequence": [ // timed phases, in order; virtual ms { "ms": 500 }, // no keys = idle { "keys": ["ArrowRight", "w"], "ms": 1000 },// held for that phase { "click": { "x": 120, "y": 80 }, "ms": 400 } // one full click (down+up) at // canvas-relative px, fired at the START of the // phase — script tower/board placements as a // sequence of click phases. true = canvas centre. ], "keys": ["space"], // held for the WHOLE capture (optional) "pointer": { "x": 200, "y": 150 } // HOLD a press through the capture — true = // canvas centre, {x, y} = canvas-relative px } } Key names accept friendly forms ("up", "space", "w") and raw codes ("ArrowUp", "KeyW"). Input semantics to know: a held phase delivers exactly ONE keydown (no OS-style auto-repeat is synthesized; keyup fires when a later phase releases the key) — so keydown-driven turn-based games get exactly one action per phase, fully deterministic. The LAST sequence phase's keys STAY HELD through the advance window (deliberate — end the sequence with a no-keys phase `{ "ms": 1 }` to release them). Unknown fields ANYWHERE in the input spec — top level or inside a phase — are rejected with the valid list, so a misshapen spec can never silently drive nothing. `input` drives page 0; with `players: 2`, pass per-page `inputs: [{...}, {...}]` instead to steer BOTH pages (`inputs[0]` = page 0, `inputs[1]` = page 1; page 0 falls back to `input`). All pages share ONE virtual clock, so per-page sequences run CONCURRENTLY — phase boundaries interleave on the same timeline, which is what makes two-player interaction checks (chase, collide, rally) scriptable; a page whose sequence ends first holds its last phase's keys while the other finishes. `readState` numeric paths get a per-tick min/max/first/last trace over the advance window, which catches transients a final frame hides. Iterate on the numbers, not on screenshots. Wildcards fan out: `state.players.*.x`, and `player.*.` for one result per player in the room — the cross-page form (you cannot know the other page's player id in advance). Traces are AGGREGATES; for the actual time series (where exactly did the run die? when did the phase flip?) pass `traceSeries: true` — each numeric path also returns parallel `{t, v}` arrays (virtual ms, value; ≤600 points, uniformly thinned) under `players[i].series`. For EVENTS rather than numerics (jumps, landings, pickups), console.log breadcrumbs (`[jump] t/x/z`) and read them back in order from `consoleLogs` — the two compose well. TIMELINE ASYMMETRY IN SIM ROOMS: your PREDICTED counters run AHEAD of what you see of other players — a predicted `resets` flag flips locally at once, while the remote body's rendered teleport arrives at snapshot + interpolation time (rtt + ~2 snapshot intervals later; >600ms under 150ms injected latency). Motion/teleport detectors must key their exemption windows on when the REMOTE body actually moves, not on when your predicted counter announces it. TWO MORE THINGS SCRIPTED RUNS HIT: (1) boot time varies slightly run to run, so anchor moving-obstacle/hazard phases to RUN START (first input), not page load — otherwise your timed jumps land on a different mover phase each run and scripts don't reproduce. (2) A verify capture is a REAL player in a REAL room: if your game auto-submits scores on a win, verified wins write real leaderboard rows — pass `sandbox: true` (fresh rooms only) to rank scores on a throwaway in-room board instead, e.g. when iterating on a game whose share link is already public. Sandbox also WAIVES the minimum-session-age check (which is wall-clock, and fast-forwarded captures compress wall time), so win-time submits succeed even in short captures. The owner endpoints above list (with playerIds) and delete rows if you forgot. MAKE YOUR GAME OBSERVABLE (classic tier): readState reaches `state.*` (shared room state) and `player..*` (per-player state) — NOT your local variables. A classic game whose logic lives in locals is invisible to probes until you mirror the numbers that matter (score, health, wave, phase) into `room.me.setState({...})` or the host's shared state. Do this from the start: it is also what makes the game debuggable after launch. Screenshots complement probes, they don't replace each other — probes prove BEHAVIOR (motion, hits, scores), screenshots catch what proportions/scale/layers actually look like (a numerically-correct disc can still visually eat the ship). CSS transitions and animations run on the WALL clock, not the virtual one; the harness fast-forwards any in flight before the final screenshot, so overlays/tweens land in their END state and the image matches the probes. RESPONSE SHAPE: results are PER PAGE under `players[i]` — `players[i].state` (readState values), `players[i].trace` (per-tick min/max/first/last), `players[i].consoleLogs` / `consoleErrors`, `players[i].screenshotBase64`. There are no top-level screenshot/state fields; `note` and `virtualMs` (total game time the page experienced) ride at the top level. TIME MODEL (read before timing anything): everything runs on ONE virtual clock — settle, your input sequence, and the advance, in that order — and rAF timestamps, performance.now() and Date.now() inside the page are ALL that same clock (rAF fires exactly once per 16.7 virtual ms, i.e. 60/virtual-second). There is no second time base: if your in-game clock disagrees with `virtualMs`, your own loop is the gap — the usual suspects are counting frames as fixed 1/60s while also capping dt, or starting your clock after boot (virtualMs counts settle+boot BEFORE your sequence). The clock advances as fast as your game's frames execute. During the fast-forward the harness SKIPS PAINTING: your draw calls run as no-ops (rendering pixels is what makes headless fast-forward slow), while ALL your JS — logic, rAF callbacks, even the draw functions themselves — still executes every frame, and the final screenshot repaints so it shows the state the traces end at. You do NOT need a "lite"/render-skip query flag for verification; don't build one. Two caveats: a game doing heavy per-frame LOGIC (physics substeps, big loops) still advances only as fast as its frames execute; and a game that READS canvas pixels back (getImageData, toDataURL, readPixels) automatically disables paint-skipping — pixels must then be real, so its fast-forward runs at rendered speed (a console warning says so; prefer JS-state collision over pixel reads). The whole capture has a WALL budget of 30s + 0.5s per advance second (+10s per extra player). When the budget cuts an advance short the run RETURNS what it gathered — `budgetTruncated: true`, with traces/states/screenshots covering the virtual time actually reached (`virtualMs`) — instead of failing with nothing. Boot/settle time elapses BEFORE your sequence starts, so never time a capture by sequence arithmetic alone: the response's `sequenceStartAtMs` is the virtual timestamp your input sequence began at — an event at sequence offset T happened at virtual `sequenceStartAtMs + T`, never at T — and `virtualMs` is the total game time the page experienced. Align timing claims against those two numbers (two cold runs filed phantom latency bugs from raw sequence arithmetic). For determinism, have the game read a seed from its query string and pass `urlParams`. ## Filing what you learned (optional, appreciated) If you hit a platform or browser issue these docs did not cover — and SOLVED it — you can file it so the docs improve for the next agent: POST /api/findings { "area": "input|netcode|rendering|audio|deploy|verify|docs|platform|other", "severity": "blocker|major|minor", "title": "...", // <= 80 chars "symptom": "...", // <= 280 chars — what went wrong, observably "fix": "...", // <= 420 chars — what actually worked "snippet": "...", // optional, <= 500 chars "hash": "" } // optional BE CONCISE — the caps are enforced (overlong fields are rejected, not truncated) and a human reviews every finding before anything reaches these docs: a few plain sentences beat an essay. File only issues you fixed yourself and only once per issue (5/hour/IP). Nothing you write is published automatically. ## Sim tier (server-authoritative — for real-time movement, physics, cheat-proof scores) Everything above is the classic tier: one client (the host) runs the game logic. There is a second tier where the SERVER runs it: add a pure `sim.js` (export schema/init/simulate) to your files map and the platform simulates authoritatively with client prediction handled for you — the client just calls `room.sim.setInput({...})` and draws from `room.sim.renderState()`. Choose it for real-time action, physics, or a leaderboard that must resist cheating; it also brings `probe_sim` (verify game logic server-side with virtual players — no browser at all). The sim tier is PAID (Pro). Writing and DEPLOYING a sim.js is free on any plan — the deploy smoke runs your module and reports what is broken — but RUNNING it on our CPU is not: live sim rooms, `probe_sim`, and `verify_game` against a sim deploy all need Pro and answer 402 PLAN_REQUIRED otherwise. Everything on this page — the classic tier, including `verify_game` — stays unlimited on every plan, keyless included. Check the account with get_account (MCP) or GET /api/usage BEFORE choosing the tier, so you don't build something the user can't run. Pro is WAITLIST-ONLY at the moment — no self-service upgrade — so if `metered.simRooms.limit` is 0 the user cannot buy their way in today: build on the classic tier described above. Full reference: call get_docs { topic: "sim" } (MCP), or fetch /llms-sim.txt.