# Client SDK

> Reference for @irt/client, the TypeScript client SDK that connects browsers and Node to rooms.

`@irt/client` runs in browsers and in Node 24 or later. It connects over
WebTransport when the platform has it and falls back to WebSocket; Node
has no WebTransport, so it uses WebSocket. See [Node clients and bots](/docs/guides/node-client)
for running it from scripts.

```ts
import {
  connect, RoomClient, type ReadyRoomClient, IrtError, CloseCode, defineSchema, defineCalls, defineStepper, entity, json,
  createRoom, joinByCode, listPublic, quickJoin, reloadForNewBuild,
  type ConnectOptions, type ConnectionStatus, type ClientEntity, type Prediction, type Interpolator, type WireStats,
} from "@irt/client";
```

## connect

```ts
function connect<S, P, C>(opts: ConnectOptions<S, P, C>): Promise<ReadyRoomClient<S, P, C>>;
// A RoomClient whose state and private are typed as set, since it has been ready.
type ReadyRoomClient<S, P, C> = RoomClient<S, P, C> & { readonly state: ClientEntity<S>; readonly private: OwnedEntity<P> };

interface ConnectOptions<S, P, C> {
  router: string;         // from the project's client config: "wss://eu.irt.io", or "http://127.0.0.1:7350" for irt dev
  project: string;        // the project's slug
  room: string;           // 1 to 64 characters of A-Z, a-z, 0-9, _ and -
  type?: string;          // the room type whose schema this is; default the build's type named default
  token?: string;         // a player token from your backend; without one, the client joins as a guest
  schema?: Schema<S, P, C>;  // the same defineSchema module the room uses
  onWaking?: () => void;  // the room isn't running and the connection waits for it to start: a wake, or a new room's first start
  timeout?: number;       // ms the first connection may take to become ready, waking included; default 15000
  targets?: Record<string, Schema>;  // schemas of the room types a handoff may take the player to, by type
  dial?: Dial;            // replaces the transport, as simulateNetwork and @irt/testing do
}
```

`connect` resolves once the client holds the room's full state, so
`room.state` and `room.private` are set. It rejects with an `IrtError`
when the router can't be reached (code 0, after trying WebTransport and
WebSocket), when the router closes with a code the client doesn't retry
(the close code), or when `timeout` passes first (code 0). After it
resolves, the client reconnects by itself.

`type` is the [room type](/docs/concepts#rooms) the client expects.
A room of another type, or a type the live build doesn't have, closes
the connection with 4015, which reloading doesn't fix. A connection that
creates its room creates it of this type.

```ts
// connect.ts
import { CloseCode, connect, defineSchema, entity, IrtError, reloadForNewBuild } from "@irt/client";

const Table = entity("table", { players: "u8" });
const schema = defineSchema({ state: Table });

try {
  const room = await connect({
    router: "wss://eu.irt.io",
    project: "my-game",
    room: "main",
    schema,
    onWaking: () => console.log("starting the room"),
  });
  console.log(room.player, room.state.players);
  room.on("closed", (error) => {
    if (error.code === CloseCode.RoomHibernated) console.log("offer a rejoin button");
  });
} catch (e) {
  if (e instanceof IrtError && e.code === CloseCode.SchemaMismatch) reloadForNewBuild();
  else throw e;
}
```

**Transports.** WebTransport at `https://<router>/v1/wt`, with one
bidirectional stream for reliable messages and datagrams for unreliable
ones; if it isn't available or doesn't connect within 3 seconds, the
client uses WebSocket at `wss://<router>/v1/ws` and stays on WebSocket
for later reconnects.

**Custom transports.** A `Dial` is `(router: string) => Promise<Transport>`,
called with `router` from the options on every connect and reconnect.
`dialWebSocket(router)` takes the same address and dials its
`/v1/ws`, so a dial can wrap it. A `Transport` may say what carries it
as `kind`, `"webtransport"` or `"websocket"`, which the client shows as
`transportKind`; keep the inner transport's when wrapping one. For
example, to count bytes sent:

```ts
// dial.ts
import { connect, dialWebSocket } from "@irt/client";

let sent = 0;
await connect({
  router: "http://127.0.0.1:7350",
  project: "my-game",
  room: "main",
  dial: async (router) => {
    const t = await dialWebSocket(router);
    const send = t.send;
    t.send = (data, unreliable) => {
      sent += data.length;
      send(data, unreliable);
    };
    return t;
  },
});
```

The client sets the transport's `onmessage` and `onclose` after the dial
resolves, so wrap incoming data by returning a transport of your own
whose handlers the client sets, as the inner one calls them.

It rejects with an `Error` naming the URL it dialled when the socket
closes before opening.

**Guests.** Without a `token`, the router mints a guest token with a
player ID of `guest:<random>`. The client keeps it in `localStorage`
under `irt.guest.<project>` (in memory where there's no `localStorage`
or it's blocked) and sends it on every connect, so a guest keeps its
player ID across reconnects, tabs and browser restarts. The token lasts
30 days and the router renews it on each connect. See
[Games without a backend](/docs/no-backend#guests).

## RoomClient

```ts
class RoomClient<S, P, C> {
  constructor(opts: ConnectOptions<S, P, C>);  // connects; await `ready` instead of connect()
  readonly ready: Promise<void>;
  readonly schema: Schema<S, P, C>;
  readonly entities: EntityStore;
  readonly presence: PresenceList;           // who's in the room; see Presence
  readonly state: ClientEntity<S> | undefined;     // undefined until ready
  readonly private: OwnedEntity<P> | undefined;    // undefined until ready; the player owns its private state
  status: ConnectionStatus;                  // "connecting" | "waking" | "open" | "reconnecting" | "closed"
  asleep: boolean;                           // the room sleeps and the router holds the connection
  connection: number;
  player: string;
  readonly project: string;                  // the project from ConnectOptions
  generation: number;                        // the room's generation; changes when it restarts or wakes
  tickRate: number;
  tick: number;                              // tick of the last state update applied
  rtt: number;                               // smallest recent round trip, in ms
  jitter: number;                            // mean deviation of the recent round trips, in ms
  transportKind: "webtransport" | "websocket" | undefined;  // what carries the connection
  joinTime: { ms: number; started: boolean } | undefined;   // how long joining took; see below
  protocol: number;                          // the client protocol the connection speaks, from welcome
  peers: { speakers: readonly string[]; listeners: readonly string[] };  // see Voice
  voice: { url: string; token: string } | undefined;                     // set when the project uses voice
  readonly stats: WireStats;                 // messages and bytes in and out; see Wire statistics
  serverTick(): number;                      // the room's current tick, estimated, with a fraction
  serverTime(): number | undefined;          // the room's wall clock, room.now(), estimated, in ms

  send(message: unknown, opts?: { unreliable?: boolean; to?: readonly string[] }): void;
  call<K extends keyof C>(name: K, args: Args<K>, opts?: { timeout?: number }): Promise<Reply<K>>;  // args optional when the call takes none
  lead(player: string): Promise<void>;      // native rooms: pass the leader role
  readonly lobby: LobbyState | undefined;   // a lobby's phase, who is ready and the countdown; see Lobbies
  setReady(ready: boolean): Promise<void>;  // lobbies: mark this player ready or not
  start(): Promise<void>;                   // lobbies: the leader starts the countdown, under any start rule
  cancel(): Promise<void>;                  // lobbies: the leader cancels it
  onHandoff(fn: (handoff: Handoff) => void): () => void;  // a lobby or room code moved the player; see Lobbies
  leave(): void;
  on(event: "waking" | "restored" | "sleep" | "wake", fn: () => void): () => void;
  on(event: "disconnected", fn: (code: number, reason: string) => void): () => void;
  on(event: "closed", fn: (error: IrtError) => void): () => void;
  on(event: "message", fn: (message: unknown, info: { unreliable: boolean; from?: string }) => void): () => void;
  on(event: "tick", fn: (tick: number) => void): () => void;
  on(event: "peers", fn: (peers: { speakers: readonly string[]; listeners: readonly string[] }) => void): () => void;
  on(event: "voice", fn: (access: { url: string; token: string }) => void): () => void;
  on(event: "lobby", fn: (state: LobbyState) => void): () => void;
  on(event: "handoff", fn: (handoff: Handoff) => void): () => void;
  onTick(fn: (tick: number) => void): () => void;
  predict<I, K>(stepper: Stepper<I>, opts: { find(room: RoomClient<S, P, C>): ClientEntity<K> | undefined; sample?(prediction: Prediction<I, K>): I; idle?(input: I): boolean }): Prediction<I, K>;
  interpolate<K extends Kind, F extends keyof ValuesOf<K> & string>(kind: K, fields: readonly F[]): Interpolator<K, F>;
  readonly playerData: PlayerData;           // this player's data; see Player data below
  collection(name: string): Collection;
  leaderboard(name: string): ClientLeaderboard;
}
```

- **`send`** sends a `Uint8Array` as a binary message and anything else
  as JSON, at most 64 KB. It throws if called before the client was
  first ready; while the client reconnects, messages are dropped. In a
  native room, `to`, 1 to 16 player IDs, sends it only to those of them
  who are connected; it throws a `RangeError` for an empty list or more
  than 16, and any other room closes the connection with 4006 for it.
- **`info.from`** of a `message` event is the sender's player ID in a
  native room, where messages come from other players, and `""` for
  one sent through the server API. It's undefined in a room with room
  code, whose messages come from the room. Held messages keep it.
- **`lead`** passes the leader role of a native room to another
  connected player, with the native room's call `lead`, and resolves once
  the room took it. It rejects with an `IrtError` when this player
  isn't the leader or the other player isn't connected. See Presence.
- **`leave`** closes the connection; the client doesn't reconnect and
  emits `closed` with code 1000.
- **`state` and `private`** are undefined until `ready` resolves, and
  set from then on, through reconnects. `connect` resolves to a
  `ReadyRoomClient`, the same object typed with both set. With
  `new RoomClient`, read them as `room.private?.coins`, or after
  `await room.ready`.
- Every `on` returns a function that removes the listener.
- **`serverTick()` and `rtt`** come from pings. The client pings three
  times 100 ms apart after each `welcome`, then every 2 seconds. The
  room answers with its current tick, and the client takes the answer
  to be from the middle of that tick and half a round trip old.
  `serverTick()` and `rtt` use the sample with the smallest round trip
  of the last 8. Across a reconnect to the same generation the client
  keeps its estimate until a new pong arrives; after a restart or a
  wake it uses the first state update's tick until then.
- **`jitter`** is the mean deviation of the last 8 round trips from
  their mean. **`transportKind`** comes from the dial; a custom `dial`
  can leave its `kind` out, and then it's undefined.
- **`joinTime`** is set when `ready` resolves: the milliseconds from
  `new RoomClient` to the first full state update, leaving out any
  `createRoom` or `quickJoin` before it, and `started`, whether the
  router sent `waking` on the way, which it does for a room that starts
  as well as one that wakes. After a reconnect it's the time from the
  drop to the resync. The [join loader](/docs/ui-kit#irt-join-loader)
  shows it.
- **How accurate `serverTick()` is.** The room only reports whole ticks,
  so on a symmetric link expect it to be off by up to half a tick: about
  25 ms at 20 Hz. Two clients in the same room can differ by up to a
  tick. It's good enough for what the SDK uses it for, sending inputs
  for the right tick and the render time of `interpolate`, and for
  timing game events in ticks. It isn't a wall clock: a stalled room
  skips the ticks it couldn't run and ticks don't count time the room
  was hibernated or restarting.
- **`serverTime()`** is the room's wall clock, `room.now()`, in
  milliseconds, for timing in real time, such as music or judging a key
  press. Each pong carries the room's clock when it answered; the
  client takes the pong with the smallest round trip of the last 8,
  adds half its round trip and carries it on with its own clock. On a
  symmetric link it's off by about a millisecond. It's undefined before
  the first pong, and from a router or room older than client protocol
  3. It follows the test clock in `@irt/testing`, as `room.now()` does.
  The client also reports its `rtt` in each ping, which the room shows
  as `conn.rtt`.
- **Asymmetric links.** Any clock estimated from round trips,
  `serverTick()`, `serverTime()` and one you build yourself, assumes half the round
  trip each way. When a link is slower one way, the clock is off by
  half the difference, and nothing in the round trip shows it: with
  150 ms up and 0 ms down, the client's clock is 75 ms behind the
  room's. To correct for it, and for audio output latency, let each
  player calibrate an offset, such as by tapping along to a beat.

**Messages before a listener.** A room's messages can reach the client
before `connect` resolves, such as those it sends in `onJoin`. The
client holds reliable messages that arrive before the first `message`
listener is added, up to 100, and gives them to the listeners once the
code that added the first one has run, so listeners added together all
get them. So `await connect(...)` then `room.on("message", ...)` gets
every message the room sent on join. Messages keep the order the room
sent them in, held ones first; a held message is delivered after the
state updates that arrived with it are applied. Unreliable messages are
not held: before the first listener, they are dropped. After the first
`message` listener has been added, nothing is held, so a listener added
later gets only messages from then on.

| Event | When |
|---|---|
| `waking` | The room isn't running and the connection is held while it starts. That's a wake of a hibernated room, but also the first start of a room this connection creates, and a restart after a crash: the router says it before it has looked up whether the room has room state. Show something neutral, such as "starting the room" |
| `disconnected` | The connection dropped; the client is reconnecting |
| `restored` | A reconnect succeeded and the client holds the full state again, resumed or sent whole |
| `sleep` | The room went to sleep and the router holds the connection; `room.asleep` is true. The connection stays open: messages, calls and writes sent meanwhile wake the room and reach it after, and unreliable messages and inputs are dropped. The client pings every 15 seconds instead of every 2 until the room wakes |
| `wake` | The room woke from sleep and the client holds its full state again. Calls made while it slept are answered, not failed; prediction starts over |
| `closed` | The connection ended for good, with an `IrtError` whose `code` is the close code |
| `message` | A message from the room arrived |
| `tick` | A whole state update was applied. The room sends one only for a tick that changed something this client sees, so a room where nothing changes fires none; for the room's current tick, use `room.serverTick()` |
| `peers` | `room.peers` changed: who this player hears or is heard by |
| `voice` | The router granted voice for this connection; `room.voice` is set |
| `lobby` | A lobby's state changed; `room.lobby` is set |
| `handoff` | A handoff connected the player to another room, or failed to; the same as `onHandoff` |

### Lobbies

```ts
interface LobbyState {
  phase: "waiting" | "countdown" | "handing-off";
  ready: string[];          // ready people in join order, then every bot
  startsAt: number | null;  // when the countdown ends, in the room's wall-clock ms; compare with serverTime()
  min: number;              // the lobby's minPlayers and maxPlayers
  max: number;
  failed: "players" | "limit" | "spending" | "unavailable" | null;  // why the last handoff failed, until the next countdown
}

type Handoff =
  | { room: string; type: string; client: RoomClient }  // connected to the new room
  | { room: string; type: string; error: IrtError };   // couldn't connect; this client stays where it was
```

- **`lobby`** is set from the lobby's state message, which a lobby sends
  after the first state update and whenever it changes. It's undefined
  in every room that isn't a lobby.
- **`setReady`, `start` and `cancel`** are the lobby's calls `ready`,
  `start` and `cancel`. They reject with an `IrtError` with the
  lobby's reason: `start` and `cancel` from anyone but the leader,
  `start` outside waiting or with fewer than `minPlayers` people and
  bots, `cancel` outside the countdown, and `setReady` while the
  lobby hands off. In a room that isn't a lobby they reject as calls the
  room doesn't answer.
- **Handoffs.** When the router moves the player, the client connects
  to the new room with `targets[type]` as its schema, the empty schema
  for a type it doesn't name, and the same token or guest. Once the new
  client holds the room's state, this one leaves its room, unless the
  handoff keeps it, as a party's does, then `onHandoff` gets the new
  client. If the new client fails for good, `onHandoff` gets its error
  and this client stays connected. See
  [Lobbies and parties](/docs/guides/lobbies).

### Wire statistics

```ts
class WireStats {
  messagesIn: number;
  bytesIn: number;
  messagesOut: number;
  bytesOut: number;
  readonly received: Record<string, { messages: number; bytes: number }>;  // by server message type
  readonly sent: Record<string, { messages: number; bytes: number }>;      // by client message type
  lastUpdate: { tick: number; parts: number; bytes: number; full: boolean } | undefined;
}
```

`room.stats` counts the client's traffic since it was created, across
reconnects. Sizes are of protocol messages as encoded, without the
transport's own framing, such as a WebSocket frame header. `received`
is keyed by type: `state` (state updates), `ack` (input acks), `pong`,
`message`, `unreliable`, `reply` and the rest; `sent` by `input` (or
`typedInput` with a schema's [input kind](/docs/guides/prediction#compact-inputs)),
`ping`, `write`, `message`, `unreliable`, `call` and `hello`. So
`stats.received.state.bytes` is what state sync cost and
`stats.sent.input.bytes` what prediction inputs cost. `lastUpdate` is
the last whole state update applied: its tick, the messages it was
split into, their size together on the wire, compressed or not, and
whether it was a full update, as on joining or on a reconnect that
couldn't resume. A tick that sent the client nothing leaves it as it
was. Sample the counters once a second to get rates:

```ts
// wire-rates.ts
import type { RoomClient } from "@irt/client";

export function logRates(room: RoomClient): void {
  let last = { in: room.stats.bytesIn, out: room.stats.bytesOut };
  setInterval(() => {
    const now = { in: room.stats.bytesIn, out: room.stats.bytesOut };
    console.log(`in ${now.in - last.in} B/s, out ${now.out - last.out} B/s, last update ${room.stats.lastUpdate?.bytes ?? 0} B`);
    last = now;
  }, 1000);
}
```

### Calls

`call(name, args)` runs the room's `calls[name]` handler and resolves to
what it returns, JSON or bytes. Its name, args and reply are typed from
the `calls` of the schema passed to `connect`, declared with
`defineCalls`; see the [calls guide](/docs/guides/calls). It rejects
with an `IrtError` when the handler throws, with the handler's message;
when the call's `parse` rejects the args; when the room has no such
call; after `timeout` milliseconds, 10,000 by default; and when the
connection drops or the room restarts first. A call made while the room
sleeps waits for it to wake and is answered then. A call that fails that way
may or may not have run, and the SDK never retries it, so make calls
that change state safe to repeat, or check with another call. Before the
client is first ready, `call` throws.

### Reloading for a new build

`reloadForNewBuild()` reloads the page after a 4004 so the browser loads
the live build's site, and returns `true`. It reloads at most once until
a connection succeeds, and returns `false` instead of reloading again: a
room still running an older build refuses the new site's clients with
4004 until it hibernates, so reloading on every 4004 would loop. It also
returns `false` outside a browser or when storage is blocked.

## Close codes

| Code | Meaning | Client SDK |
|---|---|---|
| 4000 | Protocol version not supported | Fails; update `@irt/client` |
| 4001 | Token invalid or expired | Fails |
| 4002 | Room not found | Fails |
| 4003 | Room full | Fails |
| 4004 | The client's schema differs from the room build's | Fails; the message gives both hashes. Rebuild and redeploy the client and room together |
| 4005 | Rate or size limit exceeded | Reconnects after backoff |
| 4006 | Kicked by the room, or its `onJoin` threw | Fails |
| 4007 | Room stopped | Fails |
| 4008 | Room moved or restarted | Reconnects at once |
| 4009 | Project over its spending limit | Fails |
| 4010 | Wake timed out | Reconnects after backoff |
| 4011 | The room hibernated by its own request | Fails; connect again to rejoin, which wakes it |
| 4012 | The same player connected to the room again elsewhere | Fails; the player is playing in another tab or device |
| 4013 | The player is [banned](/docs/moderation) from the project | Fails |
| 4014 | The room banned the player | Fails |
| 4015 | The room isn't of the client's `type`, or the live build has no such type | Fails; don't reload for it, since the new site has the same mistake |

Any other close, and a failure to connect at all, reconnects with
backoff: 250 ms doubling up to 10 s, with jitter. [Errors and close
codes](/docs/reference/errors) has each code's causes and the messages
`connect` and `call` reject with.

## Entities on the client

```ts
type ClientEntity<K> = Readonly<ValuesOf<K> & { id: number; kind: K }> & {
  toJSON(): ValuesOf<K> & { id: number; kind: string };   // a plain copy; kind is the kind's name
};
// An entity the local player owns: its writable fields can be set.
type OwnedEntity<K> = ClientEntity<K> & { -readonly [N in WritableOf<K>]: ValuesOf<K>[N] };

class EntityStore {
  get(id: number): ClientEntity | undefined;
  all<K extends Kind>(kind: K): IterableIterator<Entity<K> & { toJSON(): ValuesOf<K> & { id: number; kind: string } }>;
  mine<K extends Kind>(kind: K): IterableIterator<OwnedEntity<K>>;
  ownerOf(e: ClientEntity): string | undefined;   // a player ID, or undefined for the room
  readonly count: number;
  onEnter<K extends Kind>(kind: K, fn: (e: ClientEntity<K>) => void): () => void;
  onChange<K extends Kind>(kind: K, fn: (e: ClientEntity<K>, changed: (keyof ValuesOf<K>)[]) => void): () => void;
  onCorrect<K extends Kind>(kind: K, fn: (e: OwnedEntity<K>, corrected: WritableOf<K>[]) => void): () => void;
  onOwner<K extends Kind>(kind: K, fn: (e: ClientEntity<K>) => void): () => void;
  onLeave<K extends Kind>(kind: K, fn: (e: ClientEntity<K>) => void): () => void;
}
```

- Entities are read-only, except the writable fields of entities the
  local player owns, including its private state; any other write throws
  a `TypeError`. See [Ownership](/docs/guides/ownership).
- Owner writes are sent at most once per room tick. The room's
  corrections overwrite them, and `onCorrect` runs after `onChange` for
  the corrected fields.
- A state update is applied a whole tick at a time, then the callbacks
  run in order: leaves, enters and changes as they happened, then
  `tick`.
- To copy an entity, call `toJSON()`: `{ id, kind, ...fields }` with
  `kind` as the kind's name, which `JSON.stringify` also uses. Its fields
  are accessors on a shared prototype, so `{ ...entity }` copies only
  `id` and `kind`. The copy is a snapshot of the values at that moment,
  and the entity object itself keeps changing with each state update.
- The shared state is entity 0 and the private state entity 1, of the
  schema's state and private kinds; `onChange(schema.state, ...)` works.
- `count` is every entity the client holds: the shared state, its
  private state, a presence entry for each player in the room, and the
  room's entities in its view. A seated player in a room of four, with
  one entity of its own in view, has a `count` of 7. Count one kind
  with `all(kind)`. The room's `entities.count` counts only the room's
  entities.
- Entities of a [hidden kind](/docs/guides/state-sync#hidden-entities)
  never arrive, so `all(kind)` of one is always empty.
- On a reconnect or a change of generation, every entity leaves and the
  full state enters again.

## Presence

```ts
interface PresenceEntry {
  readonly player: string;
  readonly name: string;        // set by the room
  readonly status: unknown;     // JSON, at most 1 KB; null until set
  readonly connected: boolean;  // false for 10 s after the player's last connection closed
}

class PresenceList {
  get(player: string): PresenceEntry | undefined;
  readonly me: PresenceEntry | undefined;   // the local player's entry
  readonly size: number;
  [Symbol.iterator](): IterableIterator<PresenceEntry>;
  setStatus(status: unknown): void;
  onJoin(fn: (p: PresenceEntry) => void): () => void;
  onChange(fn: (p: PresenceEntry, changed: ("name" | "status" | "connected")[]) => void): () => void;
  onLeave(fn: (p: PresenceEntry) => void): () => void;
  readonly leader: string | undefined;      // native rooms: the player who leads, or undefined for none
  onLeader(fn: (player: string | undefined) => void): () => void;
}
```

- `room.presence` lists every player in the room, whatever the room's
  interest, and is set once `ready` resolves. Its callbacks run after
  the entity callbacks of the same state update, before `tick`.
- A resync doesn't fire `onLeave` and `onJoin` for players who stayed;
  `onChange` runs for whatever changed while the client was away.
- `setStatus` is an owner write: it changes `me.status` at once, without
  `onChange`, and throws a `RangeError` over 1 KB. A correction from the
  room fires `onChange`. See [Presence](/docs/guides/presence).
- **The leader.** A native room has a leader, set after the client's
  first state update and whenever it changes, when `onLeader` runs. The
  leader keeps the role while their presence lasts, so through a
  disconnect of up to 10 seconds, or until they pass it with
  `room.lead(player)`; then the earliest connected player leads, and
  the role doesn't go back to the old leader. A room with room code has
  no leader, and `leader` stays undefined.
- **In a native room** each player's status is at most 64 bytes of JSON;
  a longer one closes the connection with 4006, as does a
  `to` list of more than 16 players or a malformed message.

## Interpolation

```ts
class Interpolator<K, F extends string> {
  get(entity: ClientEntity<K>): Record<F, number>;
}
```

`room.interpolate(kind, fields)` records the listed numeric fields of
every entity of the kind as state updates arrive. `get(entity)` returns
them at the client's render time: the room's estimated tick minus two
ticks or 100 ms, whichever is longer, linearly between the two states
around it. Call it each frame. Use it for remote entities; use
[prediction](/docs/guides/prediction) for the local player.

## Prediction

```ts
class Prediction<I, K> {          // K is the kind `find` returns
  readonly entity: Entity<K> | undefined;   // has toJSON(), as client entities do
  render<F extends keyof ValuesOf<K>>(fields: readonly F[], opts?: { snapDistance?: number }): Record<F, number>;
  snap(): void;                   // drops the mispredictions render is smoothing
  readonly pending: number;       // ticks ahead of the room's state: about 2 plus half the round trip while sampling
  readonly settled: boolean;      // entity agrees with the room's last state
  mispredictions: number;         // replays that found a field the stepper writes different
  onMispredict(fn: (m: { tick: number; fields: string[]; delta: Record<string, number> }) => void): () => void;
  push(input: I): void;
  stop(): void;
}
```

`sample(prediction)` runs once per room tick, first at the next tick
after `predict` returns, and receives the `Prediction`. Its `entity` is
set before each call whenever `find` finds the entity, so it's
undefined only while `find` finds nothing: before the entity is in the
client's view, while the room has deleted it, and after a reconnect
until the full state arrives.

`render(fields, { snapDistance })` drops the correction of those fields
instead of smoothing it when it's longer than `snapDistance`, measured
as a Euclidean distance over the fields, as after a teleport; `snap()`
drops every correction at once. See the
[prediction guide](/docs/guides/prediction) for how inputs, acks and
replay work.

## HTTP helpers for games without a backend

```ts
function createRoom(opts: { router: string; project: string; token?: string; id?: string; type?: string; public?: boolean; joinCode?: boolean; meta?: unknown; maxPlayers?: number }): Promise<{ id: string; joinCode: string | null }>;
function joinByCode(opts: { router: string; project: string; token?: string; code: string }): Promise<string>;
function listPublic(opts: { router: string; project: string; token?: string; type?: string }): Promise<{ id: string; players: number; type: string; meta: unknown }[]>;
function quickJoin(opts: { router: string; project: string; token?: string; type?: string; meta?: Record<string, unknown>; maxPlayers?: number }): Promise<{ id: string; joinCode: string | null }>;
```

Each is allowed or not by a project setting (`players.createRooms`,
`players.listPublic`, `players.quickJoin`). They reject with an `IrtError` whose `code` is the HTTP
status, such as 404 for an unknown join code. Join codes are 6
characters of `A-H`, `J-N`, `P-Z` and `2-9`. `joinByCode` is case-insensitive
and ignores spaces around the code, so `"k7qx2m "` finds `K7QX2M`.

`maxPlayers` caps a room's connections below its room size's connection
limit (64 for `standard`), from 1 to that limit; a connection past it
closes with 4003, and a value out of range rejects with 400. With
`players.createRooms` on, connecting to a room that doesn't exist also
creates it, as a private room with no join code.

`type` is the [room type](/docs/concepts#rooms): `createRoom`
creates a room of it, `quickJoin` picks or creates one, and `listPublic`
lists only rooms of it. Without one, `createRoom` and `quickJoin` use the
type named `default` and reject with 400 naming the build's types when
it has none, and `listPublic` lists every type, each room with its
`type`. A type's own `maxPlayers` and size apply to its new rooms.

`quickJoin` returns a public room to connect to, for games that fill rooms
with whoever comes. It picks the active public room with the most players
that's under its connection limit and its own `maxPlayers`, among those
whose `meta` has every top-level key of `opts.meta` with an equal value.
Without one it picks such a hibernated room, and without that it creates
a public room with `opts.meta` as its `meta` and `opts.maxPlayers` as its
`maxPlayers`. It holds the room's slot for 10 seconds or until the client
connects, so players who quick join at once don't overfill a room or create
one each. `joinCode` is the room's join code, or `null`. The setting is off by
default; matchmaking by skill, parties or queues belongs in your backend.

```ts
// rooms.ts
import { connect, createRoom, joinByCode, listPublic, quickJoin } from "@irt/client";

const router = "wss://eu.irt.io";
const project = "my-game";

export async function host() {
  const { id, joinCode } = await createRoom({ router, project, public: true, joinCode: true, meta: { mode: "duel" } });
  console.log("share", joinCode);
  return connect({ router, project, room: id });
}

export async function join(code: string) {
  return connect({ router, project, room: await joinByCode({ router, project, code }) });
}

export async function browse() {
  for (const r of await listPublic({ router, project })) console.log(r.id, r.players, r.meta);
}

export async function play(mode: string) {
  const { id } = await quickJoin({ router, project, meta: { mode } });
  return connect({ router, project, room: id });
}
```

## Player data, collections and leaderboards

```ts
function dataClient(opts: { router: string; project: string; token?: string }): DataClient;

class DataClient {
  readonly playerData: PlayerData;             // get, set, increment, delete, list, as in the room SDK, for this player
  collection(name: string): Collection;        // get, put, list, delete
  leaderboard(name: string): ClientLeaderboard;
  player(): Promise<string>;                   // the player ID calls are made as; mints a guest without a token
}

class ClientLeaderboard {
  submit(score: number, opts?: { meta?: Record<string, string | number | boolean> }): Promise<{ period: string; score: number }[]>;
  top(opts?: { period?: string; limit?: number; after?: string | null }): Promise<{ entries: RankedEntry[]; next: string | null }>;
  around(opts?: { player?: string; period?: string; count?: number }): Promise<{ rank: number; entries: RankedEntry[] } | undefined>;  // this player by default
}
```

The same calls as [in the room](/docs/reference/room-sdk#player-data-collections-and-leaderboards),
for the player the client is, over the router's `POST /v1/data/{project}`,
with no room connection needed. `RoomClient` has them too, as
`room.playerData`, `room.collection(name)` and `room.leaderboard(name)`,
made with the connection's token or guest.

- **Guests.** Without a `token`, the first call takes the guest token
  `connect` keeps in `localStorage`, or gets one from `POST /v1/guest`
  and keeps it there, so a guest is one player in rooms and data calls.
  An expired guest token gets a new guest, as connecting does. With
  storage blocked, the token lives as long as the page.
- **Rights.** A client reads its own player data and writes only the
  keys in `data.player.clientWrite`; reads entries and boards with
  `clientRead`; and with `clientWrite: "own"` creates entries, changes
  and deletes its own, and submits its own score. `owner` is always the
  client's player. A score a client submits can be faked.
- **Nothing is cached** on the client; every call goes to the router.
- **Errors.** Calls reject with `DataError` (also exported here) and a
  contract code: `E_UNAVAILABLE` when the router or data service can't be
  reached or answers 503, `E_LIMIT` over the router's 10 calls a second
  per address and per player or 10 guests a minute per address,
  `E_FORBIDDEN` for a rejected token. See the
  [guide](/docs/guides/player-data).

```ts
// data.ts
import { DataError, dataClient } from "@irt/client";

const data = dataClient({ router: "wss://eu.irt.io", project: "my-game" });

export async function saveSettings(volume: number) {
  try {
    await data.playerData.set("settings", { volume });
  } catch (e) {
    if (e instanceof DataError && e.code === "E_UNAVAILABLE") return false;
    throw e;
  }
  return true;
}

export async function weeklyTop() {
  const { entries } = await data.leaderboard("highscores").top({ period: "week", limit: 10 });
  return entries.map((e) => `${e.rank}. ${e.meta.name ?? e.player} ${e.score}`);
}
```

## Simulated network

```ts
import { simulateNetwork, type NetworkConditions, type SimulateOptions } from "@irt/client/dev";

function simulateNetwork(conditions: NetworkConditions, opts?: SimulateOptions): Dial;

interface NetworkConditions {
  rttMs: number;      // added round trip, half each way
  jitterMs?: number;  // each one-way delay varies by up to this much either side of rttMs / 2
  loss?: number;      // fraction of unreliable messages dropped each way, 0 to 1
}

interface SimulateOptions {
  dial?: Dial;            // the transport to wrap; default WebTransport, then WebSocket
  clock?: Clock;          // timers, for tests
  random?: () => number;  // uniform in [0, 1), for tests
  allowRemote?: boolean;  // allow a router other than localhost, 127.0.0.1 or ::1
}
```

Pass the result as `dial` to `connect`. Reliable messages are delayed and
stay in order; unreliable messages, inputs and pings can also be dropped
and reordered. Against a router that isn't local, without `allowRemote`,
`connect` rejects with an `IrtError` of code 0 saying so. It's a separate
entry, so it adds nothing to a bundle that doesn't import it. See
[Dev tools](/docs/dev-tools#simulated-network-conditions).

## Voice

`room.peers` is `{ speakers, listeners }`, the player IDs this player
hears and is heard by, as the room last decided; the `peers` event fires
when it changes. `room.voice` is the router's `{ url, token }` when the
project uses voice, and the `voice` event fires when it's granted. On
each reconnect `room.voice` is undefined and `room.peers` empty until the
router and the room send them again. `joinVoice(room, { position?, spatial?, range? })` from
`@irt/client/voice` joins the voice server. The `Voice` it returns
has `participants`, the other players on the voice server, with a
`participants` event when one joins or leaves it, next to `speaking`,
`mutedPlayers` and the player's own controls; see
[Proximity voice](/docs/guides/proximity-voice#client).

## Replays

`loadReplay(bytes, { schema? })` from `@irt/client/replay` plays a
replay or clip file into `replay.entities`, an `EntityStore` with the
usual events, with `replay.state` and `replay.presence`. `play()`,
`pause()`, `speed`, `seek(tick)` and `step()` move it between
`firstTick` and `lastTick`; the `tick`, `message` and `end` events
follow. `kind(name)` returns a recorded kind that differs from the
schema's, and `privateOf(player)` a player's private state when it was
recorded. It's a separate entry, so it adds nothing to a bundle that
doesn't import it. See [Replays and clips](/docs/guides/replays).

## Common mistakes

- **Not awaiting `connect`.** `room.state` and `room.private` are set only
  once it resolves, and `send` throws before then.
- **Reloading on every close.** Reload only on 4004. The client already
  reconnects on the codes that allow it.
- **Reconnecting by hand after 4011.** That's the room asking to
  hibernate; offer the player a rejoin instead of looping.
- **Writing a client entity.** Send a message, or predict the local
  player.
- **Keeping references across a resync.** After `restored`, entities
  the room sent again are new objects, every entity after a full update
  and those that changed after a resume; look them up again or rebuild
  from `onEnter`.
- **Opening two clients as the same player.** The router closes the
  older one with 4012, and it doesn't reconnect. Two tabs of one browser
  are the same guest.
