# 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.

```ts
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

```ts
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](/docs/hibernation#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](#sleeping-rooms) | the room stops |
| `onAlarm` | when the room's alarm is due, active or hibernated; see [Alarms](#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](#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](/docs/guides/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:

```ts
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.

```ts
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](#alarms), 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](/docs/limits#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](/docs/guides/state-sync#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:

```ts
// 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](/docs/concepts#rooms): 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

```ts
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](/docs/guides/lobbies#from-your-own-room-code).
- **`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`](#history) 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](/docs/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](#sleeping-rooms).
- **Alarms.** `setAlarm(at)` asks for `onAlarm` at `at`; see
  [Alarms](#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](/docs/guides/replays).
- 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

```ts
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](/docs/guides/player-data).

- **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](/docs/reference/errors#data-calls). 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:

```ts
// 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](/docs/guides/state-sync#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](/docs/room-state#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](/docs/cli#irt-secrets),
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](/docs/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.

```ts
// 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

```ts
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](/docs/guides/state-sync#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](/docs/guides/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](/docs/guides/state-sync) for field types,
  size limits and how writes are tracked.

## Presence

```ts
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](/docs/guides/presence).

## Schemas

```ts
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](/docs/guides/state-sync#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](/docs/guides/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](/docs/guides/prediction#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

```ts
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](/docs/guides/calls).

- 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

```ts
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](/docs/guides/state-sync#combining-grids-and-keys).

Caps: 4,096 subscribed keys and 16,384 visible entities per connection.
See [State sync and interest](/docs/guides/state-sync#the-grid).

## 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:

```ts
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;
}
```

```ts
// 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](/docs/hibernation#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](/docs/hibernation#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)`](/docs/reference/testing#testhost).

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](/docs/dev-tools#the-tick-profiler).

## History

```ts
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](/docs/guides/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:

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

[Self-hosting](/docs/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](#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](/docs/guides/state-sync#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.
