# 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](/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`](/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://<slug>.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 <command>` 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<Record<string, number>>() });
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<Input>({
  parseInput(raw) {
    const i = raw as Partial<Input>;
    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<T>()` 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](/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<string>();
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:<random>`, 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](/docs/integrations/overview) and the
[Phaser](/docs/integrations/phaser), [PixiJS](/docs/integrations/pixi),
[Three.js](/docs/integrations/three), [Babylon.js](/docs/integrations/babylon)
and [React](/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](/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](/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](/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](/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](/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](/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](/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](/docs/hibernation#timers-across-hibernation).

See [Testing rooms](/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](/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 <id>`
  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](/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](/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](/docs/no-backend). Don't write the room screens
yourself: the [UI kit](/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`). `<irt-room-panel router project>` 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 `<irt-invite>`
(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`.
`<irt-room-browser router project type>` lists public rooms with
players out of `maxPlayers`; `<irt-lobby>` is a lobby with an invite;
`<irt-voice-settings>` and `<irt-voice-chat>` take a `voice`;
`<irt-network>` and `<irt-join-loader>` 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](/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](/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](/docs/migrate/colyseus),
[Playroom Kit](/docs/migrate/playroom) and [Socket.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](/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](/docs/limits).
