Client SDK

@irtio/client connects your game to a room. Use room.state for logic and writes, and room.render for drawing. The tables below list join options, runtime methods, and events.

joinRoom

function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> & string>(
  schema: S,
  options?: JoinOptions<S, Role>,
): Promise<Room<S, Role>>;

The promise resolves once the first WELCOME frame arrives. A fatal error before that (bad key, wrong origin, room full, schema skew) rejects with an Error whose message is `${code}: ${message}`. See the error catalogue.

OptionTypeDefaultMeaning
roomstring?room= from the page URL, else a new roomA room code (ABCD) or a whole share link
mustExistbooleanfalseJoin only a room that already exists. An id nobody created is refused with E_ROOM_NOT_FOUND instead of being created. Use it on pages that join from a shared link. Throws if there is no room to join. A server older than this release ignores it and creates the room, so it is advisory until every server is upgraded
privateCodebooleanfalseWhen this join creates a room, mint a 10-character code (about 50 bits) instead of the short default. An explicit room wins. See room resolution
rolea role your schema declaresnoneAsk to join as this role. Honoured only when the schema sets clientRoles: true or a JWT role claim names it; otherwise the client gets the first role. Passing a literal narrows room.state to that role’s view. At most 32 UTF-8 bytes; longer throws a TypeError
namestring''Display name, visible in presence and as ctx.name on the server. At most 32 UTF-8 bytes (about 10 CJK characters); longer throws a TypeError before connecting
rpcimplementations of every server-to-client RPCnoneExhaustive by type when the schema declares any
urlstringsee belowEndpoint override
regionstringeuThe hosted region your project is deployed to. Only used when no url is set. See below
keystringsee belowPublic project key
tokenstring \| (() => string \| Promise<string>)noneA JWT layered on top of the project key, asserting player identity. Prefer a function: it is called before every HELLO, including reconnects, so a near-expiry token refreshes itself automatically
identityboolean \| IdentitynoneJoin under an irtio anonymous persistent identity. true mints one on this browser’s first run and keeps it; an Identity is used as-is; ctx.playerId in your room becomes a stable irt:<subject>. Mutually exclusive with token
controlUrlstringhttps://irt.ioWhere the identity exchange talks to. A local irtio dev has no control plane, so it is the default that carries a local page’s identity calls to the hosted one. See Testing identity locally
onStatus(status: Status) => voidnoneSame as room.on('status', ...), but set before the first frame
writeIntervalMsnumber50Hard cap on the owned-write flush window
interpDelayMsnumbermax(50, 2 x tick interval)How far behind arrival room.render draws non-owned entities
physicsClientPhysicsOptionsnoneThe client half of the shared world builder. Requires module, the default export of @irtio/client/rapier3d. See physics
physicsCustomClientCustomOptionsnonePrediction for a room running your own stepper. Mutually exclusive with physics and physics2d. Takes world, the factory your room config also imports, and no engine module. See custom physics
physics2dClientPhysics2dOptions or ClientRapier2dOptionsnonePrediction for Matter or Rapier 2D. Mutually exclusive with physics. Requires module, the default export of @irtio/client/matter or @irtio/client/rapier2d; use engine: 'rapier2d' for Rapier. See 2D physics
profilebooleanfalseKeep a bandwidth ledger, readable as room.profile. A development surface. See the profiler
conditions{ rttMs, jitterMs, loss, duplicate, reorder, reorderMs, seed }noneShape this session’s network on a local endpoint. See Testing under latency
transportthe value replay() returnsa socketPlay a recorded clip instead of connecting. This is the only supported use. See Replay playback

A token doesn’t replace the project key — both travel together, the key identifying the project and the token identifying the player. Your server mints it with the project’s JWT signing secret (npx irtio keys jwt-secret, npx irtio keys jwt-mint — see the CLI reference).

Identity options

new Identity(options) builds an identity you pass as joinRoom’s identity, so several joins can share one.

OptionTypeDefaultMeaning
projectstringrequiredThe project id your join uses: the schema’s project, or joinRoom’s key if you pass one. If they differ, the room refuses the join
controlUrlstringhttps://irt.ioThe identity service’s HTTPS origin. Pass it when you test identities against a control plane other than the default
storageIdentityStorage \| nulllocalStorage for the page’s originWhere the device credential is kept. null keeps it in memory for the life of the page, so every page load mints a new credential and creates a new account. See shared origins

Testing identity locally

Serve your page locally and connect it to a deployed room to test identity: true. The identity exchange uses controlUrl (https://irt.io by default) and requires a registered project. An unknown project returns 404 E_NOT_FOUND.

A bare irtio dev has no identity verification keys and refuses assertions with E_ASSERTION_UNVERIFIABLE. It also has no JWT secret, so token joins fail with E_AUTH. For offline room development, omit identity and token. See player identity.

Testing under latency

conditions delays, drops, duplicates and reorders this session’s frames, so you can play your game on localhost the way a player on a slow connection will:

const room = await joinRoom(schema, {
  conditions: { rttMs: 150, jitterMs: 30, loss: 0.02 },
});
FieldTypeDefaultMeaning
rttMsnumber0Round trip in milliseconds; each direction carries half
jitterMsnumber0Extra delay per frame, drawn uniformly from [0, jitterMs)
lossnumber0Chance in [0, 1] that a state frame is dropped
duplicatenumber0Chance that a state frame is delivered twice
reordernumber0Chance that a state frame is held back past its neighbours
reorderMsnumber50How far a reordered frame may be held back
seednumber1Seeds the dice, so the same seed injects the same delays and drops

Loss, duplication and reorder touch state frames only — writes, deltas, corrections and messages. Session and RPC frames always arrive: dropping a welcome would hang the join rather than teach you anything. Delay applies to every frame.

Local endpoints only. Against anything other than localhost, 127.0.0.1 or [::1], the join throws instead of connecting, so a build that ships with this option left in fails on your machine rather than degrading a real player’s session.

irtio simulate --conditions runs the same model for bots. See Latency and prediction.

matchRoom

function matchRoom<S extends AnySchema, Role extends string = RoleOf<S> & string>(
  schema: S,
  options?: MatchOptions & JoinOptions<S, Role>,
): Promise<Room<S, Role>>;

Queue for a game, then join whatever room the queue answers with. Everything joinRoom accepts, plus:

OptionTypeDefaultMeaning
queuestringdefaultWhich queue to join. See quick match
timeoutMsnumber30,000How long to wait before giving up. Capped at two minutes
controlUrlstringhttps://irt.ioWhere the matchmaker lives

A queue that does not fill rejects with E_NO_MATCH rather than putting the player alone in a room. findMatch(project, options) is the queue call on its own, for a game that wants to render its own waiting state and join by hand.

Key resolution

joinRoom and joinRelay pick the project key in this order:

  1. The key option, if it is a non-empty string.
  2. schema.project, if the schema carries one. This is what npx irtio init writes, and it is the normal case for every deployed build and every local build alike.
  3. The literal string 'dev' — but only when the resolved endpoint is localhost, 127.0.0.1 or [::1].
  4. Otherwise joinRoom throws, pointing at npx irtio init or the key option.

Step 3 is not about where the page is served from — it’s a fallback for a schema with no project id at all, the state you’re in before ever running irtio init. A page served from localhost whose schema already carries a real project id resolves at step 2 and never reaches the 'dev' fallback, so a local page can point at staging just by setting url (or IRT_URL).

Endpoint resolution

joinRoom picks an endpoint in this order:

  1. The url option, if you passed one.

  2. IRT_URL from the Node environment, or window.IRT_URL in a browser.

    window.IRT_URL is the option without a build step: set it before your bundle runs and every joinRoom in the page follows it.

    <script>window.IRT_URL = 'ws://localhost:7071';</script>
    <script type="module" src="/main.js"></script>

    irtio dev --serve injects this line for you, pointing at the origin the page was loaded from, and a value you set yourself wins. When the dev server cannot take port 7070 it prints the port it did take and this line to point a page at it.

  3. ws://localhost:7070 when the page is on localhost, 127.0.0.1 or [::1], or when there is no page at all (Node, bots, tests).

    If nothing answers there, the first join probes 7171, 7272, 7373 and 7474 in order — the same ports irtio dev falls back through when it cannot bind 7070 — and later joins in the page start at whichever port answered. Only this step is probed: a url option or an IRT_URL value is used exactly as given, and fails if it is wrong.

  4. wss://<region>.irt.io otherwise, where region is the region option and defaults to eu.

Today eu is the only region, so leave region unset. Rooms are placed in one region and stay there. When more regions open, set region to where your project is deployed:

const room = await joinRoom(schema, { region: 'eu' }); // the default; others when regions open

It has to match the region the project is set to in the dashboard. npx irtio deploy prints that project’s endpoint on the line reading reachable at wss://<region>.irt.io, so the value to pass is the one you already saw when you deployed. A region label is lowercase letters and digits, starting with a letter; anything else throws when you join rather than quietly connecting you to the default region.

region only decides step 4. A page on localhost still gets your local irtio dev server, and an explicit url or IRT_URL still wins, so a project in another region needs no code change during development.

Room resolution

room may be a bare code or a full share link; a link is parsed for its ?room= parameter.

With no room option in a browser, joinRoom reads ?room= from the current URL. If there is nothing there either, it creates a room and appends ?room=CODE to the address bar with history.replaceState. That is why the two-tab test works: open the page, copy the URL that now has a code in it, paste it into a second tab.

In Node there is no page, so with no room option a room is always created.

A created room gets a short code by default: four characters, easy to read aloud and easy to guess, so fine for casual, public invites. For a private game pass privateCode: true on the creating side, which mints a 10-character code from Web Crypto, and mustExist: true on the joining side, so a guessed or mistyped code is refused rather than creating a room. A room bound to a JWT is the strongest option. mustExist with no room throws, since a room nobody has created cannot exist yet. A server older than this release ignores mustExist and creates the room.

room.link is the shareable URL either way. It is the current page URL with ?room= set and role removed, so handing it to somebody does not also hand them this client’s seat. The address bar keeps role, so reloading a page opened as ?role=host still asks for the host role. That holds whether the code came from the address bar, from a room option your own code passed, or from a room this client just created. Only outside a page — Node, tests — does link fall back to the connect endpoint.

The room handle

interface Room<S, Role> {
  readonly me: string;                    // your client id, ctx.clientId on the server
  readonly id: string;                    // the room code
  readonly role: string;                  // this client's role, live through setRole
  readonly link: string;                  // shareable URL carrying ?room=<id>
  readonly tick: number;                  // last server tick this client saw
  readonly maxClients: number;            // the room's configured cap, or 0 when unknown
  readonly status: Status;
  readonly rtt: number;                   // ms, smoothed over recent pings; 0 before the first
  readonly state: ClientState<S, Role>;
  readonly render: ClientState<S, Role>;
  readonly clients: readonly PresenceRecord[];
  readonly prediction?: PredictionStatus; // present when the join passed `physics`
  readonly profile?: RoomProfile;         // present when the join passed `profile: true`
  readonly call: RoomCallProxy<S>;
  asRole<R>(): Room<S, R>;                // compile-time re-narrowing after a role change
  requestOwnership(entity: string, id: string): Promise<boolean>;
  message(target: MessageTarget, bytes: Uint8Array): void;
  onMessage(cb: (from: 'server' | string, bytes: Uint8Array) => void): Unsubscribe;
  readonly messages: RoomMessages<S>;     // one channel per declared message shape
  readonly stats: RoomStats;              // { messages: { sent, received, dropped } }
  on<K extends keyof RoomEvents>(event: K, cb: (value: RoomEvents[K]) => void): Unsubscribe;
  flush(): void;
  leave(): void;
}

on returns an unsubscribe function. So does onMessage.

room.role is the role this client currently holds. It starts as the role the join was granted and follows a server-side room.setRole, because it reads this client’s presence record, which is what setRole updates. The types do not follow it: room.state stays narrowed to the role the join was typed against. Once your code has checked room.role, room.asRole<'host'>() hands back the same room object typed for that role. It is an assertion, not a check, so read room.role first.

Reading state

room.state mirrors your schema. Entity collections read like a Map with index sugar on top:

room.state.players.get(id);        // T | undefined
room.state.players[id];            // the same thing
room.state.players.has(id);
room.state.players.ownerOf(id);    // client id, or '' for the server
room.state.players.size;
for (const [id, p] of room.state.players) { /* ... */ }
for (const id of room.state.players.ids()) { /* ... */ }

Singletons are plain objects: room.state.match.phase.

A bytes(n) field reads as a read-only Uint8Array view: index it, loop over it, or call subarray() and slice(). An update that carries changed ranges writes them into the same buffer in place, and an update that carries the whole field replaces it, so call .slice() if you need to keep the contents from one frame to the next. The client cannot write a bytes field in this release; change one through an RPC.

Every room also carries a built-in clients presence collection, exposed as room.clients. Each record is { clientId, role, name, connected }, ordered by join. Presence is server owned, so it is read only on the client, and it needs nothing in your schema. room.maxClients is the room’s configured cap from WELCOME, or 0 when the server does not report one (a relay room, or a server predating this field).

room.state versus room.render

room.state is authoritative: exactly what the server last said, plus your own local writes. Read it for game logic and in tests.

room.render has the same shape, but non-owned entities are interpolated at now - interpDelayMs. Numeric fields lerp; everything else steps, including bytes(n) fields. Set interpolate: false on a collection of tile chunks, since there is nothing to blend. It never extrapolates: when the render time passes the newest delta, the value holds there until the next one arrives. Entities you own return your predicted local values, so your own input still feels instant. Read it in your draw loop.

In a physics room (the shared world-builder passed as joinRoom({ physics })), room.render also serves predicted bodies: your own bodies simulated ahead with your inputs, plus any collection the schema marks predicted: true, simulated ahead from its last authoritative state. See client-side prediction.

Switching one is a one-word change: room.state.players becomes room.render.players.

Owned writes

An instance your client owns is a writable proxy. Assign to a field and the write is marked dirty:

const me = room.state.players[room.me];
me.x += dx;
me.y += dy;

Three rules govern this:

  • Owned means local. Every field you write inside one flush window leaves as a single WRITE frame.
  • Server wins. Incoming deltas never overwrite fields of instances you own. Only a correction does, and a correction clears the local dirty marks it supersedes and fires 'correct'.
  • Everything else is frozen. Non-owned instances, serverOwned collections and every singleton are read only at compile time and warn-once no-ops at runtime. Writing an instance someone else owns does nothing locally and is not sent.

To write something you do not own, either call an RPC or ask for ownership:

const granted = await room.requestOwnership('pieces', pieceId);

Re-read the instance after a grant. Object identity is not preserved across an ownership change. Ownership covers the model in full.

requestOwnership is unchanged by how the room decides. A collection can declare its own transfer rule in the schema (ownership: { transfer: 'free' | 'closer' | 'never' | 'ask' }), and the room answers by that rule instead of a coded onOwnershipRequest. Nothing about calling requestOwnership from the client differs either way. See Ownership.

How writes are batched

In a browser, the flush window is one animation frame, with writeIntervalMs (50 ms by default) as a hard cap. Outside a browser there is no animation frame, so the cap alone drives it. Every owned field written inside a window goes out as one WRITE.

room.flush() sends whatever is pending immediately. Reach for it when an input has to leave now and cannot wait for the next frame. Calling it in a loop defeats the batching and is a good way to get rate limited.

Local writes are replayed over corrections up to a resimulation depth of 20 flushed writes. A correction older than that snaps: correction.snapped is true and nothing is replayed.

RPCs

room.call is a proxy over every server-direction RPC your schema declares:

await room.call.answer({ choice: 2 });                    // void
const { cards } = await room.call.dealCards({ count: 5 }); // typed return

An RPC declared without returns resolves to undefined, not to a status object. Await it for the acknowledgement and ignore the value:

const ack = await room.call.answer({ choice: 2 }); // ack === undefined

A call rejects with a plain Error. The message is deny: <reason> when the handler refuses with ctx.deny(reason), and E_INTERNAL: rpc <name> when the handler throws (the thrown text is withheld). It also rejects when the params do not match the declared shape, when the RPC is unknown, and after a 10 second timeout. A call made while the socket is not open fails fast rather than waiting out the timeout.

Server-to-client RPCs are the rpc option on joinRoom. The type requires you to implement all of them:

const room = await joinRoom(schema, {
  rpc: {
    shake({ intensity }) { screenShake(intensity); },
    pickCard: async ({ options }) => ({ chosen: await promptUser(options) }),
  },
});

The server side is a 5 second timeout, so an implementation that never resolves rejects the room’s call. See RPCs.

Events

room.on('status', (status) => setBanner(status));
room.on('error', (e) => { if (e.fatal) showDisconnected(e.message); });
room.on('correct', (c) => { if (!c.suppressed) flash(c.collection, c.id); });
room.on('clients', (clients) => setPlayerCount(clients.length));
room.on('rtt', (rtt) => setPing(rtt));
EventPayload
statusStatus
error{ code, message, fatal }
correcta Correction
clientsreadonly PresenceRecord[], the same array as room.clients
rttnumber, the same value as room.rtt
notice{ kind, phase, failingForMs, closesInMs? }: the server cannot save the room. kind is 'save-loss' and phase is 'warning', 'recovered', or 'closing'. See what a player sees
audiencenumber, the room’s audience size. Fires when it changes, at most every couple of seconds, and only for participants

clients fires whenever the built-in presence collection changes: someone joins, leaves, or a presence field updates. rtt fires after each PONG updates the smoothed round-trip time, about every PING_INTERVAL_MS. Both are a way to subscribe instead of polling the matching getter.

A Correction carries collection, id, fields, patch (the server’s values, already applied), previous (your predicted values just before the snap), tick, clientTick (the write the server judged), replayed (how many pending local writes were re-applied over it), snapped, simulation (server-authoritative body state reaching its owner rather than a disagreement) and suppressed (a predicted body’s correction that matched the prediction within the epsilon). Misprediction magnitude is the numeric distance between previous and patch.

Collection subscriptions

Rows in an entity collection have their own subscriptions, separate from room.on. Each takes a collection name your role can see and returns an Unsubscribe.

const off = room.onAdd('bullets', (id, row) => spawnSprite(id, row));
room.onRemove('bullets', (id, lastRow) => burst(lastRow.x, lastRow.y));
room.onChange('bullets', (id, row) => syncSprite(id, row));
MethodSignatureFires when
room.onAdd(collection, (id, row) => void) => UnsubscribeA row appears in the collection
room.onRemove(collection, (id, lastRow) => void) => UnsubscribeA row disappears from the collection
room.onChange(collection, (id, row) => void) => UnsubscribeA server frame updates the fields of a row that is already there

onAdd announces rows that are already present when you subscribe, synchronously, inside the onAdd call itself. The join snapshot has landed by the time joinRoom resolves, so a listener registered right after the join sees the whole initial set and then every later arrival, with no separate pass over room.state.

onRemove hands the callback the row’s last values, so a death effect needs no parallel message. That row is read-only and is no longer in room.state. It never fires for rows that were removed before you subscribed.

onChange fires after a server frame changes an existing row, which includes a correction to a row this client predicted and a re-add of an id that is already present. It does not fire for this client’s own local writes to rows it owns: those you already know about, at the moment you make them. There is no subscribe-time catch-up either, so a listener registered late hears nothing until the next update. After a resync the client’s copy of the collection is rebuilt, and every surviving row is announced.

Connection status

type Status = 'connecting' | 'starting' | 'connected' | 'reconnecting' | 'closed';
StatusWhat it means
connectingThe initial connection is in flight. Every session starts here
startingThe server for this project was asleep and is waking. Not an error; keep waiting
connectedJoined. room.state is live
reconnectingThe socket dropped and the client is retrying with a resume token, backing off with full jitter from 250 ms, doubling to a 30 s cap; the ladder resets once a connection has held for 10 s; a socket silent for three pings is treated as dropped; credentials are fetched again before each reconnect dial
closedThe room is gone: leave() was called, or a fatal error arrived

The usual path is connecting to connected. A cold project inserts starting in between. A dropped socket goes connected to reconnecting and back to connected; if the reconnection grace window (30 seconds by default) closed while you were away, you rejoin fresh and room.me changes, so re-read anything you cached from it.

A connection that fails before the room was ever joined does not retry. It rejects the joinRoom promise with E_CONNECT_FAILED, because a join that never succeeded should fail rather than loop.

Messages

For anything that is not worth syncing as state, there is a message channel. Declare a shape in the schema and it is typed end to end:

room.messages.emote.send('all', { kind: 'wave', x: 12, y: 4 });
room.messages.emote.send(clientId, { kind: 'thanks', x: 0, y: 0 });
room.messages.emote.send({ role: 'host' }, { kind: 'wave', x: 0, y: 0 });
const off = room.messages.emote.on((from, emote) => { /* from is a client id or 'server' */ });

room.messages is {} for a schema that declares none, so a game can be written before the schema has anything to say. room.stats.messages.dropped counts typed messages this client could not read — a peer on a schema this build does not have. See peer messages.

Raw messages

For bytes you encode yourself:

room.message('all', bytes);            // everyone but you
room.message(clientId, bytes);         // one client
room.message({ role: 'host' }, bytes); // everyone with that role
const off = room.onMessage((from, bytes) => { /* from is a client id or 'server' */ });

Messages are not state: nothing is retained, nothing replays on reconnect, and a late joiner sees nothing that came before. The room can drop them in its onMessage handler.

The two channels never cross: onMessage is never called for a typed message, and room.messages.<name>.on is never called for raw bytes.

joinVoice

function joinVoice<S extends AnySchema, R extends string>(
  room: Room<S, R>,
  options?: VoiceOptions,
): Promise<VoiceHandle>;

Joins the room’s voice call and resolves to a handle. It takes a Room from joinRoom, asks the browser for the microphone, and rejects without touching the game connection when the player refuses or the region serves no voice. The voice code loads on the first call, so a game that never calls it never downloads it.

MemberTypeMeaning
mute(muted: boolean) => voidStop or resume sending your microphone. Signalled to the server, which pauses the audio at the source and tells the other participants
mutedbooleanYour current mute setting
peersreadonly string[]The client ids in the call
setPeerVolume(peerId: string, volume: number) => voidLocal playback volume for one peer, 0 to 1, clamped and never signalled
mutePeer(peerId: string, muted: boolean) => voidSilence one peer on this machine only, separate from that peer’s own mute
peerState(peerId: string) => VoicePeerState \| undefined{ muted, mutedLocally, volume, position, filter } for one peer, or undefined when nobody with that id is in the call
peerLevel(peerId: string) => numberHow much sound one participant is making right now, 0 to 1. room.me is your microphone
setListener(pose: VoiceListenerPose) => voidWhere you hear from: { x, y, z?, forward?, up? }. Needs positional
setPeerPosition(peerId: string, position: VoiceVec \| null) => voidPlace one peer for panning and distance falloff, or null to play them flat. Needs positional
setPeerFilter(peerId: string, filter: VoiceFilter \| null) => voidFilter how you hear one peer, locally
setMicFilter(filter: VoiceFilter \| null) => Promise<void>Filter your microphone before it is sent
micFilterVoiceFilter \| nullThe microphone filter in effect
noiseReduction'browser' \| 'rnnoise' \| 'off'The noise reduction running on your microphone
on(event, cb) => Unsubscribepeer-joined, peer-left, peer-muted, peers-changed, speaking and error
leave() => Promise<void>Leave the call while staying in the game

VoiceOptions:

PropertyTypeDefaultMeaning
positionalboolean \| VoicePositionalOptionsfalsePan and fade peers by position. The object takes panningModel, distanceModel, refDistance, maxDistance and rolloffFactor
noiseReduction'browser' \| 'rnnoise' \| 'off''browser'Microphone noise reduction
micFilterVoiceFilternoneA microphone filter to start with

A VoiceFilter is 'muffled', 'telephone', 'radio', 'robot', or an array of { type, frequency, Q?, gain? } biquad stages. joinVoice, setPeerFilter and setMicFilter throw a TypeError for an unknown preset or a stage without a valid type; joinVoice also throws one for an unknown noiseReduction, before asking for the microphone.

Peer ids are room client ids, so a peer matches a player in your game state. See voice chat for the button, the permission prompt, the roster, positional audio, filters and noise reduction.

VoiceError

class VoiceError extends Error {
  readonly name: 'VoiceError';
  readonly code: VoiceErrorCode | (string & {});
}

A failed joinVoice rejects with a VoiceError. Branch on err.code, never on the message. The VoiceErrorCode values a game usually handles are E_VOICE_UNAVAILABLE (no voice server for this project, including irtio dev), E_VOICE_ROOM_FULL, E_VOICE_MIC_DENIED, E_VOICE_NO_MIC, E_VOICE_NO_MEDIA (no microphone API, usually a page not served over HTTPS), E_VOICE_UNSUPPORTED and E_VOICE_FAILED. When the browser threw first, as it does for a denied microphone, its original exception is on err.cause. See handling a failed join.

joinRelay

import { joinRelay } from '@irtio/client';

const relay = await joinRelay({ room: 'ABCD' });
relay.message('all', encode(payload));

// with a schema the project deployed (no room code): typed messages too
const typed = await joinRelay({ room: 'ABCD', schema });
typed.messages.emote.send('all', { kind: 'wave', x: 0, y: 0 });

A relay room has no state and no RPCs. You get me, id, link, status, rtt, maxClients, clients, message, onMessage, messages, stats, on and leave, and nothing else. Its options are room, mustExist, privateCode, role, name, url, region, schema, key, identity, audience, controlUrl and onStatus; mustExist and privateCode work as they do on joinRoom. key follows the same key resolution joinRoom uses, so passing schema also supplies the project id. identity works as it does on joinRoom; in a crowd room it is what lets the room’s creator eject. audience joins as a viewer; see audience rooms.

A schema-less joinRelay is only admitted by a project with no room code deployed. Against a project that has room code, including a local npx irtio dev, pass schema.

Reach for it when you want irtio’s rooms, presence and share links but you are keeping your own state on top of a byte channel. Reach for joinRoom for everything else: state sync, validation, interpolation and prediction all live on the schema.

Replay playback

replay(source, options) is a transport that plays a recorded clip through the same pipeline as a live room. Pass it to joinRoom instead of connecting a socket:

import { joinRoom, replay, replayIdFromLocation } from '@irtio/client';
import { schema } from './irtio/schema.js';

const clipId = replayIdFromLocation({ project: schema.project }); // reads ?replay= from the page URL
const room = await joinRoom(schema, {
  transport: replay(clipId, { project: schema.project }),
  room: '',                                 // a replay joins no room
});

source is a clip id or a whole blob URL; a bare id needs options.project to build the URL. A project/clipId ref whose project differs from options.project throws.

replayIdFromLocation({ project }) returns ?replay= only when it is a clip ref: a bare clip id, or project/clipId with one /, each part letters, digits, _ and -. A URL, a path, or a ref naming a project other than project returns '', so a link somebody else wrote cannot make your page fetch and draw whatever it points at. replayRefFrom still accepts whole URLs, for your own code to hand it an address it chose; never pass it untrusted page input. replayUrl(clipId, project, controlUrl?) builds that same URL on its own, for a game that wants to prefetch or cache a clip. options.speed sets the starting rate and options.paused starts the clip paused.

replay() returns a transport with playback controls on it:

Call or propertyWhat it does
seek(seconds)Jumps to seconds from the start of the clip
speed(rate)Playback rate; must be positive. 0.25 for slow motion, 4 for fast forward
pause() / resume()Stops and restarts the feed
durationSeconds of footage in the clip, 0 before it loads
positionWhere playback is, in seconds from the start
endedtrue once the last frame has been delivered
onEnd(cb)Fires when the last frame has been delivered. Returns an unsubscribe function
errorThe refusal that stopped playback, if one did

A clip that fails to fetch or decode refuses the same way any fatal join error does: the joinRoom promise rejects rather than room.on('error', ...) firing. A clip recorded against a different schema than the one this page joined with refuses with E_REPLAY_SCHEMA_MISMATCH.

A replay session writes nothing back: writes, RPCs and messages your game makes during playback go nowhere, so a replay page needs no special case in your input code. See Replays.

The lobby element

@irtio/lobby is a zero-dependency custom element that renders the room code, the share link, a QR code, a connection dot and an optional name box.

<irt-lobby name-entry></irt-lobby>
<script type="module">
  import { joinRoom } from '@irtio/client';
  import '@irtio/lobby';                  // importing registers <irt-lobby>
  import { schema } from './irtio/schema.js';

  const room = await joinRoom(schema);
  document.querySelector('irt-lobby').attach(room);
</script>

attach(room) copies id, link and status in and follows status afterwards, including a code change after a resumed session. When the room reports clients or maxClients, it also seeds and follows the player count. It returns an unsubscribe function, though you rarely need it: the element unsubscribes itself when it leaves the DOM.

AttributePropertyMeaning
room-coderoomCodeThe code shown large
linklinkThe share URL, also encoded into the QR
statusstatusOne of the Status values. Anything else renders grey with that text
name-entrynameEntryBoolean. Shows the name box
playersplayersThe connected player count. Absent hides the row entirely
max-playersmaxPlayersThe room’s capacity. Absent or 0 means unknown
modalmodalBoolean. Renders the panel inside a fixed, dimmed backdrop
openisOpenBoolean. Whether a modal panel is currently shown

A framework that owns element registration can import { defineLobby, IrtLobbyElement } and drive the element by attribute instead of calling attach.

Player count

Set players (and, if you know it, max-players) to show a right-aligned count in the status row: “3/8 players”, or “3 players” when the max is unknown. Leave players off and the row shows just the status dot, unchanged. attach(room) sets both from room.clients and room.maxClients when the room reports them, and keeps the count current as clients join and leave.

Set the boolean modal attribute to render the panel inside a fixed, full-viewport backdrop instead of inline, hidden unless the boolean open attribute is present:

<button id="share">share</button>
<irt-lobby modal></irt-lobby>
<script type="module">
  document.getElementById('share').addEventListener('click', () => {
    document.querySelector('irt-lobby').open();
  });
</script>

The element supplies no trigger of its own — wire your own button to .open(). close() and toggle() are also available, and the panel closes itself on a backdrop click, on Escape, or from its own × button. Leaving modal off keeps the inline rendering exactly as it was before.

The status element

<irt-status> is a smaller sibling of <irt-lobby> for when you already have your own share UI and just want the connection indicator: a coloured dot, and optionally the latest ping.

<irt-status show-ping></irt-status>
<script type="module">
  import { joinRoom } from '@irtio/client';
  import '@irtio/lobby';                    // registers <irt-status> too
  import { schema } from './irtio/schema.js';

  const room = await joinRoom(schema);
  document.querySelector('irt-status').attach(room);
</script>
AttributePropertyMeaning
statusstatusOne of the Status values. Anything else renders grey with that text
pingpingThe latest round-trip time in milliseconds
show-pingshowPingBoolean. Shows the ping as e.g. “55ms” next to the dot

attach(room) seeds status and ping and follows room.on('status', ...) afterwards. For the ping, it follows room.on('rtt', ...) when the room emits one, and otherwise polls room.rtt every two seconds. A framework that owns registration can import { defineStatus, IrtStatusElement } instead of relying on the side effect of importing @irtio/lobby.

The profile element

<irt-profile> is a dev-mode bandwidth overlay: where this client’s bytes are going, by collection and field, refreshed once a second. It needs a room joined with profile: true, and says so when it does not have one. Mount it behind a flag rather than shipping it enabled; dive mounts it when the page URL carries ?profile.

<irt-profile rows="8"></irt-profile>
<script type="module">
  import { joinRoom } from '@irtio/client';
  import '@irtio/lobby';                    // registers <irt-profile> too
  import { schema } from './irtio/schema.js';

  const room = await joinRoom(schema, { profile: true });
  document.querySelector('irt-profile').attach(room);
</script>
AttributePropertyMeaning
rowsrowsHow many rows to show, heaviest first. Default 8
title-texttitleTextThe heading above the table. Default “bandwidth”
roomThe room to read. Setting it starts the 1 Hz refresh; setting undefined stops it

attach(room) is the same call the other two elements take and returns a detach function. refresh() reads the ledger once, for a test that would rather not wait out the interval. The churn row is labelled enter/leave (incl. spawns) because a client cannot tell an area-of-interest arrival from a real spawn: see the profiler.

Exported constants

ConstantValueWhat it is
DEFAULT_WRITE_INTERVAL_MS50Default writeIntervalMs
PING_INTERVAL_MS2000How often the client pings, which is what room.rtt measures
CALL_TIMEOUT_MS10000Client-to-server RPC timeout
RESIM_DEPTH20Flushed writes replayed over a correction before it snaps
MAX_PREDICTED_BODIES64Default cap on non-owned predicted bodies
SMOOTHING_HALF_LIFE_MS70Default half-life for render-error smoothing; 0 disables
SMOOTHING_SNAP_UNITS4Default distance past which a render-error offset is dropped rather than eased
PREDICTION_EPSILON0.05Default correction-suppression tolerance, world units
DEV_PORT7070irtio dev’s port, used in endpoint resolution
DEFAULT_REGION'eu'The region wss://<region>.irt.io resolves to
E_CONNECT_FAILED'E_CONNECT_FAILED'Client-local code for a failure before the join

resolveUrl, roomIdFrom, linkForUrl, webSocketTransport and defaultScheduler are exported too, for tooling that needs to resolve an endpoint or wrap the transport.

The two caps

With physics passed to joinRoom, this client simulates its own bodies ahead in a local world, plus any collection the schema marks predicted. The cap on non-owned predicted bodies is 64 by default, tunable with physics.maxPredictedBodies.

Everything else with a body factory, meaning over-cap instances and every instance of a collection that is not predicted, gets a kinematic proxy: the same collider, built by the same factory, moved every local step to the pose the renderer draws. It is solid and it is never moved by anything local. So the cost of setting maxPredictedBodies too low is not a player falling through a crate; it is a player shoving one and seeing nothing happen until the server answers. Counted as room.prediction.stats.overCap, and warned about once per new high-water mark.

The cap that does have that cost is physics.maxProxyBodies, 256 by default. Past it an instance has no local body at all and a predicted body passes straight through it. That is room.prediction.stats.absent, and it is the number to keep at zero. proxy: false on a collection opts it out of proxies entirely, which is what to do for decoration so the budget goes to the things players touch.