# Room state and the managed store

> What IRT saves for a room, when it's written, what a crash or a failed host loses, and the limits on room state.

**Room state** is everything IRT saves for a room so it can hibernate,
wake in another room host, and survive crashes. The SDK's **managed
store** writes it for you: room code never saves or loads anything. This
page says what's in it and when it's written; [How rooms hibernate and
wake](/docs/hibernation) says when rooms sleep and wake.

## What's saved

| Saved | Not saved |
|---|---|
| Every entity, hidden ones included: its ID, kind, fields and owner | `room.data`, rebuilt by `data()` on each start and wake |
| `room.state` | `conn.data` |
| Each player's private state, by player ID, and when the player was last connected | Variables at module level, which every room in the room host shares |
| `room.tick`, and the next automatic entity ID | Connections; clients reconnect and `onJoin` runs again |
| What the stepper's `save` returned, as bytes or JSON | Timers, promises, pending calls and messages in flight |
| The room state version and the declarations it was written with | |

So game state that must outlast a hibernation, a crash or a deploy lives
in entities, `room.state` or private state. State no client may see,
such as a deck's order, lives in entities of a
[hidden kind](/docs/guides/state-sync#hidden-entities), not in
`room.data`. Anything else is rebuilt in
`onWake`; see [Where player data lives](/docs/guides/state-sync#where-player-data-lives)
for which of the three suits which data.

Private state is kept for the room definition's `privateRetention` of
wall-clock time after the player was last connected, hibernated time
included: 24 hours by default (`PRIVATE_RETENTION` in `@irt/room`). It's
checked when the room wakes and every 30 seconds, and a player who comes
back within it gets theirs back. Room state as a whole is kept until the
room is deleted: after `rooms.retention` passes with the room
hibernated, 7 days by default, or through the API.

So by default a player's private state can expire long before the room
does. A room players come back to over days, such as a saved dungeon run
whose inventories are the progress, sets a longer retention, or
`"forever"` to keep it as long as the room:

```ts
export default defineRoom({
  schema,
  privateRetention: 30 * 24 * 3600 * 1000, // 30 days
});
```

The default keeps a bound for a reason: a room many players pass
through, such as a public lobby, would otherwise keep the private state
of every player who ever joined, and its room state would only grow.

## When it's written

Room state is written as a **log** of changes on top of a **base**, a
full copy:

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

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

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

After any restart, the room starts from room state with `onWake`, not
`onCreate`, and `onLeave` doesn't run for the connections that were
open. See [Hooks](/docs/reference/room-sdk#hooks).

## Steppers

A stepper's `save` and `load` let it keep state of its own, such as a
physics engine's world, in room state. [`@irt/physics`](/docs/guides/physics)
saves its Rapier world this way when the world has dynamic bodies.

```ts
interface Stepper<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:

```ts
// physics-stepper.ts
import { defineSchema, defineStepper, entity, type World } from "@irt/room";

export const Ball = entity("ball", { x: "f32", y: "f32", vx: "f32", vy: "f32" });
export const schema = defineSchema({ entities: [Ball] });

interface Cache {
  spins: Record<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](/docs/guides/prediction#the-prediction-world).

## Limits

Room state is at most 32 MB, the base plus its log. A room that grows
past it stops with `room state is 33.0 MB; the limit is 32 MB` and its
clients close with 4007. Past 24 MB, the project's logs say so once, as
`room <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](/docs/limits#state-sync).

Big rooms are usually big because of a few kinds of data:

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

## Reading room state

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

## Changing its shape

A build that changes the declarations in a way old room state doesn't
fit, such as renaming or removing a field, bumps `version` and adds a
migration. See [Room state versions](/docs/builds#room-state-versions).

## Common mistakes

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