Reference

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 for running it from scripts.

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

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

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

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

RoomClient

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

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.

Wire statistics

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), 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:

// 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. 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 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 has each code’s causes and the messages connect and call reject with.

Entities on the client

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

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

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 for the local player.

Prediction

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 for how inputs, acks and replay work.

HTTP helpers for games without a backend

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

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

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, 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.
// 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

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.

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.

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.

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.

View this page as Markdown