The rest of the docs are indexed at https://irt.io/llms.txt and concatenated at https://irt.io/llms-full.txt. # Building with a coding agent > The whole IRT integration on one page, sized for a coding agent's context: the room and client contract, state sync, ownership, prediction, calls, testing, deploying and the mistakes to avoid. This page is the IRT integration in one place, for coding agents and for people who want the short version. It's also served alone as [/llms-small.txt](https://irt.io/llms-small.txt). Every section links to the full page. For an agent working in a project, `npx irt skill install` writes these rules as a Claude Code skill to `.claude/skills/irt/SKILL.md`, and [`irt mcp`](https://irt.io/docs/reference/mcp) is an MCP server that lets the agent search the docs and read the project's builds, rooms and logs. ## What IRT is IRT hosts a web game's multiplayer: **rooms** (one running game instance each, addressed by a room ID) run your TypeScript room code in Node on IRT's machines, sync typed state to clients as binary deltas, hibernate to object storage when empty and wake when someone connects. A build can include the game's **site**, served at `https://.irt-serve.com`. | Package | Where | What | |---|---|---| | `@irt/room` | room code, shared modules | `defineRoom`, `defineSchema`, `entity`, `json`, `defineStepper`, `grid`, `combine`, `CloseCode`, `DataError` | | `@irt/client` | browser and Node clients | `connect`, `RoomClient`, `IrtError`, `createRoom`, `joinByCode`, `listPublic`, `dataClient`, `DataError`, and the same `defineSchema`, `entity`, `defineStepper` | | `@irt/testing` | tests | `testRoom`, `createTestHost`, `RoomStoppedError` | | `@irt/cli` | shell, `npx irt` | `init`, `dev`, `test`, `build`, `deploy`, `logs`, `rooms`, `secrets`, `projects`, `skill`, `mcp` | Node 24 or later. The npm package is `@irt/cli`; inside a project that has it as a devDependency, `npx irt ` runs it, and `npx @irt/cli` works anywhere. ```sh npx @irt/cli init my-game --project my-game # scaffold cd my-game && npm install npx irt projects create my-game # once, after npx irt login npx irt dev # room host and site on http://127.0.0.1:7350 npx irt test # vitest npx irt deploy # build, upload, make live ``` A project: `irt.config.ts` (slug, room module, site, settings), `src/schema.ts` (shared by room and client), `src/room.ts` (default export `defineRoom(...)`), `src/room.test.ts`, and `web/` for the client. ## Schema All synced state is **entities** of declared kinds. The room and its clients import one schema module; its hash must match exactly or the client is closed with 4004. ```ts // shared.ts import { defineCalls, defineSchema, defineStepper, entity, json } from "@irt/room"; export const Runner = entity("runner", { owner: "string", x: "f32", y: "f32", score: "u16" }); export const Flag = entity("flag", { x: "f32", y: "f32" }); export const Aim = entity("aim", { angle: "f32" }, { writable: ["angle"] }); export const Match = entity("match", { round: "u16", endsAt: "u32", scores: json>() }); export const Mine = entity("mine", { coins: "u32", volume: "u8" }, { writable: ["volume"] }); export const schema = defineSchema({ state: Match, private: Mine, entities: [Runner, Flag, Aim], // Typed calls: the room's handlers and the client's room.call() are checked against these. calls: defineCalls<{ buy(args: { price: number }): { coins: number } }>({ buy(raw) { const price = (raw as { price?: unknown } | null)?.price; if (typeof price !== "number" || !Number.isInteger(price) || price < 0) throw new Error("price must be a whole number"); return { price }; }, }), }); export interface Input { dx: number; dy: number; } export const stepper = defineStepper({ parseInput(raw) { const i = raw as Partial; if (typeof i.dx !== "number" || typeof i.dy !== "number" || Math.abs(i.dx) > 1 || Math.abs(i.dy) > 1) { throw new TypeError("input is { dx, dy } with each from -1 to 1"); } return { dx: i.dx, dy: i.dy }; }, step(world, inputs, dt) { for (const r of world.all(Runner)) { const input = inputs.get(r.owner); if (!input) continue; r.x = Math.min(100, Math.max(0, r.x + input.dx * 20 * dt)); r.y = Math.min(100, Math.max(0, r.y + input.dy * 20 * dt)); } }, }); ``` - Field types: `bool`, `u8`/`u16`/`u32`, `i8`/`i16`/`i32`, `f32`, `f64`, `string` (16 KB), `bytes` (`Uint8Array`, 48 KB), `json` (16 KB serialized). `json()` types a json field; it starts `null`. An entity must fit in 61,376 bytes; split big data, such as worlds, into chunk entities with `bytes` and `entity.patch(field, offset, bytes)`. - `state` is the shared state every connection sees (`room.state`). `private` is per player, seen only by that player (`room.private(conn)`), kept by player ID for 24 hours after they leave by default (`privateRetention` in `defineRoom`, milliseconds or `"forever"`). `entities` lists the entity kinds; their order is part of the hash. - Field names `id`, `kind` and `patch`, and kind names starting with `irt_`, are reserved. Changing any field, type, writable list or the order of `entities` changes the hash. - `{ writable: [...] }` lists fields the entity's **owner** may write from the client. The shared state can't have writable fields. ## The room ```ts // room.ts import { CallRejected, defineRoom, type RoomEntity } from "@irt/room"; import { Aim, Flag, Runner, schema, stepper } from "./shared.js"; const TICK_RATE = 20; export default defineRoom({ schema, tickRate: TICK_RATE, stepper, onCreate(room) { room.entities.create(Flag, { x: 50, y: 50 }); room.state.scores = {}; room.state.endsAt = 60 * TICK_RATE; }, onJoin(room, conn) { // After a crash the player's entities still exist and onJoin runs again: reuse them. const existing = [...room.entities.all(Runner)].find((r) => r.owner === conn.player); conn.data.runner = existing ?? room.entities.create(Runner, { owner: conn.player, x: 10, y: 10 }); if ([...room.entities.ownedBy(conn.player, Aim)].length === 0) room.entities.create(Aim, {}, { owner: conn.player }); room.send(conn, { type: "hello", round: room.state.round }); }, onMessage(room, conn, message) { const m = message as { type?: unknown }; if (m.type === "taunt") room.broadcast({ type: "taunt", from: conn.player }, { except: conn }); }, onWrite(room, conn, entity, next) { if (entity.kind === Aim) return { angle: ((next.angle as number) % 360 + 360) % 360 }; return next; }, onTick(room) { const flag = [...room.entities.all(Flag)][0]!; for (const r of room.entities.all(Runner)) { if (Math.hypot(r.x - flag.x, r.y - flag.y) > 3) continue; r.score += 1; room.state.scores = { ...room.state.scores, [r.owner]: (room.state.scores[r.owner] ?? 0) + 1 }; flag.x = (flag.x + 37) % 100; } if (room.tick >= room.state.endsAt) { room.state.round += 1; room.state.endsAt = room.tick + 60 * TICK_RATE; } }, onPlayerLeave(room, player) { for (const e of room.entities.all(Runner)) if (e.owner === player) room.entities.delete(e); }, calls: { buy(room, conn, { price }) { const mine = room.private(conn); if (mine.coins < price) throw new CallRejected("not enough coins"); mine.coins -= price; return { coins: mine.coins }; }, }, }); ``` | Hook | Runs | If it throws | |---|---|---| | `onCreate(room)` | once, for a new room | room stops (4007) | | `onWake(room, { elapsed })` | when the room starts from room state: after hibernating, or after a crash | room stops | | `onJoin(room, conn)` | each connection, each reconnect included; again after a crash, with no `onLeave` before | that connection closes with 4006 | | `onMessage(room, conn, message, { unreliable })` | each message, between ticks | logged, message dropped | | `onWrite(room, conn, entity, next)` | each owner write, at the start of a tick; synchronous; return the fields to apply or `undefined` to reject | write rejected | | `onTick(room, dt)` | each tick, after the stepper | room stops | | `onLeave(room, conn)` | when a connection closes, a dropped network included, and for every connection before any hibernation; never after a crash | logged | | `onHibernate(room)` | before the room saves and leaves its room host | room stops | | `onRequest(room, message)` | a server API message; the return or resolved value is the reply | API gets a 422 error; logged as an error unless it's a `CallRejected` | | `calls[name](room, conn, args)` | a client's `call(name, args)`; the return or resolved value is the reply | the client's call rejects with the message; logged as an error unless it's a `CallRejected` | Tick order: `room.tick` increments, owner writes apply, the stepper runs with this tick's inputs, `onTick`, then the state update goes out, then input acks. Writes made between ticks go out with the next tick, or sooner when a connection joins: a join's full state goes out as soon as `onJoin` has run (or its promise settled), not at the next tick, and the same update sends every other connection the changes since the last tick. `onCreate`, `onWake` and `onHibernate` may be async and are awaited; other hooks aren't. - `room`: `id`, `project`, `joinCode` (or null), `tick`, `tickRate`, `entities`, `state`, `private(conn)`, `private(player)` (undefined if none; `{ create: true }` creates it), `privatePlayers()`, `connections`, `data`, `send(conn, msg, { unreliable })`, `broadcast(msg, { unreliable, except })`, `kick(conn)` (4006), `requestHibernate()` (clients close with 4011), `rewind(history, tick)`. - `conn`: `id`, `player` (the token's `sub`, or `guest:...`), `meta` (the token's `meta` claim, or null), `data` (yours, not saved), `viewTick` (the tick the player saw when sending this tick's input), `input` (that input, in `onTick`). - `room.entities`: `create(kind, values?, { id?, owner? })`, `delete(e)`, `get(id)`, `all(kind)`, `count`, `setOwner(e, player | undefined)`, `ownerOf(e)`, `ownedBy(player, kind)`. IDs start at 2; `{ id }` sets one you compute, such as from chunk coordinates. `get` is a map lookup; `all` visits every entity of the kind. - Messages are JSON, or binary for a `Uint8Array`, at most 64 KB. Clients may send 120 a second. `unreliable` uses datagrams where available. - Writes: setting a field records it and the delta goes out at the end of the tick. Setting a `json` field to a deep-equal value records nothing, and mutating one in place records nothing: assign a new value. - `room.data` (from `data: () => ({...})`) is per-room derived data, not saved; rebuild it in `onCreate` and `onWake`. Module-level variables are shared by every room in the room host: keep only constants there. ### Where data lives | Data in | Leave and rejoin | Network drop | Crash | Hibernate and wake | |---|---|---|---|---| | An entity `onLeave` deletes | lost | lost | kept, `onJoin` runs again | lost | | An entity the room keeps | kept | kept | kept | kept | | `room.state` | kept | kept | kept | kept | | `room.private(conn)` | kept 24 h by default | kept | kept | kept 24 h by default | | `conn.data`, `room.data`, module variables | lost | lost | lost | lost | | `room.playerData(player)`, collections, leaderboards | kept | kept | kept | kept, across every room of the project | Room state (entities with owners, `room.state`, private state, the tick, the stepper's `save`) streams out of the VM after every tick, so a crash loses at most the tick in progress; it's written to object storage every 30 seconds and on hibernate. Limit 32 MB. Rooms hibernate 30 seconds after their last connection closes. See [Room state](https://irt.io/docs/room-state). ## The client ```ts // client.ts import { CloseCode, connect, IrtError, reloadForNewBuild } from "@irt/client"; import { Aim, type Input, Runner, schema, stepper } from "./shared.js"; const router = location.hostname.endsWith(".irt-serve.com") ? "wss://eu.irt.io" : location.origin; const keys = new Set(); addEventListener("keydown", (e) => keys.add(e.key)); addEventListener("keyup", (e) => keys.delete(e.key)); async function start() { const room = await connect({ router, project: "my-game", room: "arena", schema, onWaking: () => console.log("waking") }); room.on("message", (m) => console.log("message", m)); room.on("disconnected", (code) => console.log("reconnecting", code)); room.on("restored", () => console.log("back")); room.on("closed", (e) => { if (e.code === CloseCode.SchemaMismatch) reloadForNewBuild(); else console.log(e.message); }); const me = room.predict(stepper, { find: (r) => [...r.entities.all(Runner)].find((e) => e.owner === r.player), sample: (): Input => ({ dx: (keys.has("ArrowRight") ? 1 : 0) - (keys.has("ArrowLeft") ? 1 : 0), dy: (keys.has("ArrowDown") ? 1 : 0) - (keys.has("ArrowUp") ? 1 : 0), }), }); const others = room.interpolate(Runner, ["x", "y"]); addEventListener("pointermove", (e) => { for (const aim of room.entities.mine(Aim)) aim.angle = (Math.atan2(e.clientY, e.clientX) * 180) / Math.PI; }); room.private.volume = 7; const { coins } = await room.call("buy", { price: 0 }); console.log("coins", coins, "round", room.state.round); function frame() { const local = me.render(["x", "y"]); for (const r of room.entities.all(Runner)) { const at = r.owner === room.player ? local : others.get(r); console.log(r.owner, at.x, at.y); } requestAnimationFrame(frame); } frame(); } start().catch((e) => { if (e instanceof IrtError && e.code === CloseCode.SchemaMismatch) reloadForNewBuild(); else throw e; }); ``` - `connect({ router, project, room, token?, schema, onWaking?, timeout? })` resolves once the client holds the full state; `room.state` and `room.private` are set from then. It rejects with `IrtError`. After that, the client reconnects by itself, and on every reconnect or room restart all entities leave and enter again: don't hold entity objects across `restored`. - `router`: `location.origin` under `irt dev`, the region's router, such as `wss://eu.irt.io`, when deployed; `irt deploy` prints it. - Without `token` the client is a **guest**: `guest:`, kept in `localStorage` for 30 days and renewed on each connect, so every tab of a browser is the same player. - `room.entities`: `get`, `all(kind)`, `mine(kind)`, `ownerOf`, `count`, `onEnter`, `onChange(kind, (e, changed) => ...)`, `onLeave`, `onCorrect`, `onOwner`. Client entities are read-only except writable fields of entities the player owns. - `room`: `send(msg, { unreliable })`, `call(name, args, { timeout })`, `leave()`, `on(event)`, `onTick`, `predict`, `interpolate`, `player`, `connection`, `tick`, `serverTick()`, `rtt`, `status`, `generation`. `send` and `call` throw before `connect` resolves. - Events: `waking`, `disconnected`, `restored`, `closed`, `message`, `tick`, `peers`, `voice`. ### In an engine or framework There are no adapters; the client works with any engine. Construct `new RoomClient(opts)` and add `onEnter`, `onChange` and `onLeave` listeners before `await room.ready`: `connect` resolves after the first state is applied and `onEnter` doesn't replay it. Map entity IDs to engine objects, and rebuild them on `restored`, when every entity leaves and enters again as a new object. In the engine's frame loop, draw remote entities at `interpolator.get(e)` and the local player at `prediction.render(fields)`. On scene or component teardown: remove the listeners, then `prediction.stop()`, `voice.disconnect()` and `room.leave()`. In React, connect in an effect, never in `useMemo` or render, and draw the world from a frame loop, not React state. See [Engines and frameworks](https://irt.io/docs/integrations/overview) and the [Phaser](https://irt.io/docs/integrations/phaser), [PixiJS](https://irt.io/docs/integrations/pixi), [Three.js](https://irt.io/docs/integrations/three), [Babylon.js](https://irt.io/docs/integrations/babylon) and [React](https://irt.io/docs/integrations/react) guides. ## Ownership Every entity has an owner: the room, or a player ID. The owner writes the kind's writable fields on the client, `entity.field = value`, sent at most once per tick; the room checks each in `onWrite` and wins. When the room's value differs from the owner's, the owner gets a **correction** (`onCorrect`). Clients can't create or delete entities or change owners. A player always sees what it owns, and owns its private state. Use it for cursors, aim, drag-and-drop and settings, not for anything the stepper moves. See [Ownership](https://irt.io/docs/guides/ownership). ## Presence Every room has presence: one entry per player in the room, `{ player, name, status, connected }`, synced to every connection whatever interest says. The runtime adds it before `onJoin` and removes it 10 seconds after the player's last connection closes (`connected` is false meanwhile), so a reconnect doesn't read as leave and join. Don't declare a player-list kind or write join and leave code for one. ```ts // client for (const p of room.presence) list.add(p.player, p.name); room.presence.onJoin((p) => list.add(p.player, p.name)); room.presence.onChange((p, changed) => list.update(p.player, p)); room.presence.onLeave((p) => list.remove(p.player)); room.presence.setStatus({ typing: true }); // JSON, 1 KB at most; an owner write // room room.presence.set(conn.player, { name: "Alice" }); // or the token meta's `name` room.presence.get(player)?.status; ``` The status is the player's claim and skips `onWrite`; the room corrects it with `presence.set`. Presence isn't room state: it's empty after a wake until players reconnect, so keep lasting per-player data in private state. See [Presence](https://irt.io/docs/guides/presence). ## Prediction A **stepper** is a step function shared by room and client: `step(world, inputs, dt, tick)`, where `inputs` maps player IDs to this tick's input. The room runs it every tick with each player's input; the client runs it in `room.predict(stepper, { find, sample })` for the local player, sends each input tagged with its tick, and replays unacknowledged inputs when the room's state arrives. `sample` runs once per room tick. Draw `prediction.render(fields)`, use `prediction.entity` for logic, `settled` to know it agrees with the room. Interpolate everyone else with `room.interpolate(kind, fields)`. - The stepper must depend only on `world`, `inputs` and `dt`: no `Math.random`, `Date.now` or module state. - `parseInput` validates the client's JSON on the room; throw to drop it. - Don't move the predicted entity in `onTick`; every such move is a correction. - Inputs more than 32 ticks ahead, or for past ticks, are dropped. See [Client prediction](https://irt.io/docs/guides/prediction). ## Physics `@irt/physics/rapier2d` and `/rapier3d` are steppers on Rapier; install `@dimforge/rapier2d-compat` or `-3d-compat` beside it. `physics({ gravity, bodies: [body(Kind, { type, colliders, player, control })] })` gives each entity of a kind a body posed by its fields: `x`, `y`, (`z`), and optionally `angle` (3D `qx`..`qw`) and, for dynamic bodies, `vx`, `vy`. Types are `dynamic`, `fixed`, `kinematic` and `character`; a character's `control(c, input, dt)` calls `c.move(dx, dy)`, which returns whether it's grounded. The entities are the truth: writing a pose in room code teleports the body. Pass the stepper to `defineRoom` and `room.predict`; the client predicts its own body against the rest as kinematic proxies. Drive movement with inputs, not writable fields; pose fields can't be writable. Use capsule characters spawned above the ground. Colliders are made once unless the kind gives a `shapeKey`, such as a chunk's edit count, which makes them again (or `reshape` updates them) when it changes. `stepper.bodyOf(world, entity)` and `entityOf(world, collider)` map between entities and bodies. See [Physics](https://irt.io/docs/guides/physics). ## Lag compensation To check a shot against where the shooter saw the target, record the fields a hit check reads and rewind them: `const poses = defineHistory(Player, ["x", "y"])` (number and bool fields only), `defineRoom({ history: [poses], ... })`. Put the shot in the input, ignore it in the stepper, and check it in `onTick`: `conn.input` is this tick's parsed input (undefined without one), and `room.rewind(poses, conn.viewTick)` is a `ReadonlyMap` of ID to frozen `{ id, x, y }` as that tick's state update sent them, without entities that didn't exist then. Fire from the shooter's present position. Steppers can't rewind: they also run on the client. The room keeps 250 ms plus two ticks, 8 bytes per field per tick per entity, at most 8 MB a room, in memory only: it's empty after a wake. With physics, `castRay(ray, maxToi, targets, shape)` from `@irt/physics/rapier2d` or `/rapier3d` casts against rewound poses with Rapier shapes and no world. See [Lag compensation](https://irt.io/docs/guides/lag-compensation). ## Calls Declare calls once in the schema with `defineCalls`, as above: the room's `calls` handlers and the client's `room.call(name, args)` are typed from it, and an optional `parse` per call checks the untrusted args before the handler (a parse that throws rejects the call). Without `parse`, args are unchecked JSON. See [Calls](https://irt.io/docs/guides/calls). `room.call(name, args)` runs `calls[name]` in the room and resolves to its return value. When the handler changed room state, the reply waits for the next tick, which saves the change outside the VM first, so a reply means the change survives a room host crash; in `@irt/testing`, tick before awaiting it. It rejects with an `IrtError` with code 0 when the handler throws (`call buy failed in the room: not enough coins`), there's no such handler, 10 seconds pass, or the connection drops or the room restarts first. Throw `new CallRejected(message)` for an expected rejection, such as a game rule: the client gets the message and the room doesn't log it, where any other error is logged with its stack. A failed call may or may not have run and is never retried: make state-changing calls safe to repeat. Use calls for request and reply, messages for fire-and-forget, state sync for anything continuous. ## Interest Without `interest`, every connection sees every entity. With it, a connection sees entities that share a key with it: ```ts interest: grid({ cellSize: 50, range: 120, dims: "xy", around: (_room, conn) => conn.data.runner }) ``` `grid` places entities by their `x`/`y`/`z` (or `position`); entities with no position are seen by everyone. `range` is a distance in world units, `radius` a number of cells. `combine(gridA, gridB, custom)` joins parts; `limit: { max, priority }` caps a view. Caps: 4,096 keys and 16,384 entities per connection. See [State sync and interest](https://irt.io/docs/guides/state-sync). ## Testing `@irt/testing` runs the room in process with real clients and a clock the test controls: ```ts // room.test.ts import { testRoom } from "@irt/testing"; import { expect, test } from "vitest"; import room from "./room.js"; import { Aim, Runner, stepper } from "./shared.js"; test("players see each other, keep coins and get corrected aim", async () => { const t = testRoom(room); const alice = await t.connect({ player: "alice" }); const bob = await t.connect({ player: "bob" }); alice.expectMessage({ type: "hello" }); await expect(alice.client.call("buy", { price: 5 })).rejects.toThrow("not enough coins"); t.live.private([...t.live.connections.values()].find((c) => c.player === "alice")!).coins = 9; // A call that changed room state replies once a tick has saved it. const bought = alice.client.call("buy", { price: 5 }); await t.host.tick(); await expect(bought).resolves.toEqual({ coins: 4 }); const [aim] = alice.mine(Aim); aim!.angle = 400; await t.host.tick(2); alice.expectCorrected(aim!.id, "angle"); expect(bob.view(Aim).find((a) => a.id === aim!.id)).toMatchObject({ angle: 40 }); expect(bob.view(Runner).map((r) => r.owner).sort()).toEqual(["alice", "bob"]); }); test("a crash keeps one runner per player; hibernation keeps private state", async () => { const t = testRoom(room); const alice = await t.connect({ player: "alice" }); await t.host.crash(); await alice.reconnected(); expect(alice.view(Runner)).toHaveLength(1); alice.private.volume = 3; await t.host.tick(2); await t.hibernate(); expect(alice.closedWith).toBe(4011); const back = await t.connect({ player: "alice" }); expect(back.private.volume).toBe(3); expect(back.view(Runner)).toHaveLength(1); }); test("the stepper moves a predicted runner and the room agrees", async () => { const t = testRoom(room); const alice = await t.connect({ player: "alice" }); const me = alice.predict(stepper, { find: (r) => [...r.entities.all(Runner)].find((e) => e.owner === r.player), sample: () => ({ dx: 1, dy: 0 }), }); await t.host.tick(40); expect(me.entity!.x).toBeGreaterThan(10); }); ``` - `testRoom(def, { settings? })` is one room; `createTestHost(def)` holds several. `t.connect({ player?, meta? })`, `t.live` (the running room), `t.stored()` (saved room state), `t.wake()`, `t.hibernate()`, `t.request(msg)` (`onRequest`), `t.host.tick(n)`, `t.host.advance(ms)`, `t.host.crash()`, `t.host.createRoom/joinByCode/listPublic`. - A test client: `client` (the real `RoomClient`), `view(kind)`, `private`, `mine(kind)`, `messages`, `events`, `closedWith`, `send`, `predict`, `drop()`, `reconnected()`, `leave()`, `expectSees`, `expectNotSees`, `expectMessage`, `expectCorrected`, `lastUpdate`. - `tick` and `advance` throw `RoomStoppedError` if a room's code threw where that stops it. - `t.connect` rejects with `IrtError` and the close code when the room refuses it. `reconnected()` resolves at once if the client already reconnected since its last drop or crash. - Assert after `await t.host.tick()`. Room code's own timers and `Date.now()` don't follow the test clock: time things in ticks. Ticks run for 30 s after the last player leaves, then stop while hibernated; see [Timers across hibernation](https://irt.io/docs/hibernation#timers-across-hibernation). See [Testing rooms](https://irt.io/docs/guides/testing). ### Simulating players `npx irt simulate --players 20` runs the room locally with 20 bots on real client connections and checks invariants every tick: visibility leaks (a client holding an entity the room's interest excludes), mispredictions, owned writes lost without a correction, errors the room logs, room state limits, and what reconnects restore. Add `--drops 4` to exercise reconnects and `--rtt 120 --jitter 20 --loss 0.02` for a slow network. It exits 1 on a failure and writes `.irt/simulate/trace.jsonl`; its last line has the verdict and the first failure's tick, players and entity values on the room and the client. For scenarios, write `irt.sim.ts`: ```ts import { chat, defineSimulation, walk } from "@irt/simulate"; import room from "./src/room.js"; export default defineSimulation(room, { players: 16, drops: 2, behaviour: [walk(), chat()], async run(sim) { await sim.wait(100); await sim.crash(); }, invariants: { "score never negative": ({ room }) => room.state.score >= 0 || `score is ${room.state.score}`, }, }); ``` Bots have `send`, typed `call`, `write` (checked owned writes), `predict`, `wait(ticks)`, `until(test)`, `drop()` and `leave()`. A run is deterministic for `--seed`. See [Simulating players](https://irt.io/docs/guides/simulate). ## Deploying ```ts // irt.config.ts import { defineConfig } from "@irt/cli/config"; export default defineConfig({ project: "my-game", room: "src/room.ts", site: { dir: "dist", build: "npm run build:web", crossOriginIsolated: false }, settings: { players: { guests: true, createRooms: true, listPublic: true }, rooms: { size: "standard", retention: "7d", buildUpdate: "hibernate" }, }, }); ``` - `npx irt deploy` bundles the room, runs `site.build`, uploads the build and makes it live: the site and room code go live together. `limits` and `webhookUrl` can't be set from code. - **Schema hash.** Clients must match the hash of their room's build exactly, or they close with 4004. A room still on an older build refuses the new site's clients until it hibernates and moves. Sites deploy with their builds, so a web client handles 4004 by reloading. Bots and other clients must be rebuilt. - **Rooms move** to a new build when they next hibernate. - **Rooms are created on first connect only with `players.createRooms` on**, deployed and under `irt dev` alike. With it off, a connection to a room that doesn't exist closes with 4002; use `irt rooms create ` or `POST /projects/{p}/rooms`. - **Migrations.** A change old room state doesn't fit (renaming or removing a field or kind, changing a type) needs `version: n + 1` and `migrations: { [n + 1]: (data) => data' }` on plain `RoomData`. Adding fields or kinds doesn't. Rolling back past a version bump stops migrated rooms. See [Builds and deploys](https://irt.io/docs/builds). - CI: `npx irt test`, then `npx irt deploy` with `IRT_TOKEN` set to a project API key. - Other commands: `irt logs [--follow]`, `irt rooms list|create|delete|send`, `irt secrets list|set|unset` (secrets reach room code as environment variables), `irt build`. - **Dev tools.** While `irt dev` runs, `http://127.0.0.1:7350/v1/dev/` shows each room's connections, `room.state`, private state, entities with owners, the last 200 messages and calls, and a tick profile (p50, p95 and max per second for `tick`, `stepper`, `onTick`, `flush`, owner writes and each hook); `GET /v1/dev/rooms/{project}/{id}` returns it as JSON. `simulateNetwork({ rttMs, jitterMs, loss })` from `@irt/client/dev` is a `dial` for `connect` that adds latency and drops unreliable messages, against local routers only. See [Dev tools](https://irt.io/docs/dev-tools). ## Games without a backend Guests, `createRoom({ router, project, public?, joinCode?, meta? })` → `{ id, joinCode }`, `joinByCode({ router, project, code })` → room ID, and `listPublic({ router, project })` → active public rooms, gated by `players.guests`, `players.createRooms` and `players.listPublic`. They reject with `IrtError` whose code is the HTTP status (403 setting off, 404 unknown code, 409 ID taken, 429 limited). See [Games without a backend](https://irt.io/docs/no-backend). Don't write the room screens yourself: the [UI kit](https://irt.io/docs/ui-kit), `@irt/client/ui` or one entry per element such as `@irt/client/ui/room-panel`, has framework-free custom elements sharing one theme (`--irt-bg`, `--irt-fg`, `--irt-accent` and the rest on `:root`). `` creates, joins by code and quick joins, fires `room-join` with `{ room, joinCode }` for the project to connect and set `panel.client`, and shows an `` (join code, share link with `?code=`, joined by itself when opened, a QR code with the `qr` attribute), the status and the players; the name the player types goes in their presence status as `name`. `` lists public rooms with players out of `maxPlayers`; `` is a lobby with an invite; `` and `` take a `voice`; `` and `` take a `client`. Any of them renders as a modal dialog with the `dialog` attribute and `show()`, or `open("irt-room-browser", { router, project })`. Games with accounts sign [player tokens](https://irt.io/docs/player-tokens) (HS256 JWT: `sub`, `prj`, `iat`, `exp` at most 24 h later, optional `room` and `meta`) on their backend. ## Player data and leaderboards Data that outlives rooms, per project: **player data** (keys per player, JSON or bytes up to 64 KB, 256 keys and 1 MB a player, each with a `version`), **collections** of entries (`id`, value, flat `meta` filtered on, `score` sorted on, `owner`) and **leaderboards** (one score per player per period). Declare collections in `irt.config.ts`: ```ts settings: { data: { player: { clientWrite: ["settings"] }, // keys clients may write; default none collections: { levels: { clientRead: true, clientWrite: "own", maxOwn: 50 }, highscores: { clientRead: true, leaderboard: { order: "desc", keep: "best", periods: ["all", "week"] } }, }, }, }, ``` - Room code: `room.playerData(p)`: `get(key)` → `{ value, version }` or undefined, `set(key, v, { version? })`, `increment(key, by)` → number, `delete`, `list`, `refresh`. `room.collection(name)`: `get(id)`, `put({ id?, value, meta?, score?, owner?, version? })` → `{ id, version }` (replaces the whole entry), `list({ where, owner, order: "-score", limit, after })` → `{ entries, next }` without values, `delete`. `room.leaderboard(name)`: `submit(player, score, { meta })`, `top({ period: "week", limit })`, `around(player, { count })`. - `version: 0` means must not exist; a mismatch is `E_VERSION`. Calls reject with `DataError` (`code` `E_INVALID`, `E_FORBIDDEN`, `E_UNKNOWN`, `E_VERSION`, `E_TYPE`, `E_QUOTA`, `E_LIMIT`, `E_SPENDING`, `E_UNAVAILABLE`). - The room caches: a `get` within 5 s of its last read or write of that key or entry needs no call; its own writes show at once, others' within 5 s; `refresh()` drops it. `list`, `top`, `around` are calls every time. Load a player's data in `onJoin`, keep it in `conn.data` or private state and write it back on change; never `list` or `top` per tick. - After `E_UNAVAILABLE` a write may or may not have happened (5 s deadline, nothing queued): retry it with `version`. `increment` can't be retried safely. - Clients: `dataClient({ router, project, token? })`, or `room.playerData` on a `RoomClient`, has the same calls for its own player over HTTP, no room needed; a guest is one player in rooms and data calls. Clients read their own data and write `clientWrite` keys; `clientRead` lets them read a collection; `clientWrite: "own"` lets them write their own entries, with `owner` forced to them. - Authorship is `owner`, never `meta`. A score a client submits can be faked: submit scores from room code. - Tests: `host.playerData(p)`, `host.collection(n)`, `host.leaderboard(n)` seed and read an in-memory store; pass `settings: { data }` to `createTestHost`. See [Player data, collections and leaderboards](https://irt.io/docs/guides/player-data). ## Porting from another framework There's no compatibility layer for Colyseus, Playroom Kit or Socket.IO; port the room code to `defineRoom` and the client to `@irt/client`. Their handlers become `onMessage`, their acks and RPCs with replies become calls, data keyed by session or socket ID is keyed by `conn.player`, and state that was broadcast by hand becomes entities. Playroom's host-player logic moves into the room. Matchmaking beyond `quickJoin` and anything that crosses rooms goes to a backend: rooms don't message each other. See the guides for [Colyseus](https://irt.io/docs/migrate/colyseus), [Playroom Kit](https://irt.io/docs/migrate/playroom) and [Socket.IO](https://irt.io/docs/migrate/socket-io). ## Close codes | Code | Meaning | Client SDK | |---|---|---| | 4000 | Protocol version | fails; update `@irt/client` | | 4001 | Token invalid or expired, or guests off | fails; get a new token | | 4002 | Room not found, and `players.createRooms` off | fails; create it first | | 4003 | Room full | fails | | 4004 | Schema hash differs from the room's build | fails; reload the site | | 4005 | Rate or size limit | reconnects after backoff | | 4006 | Kicked, or `onJoin` threw | fails | | 4007 | Room stopped from an error | fails; read `irt logs` | | 4008 | Room moved or restarted | reconnects at once | | 4009 | Spending limit | fails | | 4010 | Wake timed out | reconnects after backoff | | 4011 | Room hibernated by `requestHibernate` | fails; offer a rejoin | | 4012 | Same player connected again elsewhere | fails | Anything else reconnects with backoff. See [Errors and close codes](https://irt.io/docs/reference/errors). ## Common mistakes 1. Creating a second entity for a player in `onJoin`; after a crash the old one exists. Find it first, by `owner` or `ownedBy`. 2. Keeping a score or inventory on an entity `onLeave` deletes; `onLeave` runs before every hibernation too. Use private state or `room.state`. 3. Keeping game state in module variables, `room.data` or `conn.data`. None is saved, and module variables are shared by every room in the room host. 4. Mutating a `json` field in place. Assign a new value. 5. Trusting client input. Validate every message, call argument, input (`parseInput`) and owner write (`onWrite`). 6. A non-deterministic stepper, or moving the predicted entity outside it: constant mispredictions. 7. Making stepper-driven fields writable. The room overwrites them every tick. 8. Two copies of the schema. Import one module in room and client, or get 4004. 9. Connecting to a fixed room ID that was never created with `players.createRooms` off: 4002. 10. Reconnecting by hand on `closed`, or reloading on anything but 4004. 11. Testing two players in two tabs of one browser: one guest, and 4012. 12. Asserting in a test before `await t.host.tick()`. 13. Slow work in `onTick`: rooms in a room host share one event loop, and a 5-second block kills the room host. 14. Renaming or removing a field without a migration: rooms stop on wake. 15. Retrying a failed `call` that isn't safe to repeat. 16. Hard-coding `http://127.0.0.1:7350` as the router in the site. 17. Building a player list from entities and `onJoin`/`onLeave`; use `room.presence`. 18. Keeping progress that should outlive the room in private state; use `room.playerData`. Calling `get` or `top` every tick instead of keeping the data in memory. 19. Trusting a leaderboard clients submit to, or `meta` as the author of an entry; use room-code submits and `owner`. ## Limits Tick rate 1 to 120, default 20. Messages 64 KB, 120 a second per connection. Room state 32 MB. Private state kept 24 hours by default. Rooms hibernate 30 seconds after the last connection. Wake timeout 10 s. One connection per player per room. Player tokens at most 24 hours; guest tokens 30 days. Room IDs 1 to 64 of `A-Z a-z 0-9 _ -`. Data calls 200 a second per room, 10 a second per client. See [Limits](https://irt.io/docs/limits).