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 androom.alarmreads 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.
onAlarmruns at the alarm’s time byroom.now(), never early, from the tick loop. A hibernated room wakes for it, withonWake’sreasonset to"alarm", and then runsonAlarmat once. When it can’t wake in time, as when the project is out of credit, it runs at the next wake withscheduledAtin the past.scheduledAtis the time the alarm was set to. - At least once. The alarm is cleared once
onAlarmreturns 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
onAlarmthat throws runs again after 2, 4, 8, 16, 32 and 64 seconds (ALARM_RETRIES), withretrycounting 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.
setAlarmthrows aRangeErrorfor 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
onAlarmis 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 throughroom.sleep(). Opting in promises that the room keeps every deadline in its alarm, not insetTimeoutor by counting ticks, and keeps in room state, or rebuilds inonWake, anything else it needs. - What’s kept. Presence, and each connection with its
dataas JSON of at mostMAX_SLEPT_DATA(4 KB); a connection’sdataover that, or that doesn’t serialize, wakes as{}and the room logs it.onLeaveandonPlayerLeavedon’t run;onHibernatedoes. - 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
onLeavefor everyone it saved. - At the wake. Each connection comes back with a new ID and its
data, withoutonJoin.onWakeruns withslept: true, thenonLeavefor 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.maxSleepingConnectionsconnections in a region, 10,000 by default; past it, a room that sleeps closes its connections with 4011, asrequestHibernatedoes. 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.afteravoids 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
}
-
metaandcreator.metais whatcreateRoom, the server API or a quick join gave the room.creatoris the player whose client created it: throughcreateRoomwith 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 bycreateRoomwithout a token, has none. Both are the same after every wake. -
handoffasks the router to move people connected to this room into another room: a new private room ofto.typewithto.meta, or withquickJoina public room of it whosemetamatches, 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 unlessstayis set. It resolves to the new room’s ID and type once they’ve been told. It rejects with anError, sending nothing, for a bot, a player who isn’t connected, or one whose client is older than protocol 4, and withHandoffFailed, whosereasonisplayers(none of them was still connected),limit(the project is atlimits.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) orinvalid, and nobody moves. See Lobbies and parties. -
fromis set in a room a handoff created, from a lobby or another room’shandoff, beforeonCreateand after every wake; it’snullotherwise, and in a room a handoff’s quick join picked or made. A lobby’s bots are inroom.botswith their IDs, names and statuses beforeonCreate, runningfrom.behaviour. -
rttis 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.
viewTickis that tick for the input applied this tick, or the last one applied, and never more thanMAX_REWIND_MS(250 ms) behind the room. Rewind to it withroom.rewindto check a hit against what the shooter saw. -
Input.
inputis the input applied this tick, as the stepper’sparseInputreturned it, orundefinedwhen the player sent none for this tick or the room has no stepper. It’s set before the stepper runs and cleared afteronTick, soonTickcan act on a shot in the input with theviewTickit was aimed at. It’sundefinedbetween ticks. -
Messages. A
Uint8Arrayis sent as a binary message; anything else as JSON. A message is at most 64 KB;sendandbroadcastthrow aRangeErrorover that.unreliable: truesends 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
privateRetentionof wall-clock time, hibernated time included, checked on wake and every 30 seconds.privateRetentiondefaults toPRIVATE_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 fromonRequest. It returnsundefinedfor 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: withprivateRetention: "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 aTypeError. 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 mostMAX_CLOSE_REASON(123) UTF-8 bytes or it throws, reaches the client asIrtError.reason.onLeaveruns for the connection. A connection kicked before its first state update, as fromonJoinbefore anyawait, never shows in any client’s presence. -
ban(player, { reason, expiresAt })bans a player from this room only: their open connections close with 4014 andreason, and later ones are closed the same way beforeonJoinruns or presence shows them.expiresAtis wall-clock milliseconds, asDate.now()gives; without it the ban lasts untilunban. Bans are kept in room state, so they survive hibernation, and they’re inRoomData.bans; a migration can’t drop them. A room holdsMAX_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. -
requestHibernatehibernates the room now. Open connections runonLeaveand are closed with 4011; their clients don’t reconnect by themselves. Server API messages the room is still answering finish first, so anonRequestthat calls it still sends its reply, and what it writes is in the room state. Messages that arrive meanwhile get an error. -
sleepasks to sleep: hibernate while the router holds the room’s connections; see Sleeping rooms. -
Alarms.
setAlarm(at)asks foronAlarmatat; see Alarms. -
Replays.
recordstarts recording what a connection that sees everything would see, without private state unlessprivate: true, and returns the replay’s ID; it stops at hibernation and must be started again inonWake.clipkeeps 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
onLeaveruns for it beforeonJoinfor 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 ofA-Z a-z 0-9 _ . : -. - Versions start at 1 and go up on every write.
versiononset,putanddeletemakes the write fail withE_VERSIONunless the key or entry has it;0means it must not exist.incrementtakes none. - Lists give at most 100 entries a page, 20 by default, without
values; pass
nextasafter.wherematchesmetakeys by equality. Ties go byid; entries without a score come last in bothscoreorders.putreplaces the whole entry and keepscreated. - Leaderboards.
submitapplies the board’skeepto every period it declares.periodis"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 earliestupdated.countonaroundis 0 to 10, default 2. - The cache. A
getwithin 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,topandaroundare never cached. - Errors. Calls reject with
DataErrorfrom@irt/room, whosecodeisE_INVALID,E_FORBIDDEN,E_UNKNOWN,E_VERSION,E_TYPE,E_QUOTA,E_LIMIT,E_SPENDINGorE_UNAVAILABLE; see Errors. AfterE_UNAVAILABLEa write may or may not have happened: retry it withversion. A room host whose parent is too old for data calls getsE_UNAVAILABLEfor 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, andfetchrejects.fetchonly.http://URLs,node:httpandnode: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
fetchcan 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.onLeaveruns 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 toMAX_ENTITY_ID(2^32 − 1) that no live entity has, such as one computed from a chunk’s coordinates. Otherwisecreatethrows, aRangeErrorfor an ID out of range and anErrornaming 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)andallof the state kind giveroom.state, as a stepper’s world does on both sides.countis the number of entities, hidden ones included;room.state, private state and presence aren’t entities here, unlike on the client.- Fields missing from
createget defaults:false,0,"", empty bytes,null. { owner }andsetOwnergive 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 mostMAX_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 aRangeError, 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
onJoinand removes itPRESENCE_GRACE(10 seconds) after its last connection closes; from the tick after that close until then,connectedis false. A player who reconnects in time keeps its entry and status. namestarts as thenamestring of the token’smeta, when it has one of at most 64 characters, else"". The player writesstatusfrom the client;onWriteisn’t called for it.setthrows 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
interestsays, unless the room definesseesPresence(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 mostMAX_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.entitiesdoesn’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 throughroom.entitiesin hooks and calls. A stepper must not depend on them: the room’sworldisroom.entitiesand 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, whateverinterestreturns, voice doesn’t see them, and their creation, changes and deletion send nothing. Only the inspector inirt devshows them, marked hidden. See Hidden entities.entitythrows for a hidden kind withwritableorownerOnlyfields, since no player could see an entity it owned, and{ owner }andsetOwnerthrow for its entities.defineSchemathrows for a hiddenstateorprivatekind.ownerOnlyfields are sent only to the connections of the entity’s owner; see Ownership.defineSchemathrows for them on thestate,privateorinputkind.- 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 ofundefinedarrives asnull. - Args come from the client and the types don’t check them at runtime.
Without a
parsefunction 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
callrejects 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 = "*";
rangeis a distance in world units. A connection sees exactly the entities within it ofaround(connection): a circle on"xy"and"xz", a sphere on"xyz". The grid picks the cells that overlap it and itsseesdrops the rest.radiusis a whole number of cells. A connection sees every entity in the square, or cube, of cells withinradiuscells of its own cell on each axis, so the distance it sees varies with where it stands in its cell.seesis undefined.gridthrows aTypeErrorgiven both or neither.combinejoins parts into one interest: a connection sees an entity when, in some part, they share a key and that part’ssees, if any, passes it. Each grid keeps its own cells and range. Spread it to add alimit:{ ...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 andonTick, so inside the stepper andonTickit’s the number of the tick running, 1 for a new room’s first. - Between ticks, in
onJoin,onMessage,onLeaveandonRequest, it’s the number of the last tick run. Writes made there go out with tickroom.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.tickis 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
tickis absent. - A tick older than the oldest kept is clamped to it. A tick not yet
recorded, such as
room.tickinonTick, 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) + 2ticks: 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.dataorconn.data. None of them is room state; it’s lost on hibernate and on a room host restart. Use entities,room.stateand 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
onMessageto reject a message. It works, but it’s logged as an error. Return early instead. - Expecting
onLeaveafter a crash. A room host that crashes runs no hooks; the room wakes from its room state andonWakeruns, thenonJoinfor 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.onLeaveruns 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
onWakeor by ID.