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:

  1. After each tick that changed anything, the room host sends that tick’s changes, as the same binary deltas clients get, to IRT’s host daemon, outside the VM. Changes made between ticks, in onMessage, onJoin or a call, go out with the next tick’s, or with a join’s update when a connection joins first.
  2. Every 30 seconds, and when the log grows past 4 MB or 4,096 ticks, the room host sends a new base, which replaces the base and log the host daemon holds. The stepper’s save runs then, and only then.
  3. The host daemon writes the room state to object storage every 30 seconds when it has changed, and always when the room hibernates and when its room host drains.

The host daemon holds each room’s room state in memory as it arrives, so room code that crashes, hangs or runs out of memory can’t lose it:

What fails What the room loses
Room code throws where that stops the room Nothing before the tick that threw. Clients close with 4007, and the next connection wakes the room from its room state
The room host crashes, or the watchdog kills it after 5 seconds of blocked event loop Nothing already sent: at most the tick in progress. Its rooms restart in a new room host and clients reconnect with 4008
The VM crashes The same as a room host crash
The host machine, or IRT’s host daemon on it, fails Up to the last 30 seconds, since the last write to object storage, and more while object storage can’t be reached. Player and project data writes are kept, so see Writes that may have happened

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;
}
  • save runs whenever a base is written: every 30 seconds, on hibernate and on drain. Never per tick.
  • A Uint8Array is stored as bytes; anything else with JSON.stringify. load gets back the same: a Uint8Array, or the parsed JSON.
  • load runs on wake, before onWake, with undefined when nothing was saved. It doesn’t run for a new room.
  • What save returns counts toward the room state limit.
  • release runs when the room leaves its room host: after the save on hibernate, and when it stops. It’s for caches the garbage collector can’t free promptly, such as WebAssembly memory.

One stepper object serves every room in a room host, so save and load get the room’s world: the same object step gets for that room, from its wake until it hibernates. Keep per-room caches in a WeakMap keyed by world; a cache goes away with its room. load runs before the room’s first step after a wake, so step finds the cache load made:

// 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 bytes fields, start empty chunks as empty bytes, and patch them, so the log carries only edits.
  • Private state of players long gone. It’s dropped after privateRetention, 24 hours by default; don’t copy it into room.state, and keep "forever" for rooms with a bounded set of players.
  • Histories. A chat log or move list in a json field is rewritten whole on each change and grows forever. Cap it, or keep it in your backend.

Reading room state

  • In tests, t.stored() returns what a wake would start from, decoded as RoomData; see Testing rooms.
  • Your backend can read a room’s room.state with GET /projects/{p}/rooms/{id}/state, or ask room code for anything else through onRequest; see the HTTP API. Both wake a hibernated room.
  • Under irt dev, room state is in .irt/data, so it survives restarts of irt dev. Delete the directory to start every room fresh.

Changing its shape

A build that changes the declarations in a way old room state doesn’t fit, such as renaming or removing a field, bumps version and adds a migration. See Room state versions.

Common mistakes

  • Saving by hand. There’s nothing to call; every write to an entity, room.state or private state is saved.
  • Keeping game state in room.data or module variables. It’s gone after a hibernate or a crash. Rebuild derived data in onWake.
  • Expecting onLeave before a crash. It doesn’t run; the room wakes with every entity as it was.
  • Storing per-room data in a stepper’s closure. Every room in the room host shares it; key it by world.
  • Growing a json field without bound. It’s rewritten whole on every change and counts toward 32 MB.

View this page as Markdown