Start here

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. 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 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.

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.

// 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

// 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.

The client

// 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 and the Phaser, PixiJS, Three.js, Babylon.js and 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.

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.

// 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.

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.

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.

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.

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.

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:

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.

Testing

@irt/testing runs the room in process with real clients and a clock the test controls:

// 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.

See Testing rooms.

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:

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.

Deploying

// 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.
  • 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.

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. Don’t write the room screens yourself: the 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 (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:

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.

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, Playroom Kit and 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.

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.

View this page as Markdown