# Overview > What IRT is, what it runs for your project, and how the parts fit together. IRT hosts the whole multiplayer stack of a web game. You write the room code and the client; IRT runs the rooms, serves the site, carries voice between players and gives your backend, if you have one, an API and webhooks. IRT is in development and invite-only. These docs describe the first release. Where something isn't settled yet, the page says so. ## What IRT runs for a project **Rooms.** A room is one running game instance, addressed by a room ID. Your room code runs in a room host, a Node process inside a Firecracker VM that belongs to your tenant alone. A room host runs many rooms of the same build, and each room starts, hibernates and stops on its own. **State sync.** Rooms declare typed entities. Changes go to clients as binary deltas, encoded once per tick, and interest keys decide which entities each connection sees, so a voxel world or a map with many NPCs sends each player only what is near them. **Sites.** A build can include your web client. It's served from `.irt-serve.com` and goes live together with the room code it was built against. **Voice.** Rooms can get a voice server. Who hears whom comes from the room: by default each listener hears the closest speakers it can see. **Prediction.** The client SDK includes a prediction library built around a step function shared with the room, for games that need responsive movement, such as shooters. ## How the parts connect ``` browser ── WebTransport or WebSocket ──> router (eu.irt.io) ──> room host in a VM │ │ └── site from .irt-serve.com room state in object storage ``` - Clients connect to the router of a region. The first region is `eu`, at `eu.irt.io`. WebTransport is tried first, with WebSocket as the fallback. - The router authenticates the connection with a player token, or as a guest when the project allows guests, and forwards it to the room host that runs the room. - When the last connection leaves, the room hibernates: its room state goes to object storage and it costs no room-hours. The next connection wakes it. See [How rooms hibernate and wake](/docs/hibernation). - Your backend, if you have one, creates and deletes rooms, sends messages into them and reads their state through the [HTTP API](/docs/http-api), and hears about them through [webhooks](/docs/webhooks). Games with no backend are a first-class case: project settings let clients join as guests, create rooms, join by code and list public rooms. ## What you use | Tool | For | |---|---| | The `irt` CLI | Logging in, running rooms and the site locally, testing, deploying, logs | | The dashboard at `app.irt.io` | Projects, settings, keys, secrets, builds, rooms, logs, usage | | The HTTP API at `api.irt.io/v1` | Everything the dashboard does, for backends and CI | | `@irt/server` | Signing player tokens in your backend | | `@irt/testing` | Running rooms in process in tests, with fake clients and a controlled clock | ## Next - [Concepts](/docs/concepts) defines the words used everywhere in these docs. - [Deploying with the CLI](/docs/cli) walks through a project from login to live. - [Limits](/docs/limits) lists the numbers rooms work within. - [Self-hosting](/docs/self-hosting) runs a build on your own machine with the open-source room host. - [Building with a coding agent](/docs/guides/agents) is the whole integration on one page, for agents and people in a hurry. - [Engines and frameworks](/docs/integrations/overview) shows the client in [Phaser](/docs/integrations/phaser), [PixiJS](/docs/integrations/pixi), [Three.js](/docs/integrations/three), [Babylon.js](/docs/integrations/babylon) and [React](/docs/integrations/react). - Coming from another framework? There are migration guides from [Colyseus](/docs/migrate/colyseus), [Playroom Kit](/docs/migrate/playroom) and [Socket.IO](/docs/migrate/socket-io). --- # Concepts > The terms IRT uses for tenants, projects, rooms and the infrastructure that runs them. IRT uses these words consistently in the docs, the dashboard, the API and logs. Two rules matter most: **hibernate** and **wake** are things rooms do, and a room's saved data is its **room state**. ## Your account **Tenant.** Your account. Your rooms run in VMs that are never shared with another tenant. **Project.** One game or app, with its own settings, secrets, keys and builds. A project has a slug, unique across IRT, used in URLs and in its site's address: `my-game` is served at `my-game.irt-serve.com`. There are no environments inside a project; use a separate project for staging. **Build.** A deployable version of a project: its bundled room code and, optionally, its site. One build is live at a time. Making a build live switches the site at once and moves rooms to it according to the project's `rooms.buildUpdate` setting. **Site.** A project's static files, such as its web client, served from `.irt-serve.com`. Your content is never served from `irt.io`. **Backend.** Your own server-side code outside rooms, if you have one. It talks to IRT through the [HTTP API](/docs/http-api) and [webhooks](/docs/webhooks), and signs [player tokens](/docs/player-tokens). ## Rooms **Room.** One running game instance, addressed by a room ID that is 1 to 64 characters of `A-Z`, `a-z`, `0-9`, `_` and `-`, unique within the project. Rooms are the unit of pricing. **Room type.** One of the named room definitions a build declares in `irt.config.ts`'s `rooms`, such as a `lobby` and a `match`, each with its own room module, schema, room state version and migrations, so the rooms of one project share its players and player data. A room's type is set when it's created and never changes. A create, quick join or connection that names no type gets the type named `default`, which a project with `room` instead of `rooms` has as its only type; a build without one needs a type named every time. A connection for another type than its room's closes with 4015. A type can also be native, with no room code of yours: a **native room** runs only IRT's compiled code, natively on the host rather than in a VM. It sends each message on to the other players and keeps presence and a leader, and with a schema whose entity kinds declare rules, players spawn, own and write its entities. (Native here means native to IRT, not a native, non-web client.) A type may set its own room size, `maxPlayers` and retention. **Room host.** A Node process inside a VM that runs one or more rooms of the same build, of any of its standard room types. Rooms in a room host start, hibernate and stop independently. An exception in one room stops only that room. **Room size.** A room's class: standard, large or extra large. It sets the room's connection limit, memory, CPU and price. Sizes are provisional until launch. **Room-hour.** The unit rooms are billed in: an hour of a room from the start of a wake until it has hibernated or stopped, priced by room size. It's counted outside the VM, by IRT's host daemon. **Room state.** The serialized state of a room, kept in object storage while it hibernates. Entities, `room.state` and private state are room state, saved and restored without any code of yours. **Managed store.** How the IRT SDK saves room state: each tick's changes are streamed out of the VM as they happen, so a crashed room host restarts its rooms with nothing lost. **Checkpoint.** Saving room state while the room keeps running, for crash safety. **Hibernate.** A room saves its room state and is removed from its room host. It happens 30 seconds after the last connection leaves, or when the room asks. The room host keeps running its other rooms. **Wake.** Starting a hibernated room again, in any room host of the same build, and restoring its room state. A connection or an API call to a hibernated room wakes it. ### Lifecycle ``` starting -> active -> hibernating -> hibernated -> waking -> active \ | +------------------> stopped <----------------+ ``` `stopped` means the room was deleted and its room state may be removed. The API reports a room's `status` as `active`, `waking` or `hibernated`. A room created through the API is `hibernated` until its first connection, and that first start shows as `waking` to the client, though it runs `onCreate`. ## Connections and sync **Connection.** One client's WebTransport or WebSocket session with a room. A player has one connection per room: connecting again closes the older connection. **Player token.** A short-lived JWT your backend signs to say who a player is. Projects that allow guests can skip it. See [Player tokens](/docs/player-tokens). **Unreliable message.** A message that may be dropped or arrive out of order, for data where only the newest value matters. Sent as a WebTransport datagram, or over WebSocket in order with stale messages dropped when the socket backs up. **Tick.** One fixed-timestep step of a room's simulation, run by the IRT SDK's tick scheduler. **Entity.** A synced object of a declared kind in a room, with typed fields such as `f32`, `u8`, `string` and `bytes`. **State update.** A message from a room telling a client the simulation state at a tick: which entities entered its view, which changed, which left. A client applies each update atomically. **Interest key.** A value, such as a grid cell, a team or a chunk, that entities have and connections subscribe to. A connection sees the entities that share a key with it. **Stepper.** A simulation step shared by a room and its clients. The room runs it every tick; the prediction library runs the same step on the client to predict the local player. ## Infrastructure **Region.** A geographic location with its own hosts and router. A room runs in one region. The first region is `eu`. **Router.** The edge clients connect to, at `eu.irt.io` for `eu`. It authenticates connections, enforces limits, holds connections while a room wakes and forwards them to the room host running the room. **VM.** A Firecracker microVM. Each belongs to one tenant and runs many rooms. VMs start from a base snapshot, and stop 5 minutes after their last room hibernates. **Voice server.** The part of a region that forwards audio between a room's connections. It runs outside VMs and runs no tenant code. --- # Quickstart > Build, test and deploy a small multiplayer room and its web client from scratch. This guide builds "dots": every player is a dot on a shared board, a click moves your dot, the room counts everyone's moves, and each player sees a count of their own. It uses the four packages a game needs: | Package | Used in | What it is | |---|---|---| | `@irt/room` | room code | `defineRoom`, entities, interest, steppers | | `@irt/client` | the web client | `connect`, the entity store, prediction | | `@irt/testing` | tests | rooms in process with test clients and a test clock | | `@irt/cli` | your shell | the `irt` command: `init`, `dev`, `test`, `deploy` | You need Node 24 or later. The CLI is `@irt/cli` on npm; the name `irt` there belongs to someone else, so run it with `npx @irt/cli`. ## 1. Create the project ```sh npx @irt/cli login npx @irt/cli init dots --project dots cd dots npm install npx @irt/cli projects create dots ``` `irt login` opens `https://app.irt.io/device` and stores a token in `~/.config/irt/credentials.json`. `irt init` writes the files below for the project slug `dots`, and `irt projects create dots` creates the project in your account. A slug is 3 to 40 lowercase letters, digits and single hyphens, starting with a letter, and is never reused, so if `dots` is taken, pick another and use it in both commands. | File | Contents | |---|---| | `irt.config.ts` | The project's slug, room module, site and settings | | `package.json` | The four packages, esbuild, and scripts for `irt dev`, `irt test`, `tsc --noEmit` (`typecheck`), `irt deploy` and `build:web` | | `build-web.mjs` | Bundles and minifies `web/main.ts` into `dist/` with esbuild, splitting each dynamic `import()` into a chunk, with hashed file names that it writes into `dist/index.html`; `site.build` runs it as `npm run build:web` | | `src/schema.ts` | The schema the room and the client share | | `src/room.ts` | The room code | | `src/room.test.ts` | A test with `@irt/testing` | | `web/index.html`, `web/main.ts` | A web client with no framework | ```ts // irt.config.ts import { defineConfig } from "@irt/cli/config"; export default defineConfig({ project: "dots", room: "src/room.ts", site: { dir: "dist", build: "npm run build:web", crossOriginIsolated: false }, }); ``` The slug lives only here: the web client imports it from this file. The rest of this guide replaces the files in `src/` and `web/main.ts` with the dots game. ## 2. Declare the schema The schema lists the room's entity kinds and its shared and private state. The room and every client import the same module, so their schema hashes match; a client built against a different schema is refused with close code 4004. ```ts // src/schema.ts import { defineSchema, entity } from "@irt/room"; export const Player = entity("player", { owner: "string", x: "f32", y: "f32", }); /** Shared state: every connection sees it. */ export const Board = entity("board", { moves: "u32" }); /** Private state: only its own player sees it, and it outlasts a visit. */ export const Mine = entity("mine", { moves: "u32" }); export const schema = defineSchema({ state: Board, private: Mine, entities: [Player] }); ``` `@irt/client` exports the same `defineSchema` and `entity`, so the module can import from either package. ## 3. Write the room ```ts // src/room.ts import { defineRoom, type RoomEntity } from "@irt/room"; import { Player, schema } from "./schema.js"; const SIZE = 100; const clamp = (v: number) => Math.min(SIZE, Math.max(0, v)); export default defineRoom({ schema, tickRate: 20, onJoin(room, conn) { // After a room host restart, the player's dot from before is still here. const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player); conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player, x: Math.random() * SIZE, y: Math.random() * SIZE, }); }, onMessage(room, conn, message) { const m = message as { type?: unknown; x?: unknown; y?: unknown }; if (m.type !== "move" || typeof m.x !== "number" || typeof m.y !== "number") return; const player = conn.data.player as RoomEntity; player.x = clamp(m.x); player.y = clamp(m.y); room.state.moves += 1; room.private(conn).moves += 1; }, onPlayerLeave(room, player) { for (const e of room.entities.all(Player)) if (e.owner === player) room.entities.delete(e); }, }); ``` The room module's default export is the result of `defineRoom`. Setting an entity's field records the change; at the end of each tick the room sends every client a state update with what changed. Each piece of data here lasts as long as it should: - A player's dot is an entity created in `onJoin` and deleted in `onLeave`, so it's on the board only while they're connected. `onLeave` runs whenever a connection closes, and for every open connection before the room hibernates. - The board's move count is `room.state`, which every player sees. - Each player's own count is private state, kept by player ID: it comes back when they rejoin, for up to 24 hours after they left by default. Entities, `room.state` and private state are room state: they're saved when the room hibernates and restored when it wakes, with no code of yours. If the room host crashes, the room restarts from its room state without running `onLeave`, and `onJoin` runs again as players reconnect, which is why it looks for an existing dot. See [Where player data lives](/docs/guides/state-sync#where-player-data-lives). ## 4. Test it ```ts // src/room.test.ts import { testRoom } from "@irt/testing"; import { expect, test } from "vitest"; import room from "./room.js"; import { Player } from "./schema.js"; test("a move reaches every player and is counted", async () => { const t = testRoom(room); const alice = await t.connect({ player: "alice" }); const bob = await t.connect({ player: "bob" }); alice.send({ type: "move", x: 10, y: 20 }); await t.host.tick(); expect(bob.view(Player).find((p) => p.owner === "alice")).toMatchObject({ x: 10, y: 20 }); expect(t.live.state.moves).toBe(1); expect(alice.private.moves).toBe(1); expect(bob.private.moves).toBe(0); }); test("the counts survive hibernation, and the dot doesn't", async () => { const t = testRoom(room); const alice = await t.connect({ player: "alice" }); alice.send({ type: "move", x: 1, y: 1 }); await t.host.tick(); await alice.leave(); await t.hibernate(); const back = await t.connect({ player: "alice" }); expect(back.client.state.moves).toBe(1); expect(back.private.moves).toBe(1); expect(back.view(Player)).toHaveLength(1); }); test("a crash keeps the player's dot", async () => { const t = testRoom(room); const alice = await t.connect({ player: "alice" }); alice.send({ type: "move", x: 5, y: 5 }); await t.host.tick(); await t.host.crash(); await alice.reconnected(); expect(alice.view(Player)).toMatchObject([{ owner: "alice", x: 5, y: 5 }]); }); ``` ```sh npx @irt/cli test ``` Time in a test room moves only when the test moves it: `t.host.tick()` runs one tick. See [@irt/testing](/docs/reference/testing). ## 5. Write the client ```ts // web/main.ts import { connect } from "@irt/client"; import config from "../irt.config.js"; import { Player, schema } from "../src/schema.js"; // irt dev serves the site and the room host on one origin; deployed sites use the region's router. const router = location.hostname.endsWith(".irt-serve.com") ? "wss://eu.irt.io" : location.origin; const canvas = document.querySelector("canvas")!; const ctx = canvas.getContext("2d")!; const status = document.querySelector("#status")!; const room = await connect({ router, project: config.project, room: "main", schema, onWaking: () => (status.textContent = "Starting the room"), }); status.textContent = `Connected as ${room.player}`; room.on("disconnected", () => (status.textContent = "Reconnecting")); room.on("restored", () => (status.textContent = `Connected as ${room.player}`)); room.on("closed", (error) => (status.textContent = error.message)); const positions = room.interpolate(Player, ["x", "y"]); canvas.addEventListener("click", (ev) => { const scale = 100 / canvas.width; room.send({ type: "move", x: ev.offsetX * scale, y: ev.offsetY * scale }); }); function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); const scale = canvas.width / 100; for (const p of room.entities.all(Player)) { const { x, y } = positions.get(p); ctx.fillStyle = p.owner === room.player ? "crimson" : "steelblue"; ctx.beginPath(); ctx.arc(x * scale, y * scale, 6, 0, Math.PI * 2); ctx.fill(); } ctx.fillStyle = "black"; ctx.fillText(`moves: ${room.state.moves}, yours: ${room.private.moves}`, 8, 16); requestAnimationFrame(draw); } draw(); ``` The router address is chosen the way the scaffolded client chooses it: `irt dev` serves the site and the room host on one origin, so locally the client connects to `location.origin`; on `.irt-serve.com` it connects to the region's router, `wss://eu.irt.io`, which `irt deploy` prints. `await connect(...)` resolves once the client holds the room's full state, so `room.state` and `room.private` are set from then on. Without a `token`, the client joins as a guest; the guest token is kept in `localStorage`, so a reload, another tab or a browser restart keeps the same player ID and the same private state. ## 6. Run it locally ```sh npx @irt/cli dev ``` `irt dev` builds the site with `npm run build:web`, runs a local room host, and serves both on `http://127.0.0.1:7350` with the same headers the hosted site gets. Open it, and open it again in a private window or another browser, and click. Two tabs of one browser share a guest, so they'd be the same player, and the newer tab would close the older one with 4012. It rebuilds and restarts the room host when a file changes. ## 7. Deploy ```sh npx @irt/cli deploy ``` This bundles the room and the site into a build, uploads it and makes it live, then prints the site's address, `https://dots.irt-serve.com`, and the router its client connects to, `wss://eu.irt.io`. The first client to connect creates the room, as under `irt dev`, because `players.createRooms` is on in `irt.config.ts`. With it off, connecting to a room that was never created closes with 4002, and rooms are created with `irt rooms create ` or by your backend; see [Games without a backend](/docs/no-backend). See [Builds and deploys](/docs/builds) for what a deploy does to clients and rooms. ## Common mistakes - **Importing the schema twice under different declarations.** The room and client must build the schema from the same module. Two copies that differ in a field name, type or order give different hashes, and the client fails with 4004. - **Trusting message contents.** `onMessage` receives whatever the client sent. Check types and ranges as the room above does before writing them to entities. - **Keeping a player's score on their entity.** The entity is deleted in `onLeave`, which also runs before the room hibernates. Keep it in private state or `room.state`. - **Creating a second entity for a player in `onJoin`.** After a room host crash the old one is still there; find it first. - **Writing a client entity.** Entities on the client are read-only; a write throws. Send a message, or use [prediction](/docs/guides/prediction) for the local player. - **Calling `send` before `connect` resolves.** It throws. Await `connect` first. - **Advancing nothing in a test.** State updates go out at the end of a tick, so assert after `await t.host.tick()`. --- # 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://.irt-serve.com`. | Package | Where | What | |---|---|---| | `@irt/room` | room code, shared modules | `defineRoom`, `defineSchema`, `entity`, `json`, `defineStepper`, `grid`, `combine`, `CloseCode`, `DataError` | | `@irt/client` | browser and Node clients | `connect`, `RoomClient`, `IrtError`, `createRoom`, `joinByCode`, `listPublic`, `dataClient`, `DataError`, and the same `defineSchema`, `entity`, `defineStepper` | | `@irt/testing` | tests | `testRoom`, `createTestHost`, `RoomStoppedError` | | `@irt/cli` | shell, `npx irt` | `init`, `dev`, `test`, `build`, `deploy`, `logs`, `rooms`, `secrets`, `projects`, `skill`, `mcp` | Node 24 or later. The npm package is `@irt/cli`; inside a project that has it as a devDependency, `npx irt ` runs it, and `npx @irt/cli` works anywhere. ```sh npx @irt/cli init my-game --project my-game # scaffold cd my-game && npm install npx irt projects create my-game # once, after npx irt login npx irt dev # room host and site on http://127.0.0.1:7350 npx irt test # vitest npx irt deploy # build, upload, make live ``` A project: `irt.config.ts` (slug, room module, site, settings), `src/schema.ts` (shared by room and client), `src/room.ts` (default export `defineRoom(...)`), `src/room.test.ts`, and `web/` for the client. ## Schema All synced state is **entities** of declared kinds. The room and its clients import one schema module; its hash must match exactly or the client is closed with 4004. ```ts // shared.ts import { defineCalls, defineSchema, defineStepper, entity, json } from "@irt/room"; export const Runner = entity("runner", { owner: "string", x: "f32", y: "f32", score: "u16" }); export const Flag = entity("flag", { x: "f32", y: "f32" }); export const Aim = entity("aim", { angle: "f32" }, { writable: ["angle"] }); export const Match = entity("match", { round: "u16", endsAt: "u32", scores: json>() }); export const Mine = entity("mine", { coins: "u32", volume: "u8" }, { writable: ["volume"] }); export const schema = defineSchema({ state: Match, private: Mine, entities: [Runner, Flag, Aim], // Typed calls: the room's handlers and the client's room.call() are checked against these. calls: defineCalls<{ buy(args: { price: number }): { coins: number } }>({ buy(raw) { const price = (raw as { price?: unknown } | null)?.price; if (typeof price !== "number" || !Number.isInteger(price) || price < 0) throw new Error("price must be a whole number"); return { price }; }, }), }); export interface Input { dx: number; dy: number; } export const stepper = defineStepper({ parseInput(raw) { const i = raw as Partial; if (typeof i.dx !== "number" || typeof i.dy !== "number" || Math.abs(i.dx) > 1 || Math.abs(i.dy) > 1) { throw new TypeError("input is { dx, dy } with each from -1 to 1"); } return { dx: i.dx, dy: i.dy }; }, step(world, inputs, dt) { for (const r of world.all(Runner)) { const input = inputs.get(r.owner); if (!input) continue; r.x = Math.min(100, Math.max(0, r.x + input.dx * 20 * dt)); r.y = Math.min(100, Math.max(0, r.y + input.dy * 20 * dt)); } }, }); ``` - Field types: `bool`, `u8`/`u16`/`u32`, `i8`/`i16`/`i32`, `f32`, `f64`, `string` (16 KB), `bytes` (`Uint8Array`, 48 KB), `json` (16 KB serialized). `json()` types a json field; it starts `null`. An entity must fit in 61,376 bytes; split big data, such as worlds, into chunk entities with `bytes` and `entity.patch(field, offset, bytes)`. - `state` is the shared state every connection sees (`room.state`). `private` is per player, seen only by that player (`room.private(conn)`), kept by player ID for 24 hours after they leave by default (`privateRetention` in `defineRoom`, milliseconds or `"forever"`). `entities` lists the entity kinds; their order is part of the hash. - Field names `id`, `kind` and `patch`, and kind names starting with `irt_`, are reserved. Changing any field, type, writable list or the order of `entities` changes the hash. - `{ writable: [...] }` lists fields the entity's **owner** may write from the client. The shared state can't have writable fields. ## The room ```ts // room.ts import { CallRejected, defineRoom, type RoomEntity } from "@irt/room"; import { Aim, Flag, Runner, schema, stepper } from "./shared.js"; const TICK_RATE = 20; export default defineRoom({ schema, tickRate: TICK_RATE, stepper, onCreate(room) { room.entities.create(Flag, { x: 50, y: 50 }); room.state.scores = {}; room.state.endsAt = 60 * TICK_RATE; }, onJoin(room, conn) { // After a crash the player's entities still exist and onJoin runs again: reuse them. const existing = [...room.entities.all(Runner)].find((r) => r.owner === conn.player); conn.data.runner = existing ?? room.entities.create(Runner, { owner: conn.player, x: 10, y: 10 }); if ([...room.entities.ownedBy(conn.player, Aim)].length === 0) room.entities.create(Aim, {}, { owner: conn.player }); room.send(conn, { type: "hello", round: room.state.round }); }, onMessage(room, conn, message) { const m = message as { type?: unknown }; if (m.type === "taunt") room.broadcast({ type: "taunt", from: conn.player }, { except: conn }); }, onWrite(room, conn, entity, next) { if (entity.kind === Aim) return { angle: ((next.angle as number) % 360 + 360) % 360 }; return next; }, onTick(room) { const flag = [...room.entities.all(Flag)][0]!; for (const r of room.entities.all(Runner)) { if (Math.hypot(r.x - flag.x, r.y - flag.y) > 3) continue; r.score += 1; room.state.scores = { ...room.state.scores, [r.owner]: (room.state.scores[r.owner] ?? 0) + 1 }; flag.x = (flag.x + 37) % 100; } if (room.tick >= room.state.endsAt) { room.state.round += 1; room.state.endsAt = room.tick + 60 * TICK_RATE; } }, onPlayerLeave(room, player) { for (const e of room.entities.all(Runner)) if (e.owner === player) room.entities.delete(e); }, calls: { buy(room, conn, { price }) { const mine = room.private(conn); if (mine.coins < price) throw new CallRejected("not enough coins"); mine.coins -= price; return { coins: mine.coins }; }, }, }); ``` | Hook | Runs | If it throws | |---|---|---| | `onCreate(room)` | once, for a new room | room stops (4007) | | `onWake(room, { elapsed })` | when the room starts from room state: after hibernating, or after a crash | room stops | | `onJoin(room, conn)` | each connection, each reconnect included; again after a crash, with no `onLeave` before | that connection closes with 4006 | | `onMessage(room, conn, message, { unreliable })` | each message, between ticks | logged, message dropped | | `onWrite(room, conn, entity, next)` | each owner write, at the start of a tick; synchronous; return the fields to apply or `undefined` to reject | write rejected | | `onTick(room, dt)` | each tick, after the stepper | room stops | | `onLeave(room, conn)` | when a connection closes, a dropped network included, and for every connection before any hibernation; never after a crash | logged | | `onHibernate(room)` | before the room saves and leaves its room host | room stops | | `onRequest(room, message)` | a server API message; the return or resolved value is the reply | API gets a 422 error; logged as an error unless it's a `CallRejected` | | `calls[name](room, conn, args)` | a client's `call(name, args)`; the return or resolved value is the reply | the client's call rejects with the message; logged as an error unless it's a `CallRejected` | Tick order: `room.tick` increments, owner writes apply, the stepper runs with this tick's inputs, `onTick`, then the state update goes out, then input acks. Writes made between ticks go out with the next tick, or sooner when a connection joins: a join's full state goes out as soon as `onJoin` has run (or its promise settled), not at the next tick, and the same update sends every other connection the changes since the last tick. `onCreate`, `onWake` and `onHibernate` may be async and are awaited; other hooks aren't. - `room`: `id`, `project`, `joinCode` (or null), `tick`, `tickRate`, `entities`, `state`, `private(conn)`, `private(player)` (undefined if none; `{ create: true }` creates it), `privatePlayers()`, `connections`, `data`, `send(conn, msg, { unreliable })`, `broadcast(msg, { unreliable, except })`, `kick(conn)` (4006), `requestHibernate()` (clients close with 4011), `rewind(history, tick)`. - `conn`: `id`, `player` (the token's `sub`, or `guest:...`), `meta` (the token's `meta` claim, or null), `data` (yours, not saved), `viewTick` (the tick the player saw when sending this tick's input), `input` (that input, in `onTick`). - `room.entities`: `create(kind, values?, { id?, owner? })`, `delete(e)`, `get(id)`, `all(kind)`, `count`, `setOwner(e, player | undefined)`, `ownerOf(e)`, `ownedBy(player, kind)`. IDs start at 2; `{ id }` sets one you compute, such as from chunk coordinates. `get` is a map lookup; `all` visits every entity of the kind. - Messages are JSON, or binary for a `Uint8Array`, at most 64 KB. Clients may send 120 a second. `unreliable` uses datagrams where available. - Writes: setting a field records it and the delta goes out at the end of the tick. Setting a `json` field to a deep-equal value records nothing, and mutating one in place records nothing: assign a new value. - `room.data` (from `data: () => ({...})`) is per-room derived data, not saved; rebuild it in `onCreate` and `onWake`. Module-level variables are shared by every room in the room host: keep only constants there. ### Where data lives | Data in | Leave and rejoin | Network drop | Crash | Hibernate and wake | |---|---|---|---|---| | An entity `onLeave` deletes | lost | lost | kept, `onJoin` runs again | lost | | An entity the room keeps | kept | kept | kept | kept | | `room.state` | kept | kept | kept | kept | | `room.private(conn)` | kept 24 h by default | kept | kept | kept 24 h by default | | `conn.data`, `room.data`, module variables | lost | lost | lost | lost | | `room.playerData(player)`, collections, leaderboards | kept | kept | kept | kept, across every room of the project | Room state (entities with owners, `room.state`, private state, the tick, the stepper's `save`) streams out of the VM after every tick, so a crash loses at most the tick in progress; it's written to object storage every 30 seconds and on hibernate. Limit 32 MB. Rooms hibernate 30 seconds after their last connection closes. See [Room state](/docs/room-state). ## The client ```ts // client.ts import { CloseCode, connect, IrtError, reloadForNewBuild } from "@irt/client"; import { Aim, type Input, Runner, schema, stepper } from "./shared.js"; const router = location.hostname.endsWith(".irt-serve.com") ? "wss://eu.irt.io" : location.origin; const keys = new Set(); addEventListener("keydown", (e) => keys.add(e.key)); addEventListener("keyup", (e) => keys.delete(e.key)); async function start() { const room = await connect({ router, project: "my-game", room: "arena", schema, onWaking: () => console.log("waking") }); room.on("message", (m) => console.log("message", m)); room.on("disconnected", (code) => console.log("reconnecting", code)); room.on("restored", () => console.log("back")); room.on("closed", (e) => { if (e.code === CloseCode.SchemaMismatch) reloadForNewBuild(); else console.log(e.message); }); const me = room.predict(stepper, { find: (r) => [...r.entities.all(Runner)].find((e) => e.owner === r.player), sample: (): Input => ({ dx: (keys.has("ArrowRight") ? 1 : 0) - (keys.has("ArrowLeft") ? 1 : 0), dy: (keys.has("ArrowDown") ? 1 : 0) - (keys.has("ArrowUp") ? 1 : 0), }), }); const others = room.interpolate(Runner, ["x", "y"]); addEventListener("pointermove", (e) => { for (const aim of room.entities.mine(Aim)) aim.angle = (Math.atan2(e.clientY, e.clientX) * 180) / Math.PI; }); room.private.volume = 7; const { coins } = await room.call("buy", { price: 0 }); console.log("coins", coins, "round", room.state.round); function frame() { const local = me.render(["x", "y"]); for (const r of room.entities.all(Runner)) { const at = r.owner === room.player ? local : others.get(r); console.log(r.owner, at.x, at.y); } requestAnimationFrame(frame); } frame(); } start().catch((e) => { if (e instanceof IrtError && e.code === CloseCode.SchemaMismatch) reloadForNewBuild(); else throw e; }); ``` - `connect({ router, project, room, token?, schema, onWaking?, timeout? })` resolves once the client holds the full state; `room.state` and `room.private` are set from then. It rejects with `IrtError`. After that, the client reconnects by itself, and on every reconnect or room restart all entities leave and enter again: don't hold entity objects across `restored`. - `router`: `location.origin` under `irt dev`, the region's router, such as `wss://eu.irt.io`, when deployed; `irt deploy` prints it. - Without `token` the client is a **guest**: `guest:`, kept in `localStorage` for 30 days and renewed on each connect, so every tab of a browser is the same player. - `room.entities`: `get`, `all(kind)`, `mine(kind)`, `ownerOf`, `count`, `onEnter`, `onChange(kind, (e, changed) => ...)`, `onLeave`, `onCorrect`, `onOwner`. Client entities are read-only except writable fields of entities the player owns. - `room`: `send(msg, { unreliable })`, `call(name, args, { timeout })`, `leave()`, `on(event)`, `onTick`, `predict`, `interpolate`, `player`, `connection`, `tick`, `serverTick()`, `rtt`, `status`, `generation`. `send` and `call` throw before `connect` resolves. - Events: `waking`, `disconnected`, `restored`, `closed`, `message`, `tick`, `peers`, `voice`. ### In an engine or framework There are no adapters; the client works with any engine. Construct `new RoomClient(opts)` and add `onEnter`, `onChange` and `onLeave` listeners before `await room.ready`: `connect` resolves after the first state is applied and `onEnter` doesn't replay it. Map entity IDs to engine objects, and rebuild them on `restored`, when every entity leaves and enters again as a new object. In the engine's frame loop, draw remote entities at `interpolator.get(e)` and the local player at `prediction.render(fields)`. On scene or component teardown: remove the listeners, then `prediction.stop()`, `voice.disconnect()` and `room.leave()`. In React, connect in an effect, never in `useMemo` or render, and draw the world from a frame loop, not React state. See [Engines and frameworks](/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 ` 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`). `` creates, joins by code and quick joins, fires `room-join` with `{ room, joinCode }` for the project to connect and set `panel.client`, and shows an `` (join code, share link with `?code=`, joined by itself when opened, a QR code with the `qr` attribute), the status and the players; the name the player types goes in their presence status as `name`. `` lists public rooms with players out of `maxPlayers`; `` is a lobby with an invite; `` and `` take a `voice`; `` and `` take a `client`. Any of them renders as a modal dialog with the `dialog` attribute and `show()`, or `open("irt-room-browser", { router, project })`. Games with accounts sign [player tokens](/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). --- # How rooms hibernate and wake > When a room hibernates, what is saved, what happens when a player connects to a hibernated room, and what it costs. Rooms on IRT don't need to run when nobody is in them. An empty room hibernates: its room state goes to object storage and it leaves its room host. When a player connects again, the room wakes with its state as it was. Your code doesn't save or load anything for this to work. ## When a room hibernates - 30 seconds after its last connection closes, unless its alarm is due within 2 minutes; then it stays active until the alarm has run. - When the room asks to, with `room.requestHibernate()`. Its open connections run `onLeave` and close with 4011, and their clients don't reconnect by themselves; the game can offer to rejoin. - When its room host drains, for example to move rooms to a new build. A room whose definition has `sleep` goes to sleep instead, so its players stay connected. - When it goes to sleep, with its players still connected; see [Sleeping rooms](#sleeping-rooms). Hibernating runs the room's `onHibernate` hook, serializes the room and writes its room state to object storage. A hibernated room costs no room-hours. A room can stay hibernated indefinitely; only deleting it removes its room state. ## What is saved Everything the SDK tracks is room state: - the room's entities, `room.state` and each player's private state, - the room's tick, - its alarm, from `room.setAlarm`, - the state of a stepper that declares `save` and `load`. Writes don't wait for hibernation. As a room runs, its changes stream out of the VM to IRT's host daemon after each tick, and the host daemon writes the whole room state to object storage every 30 seconds when it has changed, and again on hibernate. Because the changes are already outside the VM: - a room host that crashes, or that the watchdog kills for blocking its event loop, restarts its rooms with nothing lost; - a host, or IRT's host daemon on it, that fails loses the changes since the last write to object storage: at most 30 seconds, unless object storage can't be reached. Anything you keep outside the SDK, in plain variables, isn't saved. Rebuild it in `onWake`. [Room state and the managed store](/docs/room-state) has the details: what's saved, the log and base it's written as, and its 32 MB limit. ## How a room wakes A room wakes when a client connects to it, when the API sends it a message or reads its state, or when its alarm is due. A room woken for its alarm runs `onAlarm` straight after `onWake`, and with no one connected hibernates again as soon as it's done. See [Alarms](/docs/reference/room-sdk#alarms). A room's first start goes the same way. A room created through the API, with `irt rooms create` or by connecting to a new ID, is `hibernated` until its first connection or API call, and its client sees `waking` then too. The difference is only that it has no room state, so `onCreate` runs instead of `onWake`. `irt rooms list` shows only active rooms; `irt rooms list --all` shows hibernated ones too. 1. The router sees a connection for a room that isn't active. It holds the connection and tells the client the room is waking. The client SDK emits a `waking` event, so you can show it. 2. The router picks where the room runs: a VM of your tenant that already runs the room's build and has room to spare, or a new VM. 3. The room's state is read from object storage and the room starts in a room host. `onWake` runs. 4. The router sends the client its welcome, and every connection held while the room woke joins. `connect` resolves once the client holds the full state; a client that was reconnecting emits `restored`. However many players connect at once, a room wakes once. ### How fast What a wake costs depends on where it lands: - **In a room host that is already running.** Most wakes. Starting a room inside a running room host takes microseconds; what remains is reading the room state. - **In a VM that has to start.** VMs restore from a base snapshot. In our measurements, a VM restores and its room host answers in about 35 ms. - **In the first room host of a build.** The first room to start or wake on a build, such as the first after a deploy, starts that build's room host, which loads the room runtime and your bundle before the room starts. In our measurements that added about 70 to 200 ms, and more for a large bundle, for code that does work when its module loads, or on a busy host. Later rooms of the build reuse the room host. End-to-end wake times, including reading room state from object storage, haven't been measured yet and will be published when they are. If a room hasn't woken after 10 seconds, the router closes held connections with close code 4010 and the client SDK reconnects after a backoff. The wake isn't cancelled, so a slow wake loses nothing. ## Sleeping rooms A room whose players stay connected but rarely act, such as a board game, a chat or a lobby waiting for players, can sleep: it hibernates while the router holds its players' connections, and the next thing one of them does wakes it. Nobody leaves, and time asleep costs no room-hours. ```ts import { defineCalls, defineRoom, defineSchema, entity } from "@irt/room"; const Board = entity("board", { turn: "u32" }); const schema = defineSchema({ state: Board, calls: defineCalls<{ move(): number }>() }); export default defineRoom({ schema, sleep: { after: 30_000 }, calls: { // A call made while the room sleeps wakes it, and is answered once it has. move(room) { room.state.turn += 1; return room.state.turn; }, }, }); ``` With `sleep.after`, the room sleeps once it has been quiet that long: no message from any client but a ping, nothing sent or stored, no call or server API request in flight. `room.sleep()` sleeps it at once. Presence and each connection's `data` are saved with the room state, so when a message, call or close from a held connection, a new connection, the alarm or an API call wakes it, `onWake` runs with `slept: true` and the connections are back, without `onJoin`. A held connection that closed meanwhile gets `onLeave` then. Clients see `room.asleep` and the `sleep` and `wake` events, and their calls are answered after the wake. Opting in promises that the room keeps every deadline in its [alarm](/docs/reference/room-sdk#alarms) rather than in timers or by counting ticks, since nothing ticks while it sleeps. See [Sleeping rooms](/docs/reference/room-sdk#sleeping-rooms) for the details and limits. A wake is billed at least 10 seconds, so set `sleep.after` long enough that a conversation doesn't wake the room for every message. A room whose definition has `sleep` also sleeps, rather than closing its players' connections, when the server it runs on is taken out of service, and wakes on another one when a player next acts. Players whose connection drops while their room sleeps, such as when IRT restarts the router they're connected to, reconnect to the sleeping room without waking it, as long as their client still holds the room's state from before the sleep. ## Timers across hibernation Store deadlines in ticks, as `endsAt = room.tick + seconds * tickRate` in room state, and compare in `onTick`. Ticks keep running for the 30 seconds between the last connection closing and the room hibernating, then stop until it wakes, and `room.tick` carries on from where it stopped. Left alone, a turn or round timer therefore runs for those 30 seconds with nobody there, then freezes: a round left 20 seconds before its end expires unwatched, and one left 40 seconds before resumes with 10 left. Bots and AI turns driven from `onTick` play on in that window too. Pick one of two behaviours instead. **Freeze while empty.** The room is empty when `onLeave` sees `room.connections.size === 0`, and occupied again when `onJoin` sees `room.connections.size === 1`: the connection map already lacks the leaving connection in `onLeave` and holds the joining one in `onJoin`. Move what's left of the timer into room state there, and back on the first join. `onLeave` runs for every open connection before any hibernation but a sleep, so this covers `requestHibernate` and drains too. Gate bots the same way, or on `room.connections.size > 0` in `onTick`. ```ts // round-timer.ts import { defineRoom, defineSchema, entity } from "@irt/room"; export const Round = entity("round", { endsAt: "u32", pausedLeft: "u32" }); export const schema = defineSchema({ state: Round }); const ROUND_SECONDS = 60; export default defineRoom({ schema, onCreate(room) { room.state.endsAt = room.tick + ROUND_SECONDS * room.tickRate; }, onJoin(room) { if (room.connections.size === 1 && room.state.pausedLeft > 0) { room.state.endsAt = room.tick + room.state.pausedLeft; room.state.pausedLeft = 0; } }, onLeave(room) { if (room.connections.size === 0 && room.state.endsAt > room.tick) { room.state.pausedLeft = room.state.endsAt - room.tick; room.state.endsAt = 0; } }, onTick(room) { if (room.state.endsAt !== 0 && room.tick >= room.state.endsAt) { room.state.endsAt = 0; room.broadcast({ type: "round-over" }); } }, }); ``` ```ts // round-timer.test.ts import { testRoom } from "@irt/testing"; import { expect, it } from "vitest"; import room from "./round-timer.js"; it("freezes the round while the room is empty", async () => { const t = testRoom(room); const alice = await t.connect({ player: "alice" }); await t.host.tick(50 * 20); await alice.leave(); await t.host.advance(31_000); expect(t.status).toBe("hibernated"); const back = await t.connect({ player: "alice" }); await t.host.tick(5 * 20); expect(back.messages).toEqual([]); await t.host.tick(6 * 20); back.expectMessage({ type: "round-over" }); }); ``` **Catch up with wall time.** Let the timer run through the idle window, and on wake move each deadline back by the time the room spent hibernated, which `onWake` gets as `elapsed` milliseconds. A long absence can reset the game instead: ```ts // catch-up.ts import { defineRoom, defineSchema, entity } from "@irt/room"; const Round = entity("round", { endsAt: "u32", lobby: "bool" }); export default defineRoom({ schema: defineSchema({ state: Round }), onWake(room, { elapsed }) { if (elapsed > 30 * 60_000) { room.state.lobby = true; room.state.endsAt = 0; } else if (room.state.endsAt !== 0) { room.state.endsAt = Math.max(room.tick, room.state.endsAt - Math.round((elapsed / 1000) * room.tickRate)); } }, }); ``` Room code's own timers, such as `setTimeout`, are neither: they don't survive hibernation and don't follow the test clock. ## Offline progress An idle or incremental game keeps producing while nobody is there, and the room hibernates then, so nothing ticks. Don't add up production per tick. Store each amount with the wall time it's valid at, and compute it in closed form at any later time: ```ts // colony.ts import { defineRoom, defineSchema, entity } from "@irt/room"; const Colony = entity("colony", { food: "f64", rate: "f64", cap: "f64", at: "f64" }); const foodAt = (c: { food: number; rate: number; cap: number; at: number }, now: number) => Math.min(c.cap, c.food + (c.rate * (now - c.at)) / 1000); export default defineRoom({ tickRate: 1, schema: defineSchema({ state: Colony }), onCreate(room) { Object.assign(room.state, { food: 0, rate: 0.5, cap: 1000, at: room.now() }); }, onMessage(room, _conn, msg) { if ((msg as { type?: string }).type !== "spend") return; // Settle the amount only when it changes, so nothing is written per tick. const now = room.now(); room.state.food = foodAt(room.state, now) - 10; room.state.at = now; }, }); ``` - `room.now()` is wall-clock milliseconds, the same as `Date.now()` once deployed (each VM's clock is set from its host's when it boots), but it also follows `@irt/testing`'s clock, so a test can move it by days. Use it rather than `Date.now()` for anything timed in real time, and rather than a clock built from `room.tick`, which stops while the room is hibernated and changes meaning when a build changes `tickRate`. - Clients compute the same function from `at` to show live amounts, so nothing has to tick for the display either. - Events on a wall-clock schedule, such as something every 4 hours of UTC or a reset at midnight, are counted between the stored time and now: `Math.floor(now / period) - Math.floor(at / period)`. - `onWake` gets `{ elapsed, savedAt, reason }`: `savedAt` is the wall time room state was last written, at the room's last tick with changes or its hibernation, and `elapsed` the milliseconds from then to the wake, never negative. A game that computes from `at` needs neither, but they suit a "while you were away" summary. ## Retention `rooms.retention` in [`irt.config.ts`](/docs/cli#irtconfigts) sets how long a room is kept after it hibernates: | Value | Meaning | |---|---| | `"none"` | The room is deleted, not hibernated, when its idle timeout passes with no connections. Its room state is still saved while it's active, so a crashed host loses nothing. | | `"h"`, `"d"` | The room is deleted after it has been hibernated that long without waking, up to `"365d"`. A wake restarts the clock at its next hibernation. | | `"forever"` | The room is never deleted except through the API. | The default is `"7d"`. A deleted room's ID can be created again; a client connecting to a deleted room gets close code 4002, or creates it again as a new room when `players.createRooms` is on, and webhooks get `room.deleted` with reason `expired`. A room created through the [HTTP API](/docs/http-api#rooms) or `irt rooms create --retention` can have a retention of its own, which wins over the project's; rooms clients create use the project's. ## Generations Each wake gives the room a new generation. A client drops all its entities and prediction state on a change of generation and on any reconnect, then receives a full state update. Your client code sees entities leave and enter again. ## New builds When a build is made live, the project's `rooms.buildUpdate` setting decides what running rooms do: - `hibernate` (the default): rooms stay on their build until they next hibernate, and wake on the new one. - `restart`: rooms hibernate, saving their room state, and wake on the new build straight away. Clients reconnect automatically, with close code 4008. Each host moves 20 rooms a second, so a project with many rooms finishes over a minute or so rather than all at once. A room with no connections wakes on the new build when something next connects. When a build changes the shape of room state, it provides an up migration, which runs when a room wakes on it. The room state from before the migration is kept for 7 days, so a rollback can restore it. A rollback makes an older build live again. A room whose room state is at a newer version than the build's wakes from its room state from before the migration, losing progress since then, and the project gets a `room.restored` webhook. A room with none kept, because it was created on the newer build or its kept state is over 7 days old, fails to wake, with the reason in `irt logs`, until a build at its version is live again. See [`irt rollback`](/docs/cli#irt-rollback-build-id). See also [Builds and deploys](/docs/builds). ## What it costs A room is billed in room-hours by room size, from the start of a wake until hibernation completes. Many rooms share a room host, and many room hosts share a VM, so an idle room doesn't hold a process or a VM of its own. A VM stops 5 minutes after its last room hibernates. This is what makes per-room pricing cheap: you pay for rooms while players are in them, not for servers that wait for players. --- # Room state and the managed store > What IRT saves for a room, when it's written, what a crash or a failed host loses, and the limits on room state. **Room state** is everything IRT saves for a room so it can hibernate, wake in another room host, and survive crashes. The SDK's **managed store** writes it for you: room code never saves or loads anything. This page says what's in it and when it's written; [How rooms hibernate and wake](/docs/hibernation) says when rooms sleep and wake. ## What's saved | Saved | Not saved | |---|---| | Every entity, hidden ones included: its ID, kind, fields and owner | `room.data`, rebuilt by `data()` on each start and wake | | `room.state` | `conn.data` | | Each player's private state, by player ID, and when the player was last connected | Variables at module level, which every room in the room host shares | | `room.tick`, and the next automatic entity ID | Connections; clients reconnect and `onJoin` runs again | | What the stepper's `save` returned, as bytes or JSON | Timers, promises, pending calls and messages in flight | | The room state version and the declarations it was written with | | So game state that must outlast a hibernation, a crash or a deploy lives in entities, `room.state` or private state. State no client may see, such as a deck's order, lives in entities of a [hidden kind](/docs/guides/state-sync#hidden-entities), not in `room.data`. Anything else is rebuilt in `onWake`; see [Where player data lives](/docs/guides/state-sync#where-player-data-lives) for which of the three suits which data. Private state is kept for the room definition's `privateRetention` of wall-clock time after the player was last connected, hibernated time included: 24 hours by default (`PRIVATE_RETENTION` in `@irt/room`). It's checked when the room wakes and every 30 seconds, and a player who comes back within it gets theirs back. Room state as a whole is kept until the room is deleted: after `rooms.retention` passes with the room hibernated, 7 days by default, or through the API. So by default a player's private state can expire long before the room does. A room players come back to over days, such as a saved dungeon run whose inventories are the progress, sets a longer retention, or `"forever"` to keep it as long as the room: ```ts export default defineRoom({ schema, privateRetention: 30 * 24 * 3600 * 1000, // 30 days }); ``` The default keeps a bound for a reason: a room many players pass through, such as a public lobby, would otherwise keep the private state of every player who ever joined, and its room state would only grow. ## When it's written Room state is written as a **log** of changes on top of a **base**, a full copy: 1. After each tick that changed anything, the room host sends that tick's changes, as the same binary deltas clients get, to IRT's host daemon, outside the VM. Changes made between ticks, in `onMessage`, `onJoin` or a call, go out with the next tick's, or with a join's update when a connection joins first. 2. Every 30 seconds, and when the log grows past 4 MB or 4,096 ticks, the room host sends a new base, which replaces the base and log the host daemon holds. The stepper's `save` runs then, and only then. 3. The host daemon writes the room state to object storage every 30 seconds when it has changed, and always when the room hibernates and when its room host drains. The host daemon holds each room's room state in memory as it arrives, so room code that crashes, hangs or runs out of memory can't lose it: | What fails | What the room loses | |---|---| | Room code throws where that stops the room | Nothing before the tick that threw. Clients close with 4007, and the next connection wakes the room from its room state | | The room host crashes, or the watchdog kills it after 5 seconds of blocked event loop | Nothing already sent: at most the tick in progress. Its rooms restart in a new room host and clients reconnect with 4008 | | The VM crashes | The same as a room host crash | | The host machine, or IRT's host daemon on it, fails | Up to the last 30 seconds, since the last write to object storage, and more while object storage can't be reached. Player and project data writes are kept, so see [Writes that may have happened](/docs/guides/player-data#writes-that-may-or-may-not-have-happened) | After any restart, the room starts from room state with `onWake`, not `onCreate`, and `onLeave` doesn't run for the connections that were open. See [Hooks](/docs/reference/room-sdk#hooks). ## Steppers A stepper's `save` and `load` let it keep state of its own, such as a physics engine's world, in room state. [`@irt/physics`](/docs/guides/physics) saves its Rapier world this way when the world has dynamic bodies. ```ts interface Stepper { save?(world: World): Uint8Array | unknown; // bytes, or anything JSON can encode load?(saved: Uint8Array | unknown, world: World): void; release?(world: World): void; } ``` - `save` runs whenever a base is written: every 30 seconds, on hibernate and on drain. Never per tick. - A `Uint8Array` is stored as bytes; anything else with `JSON.stringify`. `load` gets back the same: a `Uint8Array`, or the parsed JSON. - `load` runs on wake, before `onWake`, with `undefined` when nothing was saved. It doesn't run for a new room. - What `save` returns counts toward the room state limit. - `release` runs when the room leaves its room host: after the save on hibernate, and when it stops. It's for caches the garbage collector can't free promptly, such as WebAssembly memory. One stepper object serves every room in a room host, so `save` and `load` get the room's `world`: the same object `step` gets for that room, from its wake until it hibernates. Keep per-room caches in a `WeakMap` keyed by `world`; a cache goes away with its room. `load` runs before the room's first step after a wake, so `step` finds the cache `load` made: ```ts // physics-stepper.ts import { defineSchema, defineStepper, entity, type World } from "@irt/room"; export const Ball = entity("ball", { x: "f32", y: "f32", vx: "f32", vy: "f32" }); export const schema = defineSchema({ entities: [Ball] }); interface Cache { spins: Record; } const caches = new WeakMap(); function cacheOf(world: World): Cache { let cache = caches.get(world); if (!cache) caches.set(world, (cache = { spins: {} })); return cache; } export const stepper = defineStepper<{ kick?: boolean }>({ step(world, _inputs, dt) { const { spins } = cacheOf(world); for (const b of world.all(Ball)) { spins[b.id] = (spins[b.id] ?? 0) + Math.hypot(b.vx, b.vy) * dt; b.x += b.vx * dt; b.y += b.vy * dt; } }, save: (world) => cacheOf(world).spins, load(saved, world) { if (saved) cacheOf(world).spins = saved as Record; }, }); ``` On a client, prediction never calls `save` or `load`. Its world is one object for the life of the prediction, so a cache keyed by it lasts across steps there too; see [The prediction world](/docs/guides/prediction#the-prediction-world). ## Limits Room state is at most 32 MB, the base plus its log. A room that grows past it stops with `room state is 33.0 MB; the limit is 32 MB` and its clients close with 4007. Past 24 MB, the project's logs say so once, as `room : room state is 24.1 MB; the room stops at 32 MB`, and `irt rooms show ` prints a room's size as last saved. In tests, `t.stored()` has the room state itself. Each entity must fit in 61,376 bytes, and fields have their own caps; see [Limits](/docs/limits#state-sync). Big rooms are usually big because of a few kinds of data: - **Worlds.** Split them into chunk entities with `bytes` fields, start empty chunks as empty bytes, and `patch` them, so the log carries only edits. - **Private state of players long gone.** It's dropped after `privateRetention`, 24 hours by default; don't copy it into `room.state`, and keep `"forever"` for rooms with a bounded set of players. - **Histories.** A chat log or move list in a `json` field is rewritten whole on each change and grows forever. Cap it, or keep it in your backend. ## Reading room state - In tests, `t.stored()` returns what a wake would start from, decoded as `RoomData`; see [Testing rooms](/docs/guides/testing#hibernation-and-crashes). - Your backend can read a room's `room.state` with `GET /projects/{p}/rooms/{id}/state`, or ask room code for anything else through `onRequest`; see the [HTTP API](/docs/http-api#rooms). Both wake a hibernated room. - Under `irt dev`, room state is in `.irt/data`, so it survives restarts of `irt dev`. Delete the directory to start every room fresh. ## Changing its shape A build that changes the declarations in a way old room state doesn't fit, such as renaming or removing a field, bumps `version` and adds a migration. See [Room state versions](/docs/builds#room-state-versions). ## Common mistakes - **Saving by hand.** There's nothing to call; every write to an entity, `room.state` or private state is saved. - **Keeping game state in `room.data` or module variables.** It's gone after a hibernate or a crash. Rebuild derived data in `onWake`. - **Expecting `onLeave` before a crash.** It doesn't run; the room wakes with every entity as it was. - **Storing per-room data in a stepper's closure.** Every room in the room host shares it; key it by `world`. - **Growing a `json` field without bound.** It's rewritten whole on every change and counts toward 32 MB. --- # State sync and interest > Declare entities, sync them as binary deltas, look them up by ID, and send each connection only what it should see. All synced state is typed **entities** of kinds the room declares. Each tick, every changed entity is encoded once as a binary delta, and each connection gets the deltas of the entities it sees. With no interest set, every connection sees every entity. ## Declaring kinds ```ts // schema.ts import { defineSchema, entity } from "@irt/room"; export const Player = entity("player", { owner: "string", x: "f32", y: "f32", z: "f32", hp: "u8", }); export const Chunk = entity("chunk", { cx: "i32", cy: "i32", cz: "i32", blocks: "bytes", }); export const Match = entity("match", { round: "u16", phase: "string" }); export const Hand = entity("hand", { cards: "json" }); export const schema = defineSchema({ state: Match, private: Hand, entities: [Player, Chunk], }); ``` `entity(name, fields)` takes a name matching `[A-Za-z_][A-Za-z0-9_]{0,63}` and up to 255 fields. Field names `id`, `kind` and `patch` are reserved, since entity objects have properties of those names; using one is a type error at the `entity()` call, and an error when the module loads. Kind names starting with `irt_` are reserved too: every schema ends with the built-in `irt_presence` kind of [presence](/docs/guides/presence), so the schema hash covers it the same way for every room. | Type | Holds | Size | |---|---|---| | `bool` | `true` or `false` | 1 byte | | `u8`, `u16`, `u32` | unsigned integers | 1, 2, 4 bytes | | `i8`, `i16`, `i32` | signed integers | 1, 2, 4 bytes | | `f32` | numbers, rounded with `Math.fround` | 4 bytes | | `f64` | numbers | 8 bytes | | `string` | UTF-8 text | at most 16 KB | | `bytes` | `Uint8Array` | at most 48 KB | | `json` | anything JSON can encode, replaced whole when set | at most 16 KB serialized | A `json` field declared as `"json"` reads as `unknown`. Declare it with `json()` to give it a type, so room and client code reads it without casts: ```ts // scores.ts import { defineSchema, entity, json } from "@irt/room"; export const Match = entity("match", { round: "u16", scores: json>(), }); export const schema = defineSchema({ state: Match }); export function addPoint(state: { scores: Record }, player: string) { state.scores = { ...state.scores, [player]: (state.scores[player] ?? 0) + 1 }; } ``` `json()` is the string `"json"` at runtime: it changes nothing on the wire, and a kind declared with it has the same schema hash as one declared with `"json"`. The type isn't checked when the field is set, and like any `json` field it holds `null` until it's first set, so set it in `onCreate` or declare `json()`. An entity with every field encoded must fit in 61,376 bytes (60 KB minus 64), so it can enter a view within one state update part. A write that would make it bigger throws a `RangeError` naming the entity and field; split large data across several entities, as chunks do. `defineSchema({ state?, private?, entities? })` returns the room's `Schema`. The order of `entities` is part of the schema hash: reordering them is a schema change. ## Shared, private and entity state | In room code | Who sees it | Kept by | |---|---|---| | `room.state` | every connection | the room | | `room.private(conn)` | that connection's player only | player ID, so it survives reconnects | | `room.entities` | connections whose interest includes it | entity ID | | `room.entities` of a hidden kind | no connection | entity ID | ```ts // match-room.ts import { defineRoom, type RoomEntity } from "@irt/room"; import { Chunk, Player, schema } from "./schema.js"; export default defineRoom({ schema, onCreate(room) { room.state.phase = "lobby"; for (let cx = -2; cx <= 2; cx++) { room.entities.create(Chunk, { cx, cy: 0, cz: 0, blocks: new Uint8Array(32 * 32 * 32) }); } }, onJoin(room, conn) { // After a room host restart the player's entity is still there; see below. const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player); conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player, hp: 100 }); room.private(conn).cards = ["ace", "king"]; }, onMessage(room, conn, message) { const m = message as { dig?: [number, number] }; if (!m.dig) return; const [cx, index] = m.dig; const chunk = [...room.entities.all(Chunk)].find((c) => c.cx === cx); if (!chunk || !Number.isInteger(index) || index < 0 || index >= 32 * 32 * 32) return; // A block edit sends three bytes and the offset, not the chunk. chunk.patch("blocks", index, new Uint8Array([0])); const player = conn.data.player as RoomEntity; player.hp = Math.max(0, player.hp - 1); }, onPlayerLeave(room, player) { for (const e of room.entities.all(Player)) if (e.owner === player) room.entities.delete(e); }, }); ``` How writes are tracked: - Setting a field records it; the delta goes out at the end of the tick. There's no diff of the whole state. - Within a tick the last write to a field wins, and a replace after a patch wins. - Setting a field to its current value records nothing. For `json`, a deep-equal value records nothing; mutating a `json` value in place records nothing until you set the field again. - `entity.patch(field, offset, bytes)` writes into a `bytes` field and sends only the range. It grows the field when it writes past the end, filling the gap with zeros, so empty bytes is a fine starting value for data that's mostly blank, such as an all-air chunk. - Read a `bytes` field from the entity again after a patch. A patch that grows it replaces the `Uint8Array`, so an array you held from before is the old, shorter one; on the client every change is a new array. - Writes to `f32` fields go through `Math.fround` in the room and on the client, so both hold the same number. - Writing a deleted entity throws. ## Where player data lives A player's data can live in three places, and they last differently. What decides it is when `onLeave` and `onPlayerLeave` run. `onLeave` runs when a connection closes, which includes a dropped network before the client reconnects. `onPlayerLeave` runs when the player's presence ends, 10 seconds after its last connection closed with no new one. Both run before the room hibernates, whatever the reason, and neither when a room host crashes. | Data in | Leave and rejoin | Dropped network, reconnect | Room host crash | Hibernate and wake | |---|---|---|---|---| | An entity `onPlayerLeave` deletes | lost | kept, within 10 s | kept, and `onJoin` runs again | lost | | An entity `onLeave` deletes | lost | lost | kept, and `onJoin` runs again | lost | | An entity the room keeps | kept | kept | kept | kept | | A [hidden entity](#hidden-entities) | kept | kept | kept | kept | | `room.state`, such as a `json` map by player ID | kept | kept | kept | kept | | `room.private(conn)` | kept for `privateRetention`, 24 hours by default | kept | kept | kept for `privateRetention` | | `conn.data`, `room.data`, module variables | lost | lost | lost | lost | | A presence status | kept for 10 s | kept for 10 s | lost | lost | - **Entities** are the room's synced objects, seen through interest. A player's avatar is usually an entity created in `onJoin` and deleted in `onPlayerLeave`, so it's on screen while the player is in the room and survives a dropped network. Anything stored on it goes with it: don't keep a score there that should outlive a visit. - **`room.state`** is one entity every connection sees. Keep standings every player sees here, such as `scores: json>()` keyed by player ID. - **Private state** is kept by player ID and only that player sees it. It's created empty at a player's first join, comes back when they rejoin, and is dropped once the player has been away for the room's `privateRetention` of wall-clock time, hibernated time included: 24 hours by default, so it can expire before the room does. Set `privateRetention` in `defineRoom`, in milliseconds or `"forever"`, when players come back over days; see [Room state](/docs/room-state#whats-saved). Keep a player's own data here, such as a hand of cards or an inventory. Room code reaches any player's private state by player ID, `room.private("alice")`, connected or not; it's `undefined` for a player with none, and `room.private("alice", { create: true })` creates it. `room.privatePlayers()` lists the players that have it. - **A rejoining player's first state is their last session's private state.** Their client gets it as it was when they left, before any async work in `onJoin` has finished. A field that describes the session rather than the player, such as `loaded` set once a backend answers, still holds the old session's value. Reset such fields in `onJoin` before it awaits anything: what `onJoin` sets synchronously is in the player's first state update. Reset them in `onLeave` too, which runs before every hibernation, so private state is never kept with them set. - **Hidden entities** are entities no connection ever sees, such as a deck's order. They're room state like any entity; see [Hidden entities](#hidden-entities). - **`conn.data`** is the room's own scratch space for one connection, such as a reference to its avatar entity. It isn't room state. - **`room.data`** is the same for the whole room, for data derived from its entities, such as a lookup table. Module variables are shared by every room in the room host, so don't keep one room's data in them; see [Room data and module scope](/docs/reference/room-sdk#room-data-and-module-scope). - **[Presence](/docs/guides/presence)** is who's connected now: an entry per player with a name and a small status, which every connection sees. The runtime keeps it, and it isn't room state. After a room host crash, the room wakes from its room state with every entity as it was, clients reconnect and `onJoin` runs for each of them again, with no `onLeave` before it. A room that creates an avatar in `onJoin` finds the one from before instead, as the room above does: ```ts const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player); conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player }); ``` An avatar whose player doesn't come back after a crash stays in the room, and in its room state, until room code deletes it. A room that cares can delete, in `onTick`, avatars whose owner has had no connection for a few seconds. `onWake` is too early: it runs before any client has reconnected. ## Hidden entities Some room state must reach no client at all: a deck's order, a puzzle's answer, a bot's plan. `room.data` isn't room state, so it's gone after a wake, and hiding an entity with `interest` is easy to get wrong: without an interest every connection sees every entity, and an interest that gives the entity `EVERYWHERE` or a key some connection subscribes to shows it too. Declare the kind hidden instead: ```ts // cards.ts import { defineRoom, defineSchema, entity, json } from "@irt/room"; export const Table = entity("table", { drawPile: "u8" }); export const Hand = entity("hand", { cards: json() }); export const Deck = entity("deck", { order: json() }, { hidden: true }); export const schema = defineSchema({ state: Table, private: Hand, entities: [Deck] }); function shuffled(cards: string[]): string[] { const out = [...cards]; for (let i = out.length - 1; i > 0; i--) { const j = crypto.getRandomValues(new Uint32Array(1))[0]! % (i + 1); [out[i], out[j]] = [out[j]!, out[i]!]; } return out; } export default defineRoom({ schema, onCreate(room) { const deck = room.entities.create(Deck, { order: shuffled(["1a", "2a", "3a", "1b", "2b", "3b"]) }); room.state.drawPile = deck.order.length; }, onJoin(room, conn) { room.private(conn).cards ??= []; }, onMessage(room, conn, message) { if ((message as { type?: unknown }).type !== "draw") return; const deck = [...room.entities.all(Deck)][0]!; const [card, ...rest] = deck.order; if (card === undefined) return; deck.order = rest; const hand = room.private(conn); hand.cards = [...hand.cards, card]; room.state.drawPile = rest.length; }, }); ``` - A hidden entity is room state like any other: it's saved, restored on wake and kept across a room host crash, and hooks and calls read and write it through `room.entities`. - Don't read hidden entities in a stepper. The client runs the same `step` to predict, on a world that never holds them, so a step that depends on them predicts something other than what the room does. The room's `world` is `room.entities`, so it does hold them; nothing stops a stepper reading them, and nothing warns when prediction drifts. - The room never sends it to a connection. It gets no interest keys, so `keysOf` isn't called for it, whatever `interest` is; voice's default rule doesn't see it; and creating, changing or deleting it sends nothing. The inspector in `irt dev` shows it, marked hidden, since it's for you and not for players. - A hidden kind can't have writable fields, so no player can own one of its entities. - The kind's declaration is in the schema, so it's in the client's bundle and the schema hash; its values never leave the room. What the room sends is still yours to check. Data reaches clients in other ways than entities: - **What you copy out.** A card moved into a hand, `room.state`, a message, a call's reply or error, and presence all reach clients. - **Entity IDs.** Automatic IDs count up for every entity, hidden ones included, so a gap between two IDs a client sees says how many entities were created in between. Give visible entities `{ id }` of your own where that matters. - **Counts and timing.** A `drawPile` count, how long a reply takes, or which tick a change lands in can say what a hidden value is. In a test with `@irt/testing`, `client.view(Deck)` stays empty however the game goes, and `room.stored()` shows the deck in room state. ## Entity IDs and lookups An entity's ID is assigned from 2 by default. Pass `{ id }` to choose it, so the room and its clients can compute it: a chunk's ID from its chunk coordinates, a tile's from its row and column. Then a stepper, a hook or a client finds static data with `world.get(id)` instead of searching. ```ts // voxel-room.ts import { defineRoom, defineStepper, type Entity, type RoomEntity, type World } from "@irt/room"; import { Chunk, Player, schema } from "./schema.js"; const SIZE = 16; const N = 8; /** A chunk's entity ID, from its chunk coordinates; the world is N × N × N chunks. */ export const chunkId = (cx: number, cy: number, cz: number) => 2 + cx + cz * N + cy * N * N; /** Where a block is in its chunk's `blocks`, for whole block coordinates from 0. */ const indexOf = (x: number, y: number, z: number) => (x % SIZE) + (z % SIZE) * SIZE + (y % SIZE) * SIZE * SIZE; export function blockAt(world: World, x: number, y: number, z: number): number { const bx = Math.floor(x); const by = Math.floor(y); const bz = Math.floor(z); if (Math.min(bx, by, bz) < 0 || Math.max(bx, by, bz) >= N * SIZE) return 0; const chunk = world.get(chunkId(Math.floor(bx / SIZE), Math.floor(by / SIZE), Math.floor(bz / SIZE))); // Blocks start empty above the ground; reading past the end gives air. return (chunk as Entity | undefined)?.blocks[indexOf(bx, by, bz)] ?? 0; } export const fall = defineStepper<{ jump: boolean }>({ step(world, inputs, dt) { for (const p of world.all(Player)) { if (blockAt(world, p.x, p.y - 0.01, p.z) === 0) p.y -= 10 * dt; else if (inputs.get(p.owner)?.jump) p.y += 1; } }, }); export default defineRoom({ schema, stepper: fall, onCreate(room) { for (let cy = 0; cy < N; cy++) { for (let cz = 0; cz < N; cz++) { for (let cx = 0; cx < N; cx++) { const blocks = cy === 0 ? new Uint8Array(SIZE ** 3).fill(1) : new Uint8Array(0); room.entities.create(Chunk, { cx, cy, cz, blocks }, { id: chunkId(cx, cy, cz) }); } } } }, onJoin(room, conn) { const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player); conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player, x: 8, y: 20, z: 8 }); }, onMessage(room, conn, message) { const m = message as { place?: [number, number, number] }; if (!m.place || !m.place.every((v) => Number.isInteger(v) && v >= 0 && v < N * SIZE)) return; const [x, y, z] = m.place; const chunk = room.entities.get(chunkId(Math.floor(x / SIZE), Math.floor(y / SIZE), Math.floor(z / SIZE))); // Grows an empty chunk's blocks as far as this block. (chunk as RoomEntity).patch("blocks", indexOf(x, y, z), new Uint8Array([1])); }, }); ``` - An explicit ID is an integer from 2 that no live entity has; `create` throws otherwise, naming the entity that has it. A deleted entity's ID can be used again, even in the same tick. - Automatic IDs are always above the highest ID the room has used, so they never take an explicit one. An explicit ID can still meet an automatic one created earlier, so create the entities with computed IDs first, in `onCreate`, as this room does. - IDs are kept in room state, so they're the same after a wake. What lookups cost, in a stepper or anywhere else: - `world.get(id)` is one map lookup, in the room and on the client. - `world.all(kind)` visits every entity of that kind each time it's called: in the room, every one the room has; on the client, every one the client sees. A stepper runs every tick in the room, and on the client again for each input it replays, so a stepper that searches `all(Chunk)` for each player does chunks × players work every time. Loop over the few entities that move with `all`, and reach static data by ID. ## Interest A connection sees an entity when they share an **interest key**. Keys are strings or numbers your room assigns. ```ts interface Interest { keysOf(room: R, entity: RoomEntity): Iterable; subscriptionsOf(room: R, connection: Connection): Iterable; sees?(room: R, connection: Connection, entity: RoomEntity): boolean; limit?: { max: number; priority(room: R, connection: Connection, entity: RoomEntity): number }; } ``` Each function gets the room first, as hooks do, so a rule can read the room's own state, such as a player's block list. At the end of each tick the room calls `keysOf` for entities that changed and `subscriptionsOf` for every connection. An entity that newly shares a key with a connection enters its view with all its fields; one that no longer shares any leaves it. `sees`, when given, narrows that further: it runs each tick for every connection and entity that share a key, and the connection sees the entity only when it returns `true`. `limit` applies last. ### The grid ```ts function grid(opts: { cellSize: number; dims: "xy" | "xz" | "xyz"; position?: (room: Room, entity: RoomEntity) => [x: number, y: number, z?: number] | undefined; around(room: Room, connection: Connection): RoomEntity | [x: number, y: number, z?: number] | undefined; prefix?: string; kinds?: Kind[]; } & ({ range: number } | { radius: number })): InterestPart; type InterestPart = Pick; ``` - An entity's key is the cell at `position(entity)`; by default its `x`, `y` and `z` fields. `dims` picks the axes: `"xz"` for a 3D world split on the ground plane. - When `around` returns an entity, the grid places it with `position` too. A `position` written for chunk fields gives a player no position, and its connection then subscribes only to `EVERYWHERE`; have `around` return the player's point, `[p.x, p.y, p.z]`, or make `position` handle both kinds. - `kinds` limits the grid to entities of those kinds: others get no key from it, rather than `EVERYWHERE`, and its `sees` lets them through. - `prefix` starts every key the grid gives, `EVERYWHERE` included, so `grid({ ..., prefix: "p:" })` gives `"p:3,-1"` and `"p:*"`. - The key format is `cellKey(...cell)`: the cell coordinates joined by commas, such as `"3,-1"` or `"3,-1,0"`, where a cell coordinate is `Math.floor(position / cellSize)`. - Entities with no position on those axes get `EVERYWHERE` (`"*"`), which every connection subscribes to, so every connection sees them. - While `around(connection)` returns `undefined`, the connection subscribes only to `EVERYWHERE`. Give the grid either a `range` or a `radius`: - **`range`**, in world units: a connection sees the entities within that straight-line distance of `around(connection)` on the chosen axes, a circle for `"xy"` and `"xz"` and a sphere for `"xyz"`. The grid subscribes it to the cells that overlap that circle, and its `sees` drops the entities in those cells that are further away. Visibility is exact: an entity at distance `range` is seen and one a little past it isn't, wherever either stands in its cell. - **`radius`**, a whole number of cells: a connection subscribes to its own cell and every cell within `radius` cells of it on each axis, a square of (2 × radius + 1)² cells, or a cube for `"xyz"`. It sees every entity in them, so how far it sees depends on where it stands in its cell: at least `radius × cellSize` along an axis, up to `(radius + 1) × cellSize`, and further across the corners. `sees` is undefined. Use it when cells, such as voxel chunks, are what matters. ### Combining grids and keys `combine(...parts)` joins grids, and parts with keys of your own, into one interest. A connection sees an entity when, in at least one part, they share a key and that part's `sees`, if it has one, returns `true`. So each grid keeps its own semantics: a `radius` grid shows its whole cells, and a `range` grid's distance check applies only to what it placed, not to what another part shows. ```ts function combine(...parts: InterestPart[]): Required; ``` Give each grid the `kinds` it places, or every grid also places the other grids' entities, as `EVERYWHERE` when they have no position on its axes, which shows them to everyone. Give each grid a `prefix` too, so cells of different sizes don't share keys; `combine` stays exact without one, but entities in a cell with the same key as one of another grid are then checked by `sees` for nothing. Put `limit` beside it: ```ts // world-room.ts import { combine, defineRoom, grid, type Connection, type Room, type RoomEntity } from "@irt/room"; import { Chunk, Player, schema } from "./schema.js"; type P = RoomEntity; const CHUNK = 32; const at = (p: P | undefined) => (p ? ([p.x, p.y, p.z] as const) : undefined); // Chunks within 2 chunks on each axis, placed by chunk coordinates. const chunks = grid({ cellSize: CHUNK, radius: 2, dims: "xyz", kinds: [Chunk], prefix: "c:", position: (_room, e) => { const c = e as RoomEntity; return [c.cx * CHUNK, c.cy * CHUNK, c.cz * CHUNK]; }, // A point, since `position` above reads chunk fields. around: (_room, conn) => at(conn.data.player), }); // Players within 80 units, by their x, y and z. const players = grid({ cellSize: 32, range: 80, dims: "xyz", kinds: [Player], prefix: "p:", around: (_room, conn) => conn.data.player }); // Every player sees its own entity, however far it is from where `around` says. const self = { keysOf: (_room: Room, e: RoomEntity) => (e.kind === Player ? [`me:${(e as P).owner}`] : []), subscriptionsOf: (_room: Room, conn: Connection) => [`me:${conn.player}`], }; export default defineRoom({ schema, interest: { ...combine(chunks, players, self), limit: { max: 500, // Chunks rank first, players by distance. priority: (_room, conn, e) => { const me = conn.data.player as P | undefined; if (!me || e.kind !== Player) return Infinity; const p = e as P; return -Math.hypot(p.x - me.x, p.y - me.y, p.z - me.z); }, }, }, onJoin(room, conn) { const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player); conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player }); }, }); ``` The same goes for a `range` grid and keys of your own: with `combine`, what the keys show isn't dropped by the grid's distance check. ```ts // near-room.ts import { combine, defineRoom, defineSchema, entity, grid, type Connection, type Room, type RoomEntity } from "@irt/room"; const Soldier = entity("soldier", { team: "string", x: "f32", y: "f32", z: "f32" }); const teamOf = (c: Connection) => (c.meta as { team: string }).team; // Teammates are seen at any distance, everyone else within 600 units. const near = grid({ cellSize: 100, range: 600, dims: "xz", around: (_room, c) => c.data.soldier }); const team = { keysOf: (_room: Room, e: RoomEntity) => [`team:${(e as RoomEntity).team}`], subscriptionsOf: (_room: Room, c: Connection) => [`team:${teamOf(c)}`], }; export default defineRoom({ schema: defineSchema({ entities: [Soldier] }), interest: combine(near, team), onJoin(room, conn) { conn.data.soldier = room.entities.create(Soldier, { team: teamOf(conn) }); }, }); ``` Writing `keysOf`, `subscriptionsOf` and `sees` by hand works too. Then give the grids prefixes, and remember that an interest's `sees` applies to every entity, so it must let through what other keys show: `sees: (room, c, e) => isTeammate(c, e) || near.sees!(room, c, e)`. ### Custom keys Interest isn't tied to coordinates. Teams, zones and levels are keys too: ```ts // team-room.ts import { defineRoom, defineSchema, entity } from "@irt/room"; const Unit = entity("unit", { team: "string", level: "u8" }); export default defineRoom({ schema: defineSchema({ entities: [Unit] }), interest: { keysOf: (_room, e) => [`team:${e.team as string}`, `level:${e.level as number}`], subscriptionsOf: (_room, conn) => [`team:${(conn.meta as { team: string }).team}`], }, }); ``` ### Limits and hysteresis `limit: { max, priority }` keeps the `max` entities with the highest priority in each connection's view. A visible entity stays until it ranks below `max` plus 10%, so entities near the cut don't enter and leave every tick. ### Caps | Cap | Value | Over it | |---|---|---| | Interest keys a connection subscribes to | 4,096 | an error in the room's log; the extra keys are left out | | Entities a connection sees | 16,384 | an error in the room's log; the extra entities are left out | ## On the client ```ts // client.ts import { connect } from "@irt/client"; import { Chunk, Player, schema } from "./schema.js"; const room = await connect({ router: "wss://eu.irt.io", project: "my-game", room: "forest-7", schema }); room.entities.onEnter(Chunk, (chunk) => console.log("mesh chunk", chunk.cx, chunk.blocks.length)); room.entities.onChange(Chunk, (chunk, changed) => { if (changed.includes("blocks")) console.log("remesh", chunk.id); }); room.entities.onLeave(Chunk, (chunk) => console.log("drop", chunk.id)); room.onTick((tick) => console.log("applied tick", tick, room.state.phase, room.private.cards)); const players = room.interpolate(Player, ["x", "y", "z"]); for (const p of room.entities.all(Player)) console.log(p.owner, players.get(p)); ``` - A tick's state update is applied at once, then the `onEnter`, `onChange` and `onLeave` callbacks run, then `onTick`. A client never sees a change before an enter. - A big tick, such as a new player entering a hundred chunks, spans several messages; the client applies them together. The room compresses messages of 1 KB or more with DEFLATE, which shrinks voxel chunks about 17 times, and the client inflates them; nothing in the game changes for it, `patch` included. - On a reconnect to the same room generation the client resumes: it tells the room what it holds, and the room sends only what it doesn't hold or what changed since, and a `leave` for what it no longer sees. Entities that didn't change keep their objects, and the client emits `restored`. On a change of the room's generation, or a reconnect the room can't resume, such as after a room host restart, the client gets a full update: every entity leaves and enters again. - `interpolate(kind, fields)` draws numeric fields of remote entities a little behind the room's clock (two ticks or 100 ms, whichever is longer), between the last two states. ## Common mistakes - **Expecting `room.state` changes to be seen at once.** Changes go out at the end of the tick. Messages sent with `room.send` in the same handler arrive before the state update. - **Mutating a `json` field in place.** Set the field again after changing the value, or the change isn't sent. - **Keeping a score on an entity `onLeave` deletes.** It's gone when the player leaves, drops off the network or the room hibernates. Keep it in `room.state` or private state; see [Where player data lives](#where-player-data-lives). - **Expecting `radius` to be a distance.** It counts cells, in a square. Use `range` for "everything within 600 units". - **Putting a whole world in one entity.** An entity must fit in 61,376 bytes. Split worlds into chunk entities. - **Using a `string` for big data.** Strings are capped at 16 KB; use `bytes` (48 KB) and patch it. - **Grid entities without a position.** They get `EVERYWHERE` and every connection sees them; pass `position` when your coordinates aren't called `x`, `y` and `z`, and `kinds` when the grid shouldn't place them at all. - **Merging two grids' keys by hand without prefixes.** Their cell keys and `EVERYWHERE` collide, and a `range` grid's `sees` drops what the other grid shows. Use `combine`. - **A `position` for chunks with an `around` that returns the player.** The grid places that entity with `position` too; return a point instead. - **Searching `all(kind)` in a stepper for static data.** It visits every entity of the kind on every call. Give the data computed IDs and use `world.get`; see [Entity IDs and lookups](#entity-ids-and-lookups). - **Assuming `subscriptionsOf` runs only on change.** It runs for every connection every tick; keep it cheap. - **Hiding data with `interest`.** Without an interest every connection sees every entity, and one key in common shows it. Declare the kind `{ hidden: true }`; see [Hidden entities](#hidden-entities). - **Writing entities on the client.** They are read-only and a write throws, except the writable fields of entities the player owns; see [Ownership](/docs/guides/ownership). --- # Calls > Ask the room to do something and get its answer, with names, args and replies typed from one declaration. A **call** is a request from one client that the room answers: buy an item, claim a seat, look something up. The client awaits the reply, or the error the room threw. Use calls for actions that need an answer; for changes everyone sees, the answer can be the state itself, and for fire-and-forget events, send a message. ## Declaring calls Calls are declared once, in the schema module the room and its clients both import. `defineCalls` takes the calls' signatures as a type, each with at most one argument and its reply: ```ts // schema.ts import { defineCalls, defineSchema, entity } from "@irt/room"; export const prices = { sword: 50, shield: 30 }; export type Item = keyof typeof prices; export const Wallet = entity("wallet", { coins: "u32" }); export const schema = defineSchema({ private: Wallet, calls: defineCalls<{ buy(args: { item: Item }): { coins: number }; balance(): number; }>({ buy(raw) { const item = (raw as { item?: unknown } | null)?.item; if (typeof item !== "string" || !Object.hasOwn(prices, item)) throw new Error("unknown item"); return { item: item as Item }; }, }), }); ``` The object passed to `defineCalls` is optional and holds a `parse` function for any call that needs one. The types are only types: args come from the client, and a modified client can send anything. A `parse` function runs on the room before the handler, gets the decoded JSON, or the bytes, and returns the args or throws. Without one, the handler gets the decoded JSON unchecked, so a call without one must not trust its args; `balance` above takes none. ## In the room The room's `calls` has a handler for each declared call. TypeScript checks that none is missing, that `args` has the declared type, and that the handler returns, or resolves to, the declared reply. ```ts // room.ts import { CallRejected, defineRoom } from "@irt/room"; import { prices, schema } from "./schema.js"; export default defineRoom({ schema, onJoin(room, conn) { const wallet = room.private(conn); if (wallet.coins === 0) wallet.coins = 100; }, calls: { buy(room, conn, { item }) { const wallet = room.private(conn); if (wallet.coins < prices[item]) throw new CallRejected("not enough coins"); wallet.coins -= prices[item]; return { coins: wallet.coins }; }, balance: (room, conn) => room.private(conn).coins, }, }); ``` - What a handler throws becomes the client's error, with its message, and the room keeps running. - Throw `CallRejected` for a rejection that's part of the game, such as "not enough coins" or "the stairs are sealed": the client gets its message and the room logs nothing. Any other error is logged at error level with the room, player, tick and stack, because it's taken for a bug. A `parse` that throws rejects the call without logging too. - A handler may be async; the reply goes out when its promise settles. - Handlers run as calls arrive, between ticks. A call that changed room state (shared or private state, entities, ownership, bans) replies after the next tick has saved the change and sent its state update, up to a tick later: a reply means the change is saved outside the VM and a room host crash can't lose it, and the client already has it. A call that changed nothing replies at once, unless another change is waiting for the same tick. Errors go out at once. What isn't room state, such as `room.data` or a stepper's own state, isn't covered. - A call that changes room state just as the room hibernates fails rather than replies, as any call in flight when the connection drops does. ## On the client ```ts // client.ts import { connect, IrtError } from "@irt/client"; import { schema } from "./schema.js"; const room = await connect({ router: "wss://eu.irt.io", project: "my-game", room: "shop-1", schema }); try { const { coins } = await room.call("buy", { item: "sword" }); console.log("coins left", coins); } catch (e) { if (e instanceof IrtError) console.log("couldn't buy:", e.message); else throw e; } const balance: number = await room.call("balance"); console.log(balance); ``` `call` takes only declared names, with their args, and resolves to the declared reply. It rejects with an `IrtError` when the room throws, after a timeout of 10 seconds by default (`room.call("balance", undefined, { timeout: 2000 })`), and when the connection drops or the room restarts before the reply. In those last cases the call may or may not have run, and the SDK doesn't retry it. The error's `code` is 422 when the room answered with an error, such as a `CallRejected`, and 0 when no answer came; see [Errors](/docs/reference/errors#call). ## Common mistakes - **Rejecting with a plain `Error`.** It works, but every rejection is logged as an error with a stack, which buries real bugs in normal traffic. Use `CallRejected` for expected outcomes. - **Trusting args without `parse`.** The declared type is a promise the client makes, not one the room can rely on. Check anything that indexes, loops or spends. - **Retrying a call that changes state.** After a dropped connection the room may already have run it. Check with another call, such as `balance`, or make the call safe to repeat. - **Waiting on a call for state everyone sees.** Change the entity in the handler and let every client get it from the state update; reply with what only the caller needs. --- # Ownership > Let players write the values they control, such as a cursor or a dragged card, while the room decides what everyone sees. Every entity has an **owner**: the room, or a player. The room owns everything unless it gives an entity to a player, and the owner of an entity may write the fields its kind declares **writable** straight from the client, with no round trip. The room still checks each write and always has the last word. Use ownership for values a player sets directly: a cursor, aim, a camera, a piece being dragged, per-player settings. Movement the room simulates stays room-owned and goes through [prediction](/docs/guides/prediction). Scores, hit results and anything else that decides the game stay room-owned too. ## Declaring writable fields ```ts // schema.ts import { defineSchema, entity } from "@irt/room"; export const Cursor = entity("cursor", { x: "f32", y: "f32", color: "u8", }, { writable: ["x", "y"] }); export const Settings = entity("settings", { volume: "u8", coins: "u32" }, { writable: ["volume"] }); export const schema = defineSchema({ private: Settings, entities: [Cursor] }); ``` - Only the listed fields can be written by the owner. The rest are the room's, even on an entity a player owns, so `color` above is set by the room alone. - Any kind that isn't [hidden](/docs/guides/state-sync#hidden-entities) can be given to a player, with or without writable fields. The owner of a kind with none can't write it, but the entity is always in its view, it's listed by `ownedBy` and `mine`, and its owner-only fields reach it. - `ownerOnly` lists fields only the owner's connections are sent; every other connection holds their defaults and gets no changes to them. A predicted car's velocity, which only its driver's replay reads, is one: `entity("car", { x: "f32", y: "f32", vx: "f32", vy: "f32" }, { ownerOnly: ["vx", "vy"] })`. A new owner gets them right after the ownership change; the previous owner keeps the values it had. They save bandwidth, and hide only values a player never owned. Room state saves them as any field, and they're part of the schema hash. - The shared state, `room.state`, is always the room's and can't have writable fields. - A player owns its own private state, so it can write the private kind's writable fields. - Writable fields are part of the schema hash: a client built with different writable fields fails to connect with 4004. ## In the room ```ts // room.ts import { defineRoom } from "@irt/room"; import { Cursor, schema } from "./schema.js"; export default defineRoom({ schema, onJoin(room, conn) { if ([...room.entities.ownedBy(conn.player, Cursor)].length > 0) return; room.entities.create(Cursor, { color: conn.player.length % 8 }, { owner: conn.player }); }, onWrite(room, conn, entity, next) { if (entity.kind !== Cursor) return next; const x = next.x as number | undefined; if (x !== undefined && (x < 0 || x > 800)) return { ...next, x: Math.min(Math.max(x, 0), 800) }; return next; }, }); ``` - `create(kind, values, { owner })` gives a new entity to a player. `room.entities.setOwner(entity, player)` moves one, and `setOwner(entity, undefined)` gives it back to the room. `ownerOf(entity)` returns the player, or `undefined` for the room, and `ownedBy(player, kind)` iterates what a player owns. - Owners are player IDs, not connections. Ownership survives reconnects and is saved in room state, so it survives hibernation. Leaving releases nothing; call `setOwner` in `onLeave` if the game needs that. - The room may write any field of any entity, including fields a player owns. ### onWrite `onWrite(room, connection, entity, next)` runs for each owner write at the start of the tick, before the stepper. `next` holds the fields written. Return: | Return | Applies | |---|---| | `next` | the write as sent | | other values | those values, such as `next` clamped to the board | | `undefined` | nothing; the write is rejected | `onWrite` must be synchronous. If it throws, the error is logged and the write is rejected. Without `onWrite`, writes apply as sent: a writable field is then a claim the room accepts from the player. Before `onWrite` runs, the room drops writes to entities the player doesn't own, to fields that aren't writable, and values of the wrong type. ## On the client ```ts // client.ts import { connect } from "@irt/client"; import { Cursor, schema } from "./schema.js"; const room = await connect({ router: "wss://eu.irt.io", project: "my-game", room: "board-1", schema }); addEventListener("pointermove", (e) => { for (const cursor of room.entities.mine(Cursor)) { cursor.x = e.clientX; cursor.y = e.clientY; } }); room.private.volume = 5; room.entities.onCorrect(Cursor, (cursor, fields) => console.log("the room moved", cursor.id, fields)); room.entities.onOwner(Cursor, (cursor) => console.log(cursor.id, "is now", room.entities.ownerOf(cursor) ?? "the room's")); ``` - `room.entities.mine(kind)` iterates the entities of a kind the local player owns. Setting a writable field changes the client's copy at once; writing any other field, or an entity the player doesn't own, throws a `TypeError`. - The client sends its writes at most once per room tick. Within that time the last write to a field wins. - Clients can't create or delete entities, or change an owner. Send the room a message and let it decide. ## Corrections The room wins. A client keeps its own values for the writable fields it owns and ignores the room's ordinary changes to them. When the room's value differs from what the owner wrote, because `onWrite` clamped or rejected it or room code wrote the field, the owner gets a **correction** with the room's value: - It overwrites the client's copy and drops the client's unsent writes to those fields. - `onChange` runs for the fields, then `onCorrect`, where a game can snap or smooth. - Every other client only ever sees the room's value. A correction arrives with the rest of its tick's state update. On a reconnect or a change of generation, unsent writes are dropped and the full state update brings the room's values. ## Interest A player always sees the entities it owns, whatever the room's interest says. They count toward the cap on entities a connection sees. ## Common mistakes - **Making fields the stepper writes writable.** The room overwrites the owner's value every tick and sends a correction each time. Keep simulated movement room-owned and use prediction. - **Trusting writable fields without `onWrite`.** A modified client can write any value of the field's type. Bound what's possible, such as a speed or the board's edges. - **Writing in `onEnter` before the owner op arrives.** An entity given to a player mid-game becomes writable when its owner change is applied; use `onOwner` or `mine` rather than assuming. --- # Client prediction > Make movement feel instant with a stepper shared by the room and the client. The room is the authority, so a client that waits for the room to move its player feels a round trip of lag. Prediction runs the room's own simulation step on the client for the local player, sends the input it used, and reconciles when the room's state arrives. Everything else on screen is [interpolated](/docs/guides/state-sync#on-the-client). ## The stepper A **stepper** is a simulation step the room and its clients share, in a module both import: ```ts function defineStepper(stepper: { step(world: World, inputs: ReadonlyMap, dt: number, tick: number): void; parseInput?(raw: unknown): I; save?(world: World): unknown; load?(saved: unknown, world: World): void; release?(world: World): void; }): Stepper; interface World { all(kind: K): Iterable>; get(id: number): Entity | undefined; readonly predicted?: Entity; // on a client's prediction world only readonly stateTick?: number; // on a client's prediction world only } ``` - `world` is the room's entities, read and written in place; on the client it's the client's view. `world.get(id)` is a map lookup; `world.all(kind)` visits every entity of the kind on each call, so reach static data, such as voxel chunks, by computed IDs; see [Entity IDs and lookups](/docs/guides/state-sync#entity-ids-and-lookups). The shared state is in it on both sides, as `world.get(SHARED_ID)` (0) and in `world.all(schema.state)`, so a stepper can read a race's start tick from it. Private state isn't: the room has one per player. - `inputs` maps player IDs to this tick's input. On the room it holds every player that sent one for this tick; on the client only the local player's. - `dt` is the fixed timestep in seconds, `1 / tickRate`. - `tick` is the room tick being stepped: `room.tick` on the room, and on the client the tick of the input being stepped, which is the tick the room runs that input at. A rule that depends on time, such as holding cars on the grid until `tick` reaches the start, predicts exactly. - `world.predicted` is the predicted entity on a client and undefined on the room, for a stepper that simulates only that entity on the client. - `world.stateTick` is the room tick of the state the client holds, and undefined on the room. - `parseInput` runs on the room only. Inputs arrive as JSON from the client, or as the schema's input kind's fields (see [Compact inputs](#compact-inputs)); without `parseInput` the room passes the decoded input as is, so validate it here and throw to drop a bad one. - `save` and `load` are for caches of the stepper's own, such as a physics engine's world. They run on the room only, on hibernate, at checkpoints and on wake, never per tick. One stepper serves every room in a room host, so they get the room's `world`, the object `step` gets; key caches by it. See [Steppers](/docs/room-state#steppers). - `release` frees what a stepper keeps for a `world` that's gone: on the room when the room leaves its room host, and on the client when the prediction stops. Only caches the garbage collector can't free promptly need it, such as a physics engine's WebAssembly memory. - For rigid bodies and characters, [`@irt/physics`](/docs/guides/physics) is a stepper built on Rapier. ```ts // shared.ts import { defineSchema, defineStepper, entity } from "@irt/room"; export const Runner = entity("runner", { owner: "string", x: "f32", y: "f32" }); export const schema = defineSchema({ entities: [Runner] }); export interface Input { dx: number; dy: number; } const SPEED = 6; export const stepper = defineStepper({ parseInput(raw) { const i = raw as Partial; if (typeof i.dx !== "number" || typeof i.dy !== "number" || Math.abs(i.dx) > 1 || Math.abs(i.dy) > 1) { throw new TypeError("input is { dx, dy } with each from -1 to 1"); } return { dx: i.dx, dy: i.dy }; }, step(world, inputs, dt) { for (const r of world.all(Runner)) { const input = inputs.get(r.owner); if (!input) continue; r.x += input.dx * SPEED * dt; r.y += input.dy * SPEED * dt; } }, }); ``` ## The room ```ts // room.ts import { defineRoom } from "@irt/room"; import { Runner, schema, stepper } from "./shared.js"; export default defineRoom({ schema, tickRate: 30, stepper, onJoin(room, conn) { // After a crash or a wake the player's runner is still here, and onJoin runs again. const existing = [...room.entities.all(Runner)].find((r) => r.owner === conn.player); conn.data.runner = existing ?? room.entities.create(Runner, { owner: conn.player }); }, onTick(room) { // Rules only the room knows; the client reconciles with them. for (const r of room.entities.all(Runner)) r.x = Math.min(r.x, 50); }, onPlayerLeave(room, player) { for (const r of room.entities.all(Runner)) if (r.owner === player) room.entities.delete(r); }, }); ``` `onJoin` reuses the player's runner rather than creating one each time: after a room host crash it runs again with no `onLeave` or `onPlayerLeave` first. The runner goes in `onPlayerLeave`, which runs 10 seconds after the player's last connection closed with no new one, so a dropped network and a reconnect keep it, and before every hibernation; see [State sync](/docs/guides/state-sync). Each tick the room runs the stepper with each player's input for that tick, then `onTick`, then sends the state update, then acknowledges the inputs it applied. ## The client ```ts // client.ts import { connect } from "@irt/client"; import { type Input, Runner, schema, stepper } from "./shared.js"; const room = await connect({ router: "wss://eu.irt.io", project: "runners", room: "track-1", schema }); const keys = new Set(); addEventListener("keydown", (e) => keys.add(e.key)); addEventListener("keyup", (e) => keys.delete(e.key)); 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"]); 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(); ``` `room.predict(stepper, { find, sample })` returns a `Prediction`, typed by the kind `find` returns: | Member | Meaning | |---|---| | `entity` | The predicted entity: the room's last state plus the inputs it hasn't acknowledged, or `undefined` until `find` finds it. Use it for game logic. | | `render(fields, opts?)` | The predicted numeric fields plus a decaying share of recent mispredictions, as `Record`. Before the first input it's the room's state of the entity, and `NaN` for each field while `find` finds nothing. Use it for drawing. With `{ snapDistance }`, a misprediction of these fields longer than that is dropped instead of smoothed; see [Respawns and teleports](#respawns-and-teleports). | | `snap()` | Drops every misprediction `render` is smoothing, so it draws `entity` as it is | | `pending` | Ticks stepped ahead of the room's state: inputs sent and not yet acknowledged, and [idle](#idle-inputs) ticks not sent. With `sample` this stays at about 2 plus half the round trip in ticks, never 0, because inputs are sent ahead of the room's clock | | `settled` | `true` when the prediction agrees with the room: `entity` equals the room's last state, so the pending inputs move nothing. `render` may still be smoothing away an earlier misprediction | | `mispredictions` | How many replays found the room's state different from the prediction in a field the stepper writes | | `onMispredict(fn)` | Calls `fn({ tick, fields, delta })` after each such replay: the room tick it replayed from, the fields that differed, and for numeric ones the prediction's value less the replayed one, which `render` smooths away. Returns an unsubscribe | | `push(input)` | Sends an input for the next tick and steps the prediction. Only without `sample`. | | `stop()` | Stops sampling | How it works: 1. Once per room tick, by the client's estimate of the room's clock, the prediction calls `sample()`, steps the predicted entity with the input and sends it tagged with the room tick it is for. `sample` receives the `Prediction` it belongs to, so it can steer by `entity`, which is set before each call whenever `find` finds the entity. It's undefined only while `find` finds nothing: before the entity enters the client's view, while the room has deleted it, and after a reconnect until the full state arrives, so read it as `p.entity?.x ?? 0`. The first call is at the next room tick after `predict` returns, never during it. After the page was paused it calls `sample` up to 4 times at once to catch up and skips the rest. It aims 2 ticks ahead of the room, plus half the round trip, so the input arrives in time. Each message repeats the last 4 inputs, so a lost one costs nothing; see [Compact inputs](#compact-inputs) for what that costs. 2. The room holds each input until the tick it names and applies it then. An input for a tick that already ran is dropped, and so is one more than 32 ticks ahead (`INPUT_WINDOW`). One input per player per tick: a later one for the same tick replaces the earlier. 3. After each tick's state update the room sends an ack with the highest input it applied or dropped. The client resets the predicted entity to the room's state and replays the inputs after the ack. A field that a step has written and that came out different is a misprediction, and the difference goes into what `render` smooths away, 20% per tick. A field no step writes, such as a lap counter only `onTick` sets, is the room's: its changes are taken as they come, never counted or smoothed. 4. On a reconnect or a new room generation, prediction state is dropped and starts again from the room's state. ### Compact inputs Inputs are JSON unless the schema declares their fields. Each message repeats the last 4 inputs, so JSON inputs can cost more upstream than the state a client receives: a shooter's `{"mx":1,"my":0,"aim":0.1234567,"fire":true,"rocket":false}` is 200 to 300 bytes a message, 6 to 9 KB/s at 30 ticks a second. Declare the input as a kind, with `entity`, and pass it to `defineSchema` as `input`; inputs then go as binary, each field encoded as in a state update, with the repeats' ticks as differences: ```ts // compact.ts import { defineSchema, defineStepper, entity, type ValuesOf } from "@irt/room"; export const Ship = entity("ship", { owner: "string", x: "f32", aim: "f32" }); export const ShipInput = entity("input", { mx: "i8", aim: "f32", fire: "bool" }); export type ShipInput = ValuesOf; export const compactSchema = defineSchema({ entities: [Ship], input: ShipInput }); export const shipStepper = defineStepper({ parseInput(raw) { const i = raw as ShipInput; if (Math.abs(i.mx) > 1) throw new RangeError("mx is -1, 0 or 1"); return i; }, step(world, inputs, dt) { for (const s of world.all(Ship)) { const input = inputs.get(s.owner); if (!input) continue; s.x += input.mx * 100 * dt; s.aim = input.aim; } }, }); ``` That shooter's input is then 48 bytes a message, about 1.4 KB/s. `sample` returns the same object as before. Fields it returns that the kind doesn't declare are dropped, on the client as on the room. An input that isn't an object, or with a field missing or of the wrong type, throws a `TypeError` naming the field and stops the prediction, as `stop()` does; call `predict` again once `sample` is fixed. `f32` fields are rounded with `Math.fround` before the client steps with them, as the room will see them, so the prediction stays exact. `parseInput` gets an object of the kind's fields with their types, so check ranges and leave types to the encoding. The input kind is part of the schema hash, so a site built with a different input layout fails to connect with 4004, as for any other schema change. `room.stats.sent` shows what inputs cost, under `typedInput` for these and `input` for JSON ones. ### Idle inputs A predicting client sends an input message every tick, even standing still. Pass `idle` to say which inputs mean the same as none: ```ts const me = room.predict(stepper, { find, sample, idle: (i) => i.dx === 0 && i.dy === 0 }); ``` Once the room has acknowledged every input that wasn't idle, an idle input isn't sent, and both sides step that tick with no input: the room because none arrived, the client because it skipped it. That holds only if the stepper treats a missing input as the idle one, as the runner's `if (!input) continue` does. While an earlier input awaits its ack, idle inputs are still sent, so the repeats in each message carry the inputs from just before the player stopped past a lost message. A standing player then sends only pings. ### Respawns and teleports A respawn or a teleport that room code does in `onTick` is a rule the stepper can't predict, so it arrives as a misprediction, and `render` would slide the player from the old spot to the new one over half a second. Pass a `snapDistance` to `render`, longer than any misprediction of ordinary play, and one longer than that is drawn at once: ```ts const local = me.render(["x", "y"], { snapDistance: 64 }); ``` Where the entity itself says it jumped, call `me.snap()` instead; it drops whatever `render` is smoothing. The misprediction lands when the room's ack arrives, after the tick's state update and any message the room sent in that tick, so watch a field the room sets with the jump, such as a `spawns` counter, and snap in the frame where `me.entity` shows the new value: ```ts if (me.entity && me.entity.spawns !== lastSpawns) { lastSpawns = me.entity.spawns; me.snap(); } ``` ### The prediction world On the client the stepper runs on a prediction world. The predicted entity is writable. Every other entity is a copy whose writes last until the prediction next replays from the room's state, at the next ack, so a step's effect on another entity holds exactly as long as the input that caused it is unacknowledged; once the room acks it, the room's state has the effect, or doesn't if the room rejected it. Don't mutate a `bytes` value in place in a stepper; on the client it's the client's own copy of room state. Use `entity.patch(field, offset, bytes)`, which works on both sides. The prediction world is one object for the life of the `Prediction`, so a cache a stepper keys by `world` lasts across its steps and replays. A replay starts from the room's state, but such a cache doesn't rewind with it, so keep only data derived from static entities in it, such as a voxel chunk's collision shapes, never state a step advances. `@irt/physics` keeps its Rapier world this way, and makes a body's colliders again when its kind's [`shapeKey`](/docs/guides/physics#shapes-that-change) changes. ### Predicting edits An edit the player makes next to its own entity, such as digging the block under its feet, mispredicts if it goes as a message: the room applies it when it arrives, while the prediction has already stepped past with the old blocks. Send it as fields of the input instead, and apply it in the stepper: ```ts const DigInput = entity("input", { dx: "i8", edit: "u8", ex: "i16", ey: "u8", ez: "i16", eb: "u8" }); step(world, inputs) { for (const p of world.all(Player)) { const i = inputs.get(p.owner); if (i?.edit && inReach(p, i) && p.editsLeft > 0) { chunkAt(world, i.ex, i.ez)?.patch("blocks", blockIndex(i.ex, i.ey, i.ez), new Uint8Array([i.eb])); p.editsLeft--; } // ...movement, which now sees the edited block. } } ``` The room applies the edit at the input's tick and the client predicts it at the same tick, so an edit the room accepts mispredicts nothing. Checks the room makes, such as reach and a rate limit, go in the stepper too, with the rate limit's state in an entity field, as `editsLeft` is, so the client predicts it. Inputs are best-effort, unlike messages. The room drops an input that arrives after its tick has run or more than 32 ticks ahead, and acknowledges it as handled; each message repeats only the last 4 inputs; and the client keeps at most 64 unacknowledged ones. Under lag or loss an edit sent as an input can be lost without an error, and the player sees the block come back when the prediction replays. A message arrives in order or the connection drops. Use inputs for edits a player makes while moving next to them, where a lost edit costs a retry, and messages or calls for edits that must land: rare or costly ones, ones not tied to the player's movement, and anything the game records, such as a build placed from an inventory. ## Shooting and lag compensation A player aims at remote players drawn by [`interpolate`](/docs/guides/state-sync#on-the-client), which shows them a little in the past, while the room resolves the shot at its own current tick. Checking the shot against where targets are now misses anything that moves. Rewind the targets to what the shooter saw, as [Lag compensation](/docs/guides/lag-compensation) shows with `defineHistory` and `room.rewind`. The ticks involved: - **Values at tick T** are an entity's fields at the end of tick T, after the stepper and `onTick`: what the state update for tick T carries, and what `room.rewind(history, T)` returns. - **`interpolate`** draws remote entities at render time `serverTick() - max(2 ticks, 100 ms)`, between the updates for the tick before that time and the tick after it. - **`conn.viewTick`** is that render time, rounded down to a tick and never past the last update the client has, when the client sampled the input the room applies this tick. During the stepper and `onTick` it's the view tick of this tick's input, or of the last one applied, and at most `MAX_REWIND_MS` (250 ms) behind the room. The shooter saw targets between their values at `viewTick` and `viewTick + 1`, so allow a tick of their movement in the hit test. - Don't rewind the shooter. Its input moved it in this tick's step, as its prediction did on its screen. - On a link slower one way than the other, the client's estimate of the room's tick is off by half the difference, so `viewTick` is too. See [how accurate `serverTick()` is](/docs/reference/client-sdk#roomclient). ## Testing it `TestClient.predict` samples once per room tick as the test moves the clock. Since `pending` never reaches 0 while sampling, wait for `settled` to know the prediction has caught up with the room: ```ts // prediction.test.ts import { testRoom } from "@irt/testing"; import { expect, test } from "vitest"; import room from "./room.js"; import { Runner, stepper } from "./shared.js"; test("the client's prediction converges on the room's wall", async () => { const t = testRoom(room); const alice = await t.connect({ player: "alice" }); let dx = 1; const me = alice.predict(stepper, { find: (r) => [...r.entities.all(Runner)].find((e) => e.owner === r.player), sample: () => ({ dx, dy: 0 }), }); await t.host.tick(300); expect(me.settled).toBe(false); dx = 0; await t.host.tick(30); expect(me.mispredictions).toBeGreaterThan(0); expect(me.settled).toBe(true); expect(me.entity!.x).toBe(50); // render is still smoothing the last misprediction away, by 20% a tick. expect(me.render(["x"]).x).toBeCloseTo(50, 1); }); test("sample can steer by the prediction it belongs to", 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: (p) => ({ dx: (p.entity?.x ?? 0) < 10 ? 1 : 0, dy: 0 }), }); await t.host.tick(120); expect(me.settled).toBe(true); expect(me.entity!.x).toBeGreaterThanOrEqual(10); }); ``` ## Common mistakes - **Steppers that depend on anything but `world`, `inputs`, `dt` and `tick`.** `Math.random()`, `Date.now()` or module state make the client's replay differ from the room, and every tick becomes a misprediction. - **Moving the predicted entity outside the stepper.** Room code in `onTick` that moves it produces a misprediction each tick; put movement in the stepper and keep `onTick` for rules the client can't predict. - **Calling `push` and passing `sample`.** With `sample` the prediction pushes for you; pushing as well sends two inputs per tick. - **Waiting for `pending` to reach 0 in a test.** With `sample` it doesn't; assert on `settled`. - **Drawing `entity` instead of `render(...)`.** `entity` jumps on a misprediction; `render` smooths it. - **Trusting inputs in the room.** Without `parseInput`, `step` receives whatever the client sent: any JSON, or with an input kind any value its field types hold. - **Predicting other players.** Interpolate them; only the local player's entity is predicted. Others in the prediction world are where the client last saw them, or with [`@irt/physics`](/docs/guides/physics), dynamic bodies are extrapolated to the tick being predicted. --- # Lag compensation > Check a shot against where the target was on the shooter's screen, by rewinding recent entity positions on the room. A player aims at what their screen shows, and that is the past: other players are drawn with [interpolation](/docs/guides/prediction#the-client), a little behind the room's clock, and the shot takes half a round trip to reach the room. By the time the room sees the shot, the target has moved on, so a check against where the target is now misses a shot that was on target. Lag compensation fixes this on the room. Each input carries the tick of the state the player was looking at, `connection.viewTick`. The room records where the entities you choose were at each recent tick, and `room.rewind` hands back those positions at the view tick to check the shot against. ## Recording a history `defineHistory(kind, fields)` declares which fields of a kind the room records, and the room's `history` lists the declarations: ```ts export const poses = defineHistory(Player, ["x", "y"]); export default defineRoom({ schema, stepper, history: [poses] /* ... */ }); ``` After each tick's state update, the room copies those fields of every entity of the kind into a ring of recent ticks. Only `bool` and number fields can be recorded. Record what a hit check reads, usually the position and maybe a rotation, and not the rest: each field costs memory for every entity of the kind. `room.rewind(history, tick)` returns the entities as that tick's state update sent them, as a `ReadonlyMap` from entity ID to a frozen object holding `id` and the recorded fields: - An entity created after `tick`, or deleted at or before it, is absent. An ID that was deleted and created again gives whichever entity had it at `tick`. - A tick older than the oldest the room kept is clamped to the oldest. - A tick the room hasn't recorded yet gives the entities as they are now. In `onTick` that includes `room.tick` itself, since the tick is recorded once its state update goes out. - A history the room's `history` doesn't list throws. ## Checking a shot The shot travels in the player's input, so the room knows which tick the player was looking at when they fired. The stepper moves players, and `onTick`, which runs right after it, checks shots: `connection.input` is the input applied this tick, after `parseInput`, and `connection.viewTick` is the tick the player saw when sending it. A stepper can't rewind, and shouldn't decide hits. It also runs on the client to predict the local player, and a client keeps no history of the room; a hit is the room's call. Keep the shot in the input, ignore it in the stepper, and handle it in `onTick`. A top-down arena, where a shot is a ray from the shooter that hits the first other player it passes within half a unit of: ```ts // shared.ts import { defineHistory, defineSchema, defineStepper, entity } from "@irt/room"; export const Player = entity("player", { owner: "string", x: "f32", y: "f32", hp: "u8" }); export const schema = defineSchema({ entities: [Player] }); export const poses = defineHistory(Player, ["x", "y"]); export interface Input { dx: number; dy: number; /** The direction of a shot fired this tick. */ fire?: { x: number; y: number }; } const SPEED = 8; const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); export const stepper = defineStepper({ parseInput(raw) { const { dx, dy, fire } = raw as { dx?: unknown; dy?: unknown; fire?: { x?: unknown; y?: unknown } }; if (!finite(dx) || !finite(dy) || Math.hypot(dx, dy) > 1.0001) throw new TypeError("input is { dx, dy, fire? }"); if (fire === undefined) return { dx, dy }; if (!finite(fire.x) || !finite(fire.y) || (fire.x === 0 && fire.y === 0)) throw new TypeError("fire is a direction { x, y }"); return { dx, dy, fire: { x: fire.x, y: fire.y } }; }, step(world, inputs, dt) { for (const p of world.all(Player)) { const input = inputs.get(p.owner); if (!input) continue; p.x += input.dx * SPEED * dt; p.y += input.dy * SPEED * dt; } }, }); ``` ```ts // room.ts import { defineRoom, type RoomEntity } from "@irt/room"; import { type Input, Player, poses, schema, stepper } from "./shared.js"; const RADIUS = 0.5; const RANGE = 40; const SPAWNS = [ { x: -8, y: 0 }, { x: 0, y: 10 }, ]; type Point = { x: number; y: number }; /** How far along the shot it passes within RADIUS of `p`, or undefined if it doesn't. */ function along(from: Point, dir: Point, p: Point): number | undefined { const len = Math.hypot(dir.x, dir.y); const t = ((p.x - from.x) * dir.x + (p.y - from.y) * dir.y) / len; if (t < 0 || t > RANGE) return undefined; const miss = Math.hypot(from.x + (dir.x / len) * t - p.x, from.y + (dir.y / len) * t - p.y); return miss <= RADIUS ? t : undefined; } export default defineRoom({ schema, stepper, history: [poses], onJoin(room, conn) { const spawn = SPAWNS[(room.connections.size - 1) % SPAWNS.length]!; conn.data.player = [...room.entities.all(Player)].find((e) => e.owner === conn.player) ?? room.entities.create(Player, { owner: conn.player, ...spawn, hp: 100 }); }, onPlayerLeave(room, player) { for (const e of room.entities.all(Player)) if (e.owner === player) room.entities.delete(e); }, onTick(room) { for (const conn of room.connections.values()) { const fire = (conn.input as Input | undefined)?.fire; const shooter: RoomEntity = conn.data.player; if (!fire) continue; // The shot leaves from where the shooter is now, and hits others where the shooter saw them. let nearest: { id: number; t: number } | undefined; for (const target of room.rewind(poses, conn.viewTick).values()) { if (target.id === shooter.id) continue; const t = along(shooter, fire, target); if (t !== undefined && (!nearest || t < nearest.t)) nearest = { id: target.id, t }; } // The target may have left since the tick the shooter saw. const hit = nearest && (room.entities.get(nearest.id) as RoomEntity | undefined); if (!hit) continue; hit.hp = Math.max(0, hit.hp - 25); room.broadcast({ type: "hit", shooter: conn.player, target: hit.owner }); } }, }); ``` The client draws other players with `room.interpolate`, which is what the view tick describes, and fires toward a click: ```ts // client.ts import { connect } from "@irt/client"; import { type Input, Player, schema, stepper } from "./shared.js"; const room = await connect({ router: "wss://eu.irt.io", project: "arena", room: "pit-1", schema }); const UNIT = 32; const keys = new Set(); addEventListener("keydown", (e) => keys.add(e.key)); addEventListener("keyup", (e) => keys.delete(e.key)); let clicked: { x: number; y: number } | undefined; addEventListener("pointerdown", (e) => (clicked = { x: e.clientX / UNIT, y: e.clientY / UNIT })); const me = room.predict(stepper, { find: (r) => [...r.entities.all(Player)].find((p) => p.owner === r.player), sample: (prediction): Input => { const input: Input = { dx: (keys.has("ArrowRight") ? 1 : 0) - (keys.has("ArrowLeft") ? 1 : 0), dy: (keys.has("ArrowDown") ? 1 : 0) - (keys.has("ArrowUp") ? 1 : 0), }; const at = prediction.entity; if (clicked && at && (clicked.x !== at.x || clicked.y !== at.y)) input.fire = { x: clicked.x - at.x, y: clicked.y - at.y }; clicked = undefined; return input; }, }); const others = room.interpolate(Player, ["x", "y"]); room.on("message", (m) => console.log(m)); function frame() { const local = me.render(["x", "y"]); for (const p of room.entities.all(Player)) { const at = p.owner === room.player ? local : others.get(p); console.log(p.owner, p.hp, at.x * UNIT, at.y * UNIT); } requestAnimationFrame(frame); } frame(); ``` The test has the target run past while the shooter fires at it as the shooter's client draws it. By the time the room checks the shot, the target is more than a unit further on, three times the hit radius: ```ts // arena.test.ts import type { RoomClient } from "@irt/client"; import { testRoom } from "@irt/testing"; import { expect, test } from "vitest"; import room from "./room.js"; import { type Input, Player, stepper } from "./shared.js"; /** Fires once at the target as the shooter's client draws it, `offset` units along x; returns the target's hp. */ async function arena(offset: number) { const t = testRoom(room); const target = await t.connect({ player: "target" }); const shooter = await t.connect({ player: "shooter" }); const drawn = shooter.client.interpolate(Player, ["x", "y"]); const find = (r: RoomClient) => [...r.entities.all(Player)].find((p) => p.owner === r.player); target.predict(stepper, { find, sample: () => ({ dx: 1, dy: 0 }) }); let shots = 0; shooter.predict(stepper, { find, sample: (prediction): Input => { const me = prediction.entity; const other = [...shooter.client.entities.all(Player)].find((p) => p.owner === "target"); if (t.live.tick < 20 || shots > 0 || !me || !other) return { dx: 0, dy: 0 }; shots++; const at = drawn.get(other); return { dx: 0, dy: 0, fire: { x: at.x + offset - me.x, y: at.y - me.y } }; }, }); await t.host.tick(30); return [...t.live.entities.all(Player)].find((p) => p.owner === "target")!.hp; } test("a shot at the target where the shooter saw it hits, though it has moved on", async () => { expect(await arena(0)).toBe(75); }); test("a shot that missed on the shooter's screen misses", async () => { expect(await arena(-1)).toBe(100); }); ``` The shooter's own position is the present one: its client predicts it, so the room's present position is what the shooter saw of itself. ## With physics A Rapier world holds the present, and rebuilding one for a past tick costs too much for a hit check. `@irt/physics` instead offers `castRay(ray, maxToi, targets, shape)`, which casts a ray against targets placed at their poses with Rapier shapes, with no world. Record the pose fields in a history, rewind, and cast against the result: ```ts // soldiers.ts import { defineHistory, defineRoom, defineSchema, entity, type RoomEntity } from "@irt/room"; import { body, castRay, physics, RAPIER } from "@irt/physics/rapier3d"; const Soldier = entity("soldier", { owner: "string", x: "f32", y: "f32", z: "f32", vy: "f32" }); const poses = defineHistory(Soldier, ["x", "y", "z"]); const capsule = new RAPIER.Capsule(0.5, 0.3); type Input = { dx: number; dz: number; fire?: { x: number; y: number; z: number } }; const stepper = physics({ gravity: { x: 0, y: -9.81, z: 0 }, bodies: [ body(Soldier, { type: "character", colliders: () => RAPIER.ColliderDesc.capsule(0.5, 0.3), player: (s) => s.owner, control({ entity: s, move }, input, dt) { s.vy -= 9.81 * dt; if (move((input?.dx ?? 0) * 6 * dt, s.vy * dt, (input?.dz ?? 0) * 6 * dt) && s.vy < 0) s.vy = 0; }, }), ], }); export default defineRoom({ schema: defineSchema({ entities: [Soldier] }), stepper, history: [poses], onJoin(room, conn) { conn.data.soldier = room.entities.create(Soldier, { owner: conn.player, y: 1 }); }, onTick(room) { for (const conn of room.connections.values()) { const fire = (conn.input as Input | undefined)?.fire; const me: RoomEntity = conn.data.soldier; if (!fire) continue; const others = [...room.rewind(poses, conn.viewTick).values()].filter((s) => s.id !== me.id); const ray = new RAPIER.Ray({ x: me.x, y: me.y + 0.6, z: me.z }, fire); const hit = castRay(ray, 100, others, () => capsule); if (hit) room.broadcast({ type: "hit", target: hit.target.id, distance: hit.toi }); } }, }); ``` Walls and other fixed bodies don't move, so their present is their past: to stop shots at walls, cast against the room's own world with `stepper.rapierWorld(room.entities)!.castRay(ray, maxToi, true)` and count a player hit only if it's nearer than the wall. In 2D, `castRay` takes poses of `x`, `y` and an optional `angle`, as the [physics reference](/docs/reference/physics#castray) shows. ## What the room keeps - **How far back.** A view tick is never more than `MAX_REWIND_MS` (250 ms) behind the room; the room clamps it. A history keeps that many ticks plus two: 7 ticks at 20 ticks a second, 17 at 60, 32 at 120. - **Memory.** Each recorded entity costs 8 bytes per field per kept tick, plus about 128 bytes. Three fields at 60 ticks a second is 536 bytes an entity, so 1,000 players take about half a megabyte. A room's histories together are capped at 8 MB (`MAX_HISTORY`). Over it, the room logs an error once and new entities aren't recorded, so they're absent from `room.rewind`, until deleted entities leave the window and free their share. - **Not room state.** The history is in the room host's memory only. It isn't saved, isn't checkpointed, and starts empty when the room wakes or its room host restarts; until the room has run a tick again, `room.rewind` gives the present. ## Common mistakes - **Checking hits in the stepper.** The stepper runs on the client too, and has no history or view tick there. Check hits in `onTick`. - **Sending shots as messages.** A message carries no view tick, and `connection.viewTick` is that of the last input, which may be from another tick. Put the shot in the input. - **Rewinding the shooter.** Its client predicts it, so it saw itself where the room has it now. Fire from the present position. - **Recording every field.** Each field costs 8 bytes per tick for every entity of the kind. Record what the hit check reads. - **Drawing others without interpolation.** The view tick is what `room.interpolate` draws. A client that draws the latest state shows entities about two ticks ahead of its view tick, and shots there land behind the target. - **Trusting a rewound hit absolutely.** Rewinding favours the shooter: a target that just ran behind a wall can still be hit, by up to the shooter's lag. The 250 ms cap bounds it. --- # Physics > Rigid bodies and characters with Rapier 2D or 3D, stepped by the room and predicted on the client. `@irt/physics` runs [Rapier](https://rapier.rs) as a [stepper](/docs/guides/prediction#the-stepper): the room steps a Rapier world each tick and writes each body's pose into its entity's fields, and the client runs the same stepper to predict the local player. The entity fields are the synced state. The Rapier world is a cache the stepper rebuilds from them, or restores from room state on wake. Install the package and the one engine your game uses: ```sh npm install @irt/physics @dimforge/rapier2d-compat # or @dimforge/rapier3d-compat ``` Import `@irt/physics/rapier2d` or `@irt/physics/rapier3d`. Each loads only its own engine, and games that don't import either ship none of it. Rapier is compiled to WebAssembly and inlined in the package, so it works in any bundler and in the room host with no setup, but it adds over a megabyte, gzipped, to a client that imports it. The import finishes loading Rapier before your code runs. ## Bodies `physics({ gravity, bodies })` returns a stepper. Each `body(kind, ...)` gives entities of a kind a body, posed by fields with fixed names: | | 2D | 3D | |---|---|---| | Position, required | `x`, `y` | `x`, `y`, `z` | | Rotation | `angle` | `qx`, `qy`, `qz`, `qw` | | Velocity, dynamic bodies only | `vx`, `vy` | `vx`, `vy`, `vz` | | Angular velocity, dynamic bodies only | `spin` | `wx`, `wy`, `wz` | The fields must be `f32` or `f64`. A kind without rotation fields gets a body that doesn't rotate. A dynamic body's velocity is kept in the Rapier world and saved with it, so declare velocity fields only when clients need them, such as to predict a dynamic body; they add bytes to every state update while the body moves. On other body types those names are free for game state, such as a character's vertical speed. Body types: - `dynamic`: moved by gravity, collisions, forces and impulses. - `fixed`: never moves; walls, floors and terrain. - `kinematic`: moved only by your `control`, with `body.setNextKinematicTranslation`; moving platforms. - `character`: a kinematic body moved by Rapier's character controller through `move`, which slides along walls and slopes and says whether it ends on the ground. `colliders(entity)` returns the shapes attached to an entity's body, made when the body is. A shape that changes, such as a voxel chunk's, needs a `shapeKey`; see [shapes that change](#shapes-that-change). `body(desc, entity)` adjusts the body before it's made: damping, CCD, gravity scale. ## A 2D example ```ts // shared.ts import { defineSchema, entity } from "@irt/room"; import { body, physics, RAPIER } from "@irt/physics/rapier2d"; export const Crate = entity("crate", { x: "f32", y: "f32", angle: "f32" }); export const Platform = entity("platform", { x: "f32", y: "f32", w: "f32", h: "f32" }); export const Hero = entity("hero", { owner: "string", x: "f32", y: "f32", vy: "f32", grounded: "bool" }); export const schema = defineSchema({ entities: [Crate, Platform, Hero] }); export interface Input { dx: number; jump: boolean; } export const stepper = physics({ gravity: { x: 0, y: -20 }, parseInput(raw) { const i = raw as Partial; if (typeof i.dx !== "number" || Math.abs(i.dx) > 1 || typeof i.jump !== "boolean") { throw new TypeError("input is { dx, jump }"); } return { dx: i.dx, jump: i.jump }; }, bodies: [ body(Crate, { type: "dynamic", colliders: () => RAPIER.ColliderDesc.cuboid(0.5, 0.5) }), body(Platform, { type: "fixed", colliders: (p) => RAPIER.ColliderDesc.cuboid(p.w / 2, p.h / 2) }), body(Hero, { type: "character", colliders: () => RAPIER.ColliderDesc.capsule(0.3, 0.4), player: (h) => h.owner, character: (c) => c.enableSnapToGround(0.1), control({ entity: hero, move }, input, dt) { if (!input) return; hero.vy = input.jump && hero.grounded ? 9 : hero.vy - 20 * dt; hero.grounded = move(input.dx * 6 * dt, hero.vy * dt); if (hero.grounded && hero.vy < 0) hero.vy = 0; }, }), ], }); ``` ```ts // room.ts import { defineRoom } from "@irt/room"; import { Crate, Hero, Platform, schema, stepper } from "./shared.js"; export default defineRoom({ schema, tickRate: 30, stepper, onCreate(room) { room.entities.create(Platform, { x: 0, y: -0.5, w: 60, h: 1 }); for (let i = 0; i < 5; i++) room.entities.create(Crate, { x: 8, y: 1 + i * 1.2 }); }, onJoin(room, conn) { conn.data.hero = [...room.entities.all(Hero)].find((e) => e.owner === conn.player) ?? room.entities.create(Hero, { owner: conn.player, x: 0, y: 1 }); }, onPlayerLeave(room, player) { for (const e of room.entities.all(Hero)) if (e.owner === player) room.entities.delete(e); }, }); ``` ```ts // client.ts import { connect } from "@irt/client"; import { Crate, Hero, type Input, schema, stepper } from "./shared.js"; const room = await connect({ router: "wss://eu.irt.io", project: "crates", room: "yard-1", schema }); const keys = new Set(); addEventListener("keydown", (e) => keys.add(e.key)); addEventListener("keyup", (e) => keys.delete(e.key)); const me = room.predict(stepper, { find: (r) => [...r.entities.all(Hero)].find((h) => h.owner === r.player), sample: (): Input => ({ dx: (keys.has("ArrowRight") ? 1 : 0) - (keys.has("ArrowLeft") ? 1 : 0), jump: keys.has(" "), }), }); const heroes = room.interpolate(Hero, ["x", "y"]); const crates = room.interpolate(Crate, ["x", "y", "angle"]); function frame() { const local = me.render(["x", "y"]); for (const h of room.entities.all(Hero)) { const at = h.owner === room.player ? local : heroes.get(h); console.log("hero", h.owner, at.x, at.y); } for (const c of room.entities.all(Crate)) { const at = crates.get(c); console.log("crate", c.id, at.x, at.y, at.angle); } requestAnimationFrame(frame); } frame(); ``` ```ts // physics.test.ts import { testRoom } from "@irt/testing"; import { expect, test } from "vitest"; import room from "./room.js"; import { Crate, Hero, stepper } from "./shared.js"; test("crates fall and stack, and the hero's prediction agrees with the room", async () => { const t = testRoom(room); const alice = await t.connect({ player: "alice" }); let jumps = 0; const me = alice.predict(stepper, { find: (r) => [...r.entities.all(Hero)].find((h) => h.owner === r.player), sample: () => ({ dx: 0, jump: jumps++ < 3 }), }); await t.host.tick(150); const ys = [...t.live.entities.all(Crate)].map((c) => c.y).sort((a, b) => a - b); expect(ys[0]).toBeCloseTo(0.5, 1); expect(ys[4]).toBeCloseTo(4.5, 0); expect(me.mispredictions).toBe(0); expect(me.settled).toBe(true); }); ``` ## How a tick runs Each tick, on the room, the stepper: 1. Makes a body for each new entity of a declared kind, removes the bodies of deleted ones, and moves each body whose fields were written by something else, such as `onJoin` or `onTick`. Writing `x` in room code teleports the body; that's how to respawn or place something. It updates the colliders of each body whose `shapeKey` changed. 2. Runs `control` for each body of a kind that has one, with its player's input for the tick. `input` is undefined when the player sent none or the kind has no `player`. 3. Steps the Rapier world, unless every body sleeps and nothing was moved or woken, so a settled pile of crates costs almost nothing. 4. Writes the pose, and the velocity fields if declared, of every body that moved into its entity. Writing a field its current value records nothing, so sleeping bodies send nothing. `onTick` runs after the stepper, so it sees this tick's poses. For ray casts and other queries, `stepper.rapierWorld(room.entities)` returns the room's Rapier world. `stepper.entityOf(room.entities, hit.collider)` says which entity a ray hit, and `stepper.bodyOf(room.entities, entity)` returns an entity's body. A body is made again when, for example, its entity leaves a client's view and comes back, so look it up each time rather than keeping it. To check a shot against where the shooter saw its target, cast against rewound poses with `castRay`; see [Lag compensation](/docs/guides/lag-compensation#with-physics). ## Shapes that change Colliders are made from `colliders(entity)` when a body is made, and changing the fields it reads changes nothing by itself. Give the kind a `shapeKey`: a value that changes whenever `colliders` would return different shapes, such as an edit count. Each tick, on the room and in every client's prediction, the stepper compares it with the value the colliders were made with, and when it differs it makes them again. Bodies resting on the changed shape wake up, so a crate on a removed block falls. Making a collider again costs about as much as making it the first time, roughly 0.4 ms for a 16 x 32 x 16 voxel chunk. Where an edit changes little, `reshape` updates the colliders in place instead. It gets the body's colliders, the entity, and `before`, a copy of the entity's values when its colliders were last made or reshaped, so it can find what changed: ```ts import { defineSchema, entity } from "@irt/room"; import { body, physics, RAPIER } from "@irt/physics/rapier3d"; const SIZE = 16; // Blocks by x, then z, then y, one byte each; 0 is air. export const Chunk = entity("chunk", { x: "f32", y: "f32", z: "f32", rev: "u32", blocks: "bytes" }); export const schema = defineSchema({ entities: [Chunk] }); const voxel = (i: number): [number, number, number] => [i % SIZE, Math.floor(i / (SIZE * SIZE)), Math.floor(i / SIZE) % SIZE]; function voxels(blocks: Uint8Array): RAPIER.ColliderDesc { const coords: number[] = []; for (let i = 0; i < blocks.length; i++) if (blocks[i]) coords.push(...voxel(i)); return RAPIER.ColliderDesc.voxels(new Int32Array(coords), { x: 1, y: 1, z: 1 }); } export const stepper = physics({ gravity: { x: 0, y: -28, z: 0 }, bodies: [ body(Chunk, { type: "fixed", colliders: (c) => voxels(c.blocks), // The room adds 1 to rev with every edit to blocks. shapeKey: (c) => c.rev, reshape([collider], c, before) { const n = Math.max(c.blocks.length, before.blocks.length); for (let i = 0; i < n; i++) { const solid = (c.blocks[i] ?? 0) !== 0; if (solid !== ((before.blocks[i] ?? 0) !== 0)) collider!.setVoxel(...voxel(i), solid); } }, }), ], }); ``` `reshape` mustn't add or remove colliders. Colliders restored from a saved world are made again if their key isn't a number, string or boolean, since what they were made from isn't known then. Two costs come with changing shapes. With `reshape`, the stepper keeps a copy of each body's entity, bytes fields included, from when its colliders were made: about 2 MB for a world of 256 voxel chunks, on the room and again in every client's prediction. And after a fixed body's colliders are made or changed, the stepper takes a step of no time before `control` runs, so the character controller sees the new shape that tick. That step moves nothing, but it runs Rapier's contact solver, so a predicted dynamic body already in contact can come out of it with a slightly different velocity than the room's, and get a small correction. ## Players: inputs and prediction Drive players with inputs, as above: `control` reads the player's input and moves a `character` with `move`, or pushes a `dynamic` body with `body.applyImpulse`. The room validates each input with `parseInput`, and the client predicts its own body with [`room.predict`](/docs/guides/prediction), so movement responds at once. On the client the stepper keeps a Rapier world for the prediction: - The predicted entity gets its real body type and runs `control`. - Every other body of a `dynamic` kind is a dynamic proxy, posed before each step where it would be at the tick being predicted: its last pose the client received, carried on at the velocity between its last two poses, and at rest when its last update didn't move it. In 2D its angle is extrapolated too; in 3D its rotation isn't. A hit shares momentum as on the room, and what the step does to the proxy is discarded. - Every other `character` or `kinematic` body is a kinematic proxy at the pose the client last received, moved there over a step when a new one arrives, so the solver sees its velocity. Fixed bodies stay fixed. - When the room's state arrives and the prediction replays, the predicted body is moved back to the room's pose first. - `control` gets `tick`, the room tick being stepped, the same on the room and in a replay. This is prediction of the local player against everything else, not rollback of the whole world. Contact with other dynamic bodies agrees with the room as long as they keep their course; a rival's steering, or a hit on a third body, shows up as a misprediction. Other characters and kinematic bodies are where the client last saw them, a few ticks behind, which costs little at walking speed. A predicted dynamic body agrees exactly while it touches nothing, rotation included: after each step the stepper rebuilds a body's rotation from its fields, as a replay does. Around a dynamic body's impacts with a wall, expect small mispredictions, a few centimetres: Rapier's contact solver starts each step from the impulses of the last one, which live in the Rapier world, and a replay from the room's pose doesn't have the room's. Assert a bound on mispredictions in such tests rather than `settled`. A predicted `dynamic` body needs velocity fields, or the replay starts from a stale velocity. ### Ownership instead of inputs A kind can declare a [writable field](/docs/guides/ownership), such as a thrust or an aim angle, and `control` can read it from `entity` instead of reading `input`. It works, and the room applies owner writes before the stepper, but it isn't tied to ticks the way inputs are, so the client can't replay it exactly. Use inputs for movement, and writable fields for values the body only follows, such as where a turret points. Pose fields can't be writable; `physics` throws if one is. ## Saving across hibernation When the world has dynamic bodies, `save` returns it as bytes, which the room state keeps; it runs on hibernate and at checkpoints, never per tick. On wake, `load` restores the world, so bodies keep their velocities, contacts and sleep state. Then it reconciles the world with the entities, which are the truth: a body whose entity is gone is removed, an entity with no body gets one, and a body whose entity's fields differ, as after a crash restart from changes newer than the last checkpoint, is moved to them. A world without dynamic bodies, such as characters walking on fixed terrain, keeps all its state in its entities, so `save` returns nothing and `load` makes every body from its entity. That keeps colliders out of room state, where a voxel chunk's takes about 4.5 KB, and costs the time to make them on wake: about 100 ms for 256 voxel chunks of 16 x 32 x 16 on one core. A saved world includes the colliders of fixed bodies too, so a voxel world with even one dynamic body saves them all; their shape keys are saved with them, so a wake keeps the colliders whose key still matches. A restored world keeps the colliders of the build that saved it. A build that changes a kind's shapes can drop the saved world in a [migration](/docs/reference/room-sdk#room-state-and-migrations), with `stepper: undefined`, and the stepper rebuilds every body from its entity, losing only velocities. ## Memory Rapier's WebAssembly memory grows as worlds need it and never shrinks. When a room hibernates or stops, the room host calls the stepper's `release`, which frees the room's world for the next room to reuse, but the room host keeps the memory of its busiest moment until it exits. A room with thousands of bodies raises the memory of every room in its room host for that room host's lifetime. ## 3D The 3D entry is the same API with a third axis and quaternions: ```ts // arena.ts import { defineRoom, defineSchema, entity } from "@irt/room"; import { body, physics, RAPIER } from "@irt/physics/rapier3d"; export const Ball = entity("ball", { x: "f32", y: "f32", z: "f32", qx: "f32", qy: "f32", qz: "f32", qw: "f32", }); export const Floor = entity("floor", { x: "f32", y: "f32", z: "f32" }); export const Walker = entity("walker", { owner: "string", x: "f32", y: "f32", z: "f32", vy: "f32" }); export const stepper = physics<{ dx: number; dz: number }>({ gravity: { x: 0, y: -9.81, z: 0 }, parseInput(raw) { const { dx, dz } = raw as { dx: unknown; dz: unknown }; if (typeof dx !== "number" || typeof dz !== "number" || Math.hypot(dx, dz) > 1) throw new TypeError("input is { dx, dz }"); return { dx, dz }; }, bodies: [ body(Ball, { type: "dynamic", colliders: () => RAPIER.ColliderDesc.ball(0.5).setRestitution(0.6), body: (desc) => desc.setLinearDamping(0.2), }), body(Floor, { type: "fixed", colliders: () => RAPIER.ColliderDesc.cuboid(50, 0.5, 50) }), body(Walker, { type: "character", colliders: () => RAPIER.ColliderDesc.capsule(0.5, 0.3), player: (w) => w.owner, control({ entity: w, move }, input, dt) { if (!input) return; w.vy -= 9.81 * dt; if (move(input.dx * 4 * dt, w.vy * dt, input.dz * 4 * dt) && w.vy < 0) w.vy = 0; }, }), ], }); export default defineRoom({ schema: defineSchema({ entities: [Ball, Floor, Walker] }), tickRate: 30, stepper, onCreate(room) { room.entities.create(Floor, { y: -0.5 }); for (let i = 0; i < 10; i++) room.entities.create(Ball, { x: i - 5, y: 3 + i, qw: 1 }); }, onJoin(room, conn) { room.entities.create(Walker, { owner: conn.player, y: 1.5 }); }, onTick(room) { for (const b of room.entities.all(Ball)) { // Writing a pose teleports the body: balls that roll off the floor drop in again. if (b.y < -10) { b.x = 0; b.y = 5; b.z = 0; } } }, }); ``` ## Common mistakes - **Writable pose fields.** The stepper writes them every tick, so the owner would be corrected every tick; `physics` throws. - **Spawning a character touching the ground.** Rapier's character controller can slip through a collider it starts exactly in contact with. Spawn characters a little above the ground. - **Changing a shape without a `shapeKey`.** Colliders are made once from `colliders`, so editing the fields it reads does nothing in Rapier, on the room or in prediction. - **Keeping a body or collider across ticks.** Bodies are made again, as when an entity leaves a client's view and comes back, and an old handle then points at nothing. Look bodies up with `bodyOf`. - **Randomness or clocks in `control`.** As with any stepper, the client's replay then differs from the room. - **Moving the predicted body in `onTick`.** It teleports the body and makes a correction every tick; move it in `control`. - **Expecting the client to simulate other bodies.** They're proxies at their last received pose; draw them with `room.interpolate`. --- # Bots > Players the room drives itself, which take seats, appear in presence, and give their seat to a person who joins a full room. A **bot** is a player that your room's code drives instead of a person: an opponent for someone playing alone, or a seat filled so a game can start. A bot is in presence like anyone else, owns entities, and sends input to your stepper, but it has no connection. It runs inside the room, so it costs no traffic and doesn't keep the room awake. A creature or hazard that isn't a player doesn't need a bot. Make it an entity the room owns and move it in `onTick`. ```ts // src/room.ts import { defineRoom, defineSchema, defineStepper, entity } from "@irt/room"; const Car = entity("car", { owner: "string", x: "f32" }); export default defineRoom({ schema: defineSchema({ entities: [Car] }), stepper: defineStepper({ step(world, inputs) { for (const car of world.all(Car)) car.x += inputs.get(car.owner) ?? 0; }, }), bots: { racer: { start(room, bot) { if (![...room.entities.all(Car)].some((c) => c.owner === bot.player)) room.entities.create(Car, { owner: bot.player, x: 0 }); }, tick(room, bot) { bot.input = bot.data.fast ? 2 : 1; }, }, }, onCreate(room) { room.bots.add("racer", { name: "Rival", data: { fast: true } }); }, onJoin(room, conn) { room.entities.create(Car, { owner: conn.player, x: 0 }); }, onPlayerLeave(room, player) { for (const car of room.entities.all(Car)) if (car.owner === player) room.entities.delete(car); }, }); ``` ## A bot - **Its player ID** is `bot:` and a number the room gives out in order, never reused while the room exists. No player token can have a `sub` starting with `bot:`, so nobody can pose as a bot. `isBot(player)`, from `@irt/client` or `@irt/room`, tells them apart. - **Behaviours** are declared by name in `bots` in `defineRoom`, and `room.bots.add(name, { name?, status?, data? })` adds a bot running one. `start` runs when the bot is added and again after each wake; `tick` runs every tick before the stepper, in the order the bots were added. Both must be synchronous. - **Input.** With a stepper, `tick` sets `bot.input` and the stepper gets it under the bot's player ID, so a bot's car moves by the same `step` as a person's, and clients predict and interpolate it like any other player's. The room doesn't run `parseInput` on it, since it's your own code. Without a stepper, a behaviour changes entities itself. - **`bot.data`** is the behaviour's own JSON, at most 4 KB (`MAX_BOT_DATA`), saved in room state. - **Ownership and private state** work by player ID, as for people. `onWrite` doesn't run for a bot's entities: the room writes them directly. - **`room.bots.remove(bot)`** ends the bot's presence and runs `onPlayerLeave` for it before returning, so code that cleans up after a player cleans up after bots too. Bots never cause `onJoin` or `onLeave`, which take a connection. - **A behaviour that throws,** or returns a promise, has the error logged and its bot removed, and the room keeps running. So does a bot whose `data` stops being JSON or grows over 4 KB. - **Limits.** A room holds at most 64 bots (`MAX_BOTS`). Bots count toward presence's 1,024 entries. The tick profile shows their `tick`s as `bots`. - **No player data.** Bots have no player data, own no collection entries and submit no scores, since those outlast the room. `room.playerData` throws for a bot, and the data service answers a bot's ID with `E_INVALID`. Voice leaves bots out. ## Seats `room.maxPlayers` is the room's seat limit: its `maxPlayers`, or its room size's connection limit. People and bots together fill at most that many, and `room.bots.add` throws when they already do. A person can always take a bot's seat. When a person joins a room that people and bots fill, the room removes the bot added last in the join itself: `onPlayerLeave` runs for the bot, then `onJoin` for the person, before the person's first state update. Give the bot's entities to the person in `onJoin` if the game should carry on from where the bot was. The router counts only people, so the room list and quick join see a room with one person and three bots as having one player. Quick join still prefers rooms with more people. ## Hibernation and wake Bots are room state: their IDs, names, statuses, behaviours and `data` are saved and come back after a wake or a room host crash. Before the room hibernates, `onPlayerLeave` runs for every person in presence but not for bots. On wake each bot is back in presence before `onWake`, and its `start` runs after `onWake`. Bots hold no connection, so a room with only bots in it hibernates 30 seconds after its last connection closes, like any room. A game that should finish without people ends itself in `onLeave` or `onPlayerLeave`. ## Simulating and testing A room's bots are room code, so [`irt simulate`](/docs/guides/simulate) and [`@irt/testing`](/docs/guides/testing) run them with nothing more to set up. Their simulated bots are client connections, a different thing: spawn them into a room whose bots fill it to test that people take their seats. A [Node client](/docs/guides/node-client) can play a room as an external bot, connecting like a person with its own token, for a game whose logic lives only in its client. --- # Presence > Who's in the room, with a name and a small status per player, synced to every client with no kinds to declare and no join or leave code. Every room has **presence**: an entry for each player in it, with a name and a small status, synced to every client. The room runtime adds a player when it connects and removes it when it leaves, so a player list, "who's here" avatars, a lobby screen or a typing indicator need no kind in your schema and no `onJoin` or `onLeave` code. ```ts // src/room.ts import { defineRoom } from "@irt/room"; export default defineRoom({}); ``` ```ts // web/players.ts import { connect } from "@irt/client"; const room = await connect({ router: "wss://eu.irt.io", project: "my-game", room: "main" }); for (const p of room.presence) console.log(p.player, p.name, p.status); room.presence.onJoin((p) => console.log(`${p.name || p.player} joined`)); room.presence.onLeave((p) => console.log(`${p.name || p.player} left`)); room.presence.onChange((p, changed) => console.log(p.player, "changed", changed)); room.presence.setStatus({ typing: true }); ``` ## An entry | Field | Type | Who sets it | |---|---|---| | `player` | `string` | The player ID, from the token's `sub` or a guest ID. | | `name` | `string` | The room, with `room.presence.set`. It starts as the `name` string in the token's `meta`, if there is one of at most 64 characters, else `""`. | | `status` | JSON | The player, with `setStatus`, or the room. At most 1 KB serialized; `null` until set. | | `connected` | `boolean` | The runtime: `false` for 10 seconds after the player's last connection closes, then the entry leaves. | One entry per player, however often it reconnects. A dropped connection that reconnects within 10 seconds (`PRESENCE_GRACE`) keeps its entry and its status: other clients see `connected` go `false` and back to `true`, not a leave and a join. A player connecting again from a new tab, which closes its old connection with 4012, doesn't change its entry at all. ## Status The status is the player's own: a typing flag, "away", a ready check, an emoji, which seat it's looking at. Set it from the client: ```ts // web/status.ts import type { RoomClient } from "@irt/client"; export function markReady(room: RoomClient, ready: boolean): void { room.presence.setStatus({ ready }); } ``` `setStatus` changes the local copy at once and sends it with the client's other [owner writes](/docs/guides/ownership), at most once per room tick. It throws a `RangeError` for a status over 1 KB as JSON. Like any owner write, a status not yet sent when the connection drops is lost, and the room has the last word: when it changes a player's status, that player gets a correction and `onChange` runs for `status`. A status is a claim from the player, so keep anything that decides the game out of it. Your room's `onWrite` isn't called for status writes; the room checks a status after the fact and replaces it with `room.presence.set`: ```ts // src/room-status.ts import { defineRoom } from "@irt/room"; export default defineRoom({ onJoin(room, connection) { const meta = connection.meta as { displayName?: string } | null; room.presence.set(connection.player, { name: meta?.displayName?.slice(0, 64) ?? "guest" }); }, onTick(room) { for (const p of room.presence) { if (typeof p.status !== "object" || p.status === null) continue; if ("ready" in p.status && typeof p.status.ready !== "boolean") room.presence.set(p.player, { status: null }); } }, }); ``` ## In the room ```ts interface Presence { get(player: string): PresenceEntry | undefined; readonly size: number; [Symbol.iterator](): IterableIterator; set(player: string, values: { name?: string; status?: unknown }): void; } ``` `room.presence` holds the same entries clients see. `set` throws for a player with no entry, a name over 64 characters or a status over 1 KB. A player's entry exists before `onJoin` runs and is still there, with `connected` false from the next tick, when `onLeave` runs. ## What it costs, and what it isn't - **Every connection sees every entry**, whatever your [interest](/docs/guides/state-sync#interest) says. A player list is useless with gaps, and a room has at most 1,024 connections, so the whole list is at most 1,024 small entries. An entry counts toward a connection's cap of 16,384 visible entities, and a resync sends the whole list. Rooms hold at most 1,024 entries (`MAX_PRESENCE`); when a room is full of them, the entry of the player gone longest makes room. - **Unless the room hides entries.** Interest doesn't apply to presence, but `seesPresence(room, viewer, entry)` in `defineRoom` does: a viewer sees another player's entry only while it returns true, so a player who blocked someone stops seeing them in the list too. The client sees a hidden entry leave, and join again when it's shown. A player always sees its own entry. Players banned with `room.ban`, and connections the room kicks in `onJoin` before any `await`, never get an entry anyone sees. - **It isn't room state.** Presence is who's connected now, so it isn't saved and doesn't survive hibernation: after a wake, the list fills again as players reconnect, with empty statuses. Keep per-player data that should last, such as settings or a seat, in [private state](/docs/reference/room-sdk#room) or an entity. - **It isn't voice.** [Proximity voice](/docs/guides/proximity-voice) ignores presence, so seeing every entry doesn't make every player audible. - **It isn't one of your entities.** `room.entities` doesn't hold presence, it doesn't change the IDs your entities get, and an entity you create with an explicit ID that a presence entry holds moves the entry, which clients don't notice. On the client, read presence through `room.presence`, not `room.entities`. ## Testing Test clients have `client.presence` like any other: ```ts // src/presence.test.ts import { testRoom } from "@irt/testing"; import { expect, it } from "vitest"; import room from "./room-status.js"; it("shows who is ready", async () => { const r = testRoom(room); const alice = await r.connect({ player: "alice", meta: { displayName: "Alice" } }); const bob = await r.connect({ player: "bob" }); alice.client.presence.setStatus({ ready: true }); await r.host.tick(2); expect(bob.client.presence.get("alice")).toMatchObject({ name: "Alice", status: { ready: true }, connected: true }); expect(r.live.presence.size).toBe(2); }); ``` --- # Proximity voice > Give rooms voice where each listener hears the closest speakers it can see. Rooms can have voice chat in which each listener hears the speakers near it. The room decides who hears whom; `@irt/client/voice` joins the voice server and plays what the room allows. ## How it works - Voice is audio only and runs on a voice server (LiveKit) in each region, outside your VMs. It runs no tenant code. - A project turns voice on with its `voice` setting. Each connection then gets a voice token from the router after `welcome`, in a `voice` message (type 7) with the voice server's URL. The voice identity is the player ID, stable across reconnects. - **Who hears whom comes from the room.** With `voice: {}` in `defineRoom`, a listener hears the players whose entities it can see under the room's [interest](/docs/guides/state-sync#interest), ranked by the interest limit's `priority`, capped at `maxSpeakers`. An entity belongs to the player in its `owner` string field, unless you pass `owner`. `audible(room, listener)` replaces the rule, for teams, radio channels or a whole room on one call. - Each tick the room works out every connection's **speakers** (who it hears) and **listeners** (who hears it) and sends them, only when they change and at most 4 times a second, in a `peers` message (type 9). - The client subscribes only to its speakers, and sets its own microphone track's permissions to its listeners, so a modified client can't overhear anyone and can only expose its own voice. A subscribe that arrives before a speaker's permission is retried when the permission comes. - By default each speaker plays through its own `