# antics — multiplayer for AI-generated web games
Paste/deploy a single HTML game, get a multiplayer link with a leaderboard. No backend,
no accounts for players, no build step. 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.
- 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 (e.g. the host leaves). Use `room.isHost` / `room.onHostChange(...)`.
- 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. Don't use them (players already
have a stable id + name from the room); if you must touch them, wrap in try/catch.
- 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).
## 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"
});
room.code // "KX7P2M" — the room code
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.onJoin(p => {}) room.onLeave(p => {}) room.onHostChange(host => {})
// 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 }) // to everyone else; or (type, payload, toPlayerId)
room.on("shoot", (payload, from) => {})
// 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
room.leave()
const interp = interpolate({ renderDelayMs: 100 }) // optional smoothing helper:
interp.push({ x, y }, playerId); interp.sample() // lerp + snap-on-teleport/identity
A Player is { id, name, avatar?, state }.
## Example 1 — shared state (host-authoritative counter)
import { joinRoom } from "/sdk/v1.js";
const room = await joinRoom({});
room.onState((s) => { document.body.textContent = "Count: " + (s.count ?? 0); });
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 submission
import { joinRoom, getLeaderboard } from "/sdk/v1.js";
const room = await joinRoom({ key: "pk_...", game: "proj_id" }); // keyed = persistent
// ...play for at least ~10s (scores submitted sooner are rejected as anti-spoof)...
const { rank, best } = await room.submitScore(myScore);
const top = await getLeaderboard({ game: "proj_id", limit: 10 });
## 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.
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
curl -F file=@game.html https:///api/deploy # → { playUrl }
# open playUrl → it redirects to /r/; share THAT link to play together.
Or via MCP: the `deploy_game` tool returns a playable URL (keyless, before any login).