Rooms
Room state and the managed store
What IRT saves for a room, when it's written, what a crash or a failed host loses, and the limits on room state.
Room state is everything IRT saves for a room so it can hibernate, wake in another room host, and survive crashes. The SDK’s managed store writes it for you: room code never saves or loads anything. This page says what’s in it and when it’s written; How rooms hibernate and wake says when rooms sleep and wake.
What’s saved
| Saved | Not saved |
|---|---|
| Every entity, hidden ones included: its ID, kind, fields and owner | room.data, rebuilt by data() on each start and wake |
room.state |
conn.data |
| Each player’s private state, by player ID, and when the player was last connected | Variables at module level, which every room in the room host shares |
room.tick, and the next automatic entity ID |
Connections; clients reconnect and onJoin runs again |
What the stepper’s save returned, as bytes or JSON |
Timers, promises, pending calls and messages in flight |
| The room state version and the declarations it was written with |
So game state that must outlast a hibernation, a crash or a deploy lives
in entities, room.state or private state. State no client may see,
such as a deck’s order, lives in entities of a
hidden kind, not in
room.data. Anything else is rebuilt in
onWake; see Where player data lives
for which of the three suits which data.
Private state is kept for the room definition’s privateRetention of
wall-clock time after the player was last connected, hibernated time
included: 24 hours by default (PRIVATE_RETENTION in @irt/room). It’s
checked when the room wakes and every 30 seconds, and a player who comes
back within it gets theirs back. Room state as a whole is kept until the
room is deleted: after rooms.retention passes with the room
hibernated, 7 days by default, or through the API.
So by default a player’s private state can expire long before the room
does. A room players come back to over days, such as a saved dungeon run
whose inventories are the progress, sets a longer retention, or
"forever" to keep it as long as the room:
export default defineRoom({
schema,
privateRetention: 30 * 24 * 3600 * 1000, // 30 days
});
The default keeps a bound for a reason: a room many players pass through, such as a public lobby, would otherwise keep the private state of every player who ever joined, and its room state would only grow.
When it’s written
Room state is written as a log of changes on top of a base, a full copy:
- After each tick that changed anything, the room host sends that
tick’s changes, as the same binary deltas clients get, to IRT’s host
daemon, outside the VM. Changes made between ticks, in
onMessage,onJoinor a call, go out with the next tick’s, or with a join’s update when a connection joins first. - Every 30 seconds, and when the log grows past 4 MB or 4,096 ticks, the
room host sends a new base, which replaces the base and log the host
daemon holds. The stepper’s
saveruns then, and only then. - The host daemon writes the room state to object storage every 30 seconds when it has changed, and always when the room hibernates and when its room host drains.
The host daemon holds each room’s room state in memory as it arrives, so room code that crashes, hangs or runs out of memory can’t lose it:
| What fails | What the room loses |
|---|---|
| Room code throws where that stops the room | Nothing before the tick that threw. Clients close with 4007, and the next connection wakes the room from its room state |
| The room host crashes, or the watchdog kills it after 5 seconds of blocked event loop | Nothing already sent: at most the tick in progress. Its rooms restart in a new room host and clients reconnect with 4008 |
| The VM crashes | The same as a room host crash |
| The host machine, or IRT’s host daemon on it, fails | Up to the last 30 seconds, since the last write to object storage, and more while object storage can’t be reached. Player and project data writes are kept, so see Writes that may have happened |
After any restart, the room starts from room state with onWake, not
onCreate, and onLeave doesn’t run for the connections that were
open. See Hooks.
Steppers
A stepper’s save and load let it keep state of its own, such as a
physics engine’s world, in room state. @irt/physics
saves its Rapier world this way when the world has dynamic bodies.
interface Stepper<I> {
save?(world: World): Uint8Array | unknown; // bytes, or anything JSON can encode
load?(saved: Uint8Array | unknown, world: World): void;
release?(world: World): void;
}
saveruns whenever a base is written: every 30 seconds, on hibernate and on drain. Never per tick.- A
Uint8Arrayis stored as bytes; anything else withJSON.stringify.loadgets back the same: aUint8Array, or the parsed JSON. loadruns on wake, beforeonWake, withundefinedwhen nothing was saved. It doesn’t run for a new room.- What
savereturns counts toward the room state limit. releaseruns when the room leaves its room host: after the save on hibernate, and when it stops. It’s for caches the garbage collector can’t free promptly, such as WebAssembly memory.
One stepper object serves every room in a room host, so save and
load get the room’s world: the same object step gets for that room,
from its wake until it hibernates. Keep per-room caches in a WeakMap
keyed by world; a cache goes away with its room. load runs before
the room’s first step after a wake, so step finds the cache load
made:
// physics-stepper.ts
import { defineSchema, defineStepper, entity, type World } from "@irt/room";
export const Ball = entity("ball", { x: "f32", y: "f32", vx: "f32", vy: "f32" });
export const schema = defineSchema({ entities: [Ball] });
interface Cache {
spins: Record<number, number>;
}
const caches = new WeakMap<World, Cache>();
function cacheOf(world: World): Cache {
let cache = caches.get(world);
if (!cache) caches.set(world, (cache = { spins: {} }));
return cache;
}
export const stepper = defineStepper<{ kick?: boolean }>({
step(world, _inputs, dt) {
const { spins } = cacheOf(world);
for (const b of world.all(Ball)) {
spins[b.id] = (spins[b.id] ?? 0) + Math.hypot(b.vx, b.vy) * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
}
},
save: (world) => cacheOf(world).spins,
load(saved, world) {
if (saved) cacheOf(world).spins = saved as Record<number, number>;
},
});
On a client, prediction never calls save or load. Its world is one
object for the life of the prediction, so a cache keyed by it lasts
across steps there too; see
The prediction world.
Limits
Room state is at most 32 MB, the base plus its log. A room that grows
past it stops with room state is 33.0 MB; the limit is 32 MB and its
clients close with 4007. Past 24 MB, the project’s logs say so once, as
room <id>: room state is 24.1 MB; the room stops at 32 MB, and
irt rooms show <id> prints a room’s size as last saved. In tests,
t.stored() has the room state itself. Each entity must fit in 61,376 bytes, and
fields have their own caps; see Limits.
Big rooms are usually big because of a few kinds of data:
- Worlds. Split them into chunk entities with
bytesfields, start empty chunks as empty bytes, andpatchthem, so the log carries only edits. - Private state of players long gone. It’s dropped after
privateRetention, 24 hours by default; don’t copy it intoroom.state, and keep"forever"for rooms with a bounded set of players. - Histories. A chat log or move list in a
jsonfield is rewritten whole on each change and grows forever. Cap it, or keep it in your backend.
Reading room state
- In tests,
t.stored()returns what a wake would start from, decoded asRoomData; see Testing rooms. - Your backend can read a room’s
room.statewithGET /projects/{p}/rooms/{id}/state, or ask room code for anything else throughonRequest; see the HTTP API. Both wake a hibernated room. - Under
irt dev, room state is in.irt/data, so it survives restarts ofirt dev. Delete the directory to start every room fresh.
Changing its shape
A build that changes the declarations in a way old room state doesn’t
fit, such as renaming or removing a field, bumps version and adds a
migration. See Room state versions.
Common mistakes
- Saving by hand. There’s nothing to call; every write to an entity,
room.stateor private state is saved. - Keeping game state in
room.dataor module variables. It’s gone after a hibernate or a crash. Rebuild derived data inonWake. - Expecting
onLeavebefore a crash. It doesn’t run; the room wakes with every entity as it was. - Storing per-room data in a stepper’s closure. Every room in the room
host shares it; key it by
world. - Growing a
jsonfield without bound. It’s rewritten whole on every change and counts toward 32 MB.