# State sync and interest

> Declare entities, sync them as binary deltas, look them up by ID, and send each connection only what it should see.

All synced state is typed **entities** of kinds the room declares. Each
tick, every changed entity is encoded once as a binary delta, and each
connection gets the deltas of the entities it sees. With no interest
set, every connection sees every entity.

## Declaring kinds

```ts
// schema.ts
import { defineSchema, entity } from "@irt/room";

export const Player = entity("player", {
  owner: "string",
  x: "f32",
  y: "f32",
  z: "f32",
  hp: "u8",
});

export const Chunk = entity("chunk", {
  cx: "i32",
  cy: "i32",
  cz: "i32",
  blocks: "bytes",
});

export const Match = entity("match", { round: "u16", phase: "string" });
export const Hand = entity("hand", { cards: "json" });

export const schema = defineSchema({
  state: Match,
  private: Hand,
  entities: [Player, Chunk],
});
```

`entity(name, fields)` takes a name matching `[A-Za-z_][A-Za-z0-9_]{0,63}`
and up to 255 fields. Field names `id`, `kind` and `patch` are reserved,
since entity objects have properties of those names; using one is a type
error at the `entity()` call, and an error when the module loads. Kind
names starting with `irt_` are reserved too: every schema ends with the
built-in `irt_presence` kind of [presence](/docs/guides/presence), so the
schema hash covers it the same way for every room.

| Type | Holds | Size |
|---|---|---|
| `bool` | `true` or `false` | 1 byte |
| `u8`, `u16`, `u32` | unsigned integers | 1, 2, 4 bytes |
| `i8`, `i16`, `i32` | signed integers | 1, 2, 4 bytes |
| `f32` | numbers, rounded with `Math.fround` | 4 bytes |
| `f64` | numbers | 8 bytes |
| `string` | UTF-8 text | at most 16 KB |
| `bytes` | `Uint8Array` | at most 48 KB |
| `json` | anything JSON can encode, replaced whole when set | at most 16 KB serialized |

A `json` field declared as `"json"` reads as `unknown`. Declare it with
`json<T>()` to give it a type, so room and client code reads it without
casts:

```ts
// scores.ts
import { defineSchema, entity, json } from "@irt/room";

export const Match = entity("match", {
  round: "u16",
  scores: json<Record<string, number>>(),
});
export const schema = defineSchema({ state: Match });

export function addPoint(state: { scores: Record<string, number> }, player: string) {
  state.scores = { ...state.scores, [player]: (state.scores[player] ?? 0) + 1 };
}
```

`json<T>()` is the string `"json"` at runtime: it changes nothing on the
wire, and a kind declared with it has the same schema hash as one
declared with `"json"`. The type isn't checked when the field is set,
and like any `json` field it holds `null` until it's first set, so set it
in `onCreate` or declare `json<T | null>()`.

An entity with every field encoded must fit in 61,376 bytes (60 KB minus
64), so it can enter a view within one state update part. A write that
would make it bigger throws a `RangeError` naming the entity and field;
split large data across several entities, as chunks do.

`defineSchema({ state?, private?, entities? })` returns the room's
`Schema`. The order of `entities` is part of the schema hash: reordering
them is a schema change.

## Shared, private and entity state

| In room code | Who sees it | Kept by |
|---|---|---|
| `room.state` | every connection | the room |
| `room.private(conn)` | that connection's player only | player ID, so it survives reconnects |
| `room.entities` | connections whose interest includes it | entity ID |
| `room.entities` of a hidden kind | no connection | entity ID |

```ts
// match-room.ts
import { defineRoom, type RoomEntity } from "@irt/room";
import { Chunk, Player, schema } from "./schema.js";

export default defineRoom({
  schema,
  onCreate(room) {
    room.state.phase = "lobby";
    for (let cx = -2; cx <= 2; cx++) {
      room.entities.create(Chunk, { cx, cy: 0, cz: 0, blocks: new Uint8Array(32 * 32 * 32) });
    }
  },
  onJoin(room, conn) {
    // After a room host restart the player's entity is still there; see below.
    const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player);
    conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player, hp: 100 });
    room.private(conn).cards = ["ace", "king"];
  },
  onMessage(room, conn, message) {
    const m = message as { dig?: [number, number] };
    if (!m.dig) return;
    const [cx, index] = m.dig;
    const chunk = [...room.entities.all(Chunk)].find((c) => c.cx === cx);
    if (!chunk || !Number.isInteger(index) || index < 0 || index >= 32 * 32 * 32) return;
    // A block edit sends three bytes and the offset, not the chunk.
    chunk.patch("blocks", index, new Uint8Array([0]));
    const player = conn.data.player as RoomEntity<typeof Player>;
    player.hp = Math.max(0, player.hp - 1);
  },
  onPlayerLeave(room, player) {
    for (const e of room.entities.all(Player)) if (e.owner === player) room.entities.delete(e);
  },
});
```

How writes are tracked:

- Setting a field records it; the delta goes out at the end of the tick.
  There's no diff of the whole state.
- Within a tick the last write to a field wins, and a replace after a
  patch wins.
- Setting a field to its current value records nothing. For `json`, a
  deep-equal value records nothing; mutating a `json` value in place
  records nothing until you set the field again.
- `entity.patch(field, offset, bytes)` writes into a `bytes` field and
  sends only the range. It grows the field when it writes past the end,
  filling the gap with zeros, so empty bytes is a fine starting value for
  data that's mostly blank, such as an all-air chunk.
- Read a `bytes` field from the entity again after a patch. A patch that
  grows it replaces the `Uint8Array`, so an array you held from before is
  the old, shorter one; on the client every change is a new array.
- Writes to `f32` fields go through `Math.fround` in the room and on the
  client, so both hold the same number.
- Writing a deleted entity throws.

## Where player data lives

A player's data can live in three places, and they last differently.
What decides it is when `onLeave` and `onPlayerLeave` run. `onLeave`
runs when a connection closes, which includes a dropped network before
the client reconnects. `onPlayerLeave` runs when the player's presence
ends, 10 seconds after its last connection closed with no new one. Both
run before the room hibernates, whatever the reason, and neither when a
room host crashes.

| Data in | Leave and rejoin | Dropped network, reconnect | Room host crash | Hibernate and wake |
|---|---|---|---|---|
| An entity `onPlayerLeave` deletes | lost | kept, within 10 s | kept, and `onJoin` runs again | lost |
| An entity `onLeave` deletes | lost | lost | kept, and `onJoin` runs again | lost |
| An entity the room keeps | kept | kept | kept | kept |
| A [hidden entity](#hidden-entities) | kept | kept | kept | kept |
| `room.state`, such as a `json` map by player ID | kept | kept | kept | kept |
| `room.private(conn)` | kept for `privateRetention`, 24 hours by default | kept | kept | kept for `privateRetention` |
| `conn.data`, `room.data`, module variables | lost | lost | lost | lost |
| A presence status | kept for 10 s | kept for 10 s | lost | lost |

- **Entities** are the room's synced objects, seen through interest. A
  player's avatar is usually an entity created in `onJoin` and deleted in
  `onPlayerLeave`, so it's on screen while the player is in the room and
  survives a dropped network.
  Anything stored on it goes with it: don't keep a score there that
  should outlive a visit.
- **`room.state`** is one entity every connection sees. Keep standings
  every player sees here, such as `scores: json<Record<string, number>>()`
  keyed by player ID.
- **Private state** is kept by player ID and only that player sees it.
  It's created empty at a player's first join, comes back when they
  rejoin, and is dropped once the player has been away for the room's
  `privateRetention` of wall-clock time, hibernated time included: 24
  hours by default, so it can expire before the room does. Set
  `privateRetention` in `defineRoom`, in milliseconds or `"forever"`,
  when players come back over days; see
  [Room state](/docs/room-state#whats-saved). Keep a player's own data
  here, such as a hand of cards or an inventory. Room code reaches any
  player's private state by player ID, `room.private("alice")`, connected
  or not; it's `undefined` for a player with none, and
  `room.private("alice", { create: true })` creates it.
  `room.privatePlayers()` lists the players that have it.
- **A rejoining player's first state is their last session's private
  state.** Their client gets it as it was when they left, before any
  async work in `onJoin` has finished. A field that describes the
  session rather than the player, such as `loaded` set once a backend
  answers, still holds the old session's value. Reset such fields in
  `onJoin` before it awaits anything: what `onJoin` sets synchronously
  is in the player's first state update. Reset them in `onLeave` too,
  which runs before every hibernation, so private state is never kept
  with them set.
- **Hidden entities** are entities no connection ever sees, such as a
  deck's order. They're room state like any entity; see
  [Hidden entities](#hidden-entities).
- **`conn.data`** is the room's own scratch space for one connection,
  such as a reference to its avatar entity. It isn't room state.
- **`room.data`** is the same for the whole room, for data derived from
  its entities, such as a lookup table. Module variables are shared by
  every room in the room host, so don't keep one room's data in them;
  see [Room data and module scope](/docs/reference/room-sdk#room-data-and-module-scope).
- **[Presence](/docs/guides/presence)** is who's connected now: an
  entry per player with a name and a small status, which every
  connection sees. The runtime keeps it, and it isn't room state.

After a room host crash, the room wakes from its room state with every
entity as it was, clients reconnect and `onJoin` runs for each of them
again, with no `onLeave` before it. A room that creates an avatar in
`onJoin` finds the one from before instead, as the room above does:

```ts
const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player);
conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player });
```

An avatar whose player doesn't come back after a crash stays in the
room, and in its room state, until room code deletes it. A room that
cares can delete, in `onTick`, avatars whose owner has had no connection
for a few seconds. `onWake` is too early: it runs before any client has
reconnected.

## Hidden entities

Some room state must reach no client at all: a deck's order, a puzzle's
answer, a bot's plan. `room.data` isn't room state, so it's gone after a
wake, and hiding an entity with `interest` is easy to get wrong: without
an interest every connection sees every entity, and an interest that
gives the entity `EVERYWHERE` or a key some connection subscribes to
shows it too. Declare the kind hidden instead:

```ts
// cards.ts
import { defineRoom, defineSchema, entity, json } from "@irt/room";

export const Table = entity("table", { drawPile: "u8" });
export const Hand = entity("hand", { cards: json<string[]>() });
export const Deck = entity("deck", { order: json<string[]>() }, { hidden: true });
export const schema = defineSchema({ state: Table, private: Hand, entities: [Deck] });

function shuffled(cards: string[]): string[] {
  const out = [...cards];
  for (let i = out.length - 1; i > 0; i--) {
    const j = crypto.getRandomValues(new Uint32Array(1))[0]! % (i + 1);
    [out[i], out[j]] = [out[j]!, out[i]!];
  }
  return out;
}

export default defineRoom({
  schema,
  onCreate(room) {
    const deck = room.entities.create(Deck, { order: shuffled(["1a", "2a", "3a", "1b", "2b", "3b"]) });
    room.state.drawPile = deck.order.length;
  },
  onJoin(room, conn) {
    room.private(conn).cards ??= [];
  },
  onMessage(room, conn, message) {
    if ((message as { type?: unknown }).type !== "draw") return;
    const deck = [...room.entities.all(Deck)][0]!;
    const [card, ...rest] = deck.order;
    if (card === undefined) return;
    deck.order = rest;
    const hand = room.private(conn);
    hand.cards = [...hand.cards, card];
    room.state.drawPile = rest.length;
  },
});
```

- A hidden entity is room state like any other: it's saved, restored
  on wake and kept across a room host crash, and hooks and calls read
  and write it through `room.entities`.
- Don't read hidden entities in a stepper. The client runs the same
  `step` to predict, on a world that never holds them, so a step that
  depends on them predicts something other than what the room does.
  The room's `world` is `room.entities`, so it does hold them; nothing
  stops a stepper reading them, and nothing warns when prediction
  drifts.
- The room never sends it to a connection. It gets no interest keys, so
  `keysOf` isn't called for it, whatever `interest` is; voice's default
  rule doesn't see it; and creating, changing or deleting it sends
  nothing. The inspector in `irt dev` shows it, marked hidden, since
  it's for you and not for players.
- A hidden kind can't have writable fields, so no player can own one of
  its entities.
- The kind's declaration is in the schema, so it's in the client's
  bundle and the schema hash; its values never leave the room.

What the room sends is still yours to check. Data reaches clients in
other ways than entities:

- **What you copy out.** A card moved into a hand, `room.state`, a
  message, a call's reply or error, and presence all reach clients.
- **Entity IDs.** Automatic IDs count up for every entity, hidden ones
  included, so a gap between two IDs a client sees says how many
  entities were created in between. Give visible entities `{ id }` of
  your own where that matters.
- **Counts and timing.** A `drawPile` count, how long a reply takes, or
  which tick a change lands in can say what a hidden value is.

In a test with `@irt/testing`, `client.view(Deck)` stays empty however
the game goes, and `room.stored()` shows the deck in room state.

## Entity IDs and lookups

An entity's ID is assigned from 2 by default. Pass `{ id }` to choose
it, so the room and its clients can compute it: a chunk's ID from its
chunk coordinates, a tile's from its row and column. Then a stepper, a
hook or a client finds static data with `world.get(id)` instead of
searching.

```ts
// voxel-room.ts
import { defineRoom, defineStepper, type Entity, type RoomEntity, type World } from "@irt/room";
import { Chunk, Player, schema } from "./schema.js";

const SIZE = 16;
const N = 8;

/** A chunk's entity ID, from its chunk coordinates; the world is N × N × N chunks. */
export const chunkId = (cx: number, cy: number, cz: number) => 2 + cx + cz * N + cy * N * N;

/** Where a block is in its chunk's `blocks`, for whole block coordinates from 0. */
const indexOf = (x: number, y: number, z: number) => (x % SIZE) + (z % SIZE) * SIZE + (y % SIZE) * SIZE * SIZE;

export function blockAt(world: World, x: number, y: number, z: number): number {
  const bx = Math.floor(x);
  const by = Math.floor(y);
  const bz = Math.floor(z);
  if (Math.min(bx, by, bz) < 0 || Math.max(bx, by, bz) >= N * SIZE) return 0;
  const chunk = world.get(chunkId(Math.floor(bx / SIZE), Math.floor(by / SIZE), Math.floor(bz / SIZE)));
  // Blocks start empty above the ground; reading past the end gives air.
  return (chunk as Entity<typeof Chunk> | undefined)?.blocks[indexOf(bx, by, bz)] ?? 0;
}

export const fall = defineStepper<{ jump: boolean }>({
  step(world, inputs, dt) {
    for (const p of world.all(Player)) {
      if (blockAt(world, p.x, p.y - 0.01, p.z) === 0) p.y -= 10 * dt;
      else if (inputs.get(p.owner)?.jump) p.y += 1;
    }
  },
});

export default defineRoom({
  schema,
  stepper: fall,
  onCreate(room) {
    for (let cy = 0; cy < N; cy++) {
      for (let cz = 0; cz < N; cz++) {
        for (let cx = 0; cx < N; cx++) {
          const blocks = cy === 0 ? new Uint8Array(SIZE ** 3).fill(1) : new Uint8Array(0);
          room.entities.create(Chunk, { cx, cy, cz, blocks }, { id: chunkId(cx, cy, cz) });
        }
      }
    }
  },
  onJoin(room, conn) {
    const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player);
    conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player, x: 8, y: 20, z: 8 });
  },
  onMessage(room, conn, message) {
    const m = message as { place?: [number, number, number] };
    if (!m.place || !m.place.every((v) => Number.isInteger(v) && v >= 0 && v < N * SIZE)) return;
    const [x, y, z] = m.place;
    const chunk = room.entities.get(chunkId(Math.floor(x / SIZE), Math.floor(y / SIZE), Math.floor(z / SIZE)));
    // Grows an empty chunk's blocks as far as this block.
    (chunk as RoomEntity<typeof Chunk>).patch("blocks", indexOf(x, y, z), new Uint8Array([1]));
  },
});
```

- An explicit ID is an integer from 2 that no live entity has; `create`
  throws otherwise, naming the entity that has it. A deleted entity's ID
  can be used again, even in the same tick.
- Automatic IDs are always above the highest ID the room has used, so
  they never take an explicit one. An explicit ID can still meet an
  automatic one created earlier, so create the entities with computed
  IDs first, in `onCreate`, as this room does.
- IDs are kept in room state, so they're the same after a wake.

What lookups cost, in a stepper or anywhere else:

- `world.get(id)` is one map lookup, in the room and on the client.
- `world.all(kind)` visits every entity of that kind each time it's
  called: in the room, every one the room has; on the client, every one
  the client sees. A stepper runs every tick in the room, and on the
  client again for each input it replays, so a stepper that searches
  `all(Chunk)` for each player does chunks × players work every time.
  Loop over the few entities that move with `all`, and reach static data
  by ID.

## Interest

A connection sees an entity when they share an **interest key**. Keys
are strings or numbers your room assigns.

```ts
interface Interest<R = Room> {
  keysOf(room: R, entity: RoomEntity): Iterable<InterestKey>;
  subscriptionsOf(room: R, connection: Connection): Iterable<InterestKey>;
  sees?(room: R, connection: Connection, entity: RoomEntity): boolean;
  limit?: { max: number; priority(room: R, connection: Connection, entity: RoomEntity): number };
}
```

Each function gets the room first, as hooks do, so a rule can read the
room's own state, such as a player's block list. At the end of each tick the room calls `keysOf` for entities that
changed and `subscriptionsOf` for every connection. An entity that newly
shares a key with a connection enters its view with all its fields; one
that no longer shares any leaves it. `sees`, when given, narrows that
further: it runs each tick for every connection and entity that share a
key, and the connection sees the entity only when it returns `true`.
`limit` applies last.

### The grid

```ts
function grid(opts: {
  cellSize: number;
  dims: "xy" | "xz" | "xyz";
  position?: (room: Room, entity: RoomEntity) => [x: number, y: number, z?: number] | undefined;
  around(room: Room, connection: Connection): RoomEntity | [x: number, y: number, z?: number] | undefined;
  prefix?: string;
  kinds?: Kind[];
} & ({ range: number } | { radius: number })): InterestPart;

type InterestPart = Pick<Interest, "keysOf" | "subscriptionsOf" | "sees">;
```

- An entity's key is the cell at `position(entity)`; by default its `x`,
  `y` and `z` fields. `dims` picks the axes: `"xz"` for a 3D world split
  on the ground plane.
- When `around` returns an entity, the grid places it with `position`
  too. A `position` written for chunk fields gives a player no position,
  and its connection then subscribes only to `EVERYWHERE`; have `around`
  return the player's point, `[p.x, p.y, p.z]`, or make `position`
  handle both kinds.
- `kinds` limits the grid to entities of those kinds: others get no key
  from it, rather than `EVERYWHERE`, and its `sees` lets them through.
- `prefix` starts every key the grid gives, `EVERYWHERE` included, so
  `grid({ ..., prefix: "p:" })` gives `"p:3,-1"` and `"p:*"`.
- The key format is `cellKey(...cell)`: the cell coordinates joined by
  commas, such as `"3,-1"` or `"3,-1,0"`, where a cell coordinate is
  `Math.floor(position / cellSize)`.
- Entities with no position on those axes get `EVERYWHERE` (`"*"`),
  which every connection subscribes to, so every connection sees them.
- While `around(connection)` returns `undefined`, the connection
  subscribes only to `EVERYWHERE`.

Give the grid either a `range` or a `radius`:

- **`range`**, in world units: a connection sees the entities within
  that straight-line distance of `around(connection)` on the chosen axes,
  a circle for `"xy"` and `"xz"` and a sphere for `"xyz"`. The grid
  subscribes it to the cells that overlap that circle, and its `sees`
  drops the entities in those cells that are further away. Visibility is
  exact: an entity at distance `range` is seen and one a little past it
  isn't, wherever either stands in its cell.
- **`radius`**, a whole number of cells: a connection subscribes to its
  own cell and every cell within `radius` cells of it on each axis, a
  square of (2 × radius + 1)² cells, or a cube for `"xyz"`. It sees
  every entity in them, so how far it sees depends on where it stands in
  its cell: at least `radius × cellSize` along an axis, up to
  `(radius + 1) × cellSize`, and further across the corners. `sees` is
  undefined. Use it when cells, such as voxel chunks, are what matters.

### Combining grids and keys

`combine(...parts)` joins grids, and parts with keys of your own, into
one interest. A connection sees an entity when, in at least one part,
they share a key and that part's `sees`, if it has one, returns `true`.
So each grid keeps its own semantics: a `radius` grid shows its whole
cells, and a `range` grid's distance check applies only to what it
placed, not to what another part shows.

```ts
function combine(...parts: InterestPart[]): Required<InterestPart>;
```

Give each grid the `kinds` it places, or every grid also places the
other grids' entities, as `EVERYWHERE` when they have no position on its
axes, which shows them to everyone. Give each grid a `prefix` too, so
cells of different sizes don't share keys; `combine` stays exact without
one, but entities in a cell with the same key as one of another grid
are then checked by `sees` for nothing. Put `limit` beside it:

```ts
// world-room.ts
import { combine, defineRoom, grid, type Connection, type Room, type RoomEntity } from "@irt/room";
import { Chunk, Player, schema } from "./schema.js";

type P = RoomEntity<typeof Player>;
const CHUNK = 32;
const at = (p: P | undefined) => (p ? ([p.x, p.y, p.z] as const) : undefined);

// Chunks within 2 chunks on each axis, placed by chunk coordinates.
const chunks = grid({
  cellSize: CHUNK,
  radius: 2,
  dims: "xyz",
  kinds: [Chunk],
  prefix: "c:",
  position: (_room, e) => {
    const c = e as RoomEntity<typeof Chunk>;
    return [c.cx * CHUNK, c.cy * CHUNK, c.cz * CHUNK];
  },
  // A point, since `position` above reads chunk fields.
  around: (_room, conn) => at(conn.data.player),
});

// Players within 80 units, by their x, y and z.
const players = grid({ cellSize: 32, range: 80, dims: "xyz", kinds: [Player], prefix: "p:", around: (_room, conn) => conn.data.player });

// Every player sees its own entity, however far it is from where `around` says.
const self = {
  keysOf: (_room: Room, e: RoomEntity) => (e.kind === Player ? [`me:${(e as P).owner}`] : []),
  subscriptionsOf: (_room: Room, conn: Connection) => [`me:${conn.player}`],
};

export default defineRoom({
  schema,
  interest: {
    ...combine(chunks, players, self),
    limit: {
      max: 500,
      // Chunks rank first, players by distance.
      priority: (_room, conn, e) => {
        const me = conn.data.player as P | undefined;
        if (!me || e.kind !== Player) return Infinity;
        const p = e as P;
        return -Math.hypot(p.x - me.x, p.y - me.y, p.z - me.z);
      },
    },
  },
  onJoin(room, conn) {
    const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player);
    conn.data.player = existing ?? room.entities.create(Player, { owner: conn.player });
  },
});
```

The same goes for a `range` grid and keys of your own: with `combine`,
what the keys show isn't dropped by the grid's distance check.

```ts
// near-room.ts
import { combine, defineRoom, defineSchema, entity, grid, type Connection, type Room, type RoomEntity } from "@irt/room";

const Soldier = entity("soldier", { team: "string", x: "f32", y: "f32", z: "f32" });
const teamOf = (c: Connection) => (c.meta as { team: string }).team;

// Teammates are seen at any distance, everyone else within 600 units.
const near = grid({ cellSize: 100, range: 600, dims: "xz", around: (_room, c) => c.data.soldier });
const team = {
  keysOf: (_room: Room, e: RoomEntity) => [`team:${(e as RoomEntity<typeof Soldier>).team}`],
  subscriptionsOf: (_room: Room, c: Connection) => [`team:${teamOf(c)}`],
};

export default defineRoom({
  schema: defineSchema({ entities: [Soldier] }),
  interest: combine(near, team),
  onJoin(room, conn) {
    conn.data.soldier = room.entities.create(Soldier, { team: teamOf(conn) });
  },
});
```

Writing `keysOf`, `subscriptionsOf` and `sees` by hand works too. Then
give the grids prefixes, and remember that an interest's `sees` applies
to every entity, so it must let through what other keys show:
`sees: (room, c, e) => isTeammate(c, e) || near.sees!(room, c, e)`.

### Custom keys

Interest isn't tied to coordinates. Teams, zones and levels are keys too:

```ts
// team-room.ts
import { defineRoom, defineSchema, entity } from "@irt/room";

const Unit = entity("unit", { team: "string", level: "u8" });

export default defineRoom({
  schema: defineSchema({ entities: [Unit] }),
  interest: {
    keysOf: (_room, e) => [`team:${e.team as string}`, `level:${e.level as number}`],
    subscriptionsOf: (_room, conn) => [`team:${(conn.meta as { team: string }).team}`],
  },
});
```

### Limits and hysteresis

`limit: { max, priority }` keeps the `max` entities with the highest
priority in each connection's view. A visible entity stays until it
ranks below `max` plus 10%, so entities near the cut don't enter and
leave every tick.

### Caps

| Cap | Value | Over it |
|---|---|---|
| Interest keys a connection subscribes to | 4,096 | an error in the room's log; the extra keys are left out |
| Entities a connection sees | 16,384 | an error in the room's log; the extra entities are left out |

## On the client

```ts
// client.ts
import { connect } from "@irt/client";
import { Chunk, Player, schema } from "./schema.js";

const room = await connect({ router: "wss://eu.irt.io", project: "my-game", room: "forest-7", schema });

room.entities.onEnter(Chunk, (chunk) => console.log("mesh chunk", chunk.cx, chunk.blocks.length));
room.entities.onChange(Chunk, (chunk, changed) => {
  if (changed.includes("blocks")) console.log("remesh", chunk.id);
});
room.entities.onLeave(Chunk, (chunk) => console.log("drop", chunk.id));
room.onTick((tick) => console.log("applied tick", tick, room.state.phase, room.private.cards));

const players = room.interpolate(Player, ["x", "y", "z"]);
for (const p of room.entities.all(Player)) console.log(p.owner, players.get(p));
```

- A tick's state update is applied at once, then the `onEnter`,
  `onChange` and `onLeave` callbacks run, then `onTick`. A client never
  sees a change before an enter.
- A big tick, such as a new player entering a hundred chunks, spans
  several messages; the client applies them together. The room
  compresses messages of 1 KB or more with DEFLATE, which shrinks voxel
  chunks about 17 times, and the client inflates them; nothing in the
  game changes for it, `patch` included.
- On a reconnect to the same room generation the client resumes: it
  tells the room what it holds, and the room sends only what it doesn't
  hold or what changed since, and a `leave` for what it no longer sees.
  Entities that didn't change keep their objects, and the client emits
  `restored`. On a change of the room's generation, or a reconnect the
  room can't resume, such as after a room host restart, the client gets
  a full update: every entity leaves and enters again.
- `interpolate(kind, fields)` draws numeric fields of remote entities a
  little behind the room's clock (two ticks or 100 ms, whichever is
  longer), between the last two states.

## Common mistakes

- **Expecting `room.state` changes to be seen at once.** Changes go out
  at the end of the tick. Messages sent with `room.send` in the same
  handler arrive before the state update.
- **Mutating a `json` field in place.** Set the field again after
  changing the value, or the change isn't sent.
- **Keeping a score on an entity `onLeave` deletes.** It's gone when the
  player leaves, drops off the network or the room hibernates. Keep it in
  `room.state` or private state; see
  [Where player data lives](#where-player-data-lives).
- **Expecting `radius` to be a distance.** It counts cells, in a square.
  Use `range` for "everything within 600 units".
- **Putting a whole world in one entity.** An entity must fit in 61,376
  bytes. Split worlds into chunk entities.
- **Using a `string` for big data.** Strings are capped at 16 KB; use
  `bytes` (48 KB) and patch it.
- **Grid entities without a position.** They get `EVERYWHERE` and every
  connection sees them; pass `position` when your coordinates aren't
  called `x`, `y` and `z`, and `kinds` when the grid shouldn't place
  them at all.
- **Merging two grids' keys by hand without prefixes.** Their cell keys
  and `EVERYWHERE` collide, and a `range` grid's `sees` drops what the
  other grid shows. Use `combine`.
- **A `position` for chunks with an `around` that returns the player.**
  The grid places that entity with `position` too; return a point
  instead.
- **Searching `all(kind)` in a stepper for static data.** It visits every
  entity of the kind on every call. Give the data computed IDs and use
  `world.get`; see [Entity IDs and lookups](#entity-ids-and-lookups).
- **Assuming `subscriptionsOf` runs only on change.** It runs for every
  connection every tick; keep it cheap.
- **Hiding data with `interest`.** Without an interest every
  connection sees every entity, and one key in common shows it. Declare
  the kind `{ hidden: true }`; see [Hidden entities](#hidden-entities).
- **Writing entities on the client.** They are read-only and a write
  throws, except the writable fields of entities the player owns; see
  [Ownership](/docs/guides/ownership).
