Reference

Room SDK

Reference for @irt/room, the IRT SDK that room code is written with.

@irt/room is what room code imports. A room module’s default export is the result of defineRoom. Its main entry runs in Node and in browsers, so a schema or stepper module can import from it and be shared with the client.

import {
  defineRoom, defineSchema, defineCalls, defineStepper, defineHistory, entity, json, grid, combine, cellKey, EVERYWHERE, CloseCode, CallRejected, MAX_ENTITY_ID, MAX_HISTORY, MAX_REWIND_MS,
  type Room, type RoomDefinition, type Connection, type RoomEntity, type Entities, type History, type PastEntity,
  type Interest, type InterestPart, type GridOptions, type Json, type Migration, type RoomData, type Stepper, type World,
} from "@irt/room";

defineRoom

function defineRoom<S extends Kind, P extends Kind, D extends object, C extends Calls>(def: RoomDefinition<S, P, D, C>): RoomDefinition<S, P, D, C>;

interface RoomDefinition<S, P, D, C> {
  schema?: Schema<S, P, C>;        // from defineSchema; without one, the room has no entities or calls
  tickRate?: number;               // ticks per second, an integer from 1 to 120; default 20
  stepper?: Stepper<any>;          // see the prediction guide
  interest?: Interest;             // without it, every connection sees every entity
  voice?: Voice;                   // see the proximity voice guide
  history?: readonly History[];    // fields to record each tick for room.rewind; see History
  data?: () => D;                  // makes room.data when the room starts or wakes; default {}
  version?: number;                // room state version, default 1
  migrations?: Record<number, Migration>;
  privateRetention?: number | "forever";  // ms a player's private state is kept after they leave; default PRIVATE_RETENTION, 24 hours
  sleep?: { after: number };       // sleep once quiet this many ms; see Sleeping rooms

  onCreate?(room: Room<S, P, D>): void | Promise<void>;
  onWake?(room: Room<S, P, D>, info: WakeInfo): void | Promise<void>;  // { elapsed, savedAt, reason, slept }
  onAlarm?(room: Room<S, P, D>, info: { scheduledAt: number; retry: number }): void | Promise<void>;
  onJoin?(room: Room<S, P, D>, connection: Connection): void | Promise<void>;
  seesPresence?(room: Room<S, P, D>, viewer: Connection, entry: PresenceEntry): boolean; // see Presence
  onMessage?(room: Room<S, P, D>, connection: Connection, message: unknown, info: { unreliable: boolean }): void | Promise<void>;
  onLeave?(room: Room<S, P, D>, connection: Connection): void | Promise<void>;
  onPlayerLeave?(room: Room<S, P, D>, player: string): void | Promise<void>;
  onTick?(room: Room<S, P, D>, dt: number): void | Promise<void>;
  onHibernate?(room: Room<S, P, D>): void | Promise<void>;
  onRequest?(room: Room<S, P, D>, message: unknown): unknown;
  onWrite?(room: Room<S, P, D>, connection: Connection, entity: RoomEntity, next: Record<string, unknown>): Record<string, unknown> | undefined;
  calls?: { [name: string]: (room: Room<S, P, D>, connection: Connection, args: Args) => Reply | Promise<Reply> };  // one per call the schema declares
}

defineRoom throws a RangeError for a tickRate outside 1 to 120, a privateRetention that isn’t a positive number of milliseconds or "forever", or a sleep.after that isn’t a positive number of milliseconds.

Hooks

Hook Runs If it throws
onCreate once, when the room starts with no room state the room stops
onWake when the room starts from room state: after hibernating, or after its room host restarted. savedAt is the wall-clock time room state was last written (its last tick with changes, or its hibernation) and elapsed the milliseconds since, never negative; see Offline progress. reason is what woke it: "connect", "api" (a server API call), "alarm" or "message" (a held connection of a sleeping room sent something or closed); slept is true for a room that went to sleep, whose connections are back in room.connections with their data; see Sleeping rooms the room stops
onAlarm when the room’s alarm is due, active or hibernated; see Alarms it runs again after 2, 4, 8, 16, 32 and 64 seconds, then the alarm is dropped; logged; the room keeps running
onJoin when a connection opens, including each reconnect. After a room host crash it runs again for every player who reconnects, with no onLeave before it, while their entities from before still exist that connection closes with 4006; the room keeps running
onMessage for each message from a connection the error is logged with the room, player and tick, the message is dropped, the room keeps running
onLeave when a connection closes, a dropped network included, and for each open connection before the room hibernates, except when it goes to sleep. Never after a crash logged; the room keeps running
onPlayerLeave once when a player’s presence ends: PRESENCE_GRACE, 10 seconds, after its last connection closed with no new one, at the start of the tick that removes it. Before the room hibernates it runs for every player still in presence, after onLeave for each connection. Never after a crash. The place to delete a player’s avatar logged; the room keeps running
onTick each tick, after the stepper, with dt = 1 / tickRate seconds the room stops
onHibernate before the room saves its room state and leaves its room host the room stops
onRequest for a server API message (POST /projects/{p}/rooms/{id}/messages); its return value or what its promise resolves to, as JSON, is the reply the API call gets a 422 room_error reply with the message; logged unless it’s a CallRejected
calls[name] for each client call(name, args), as it arrives, after the call’s parse if it has one; its return value or what its promise resolves to, JSON or bytes, is the reply. See Calls the client’s call rejects with the error’s message; logged unless it’s a CallRejected; the room keeps running
onWrite for each owner write, at the start of the tick before the stepper; it returns the fields to apply, or undefined to reject. Synchronous. See Ownership logged; the write is rejected and the owner corrected

A room that stops reports the error and closes its connections with 4007; other rooms in the room host keep running. Errors in the stepper, in interest functions and in migrations also stop the room.

onCreate, onWake and onHibernate may be async: the room starts, or hibernates, once they resolve. The other hooks may return a promise, but the room doesn’t wait for it; a rejection is handled as a throw.

Alarms

A room can ask to run code at a time, whether it’s active or hibernated then, and without a player connected:

export default defineRoom({
  calls: {
    endTurn(room, connection) {
      room.state.turnEndsAt = room.now() + 24 * 3600_000;
      room.setAlarm(room.state.turnEndsAt);
    },
  },
  onAlarm(room, { scheduledAt, retry }) {
    if (room.state.turnEndsAt <= room.now()) skipTurn(room);
  },
});
  • One alarm per room. setAlarm(at) replaces the alarm, clearAlarm() drops it and room.alarm reads it. A room with several deadlines keeps them in its own state and sets the alarm to the earliest.
  • The alarm is room state. It survives hibernation and room host crashes, and is in RoomData.alarm; a migration can’t drop it. A call that sets one replies once it’s held, as for any change to room state.
  • When it fires. onAlarm runs at the alarm’s time by room.now(), never early, from the tick loop. A hibernated room wakes for it, with onWake’s reason set to "alarm", and then runs onAlarm at once. When it can’t wake in time, as when the project is out of credit, it runs at the next wake with scheduledAt in the past. scheduledAt is the time the alarm was set to.
  • At least once. The alarm is cleared once onAlarm returns or its promise resolves, unless it set a new one. Setting it again to the same time once that time has come doesn’t count as a new one. A room host that crashes inside it runs it again, so make it safe to repeat.
  • Retries. An onAlarm that throws runs again after 2, 4, 8, 16, 32 and 64 seconds (ALARM_RETRIES), with retry counting from 0, then the alarm is dropped and the error logged. One that set a new alarm before throwing keeps the new one and isn’t retried, unless the new one is already due, which runs on the same schedule.
  • Limits. setAlarm throws a RangeError for a time more than 365 days ahead (MAX_ALARM_AHEAD) or one that isn’t a finite number. A time in the past fires at once. Native rooms have no alarms.
  • What it costs. A room that wakes for its alarm with no one connected hibernates again as soon as onAlarm is done, and is billed at least 10 seconds for the wake. A room with no connections stays active while its alarm is less than 2 minutes away. A pending alarm counts as activity for the room’s retention.
  • Many alarms at once. Alarm wakes are limited to 50 a second per project, so thousands of rooms with an alarm at the same time, such as a daily reset, run late. Add a few minutes of jitter to such times.

irt dev, @irt/testing and the self-hosted room host fire alarms in process; in @irt/testing they follow the test clock, so room.host.advance reaches them.

Sleeping rooms

A room can hibernate while its players stay connected: it sleeps, the router holds their connections, and the next thing a player does wakes it. No one leaves, and time asleep costs no room-hours. It suits turn-based and board games, chat, presence pages and lobbies, where players stay connected but little happens.

export default defineRoom({
  sleep: { after: 60_000 },
  onWake(room, { reason, slept }) {
    // reason "message": a held connection sent something; slept: true
  },
});

room.sleep();   // sleep now, once no call is in flight
  • Opt in. With sleep.after, the room sleeps once it has gone that long with no frame from any client but a ping, nothing sent, broadcast or stored, no call or server API request in flight, and no player’s presence in its grace. Without it, a room sleeps only through room.sleep(). Opting in promises that the room keeps every deadline in its alarm, not in setTimeout or by counting ticks, and keeps in room state, or rebuilds in onWake, anything else it needs.
  • What’s kept. Presence, and each connection with its data as JSON of at most MAX_SLEPT_DATA (4 KB); a connection’s data over that, or that doesn’t serialize, wakes as {} and the room logs it. onLeave and onPlayerLeave don’t run; onHibernate does.
  • What wakes it. A message, call, owner write or presence status from a held connection, which the router holds and delivers after the wake, in order; a held connection closing, so the others see the player leave; a new connection; the alarm; a server API call. Unreliable messages and inputs are dropped while it sleeps, and the router answers pings. When the last held connection closes, the room stays asleep, and its next wake runs onLeave for everyone it saved.
  • At the wake. Each connection comes back with a new ID and its data, without onJoin. onWake runs with slept: true, then onLeave for saved connections that didn’t come back, whose players leave presence after the grace, then what they sent, then ticks resume and every client gets a full state update.
  • When it doesn’t sleep. The host refuses while the alarm is due within 2 minutes, and the room asks again after its next idle period.
  • Limits. A project’s sleeping rooms hold at most limits.maxSleepingConnections connections in a region, 10,000 by default; past it, a room that sleeps closes its connections with 4011, as requestHibernate does. A held connection may send 64 messages or 256 KB before its room wakes; past it, it closes with 4006. Native rooms don’t sleep.
  • What it costs. Every wake is billed at least 10 seconds, so a room that calls room.sleep() after every message pays 10 seconds a message; sleep.after avoids that. Held connections are metered, at no charge for now; see Credit.

Clients of @irt/client see room.asleep and the sleep and wake events; calls made while the room sleeps are answered after it wakes. irt dev, @irt/testing and the self-hosted room host hold sleeping rooms’ connections in process, as the router does.

Order within a tick: room.tick increments, then onPlayerLeave runs for players whose presence ends, then owner writes are applied through onWrite, then the stepper runs with this tick’s inputs, then onTick, then the state update goes to every connection, then input acks. Messages are handled as they arrive, between ticks, and their writes go out with the next tick’s state update.

A joining connection doesn’t wait for a tick: it gets the full state as soon as onJoin has run, or once the promise an async onJoin returns settles. That update carries the tick that last ran, and it also sends every other connection the changes made since that tick, so each connection still gets one ordered stream. Join time is the round trip plus onJoin, whatever the tick rate, so an idle game can run at tickRate: 1.

Because onLeave and onPlayerLeave run before every hibernation and onJoin runs again after a crash, where a game keeps a player’s data decides what survives; see Where player data lives. A room that creates a player’s entity in onJoin should reuse the one already there, and delete it in onPlayerLeave rather than onLeave, so a dropped network and a reconnect within 10 seconds keep it:

// avatars.ts
import { defineRoom, defineSchema, entity } from "@irt/room";

const Avatar = entity("avatar", { owner: "string", x: "f32" });

export default defineRoom({
  schema: defineSchema({ entities: [Avatar] }),
  onJoin(room, conn) {
    const existing = [...room.entities.all(Avatar)].find((a) => a.owner === conn.player);
    conn.data.avatar = existing ?? room.entities.create(Avatar, { owner: conn.player });
  },
  onPlayerLeave(room, player) {
    for (const a of room.entities.all(Avatar)) if (a.owner === player) room.entities.delete(a);
  },
});

After a room host crash the room restarts from room state with presence empty, so onPlayerLeave never runs for a player who doesn’t come back. A room that cares deletes, in onTick, avatars whose owner has had no connection for a while.

A defineRoom module is one room type: the project’s only one, named default, when irt.config.ts has room, or the type it’s named under in rooms. Each type has its own schema, version and migrations, so a lobby type and a match type migrate their room state independently. A room never changes type, so room code doesn’t check it.

Room

interface Room<S, P, D> {
  readonly project: string;
  readonly id: string;
  readonly joinCode: string | null; // the code createRoom gave the room, or null
  readonly meta: unknown;           // the meta the room was created with, or null; fixed for its life
  readonly creator: string | null;  // the player whose client created the room, or null
  readonly from: Handoff | null;    // the handoff that created the room, or null; see below
  readonly tick: number;           // see Ticks; kept across hibernation
  readonly tickRate: number;
  now(): number;                   // wall-clock ms; follows @irt/testing's clock, unlike Date.now()
  readonly entities: Entities;
  readonly presence: Presence;     // who's in the room; see Presence
  readonly state: RoomEntity<S>;   // shared state every connection sees
  private(connection: Connection): RoomEntity<P>;
  private(player: string): RoomEntity<P> | undefined;            // by player ID, connected or not
  private(player: string, opts: { create: true }): RoomEntity<P>; // creates it with defaults if missing
  privatePlayers(): string[];      // the player IDs that have private state
  readonly connections: ReadonlyMap<number, Connection>;
  readonly data: D;                // yours, for this room; not synced, not saved in room state
  send(connection: Connection, message: unknown, opts?: { unreliable?: boolean }): void;
  broadcast(message: unknown, opts?: { unreliable?: boolean; except?: Connection }): void;
  kick(connection: Connection, reason?: string): void;  // 4006
  ban(player: string, opts?: { reason?: string; expiresAt?: number }): void; // 4014, kept in room state
  unban(player: string): boolean;
  bans(): RoomBan[];               // { player, reason, expiresAt: number | null }, unexpired
  muteVoice(player: string, opts?: { expiresAt?: number }): void;  // kept in room state; see Proximity voice
  unmuteVoice(player: string): boolean;
  voiceMutes(): RoomMute[];        // { player, expiresAt: number | null }, unexpired
  requestHibernate(): void;
  sleep(): void;                   // hibernate keeping connections; see Sleeping rooms
  setAlarm(at: number): void;      // wall-clock ms; replaces any alarm set; see Alarms
  clearAlarm(): void;
  readonly alarm: number | undefined;  // the time set, or undefined
  handoff(opts: HandoffOptions): Promise<{ room: string; type: string }>;  // move people to another room; see below
  rewind<K, F>(history: History<K, F>, tick: number): ReadonlyMap<number, PastEntity<K, F>>;  // see History
  record(opts?: { id?: string; private?: boolean; messages?: boolean }): string;  // starts a replay; see below
  stopRecording(): void;
  readonly recording: string | undefined;  // the replay being recorded
  clip(opts: { seconds: number; id?: string }): string;  // keeps the last 1 to 60 seconds
  playerData(player: string): PlayerData;   // a player's data across rooms; see Player data below
  collection(name: string): Collection;     // a collection declared in irt.config.ts
  leaderboard(name: string): Leaderboard;   // a collection with leaderboard settings
}

interface Connection {
  readonly id: number;             // from 1, unique per room generation
  readonly player: string;         // the token's sub, or "guest:<random>"
  readonly meta: unknown;          // the token's meta claim, or null
  data: Record<string, any>;       // yours; not saved in room state
  readonly viewTick: number;       // the tick the player saw when it sent this tick's input; see below
  readonly input: unknown;         // the input applied this tick, in the stepper and onTick; see below
  readonly rtt: number | undefined; // the client's lowest recent round trip in ms, as it reports it
  readonly protocol: number;       // the client protocol the connection speaks
}

interface HandoffOptions {
  players: Iterable<string | { player: string }>;  // people connected to this room, by ID or presence entry
  to: { type: string; quickJoin?: boolean; meta?: Record<string, unknown> };
  stay?: boolean;                  // keep them connected here too; default false
}

interface Handoff {
  lobby: string;                   // the room they came from
  players: { id: string; name: string; status: unknown }[];  // with the presence they had there
  bots: { id: string; name: string; status: unknown }[];     // a lobby's bots, already in room.bots
  behaviour?: string;              // the behaviour those bots run
}
  • meta and creator. meta is what createRoom, the server API or a quick join gave the room. creator is the player whose client created it: through createRoom with a token, a quick join, or by connecting to a room that didn’t exist, guests included. A room created through the server API, or by createRoom without a token, has none. Both are the same after every wake.

  • handoff asks the router to move people connected to this room into another room: a new private room of to.type with to.meta, or with quickJoin a public room of it whose meta matches, with space for all of them. The router holds a seat there for each and tells their clients, which connect by themselves and leave this room unless stay is set. It resolves to the new room’s ID and type once they’ve been told. It rejects with an Error, sending nothing, for a bot, a player who isn’t connected, or one whose client is older than protocol 4, and with HandoffFailed, whose reason is players (none of them was still connected), limit (the project is at limits.maxRooms, or the room handed off 6 times in the last minute), spending, unavailable (the room couldn’t be made, or the router didn’t answer within 15 seconds) or invalid, and nobody moves. See Lobbies and parties.

  • from is set in a room a handoff created, from a lobby or another room’s handoff, before onCreate and after every wake; it’s null otherwise, and in a room a handoff’s quick join picked or made. A lobby’s bots are in room.bots with their IDs, names and statuses before onCreate, running from.behaviour.

  • rtt is what the client reports in its pings, undefined until its first and for a client older than protocol 3. A client can lie about it, as about its view tick, so cap what a game gives for it, such as the latency a rhythm hit is judged with.

  • View tick. Each input carries the tick of the room state the player was looking at when it sent it: behind the input’s tick by the round trip and the client’s interpolation delay. viewTick is that tick for the input applied this tick, or the last one applied, and never more than MAX_REWIND_MS (250 ms) behind the room. Rewind to it with room.rewind to check a hit against what the shooter saw.

  • Input. input is the input applied this tick, as the stepper’s parseInput returned it, or undefined when the player sent none for this tick or the room has no stepper. It’s set before the stepper runs and cleared after onTick, so onTick can act on a shot in the input with the viewTick it was aimed at. It’s undefined between ticks.

  • Messages. A Uint8Array is sent as a binary message; anything else as JSON. A message is at most 64 KB; send and broadcast throw a RangeError over that. unreliable: true sends a WebTransport datagram, or over WebSocket in order with stale ones dropped. Clients may send at most 120 messages per second; over that the router closes them with 4005.

  • Private state is kept by player ID, so a player who reconnects or rejoins gets theirs back. It’s created with defaults at the player’s first join, and dropped once the player has been away for the room’s privateRetention of wall-clock time, hibernated time included, checked on wake and every 30 seconds. privateRetention defaults to PRIVATE_RETENTION, 24 hours, so a room many players pass through doesn’t keep everyone’s forever; "forever" keeps it as long as the room.

  • private(player) with a player ID works whether or not the player is connected: give an item to a teammate who’s away, credit loot to a player who dropped, or grant one from onRequest. It returns undefined for a player with no private state, and creates nothing, so a mistyped or hostile ID from a message doesn’t allocate room state. private(player, { create: true }) creates it with defaults, as the player’s first join would, so a backend can grant an item before the player ever joins; its retention then counts from that moment until the player connects. Only create for IDs you trust: with privateRetention: "forever", state created for an ID that never joins is never dropped. A changed field reaches the player when they’re connected, and is in room state either way. A player ID that’s empty or over 256 characters throws a TypeError. With a connection, private(conn) always returns that connection’s state. privatePlayers() lists the player IDs that have private state, the connected ones included.

  • kick(connection, reason?) closes the connection with 4006. The client doesn’t reconnect by itself, but the player can join again. reason, at most MAX_CLOSE_REASON (123) UTF-8 bytes or it throws, reaches the client as IrtError.reason. onLeave runs for the connection. A connection kicked before its first state update, as from onJoin before any await, never shows in any client’s presence.

  • ban(player, { reason, expiresAt }) bans a player from this room only: their open connections close with 4014 and reason, and later ones are closed the same way before onJoin runs or presence shows them. expiresAt is wall-clock milliseconds, as Date.now() gives; without it the ban lasts until unban. Bans are kept in room state, so they survive hibernation, and they’re in RoomData.bans; a migration can’t drop them. A room holds MAX_ROOM_BANS (10,000). To keep a player out of every room of the project, ban them from your backend, the dashboard or the CLI; see Moderation.

  • requestHibernate hibernates the room now. Open connections run onLeave and are closed with 4011; their clients don’t reconnect by themselves. Server API messages the room is still answering finish first, so an onRequest that calls it still sends its reply, and what it writes is in the room state. Messages that arrive meanwhile get an error.

  • sleep asks to sleep: hibernate while the router holds the room’s connections; see Sleeping rooms.

  • Alarms. setAlarm(at) asks for onAlarm at at; see Alarms.

  • Replays. record starts recording what a connection that sees everything would see, without private state unless private: true, and returns the replay’s ID; it stops at hibernation and must be started again in onWake. clip keeps the end of the recording past its retention. Both throw past their limits. See Replays and clips.

  • One connection per player: when a player connects again, the older connection closes with 4012 and onLeave runs for it before onJoin for the new one. The older client doesn’t reconnect.

Player data, collections and leaderboards

interface PlayerData {
  get<T>(key: string): Promise<{ value: T; version: number } | undefined>;
  set(key: string, value: unknown, opts?: WriteOptions): Promise<{ version: number }>;
  increment(key: string, by?: number, opts?: { once?: string }): Promise<number>;   // creates the key at 0
  delete(key: string, opts?: WriteOptions): Promise<boolean>;
  list(): Promise<{ key: string; value: unknown; version: number }[]>;
  refresh(): Promise<void>;                                  // drops what the room's cache holds
}

interface Collection {
  get<T>(id: string): Promise<Entry<T> | undefined>;
  put(entry: { id?: string; value: unknown; meta?: Meta; score?: number | null; owner?: string | null; version?: number; once?: string }): Promise<{ id: string; version: number }>;
  list(opts?: { where?: Meta; owner?: string; order?: EntryOrder; limit?: number; after?: string | null }): Promise<{ entries: EntrySummary[]; next: string | null }>;
  delete(id: string, opts?: WriteOptions): Promise<boolean>;
  refresh(): void;
}

interface Leaderboard {
  submit(player: string, score: number, opts?: { meta?: Meta; once?: string }): Promise<{ period: string; score: number }[]>;
  top(opts?: { period?: string; limit?: number; after?: string | null }): Promise<{ entries: RankedEntry[]; next: string | null }>;
  around(player: string, opts?: { period?: string; count?: number }): Promise<{ rank: number; entries: RankedEntry[] } | undefined>;
  refresh(): void;
}

type Meta = Record<string, string | number | boolean>;   // flat, at most 1 KB
// `version` must match, 0 meaning none. A write with a `once` key the project used in the last
// day resolves as the first did without writing again; see Player data.
interface WriteOptions { version?: number; once?: string }
type EntryOrder = "score" | "-score" | "created" | "-created" | "updated" | "-updated";
interface EntrySummary { id: string; meta: Meta; score: number | null; owner: string | null; version: number; created: number; updated: number }
interface Entry<T> extends EntrySummary { value: T }
interface RankedEntry { rank: number; player: string; score: number; meta: Meta; updated: number }

Player data is per player per project, so it outlives every room; collections and leaderboards are per project, declared under settings.data in irt.config.ts. The guide is Player data, collections and leaderboards.

  • Values are JSON or a Uint8Array, at most 64 KB. A player has at most 256 keys and 1 MB. Keys and entry IDs are 1 to 64 of A-Z a-z 0-9 _ . : -.
  • Versions start at 1 and go up on every write. version on set, put and delete makes the write fail with E_VERSION unless the key or entry has it; 0 means it must not exist. increment takes none.
  • Lists give at most 100 entries a page, 20 by default, without values; pass next as after. where matches meta keys by equality. Ties go by id; entries without a score come last in both score orders. put replaces the whole entry and keeps created.
  • Leaderboards. submit applies the board’s keep to every period it declares. period is "all" (the default), "day", "week" or "month" for the current one in UTC, or a full name such as "day:2026-11-06", "week:2026-W45" or "month:2026-11" for one of the three before it. Ranks go by score, then the earliest updated. count on around is 0 to 10, default 2.
  • The cache. A get within 5 seconds of the room’s last read or write of the same key or entry comes from memory, so the room sees its own writes at once and other writers’ within 5 seconds. refresh() drops what the room holds of that player or collection. list, top and around are never cached.
  • Errors. Calls reject with DataError from @irt/room, whose code is E_INVALID, E_FORBIDDEN, E_UNKNOWN, E_VERSION, E_TYPE, E_QUOTA, E_LIMIT, E_SPENDING or E_UNAVAILABLE; see Errors. After E_UNAVAILABLE a write may or may not have happened: retry it with version. A room host whose parent is too old for data calls gets E_UNAVAILABLE for every one.
  • A room makes at most 200 data calls a second and 64 at once.

Room data and module scope

A room host loads the room module once and runs many rooms of it, of one build, in one process. So a variable at the top level of the module, or of any module it imports:

  • is shared by every room in that room host, which start, hibernate and stop independently of each other;
  • outlives a room that hibernates, holding whatever it left there, and doesn’t follow the room when it wakes in another room host;
  • is lost when the room host restarts, crashes or drains.

Keep constants, schemas, steppers, grid objects and other code that reads no room’s data at module level. Keep a room’s own derived data, such as a lookup table built from its entities, in room.data: one plain object per room, not synced and not saved in room state. It starts as what data() returns, or {}, each time the room starts or wakes, so build it in onCreate and again in onWake. data() also types it:

// spawns.ts
import { defineRoom, defineSchema, entity, type RoomEntity } from "@irt/room";

const Spawn = entity("spawn", { team: "string", x: "f32", y: "f32" });
type S = RoomEntity<typeof Spawn>;

function byTeam(spawns: Iterable<S>): Map<string, S[]> {
  const out = new Map<string, S[]>();
  for (const s of spawns) out.set(s.team, [...(out.get(s.team) ?? []), s]);
  return out;
}

export default defineRoom({
  schema: defineSchema({ entities: [Spawn] }),
  data: () => ({ byTeam: new Map<string, S[]>() }),
  onCreate(room) {
    room.entities.create(Spawn, { team: "red", x: 0, y: 0 });
    room.entities.create(Spawn, { team: "blue", x: 100, y: 0 });
    room.data.byTeam = byTeam(room.entities.all(Spawn));
  },
  onWake(room) {
    room.data.byTeam = byTeam(room.entities.all(Spawn));
  },
  onJoin(room, conn) {
    const team = (conn.meta as { team?: string } | null)?.team ?? "red";
    conn.data.spawn = room.data.byTeam.get(team)?.[0];
  },
});

room.data holds entity objects safely: entities are new objects after a wake, and so is room.data. Steppers don’t get room; for data a stepper needs, see Entity IDs and lookups. A stepper’s own caches key by the world that step, save and load get, one object per room; see Steppers.

Calling your backend

Room code may call out over HTTPS with fetch, to the game’s own backend or any public API. Authenticate with a secret, which room code reads from process.env.

Deployed rooms run in VMs with no network interface, and fetch goes through an egress proxy without room code doing anything. The proxy allows:

  • https:// URLs on port 443, by host name. An IP address in the URL, another port, and a host name that resolves to a private, loopback or link-local address are refused, and fetch rejects.
  • fetch only. http:// URLs, node:http and node:https, libraries built on them such as axios, WebSocket clients and raw sockets have no network.
  • 64 connections open at once and 10 MB a second in both directions together, per VM, which runs many of a project’s rooms. A connection idle for 5 minutes is closed.

Outbound traffic isn’t billed. It is counted for acceptable use, which bans running a proxy. There’s no timeout of IRT’s own on a call, so pass one, as below.

irt dev, @irt/testing and the staging stack run rooms as processes on the machine, where fetch has the machine’s network: http://, other ports and localhost work there, and not when deployed.

IRT never retries a hook or a call, except onAlarm when it throws, and only onCreate, onWake and onHibernate are awaited. So:

  • Make every write safe to repeat, with an ID the backend recognises: a fetch can time out after the backend applied it.
  • Send one room’s calls one at a time when their order matters, such as a player’s last save before the release that follows it.
  • Wait for calls in flight in onHibernate, so a room doesn’t sleep with them unsent. onLeave runs for every connection before it.
// backend.ts
import { defineRoom, defineSchema, entity } from "@irt/room";

const Score = entity("score", { total: "u32" });

async function save(player: string, total: number, id: string): Promise<void> {
  const res = await fetch("https://api.example.com/scores", {
    method: "POST",
    headers: { authorization: `Bearer ${process.env.BACKEND_KEY}`, "content-type": "application/json" },
    body: JSON.stringify({ id, player, total }),
    signal: AbortSignal.timeout(5000),
  });
  if (!res.ok) throw new Error(`saving the score failed with ${res.status}`);
}

export default defineRoom({
  schema: defineSchema({ state: Score }),
  data: () => ({ calls: Promise.resolve() }),
  onLeave(room, conn) {
    const total = room.state.total;
    const id = `${room.id}:${conn.player}:${room.tick}`;
    room.data.calls = room.data.calls.then(() => save(conn.player, total, id)).catch((e) => console.error(e));
  },
  async onHibernate(room) {
    await room.data.calls;
  },
});

Entities

class Entities implements World {
  create<K extends Kind>(kind: K, values?: Partial<ValuesOf<K>>, opts?: { id?: number; owner?: string }): RoomEntity<K>;
  delete(entity: Entity): void;
  setOwner(entity: Entity, owner: string | undefined): void;   // a player ID, or undefined for the room
  ownerOf(entity: Entity): string | undefined;
  ownedBy<K extends Kind>(player: string, kind: K): IterableIterator<RoomEntity<K>>;
  get(id: number): RoomEntity | undefined;
  all<K extends Kind>(kind: K): IterableIterator<RoomEntity<K>>;
  readonly count: number;
}

type RoomEntity<K> = ValuesOf<K> & {
  readonly id: number;
  readonly kind: K;
  patch(field: BytesField<K>, offset: number, bytes: Uint8Array): void;
};
  • Entity IDs are assigned from 2; 0 and 1 are the shared and private state. An automatic ID is above every ID the room has used, so it’s never reused.
  • { id } sets the ID instead: an integer from 2 to MAX_ENTITY_ID (2^32 − 1) that no live entity has, such as one computed from a chunk’s coordinates. Otherwise create throws, a RangeError for an ID out of range and an Error naming the live entity that has it. See Entity IDs and lookups.
  • get(id) is a map lookup. all(kind) visits every entity of the kind on each call. get(0) and all of the state kind give room.state, as a stepper’s world does on both sides. count is the number of entities, hidden ones included; room.state, private state and presence aren’t entities here, unlike on the client.
  • Fields missing from create get defaults: false, 0, "", empty bytes, null.
  • { owner } and setOwner give an entity to a player, who may then write its kind’s writable fields from the client. They throw for a hidden kind, and for an owner that isn’t a non-empty player ID of at most MAX_PLAYER_ID (256) characters. See Ownership.
  • A write of the wrong type throws a TypeError; an integer out of range or a value over its size limit throws a RangeError, naming the field.
  • See State sync and interest for field types, size limits and how writes are tracked.

Presence

interface Presence {
  get(player: string): PresenceEntry | undefined;
  readonly size: number;
  [Symbol.iterator](): IterableIterator<PresenceEntry>;
  set(player: string, values: { name?: string; status?: unknown }): void;
}

interface PresenceEntry {
  readonly player: string;
  readonly name: string;        // MAX_PRESENCE_NAME (64) characters at most
  readonly status: unknown;     // JSON, MAX_PRESENCE_STATUS (1024) bytes at most; null until set
  readonly connected: boolean;
}
  • The runtime adds a player’s entry before onJoin and removes it PRESENCE_GRACE (10 seconds) after its last connection closes; from the tick after that close until then, connected is false. A player who reconnects in time keeps its entry and status.
  • name starts as the name string of the token’s meta, when it has one of at most 64 characters, else "". The player writes status from the client; onWrite isn’t called for it.
  • set throws for a player with no entry or a value over its limit. A status it sets differently from the player’s write reaches the player as a correction.
  • Every connection sees every entry, whatever interest says, unless the room defines seesPresence(room, viewer, entry): then a viewer sees another player’s entry only while it returns true, such as to hide players the viewer blocked. It runs each tick for every pair, so keep it cheap, and a player always sees its own entry. An entry counts toward the 16,384 entities a connection sees.
  • A player banned with room.ban, or kicked before its first state update, never gets an entry anyone sees. A room holds at most MAX_PRESENCE (1,024) entries; past that, the entry of the player gone longest makes room.
  • Presence isn’t room state: it’s empty after a wake until clients reconnect. room.entities doesn’t hold it, and it doesn’t change the IDs entities get. See Presence.

Schemas

function entity<F extends Record<string, FieldType>, W extends keyof F>(name: string, fields: F, opts?: { writable?: readonly W[]; ownerOnly?: readonly (keyof F)[]; hidden?: boolean }): Kind<F, W>;
function json<T = unknown>(): Json<T>;   // a json field typed T; "json" at runtime, same schema hash
function defineSchema<S extends Kind, P extends Kind, C extends Calls>(decl: { state?: S; private?: P; entities?: readonly Kind[]; calls?: C; input?: Kind }): Schema<S, P, C>;

class Schema<S, P, C> {
  readonly hash: number;           // sent by clients in hello; a mismatch is close code 4004
  readonly state: S;
  readonly private: P;
  readonly entities: readonly Kind[];
  readonly calls: C;               // not part of the hash
  readonly input: Kind | undefined;  // part of the hash when set
}
  • { hidden: true } declares a hidden kind. Its entities are room state like any other: saved, restored on wake, kept across a room host crash, and read and written through room.entities in hooks and calls. A stepper must not depend on them: the room’s world is room.entities and holds them, but a client’s prediction world never does, so a step that reads them predicts differently from the room. The room never sends them to a connection: they get no interest keys, whatever interest returns, voice doesn’t see them, and their creation, changes and deletion send nothing. Only the inspector in irt dev shows them, marked hidden. See Hidden entities.
  • entity throws for a hidden kind with writable or ownerOnly fields, since no player could see an entity it owned, and { owner } and setOwner throw for its entities. defineSchema throws for a hidden state or private kind.
  • ownerOnly fields are sent only to the connections of the entity’s owner; see Ownership. defineSchema throws for them on the state, private or input kind.
  • A hidden kind is in the schema and its hash like any kind, and its declaration is in the client’s bundle with the schema module; its entities’ values never are.

input declares the fields of the stepper’s inputs as a kind, so prediction inputs go as binary in that layout instead of as JSON; see Compact inputs. It’s never an entity, and it can’t have writable fields. A schema without one has the same hash as before schemas could declare it.

Calls

function defineCalls<T extends { [name: string]: (args?: any) => unknown }>(parse?: { [name in keyof T]?: (raw: unknown) => Args }): Calls<T>;

A call is a request from one client that the room answers, apart from state sync. The schema declares the calls with defineCalls, as a type of signatures with at most one argument, and the room’s calls has a handler for each, checked against it: its args have the declared type and it returns or resolves to the declared reply. The client’s call is checked against the same declaration; see the calls guide.

  • Args and replies are JSON, or bytes as a Uint8Array, at most 64 KB. A reply of undefined arrives as null.
  • Args come from the client and the types don’t check them at runtime. Without a parse function the handler gets the decoded JSON as is, so it must not trust it. With one, the room runs it on the decoded JSON or bytes first and passes what it returns to the handler; if it throws, the call rejects with the error’s message and the room doesn’t log it.
  • A handler rejects a call by throwing; the client’s call rejects with the error’s message. throw new CallRejected(message) is an expected rejection, such as a game rule, and isn’t logged; any other error is logged at error level with its stack.
  • A handler runs as the call arrives, between ticks, like onMessage. When it changed room state, its reply goes out after the next tick has saved the change and sent its state update, so a reply means the change is saved outside the VM; otherwise, and for errors, at once.
  • A call in flight when the connection drops or the room restarts fails on the client and isn’t retried. The handler may or may not have run.

Interest

interface Interest {
  keysOf(room: Room, entity: RoomEntity): Iterable<string | number>;
  subscriptionsOf(room: Room, connection: Connection): Iterable<string | number>;
  sees?(room: Room, connection: Connection, entity: RoomEntity): boolean;   // narrows what shares a key, before limit
  limit?: { max: number; priority(room: Room, connection: Connection, entity: RoomEntity): number };
}

type GridOptions = {
  cellSize: number;
  dims: "xy" | "xz" | "xyz";
  position?: (room: Room, e: RoomEntity) => Position | undefined;
  around(room: Room, c: Connection): RoomEntity | Position | undefined;   // an entity is placed by `position` too
  prefix?: string;                 // starts every key the grid gives, EVERYWHERE included
  kinds?: Kind[];                  // the kinds it places; others get no key from it
} & ({ range: number } | { radius: number });   // one of the two
type InterestPart = Pick<Interest, "keysOf" | "subscriptionsOf" | "sees">;
function grid(opts: GridOptions): InterestPart;
function combine(...parts: InterestPart[]): Required<InterestPart>;
function cellKey(...cell: number[]): string;  // "3,-1" or "3,-1,0"
const EVERYWHERE = "*";
  • range is a distance in world units. A connection sees exactly the entities within it of around(connection): a circle on "xy" and "xz", a sphere on "xyz". The grid picks the cells that overlap it and its sees drops the rest.
  • radius is a whole number of cells. A connection sees every entity in the square, or cube, of cells within radius cells of its own cell on each axis, so the distance it sees varies with where it stands in its cell. sees is undefined.
  • grid throws a TypeError given both or neither.
  • combine joins parts into one interest: a connection sees an entity when, in some part, they share a key and that part’s sees, if any, passes it. Each grid keeps its own cells and range. Spread it to add a limit: { ...combine(a, b), limit }. See Combining grids and keys.

Caps: 4,096 subscribed keys and 16,384 visible entities per connection. See State sync and interest.

Room state and migrations

Room state is the room’s entities, room.state, every player’s private state, the tick, and what the stepper’s save(world) returned for the room. The SDK saves it by itself: after each tick with changes it streams the changes out of the room host, and every 30 seconds it writes a full copy. Room state is at most 32 MB; a room whose room state grows past that stops with the reason room state is N MB; the limit is 32 MB.

A build that changes room state’s shape increments version and adds an up migration for each version step. A migration receives plain data and returns it at the next version:

type Migration = (data: RoomData) => RoomData;

interface RoomData {
  version: number;
  tick: number;
  savedAt: number;                 // wall-clock ms when it was written
  nextId: number;
  state: Record<string, unknown>;
  private: Record<string, Record<string, unknown>>;  // by player ID
  privateSeen: Record<string, number>;               // when each player was last connected
  entities: { id: number; kind: string; values: Record<string, unknown> }[];
  stepper: unknown;
}
// migrations.ts
import { defineRoom, defineSchema, entity } from "@irt/room";

// Version 1 had { hp: "u8" }; version 2 renames it to health and adds a team.
const Player = entity("player", { owner: "string", health: "u16", team: "string" });

export default defineRoom({
  schema: defineSchema({ entities: [Player] }),
  version: 2,
  migrations: {
    2: (data) => ({
      ...data,
      entities: data.entities.map((e) =>
        e.kind !== "player"
          ? e
          : { ...e, values: { owner: e.values.owner, health: e.values.hp, team: "red" } },
      ),
    }),
  },
});

On wake, room state written at an older version runs through each migration in turn. A missing migration, a kind or field the build doesn’t declare, or a value of the wrong type stops the room with a reason that says which. Fields the build declares and the data lacks get defaults.

There are no down migrations. irt build records version in the build, and when a rollback makes an older build live, a room whose room state is at a newer version wakes from the room state kept from before its migration instead; see New builds.

Ticks

The room host runs every room’s ticks with a fixed timestep. Rooms in one room host tick at staggered phases. After a stall a room runs at most 4 ticks to catch up and drops the rest. Ticks stop while a room is hibernated; on wake onWake gets the elapsed time instead of catch-up ticks. Ticks still run for the 30 seconds before an empty room hibernates; see Timers across hibernation.

room.tick counts the ticks the room has run:

  • It’s 0 in onCreate. A tick first increments it, then runs the stepper and onTick, so inside the stepper and onTick it’s the number of the tick running, 1 for a new room’s first.
  • Between ticks, in onJoin, onMessage, onLeave and onRequest, it’s the number of the last tick run. Writes made there go out with tick room.tick + 1.
  • It’s kept in room state, so it carries on across hibernation and crashes, and it doesn’t count time spent hibernated.
  • The client’s room.tick is the tick of the last state update it applied.

So a round that starts in onTick and lasts 60 seconds at 20 ticks per second can store endsAt = room.tick + 1200 and end in the onTick where room.tick >= endsAt, exactly 1200 ticks later. Tests can run it to the tick with host.tick(n).

The room host times each tick’s parts and each hook, and under irt dev the inspector shows p50, p95 and max per second; see the tick profiler.

History

function defineHistory<K extends Kind, F extends RecordableField<K>>(kind: K, fields: readonly F[]): History<K, F>;

type PastEntity<K, F> = { readonly id: number } & Readonly<Pick<ValuesOf<K>, F>>;

const MAX_HISTORY = 8 * 1024 * 1024;   // bytes, all of a room's histories together

A history is the fields of one entity kind that a room records after each tick’s state update, for lag compensation. List it in the definition’s history. room.rewind(history, tick) returns the entities of the kind as tick tick’s state update sent them, keyed by ID, each a frozen { id, ...fields }:

  • An entity that didn’t exist at tick is absent.
  • A tick older than the oldest kept is clamped to it. A tick not yet recorded, such as room.tick in onTick, or a later one, gives the entities as they are now. A fractional tick is rounded down.
  • The room keeps ceil(tickRate × MAX_REWIND_MS / 1000) + 2 ticks: 7 at 20 ticks a second, 32 at 120.
  • Each recorded entity costs 8 bytes per field per kept tick, plus 128 bytes. Past MAX_HISTORY, new entities aren’t recorded until deleted ones leave the kept ticks, and the room logs an error once.
  • The history isn’t room state: it’s never saved and starts empty when the room starts or wakes.

defineHistory throws a TypeError for a field the kind doesn’t have, a string, bytes or json field, a field listed twice, or no fields. defineRoom throws when history lists a kind that isn’t one of the schema’s entity kinds. room.rewind throws for a history the definition doesn’t list, and a TypeError for a tick that isn’t a finite number.

Steppers don’t get rewind: a stepper runs on the client too, which keeps no history. Check hits in onTick.

Running rooms yourself

The room host is the irt-room-host command in @irt/room. Given a build directory from irt build, or a bundled room module as the type default, it serves clients directly over WebSocket with room state on disk, every room type of the build included, which is what irt dev runs:

npx irt-room-host .irt/build/stage

Self-hosting covers its configuration: port, address, data directory, signing keys, guest key, settings and site. Two more variables run it under a host daemon instead:

Variable Default Meaning
IRT_ROOM_HOST_SOCKET none When set, connect to this Unix socket as a room host instead
IRT_BUILD empty The build ID the room host reports on its socket

Under a host daemon it runs the build’s standard room types, loading each type’s module when its first room starts; native types run in the host daemon’s native room host instead.

A watchdog kills the process if its event loop is blocked for more than 5 seconds.

Common mistakes

  • Doing slow work in onTick. Rooms in a room host share one event loop; a long tick delays every room in it.
  • Keeping game state in module variables, room.data or conn.data. None of them is room state; it’s lost on hibernate and on a room host restart. Use entities, room.state and private state.
  • Keeping one room’s data in a module variable. Every room in the room host shares it. Use room.data; see Room data and module scope.
  • Throwing from onMessage to reject a message. It works, but it’s logged as an error. Return early instead.
  • Expecting onLeave after a crash. A room host that crashes runs no hooks; the room wakes from its room state and onWake runs, then onJoin for each player who reconnects. Find a player’s entity from before rather than creating a second one.
  • Storing what should last on an entity deleted in onLeave. onLeave runs before every hibernation, so it’s gone then too. See Where player data lives.
  • Changing a kind without a migration. A field the build doesn’t declare in old room state stops the room on wake.
  • Holding entity objects across hibernation. Entities are new objects after a wake; find them again in onWake or by ID.

View this page as Markdown