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 startsnull. An entity must fit in 61,376 bytes; split big data, such as worlds, into chunk entities withbytesandentity.patch(field, offset, bytes). stateis the shared state every connection sees (room.state).privateis per player, seen only by that player (room.private(conn)), kept by player ID for 24 hours after they leave by default (privateRetentionindefineRoom, milliseconds or"forever").entitieslists the entity kinds; their order is part of the hash.- Field names
id,kindandpatch, and kind names starting withirt_, are reserved. Changing any field, type, writable list or the order ofentitieschanges 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’ssub, orguest:...),meta(the token’smetaclaim, or null),data(yours, not saved),viewTick(the tick the player saw when sending this tick’s input),input(that input, inonTick).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.getis a map lookup;allvisits every entity of the kind.- Messages are JSON, or binary for a
Uint8Array, at most 64 KB. Clients may send 120 a second.unreliableuses datagrams where available. - Writes: setting a field records it and the delta goes out at the end of
the tick. Setting a
jsonfield to a deep-equal value records nothing, and mutating one in place records nothing: assign a new value. room.data(fromdata: () => ({...})) is per-room derived data, not saved; rebuild it inonCreateandonWake. 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.stateandroom.privateare set from then. It rejects withIrtError. 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 acrossrestored.router:location.originunderirt dev, the region’s router, such aswss://eu.irt.io, when deployed;irt deployprints it.- Without
tokenthe client is a guest:guest:<random>, kept inlocalStoragefor 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.sendandcallthrow beforeconnectresolves.- 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,inputsanddt: noMath.random,Date.nowor module state. parseInputvalidates 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 realRoomClient),view(kind),private,mine(kind),messages,events,closedWith,send,predict,drop(),reconnected(),leave(),expectSees,expectNotSees,expectMessage,expectCorrected,lastUpdate. tickandadvancethrowRoomStoppedErrorif a room’s code threw where that stops it.t.connectrejects withIrtErrorand 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 andDate.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 deploybundles the room, runssite.build, uploads the build and makes it live: the site and room code go live together.limitsandwebhookUrlcan’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.createRoomson, deployed and underirt devalike. With it off, a connection to a room that doesn’t exist closes with 4002; useirt rooms create <id>orPOST /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 + 1andmigrations: { [n + 1]: (data) => data' }on plainRoomData. Adding fields or kinds doesn’t. Rolling back past a version bump stops migrated rooms. See Builds and deploys. - CI:
npx irt test, thennpx irt deploywithIRT_TOKENset 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 devruns,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 fortick,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/devis adialforconnectthat 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: 0means must not exist; a mismatch isE_VERSION. Calls reject withDataError(codeE_INVALID,E_FORBIDDEN,E_UNKNOWN,E_VERSION,E_TYPE,E_QUOTA,E_LIMIT,E_SPENDING,E_UNAVAILABLE).- The room caches: a
getwithin 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,aroundare calls every time. Load a player’s data inonJoin, keep it inconn.dataor private state and write it back on change; neverlistortopper tick. - After
E_UNAVAILABLEa write may or may not have happened (5 s deadline, nothing queued): retry it withversion.incrementcan’t be retried safely. - Clients:
dataClient({ router, project, token? }), orroom.playerDataon aRoomClient, 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 writeclientWritekeys;clientReadlets them read a collection;clientWrite: "own"lets them write their own entries, withownerforced to them. - Authorship is
owner, nevermeta. 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; passsettings: { data }tocreateTestHost.
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
- Creating a second entity for a player in
onJoin; after a crash the old one exists. Find it first, byownerorownedBy. - Keeping a score or inventory on an entity
onLeavedeletes;onLeaveruns before every hibernation too. Use private state orroom.state. - Keeping game state in module variables,
room.dataorconn.data. None is saved, and module variables are shared by every room in the room host. - Mutating a
jsonfield in place. Assign a new value. - Trusting client input. Validate every message, call argument, input
(
parseInput) and owner write (onWrite). - A non-deterministic stepper, or moving the predicted entity outside it: constant mispredictions.
- Making stepper-driven fields writable. The room overwrites them every tick.
- Two copies of the schema. Import one module in room and client, or get 4004.
- Connecting to a fixed room ID that was never created with
players.createRoomsoff: 4002. - Reconnecting by hand on
closed, or reloading on anything but 4004. - Testing two players in two tabs of one browser: one guest, and 4012.
- Asserting in a test before
await t.host.tick(). - Slow work in
onTick: rooms in a room host share one event loop, and a 5-second block kills the room host. - Renaming or removing a field without a migration: rooms stop on wake.
- Retrying a failed
callthat isn’t safe to repeat. - Hard-coding
http://127.0.0.1:7350as the router in the site. - Building a player list from entities and
onJoin/onLeave; useroom.presence. - Keeping progress that should outlive the room in private state; use
room.playerData. Callinggetortopevery tick instead of keeping the data in memory. - Trusting a leaderboard clients submit to, or
metaas the author of an entry; use room-code submits andowner.
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.