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.
| Option | Type | Default | Meaning |
|---|---|---|---|
room | string | ?room= from the page URL, else a new room | A room code (ABCD) or a whole share link |
mustExist | boolean | false | Join 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 |
privateCode | boolean | false | When 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 |
role | a role your schema declares | none | Ask 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 |
name | string | '' | 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 |
rpc | implementations of every server-to-client RPC | none | Exhaustive by type when the schema declares any |
url | string | see below | Endpoint override |
region | string | eu | The hosted region your project is deployed to. Only used when no url is set. See below |
key | string | see below | Public project key |
token | string \| (() => string \| Promise<string>) | none | A 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 |
identity | boolean \| Identity | none | Join 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 |
controlUrl | string | https://irt.io | Where 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) => void | none | Same as room.on('status', ...), but set before the first frame |
writeIntervalMs | number | 50 | Hard cap on the owned-write flush window |
interpDelayMs | number | max(50, 2 x tick interval) | How far behind arrival room.render draws non-owned entities |
physics | ClientPhysicsOptions | none | The client half of the shared world builder. Requires module, the default export of @irtio/client/rapier3d. See physics |
physicsCustom | ClientCustomOptions | none | Prediction 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 |
physics2d | ClientPhysics2dOptions or ClientRapier2dOptions | none | Prediction 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 |
profile | boolean | false | Keep a bandwidth ledger, readable as room.profile. A development surface. See the profiler |
conditions | { rttMs, jitterMs, loss, duplicate, reorder, reorderMs, seed } | none | Shape this session’s network on a local endpoint. See Testing under latency |
transport | the value replay() returns | a socket | Play 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.
| Option | Type | Default | Meaning |
|---|---|---|---|
project | string | required | The 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 |
controlUrl | string | https://irt.io | The identity service’s HTTPS origin. Pass it when you test identities against a control plane other than the default |
storage | IdentityStorage \| null | localStorage for the page’s origin | Where 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 },
}); | Field | Type | Default | Meaning |
|---|---|---|---|
rttMs | number | 0 | Round trip in milliseconds; each direction carries half |
jitterMs | number | 0 | Extra delay per frame, drawn uniformly from [0, jitterMs) |
loss | number | 0 | Chance in [0, 1] that a state frame is dropped |
duplicate | number | 0 | Chance that a state frame is delivered twice |
reorder | number | 0 | Chance that a state frame is held back past its neighbours |
reorderMs | number | 50 | How far a reordered frame may be held back |
seed | number | 1 | Seeds 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:
| Option | Type | Default | Meaning |
|---|---|---|---|
queue | string | default | Which queue to join. See quick match |
timeoutMs | number | 30,000 | How long to wait before giving up. Capped at two minutes |
controlUrl | string | https://irt.io | Where 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:
- The
keyoption, if it is a non-empty string. schema.project, if the schema carries one. This is whatnpx irtio initwrites, and it is the normal case for every deployed build and every local build alike.- The literal string
'dev'— but only when the resolved endpoint islocalhost,127.0.0.1or[::1]. - Otherwise
joinRoomthrows, pointing atnpx irtio initor thekeyoption.
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:
The
urloption, if you passed one.IRT_URLfrom the Node environment, orwindow.IRT_URLin a browser.window.IRT_URLis the option without a build step: set it before your bundle runs and everyjoinRoomin the page follows it.<script>window.IRT_URL = 'ws://localhost:7071';</script> <script type="module" src="/main.js"></script>irtio dev --serveinjects 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.ws://localhost:7070when the page is onlocalhost,127.0.0.1or[::1], or when there is no page at all (Node, bots, tests).If nothing answers there, the first join probes
7171,7272,7373and7474in order — the same portsirtio devfalls back through when it cannot bind 7070 — and later joins in the page start at whichever port answered. Only this step is probed: aurloption or anIRT_URLvalue is used exactly as given, and fails if it is wrong.wss://<region>.irt.iootherwise, whereregionis theregionoption and defaults toeu.
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
WRITEframe. - 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,
serverOwnedcollections 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)); | Event | Payload |
|---|---|
status | Status |
error | { code, message, fatal } |
correct | a Correction |
clients | readonly PresenceRecord[], the same array as room.clients |
rtt | number, 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 |
audience | number, 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)); | Method | Signature | Fires when |
|---|---|---|
room.onAdd | (collection, (id, row) => void) => Unsubscribe | A row appears in the collection |
room.onRemove | (collection, (id, lastRow) => void) => Unsubscribe | A row disappears from the collection |
room.onChange | (collection, (id, row) => void) => Unsubscribe | A 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'; | Status | What it means |
|---|---|
connecting | The initial connection is in flight. Every session starts here |
starting | The server for this project was asleep and is waking. Not an error; keep waiting |
connected | Joined. room.state is live |
reconnecting | The 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 |
closed | The 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.
| Member | Type | Meaning |
|---|---|---|
mute | (muted: boolean) => void | Stop or resume sending your microphone. Signalled to the server, which pauses the audio at the source and tells the other participants |
muted | boolean | Your current mute setting |
peers | readonly string[] | The client ids in the call |
setPeerVolume | (peerId: string, volume: number) => void | Local playback volume for one peer, 0 to 1, clamped and never signalled |
mutePeer | (peerId: string, muted: boolean) => void | Silence 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) => number | How much sound one participant is making right now, 0 to 1. room.me is your microphone |
setListener | (pose: VoiceListenerPose) => void | Where you hear from: { x, y, z?, forward?, up? }. Needs positional |
setPeerPosition | (peerId: string, position: VoiceVec \| null) => void | Place one peer for panning and distance falloff, or null to play them flat. Needs positional |
setPeerFilter | (peerId: string, filter: VoiceFilter \| null) => void | Filter how you hear one peer, locally |
setMicFilter | (filter: VoiceFilter \| null) => Promise<void> | Filter your microphone before it is sent |
micFilter | VoiceFilter \| null | The microphone filter in effect |
noiseReduction | 'browser' \| 'rnnoise' \| 'off' | The noise reduction running on your microphone |
on | (event, cb) => Unsubscribe | peer-joined, peer-left, peer-muted, peers-changed, speaking and error |
leave | () => Promise<void> | Leave the call while staying in the game |
VoiceOptions:
| Property | Type | Default | Meaning |
|---|---|---|---|
positional | boolean \| VoicePositionalOptions | false | Pan and fade peers by position. The object takes panningModel, distanceModel, refDistance, maxDistance and rolloffFactor |
noiseReduction | 'browser' \| 'rnnoise' \| 'off' | 'browser' | Microphone noise reduction |
micFilter | VoiceFilter | none | A 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 property | What 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 |
duration | Seconds of footage in the clip, 0 before it loads |
position | Where playback is, in seconds from the start |
ended | true once the last frame has been delivered |
onEnd(cb) | Fires when the last frame has been delivered. Returns an unsubscribe function |
error | The 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.
| Attribute | Property | Meaning |
|---|---|---|
room-code | roomCode | The code shown large |
link | link | The share URL, also encoded into the QR |
status | status | One of the Status values. Anything else renders grey with that text |
name-entry | nameEntry | Boolean. Shows the name box |
players | players | The connected player count. Absent hides the row entirely |
max-players | maxPlayers | The room’s capacity. Absent or 0 means unknown |
modal | modal | Boolean. Renders the panel inside a fixed, dimmed backdrop |
open | isOpen | Boolean. 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.
Modal mode
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> | Attribute | Property | Meaning |
|---|---|---|
status | status | One of the Status values. Anything else renders grey with that text |
ping | ping | The latest round-trip time in milliseconds |
show-ping | showPing | Boolean. 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> | Attribute | Property | Meaning |
|---|---|---|
rows | rows | How many rows to show, heaviest first. Default 8 |
title-text | titleText | The heading above the table. Default “bandwidth” |
room | The 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
| Constant | Value | What it is |
|---|---|---|
DEFAULT_WRITE_INTERVAL_MS | 50 | Default writeIntervalMs |
PING_INTERVAL_MS | 2000 | How often the client pings, which is what room.rtt measures |
CALL_TIMEOUT_MS | 10000 | Client-to-server RPC timeout |
RESIM_DEPTH | 20 | Flushed writes replayed over a correction before it snaps |
MAX_PREDICTED_BODIES | 64 | Default cap on non-owned predicted bodies |
SMOOTHING_HALF_LIFE_MS | 70 | Default half-life for render-error smoothing; 0 disables |
SMOOTHING_SNAP_UNITS | 4 | Default distance past which a render-error offset is dropped rather than eased |
PREDICTION_EPSILON | 0.05 | Default correction-suppression tolerance, world units |
DEV_PORT | 7070 | irtio 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.