Relay rooms
Relay rooms
A relay room runs no code of yours. Clients join by room id, get a presence list, and send each other bytes. You bring your own message format.
// main.ts
import { joinRelay } from '@irtio/client';
const room = await joinRelay({ name: 'you' });
room.onMessage((from, bytes) => apply(from, bytes));
room.message('all', encodeMyThing());
room.clients; // presence, ordered by join
room.id; // the room code
room.link; // a URL to hand to somebody else
room.rtt; // ms, from the last ping message takes 'all', a client id, or { role: 'spectator' }.
Raw relay rooms provide presence, room codes, and messages. Deploy a schema to add shared state, or room code for custom game rules.
A schema-less joinRelay works against a project with no room code. Once a project deploys room
code, including a project you are running locally with npx irtio dev, pass the
project’s schema so the client presents the deployed schema hash and the project id:
import { joinRelay } from '@irtio/client';
import { schema } from './irtio/schema.js';
const room = await joinRelay({ name: 'you', schema }); Without it the server refuses the join with E_SCHEMA_MISMATCH and names this fix.
A relay room can also hold an audience: hundreds of viewers who send input in and stay out of presence. See audience rooms.
Message size and rate
Every message sent to 'all' is copied to everyone in the room, viewers included, so its cost is
its size times the room. A relay message may be at most 64 KB; a larger one is refused with E_WRITE_REJECTED and the connection stays open. Each connection may also send 256 KB of relay
messages a second, with a 1 MB burst; a message past that is dropped and the sender is told once
with E_RATE_LIMITED. Keep messages to inputs and small events, and put large blobs in relay state or storage instead. See limits.
Joining from a link
A join to a room id nobody has used creates that room. On a page that joins from a shared link,
pass mustExist: true so a mistyped or guessed code is refused with E_ROOM_NOT_FOUND instead of
starting an empty room. For a private game, create the room with privateCode: true, which mints a
10-character code in place of the short default one:
// The host's page: create a room with a hard-to-guess code.
const room = await joinRelay({ name: 'host', privateCode: true });
// The link's page: join only if the room already exists.
const guest = await joinRelay({ name: 'guest', mustExist: true }); A server older than this release ignores mustExist and creates the room, so treat it as
advisory until every server you are served by has been upgraded. See sharing.
Relay with state
Deploy irtio/schema.ts with no room file and your relay rooms get state. Clients join with joinRoom(schema) and use room.state exactly as they would in a room you wrote code for: a late
joiner gets the whole state, a client writes the instances it owns, and irtio decides who owns
what from rules you declare in the schema. This is still a relay room, at the relay rate.
// irtio/schema.ts
import { bool, defineSchema, entity, f32, str } from '@irtio/schema';
export default defineSchema({
players: entity({ x: f32, y: f32, name: str(24) }, {
perPlayer: true, // one record per client, id = client id, owned by them
}),
crates: entity({ x: f32, y: f32, held: bool }, {
clientCreate: true, // clients may add records here and their owner may remove them
ownership: {
transfer: 'closer', // 'free' | 'closer' | 'ask' | 'never'
by: 8, // 'closer' needs this much of a lead, so two players do not flap it
position: { x: 'x', y: 'y', players: 'players' },
onLeave: { hold: '5m', then: 'nearest' },
},
}),
}); // main.ts
import { joinRoom } from '@irtio/client';
import schema from './irtio/schema';
const room = await joinRoom(schema, { name: 'you' });
room.state.crates.get('k1'); // the shared state, same as in a coded room
await room.requestOwnership('crates', 'k1'); // ask for a crate; the schema rule decides Running one locally
irtio dev runs a project with irtio/schema.ts and no room file as a relay room with state:
the same state, ownership rules and client code as the deploy.
npx irtio init --relay-state
npx irtio dev Do not add irtio/room.ts to keep it a relay room. A project with a room file deploys as a coded
room and is billed on its size class, while a schema-only project
stays at the relay rate.
Transfer rules
ownership.transfer decides who may take an instance somebody else owns.
| Rule | Who gets it |
|---|---|
free | Anyone who asks. The last taker wins |
closer | The requester, if their players record is nearer the object than the owner’s by at least by. Granted outright when the owner has no player record |
ask | The owner, if they say yes within five seconds. No answer refuses |
never | Nobody. Only onLeave moves it |
A collection that declares no ownership uses this default: requestOwnership grants only when
nothing owns the instance.
Asking the owner
transfer: 'ask' sends the request to the client that owns the instance, and that client answers
it:
room.onOwnershipRequest((req) => {
// req.entity, req.id, req.from (the asking client's id)
return new Promise((decide) => {
showPrompt(`${req.from} wants ${req.id}`, {
onYes: () => decide(true),
onNo: () => decide(false),
});
// Answer for the player if they do not. Anything later than five seconds is ignored.
setTimeout(() => decide(false), 4000);
});
}); The browser timer uses setTimeout(fn, ms). The server timer uses room.setTimeout(ms, fn).
Return true to hand it over, or a promise that resolves true. Draw the prompt in your own UI
and keep the handler non-blocking. window.confirm and window.prompt stop the page while they
are open, which freezes rendering and stops the room’s own frames from being read.
The rules around that answer:
| Case | What happens |
|---|---|
The owner answers true inside five seconds | The requester gets the instance |
| The owner answers anything else, or the handler throws | Refused |
| Five seconds pass with no answer | Refused. An answer arriving later is ignored |
| The owner registered no handler | Refused at once, without waiting out the window |
| Somebody else is already asking for this instance | Refused at once. One request at a time per instance |
The owner is disconnected, or the instance is held by onLeave | Refused. Nobody is asked |
| Nothing owns the instance | Granted. There is nobody to ask |
On the asking side await room.requestOwnership('crates', 'k1') is the same call as for every
other rule. It resolves true or false, and takes as long as the owner takes.
A room file’s onOwnershipRequest decides for the whole room, and then nothing is forwarded to any
owner. Use one or the other, not both.
When a player leaves
onLeave: { hold, then } is one rule with two halves. When a player’s presence goes, every record
they own is held for hold: still theirs, still visible, and nobody else can write it or take it.
The same player coming back inside the hold gets it back with nothing to do. When the hold runs
out, then fires.
then | What happens |
|---|---|
release | Nothing owns it, so any transfer rule can hand it out |
nearest | The closest remaining player by the position declaration, or release if nobody is left |
random | A random connected player, or release if nobody is left |
remove | The record is deleted |
hold: 0 is the default and makes then fire at once. A perPlayer collection defaults to { hold: 0, then: 'remove' } and takes no other then, because a per-player record cannot belong
to somebody else. Hold deadlines survive hibernation: a room that sleeps for an hour with a
five-minute hold wakes with the hold already expired.
“The same player” is the player identity, not the connection. A client that presents a player identity is recognised across tabs and devices: close the tab, open a new one, and the records held for that identity come back. A client that presents no identity is recognised only by its reconnect token, so a dropped connection recovers and a new browser does not.
Present the identity on the first join. A client that joins anonymously and presents an identity later in the same session keeps the reconnect token it started with, so its holds stay bound to that token for the rest of the session. Its next fresh join binds them to the player identity.
Values that outlive the room
persist names the fields of a perPlayer record that follow the player out of the room. A client
that joins with a player identity gets those fields filled in from what it last left with, in any
room of the project. Everything else starts at the schema default.
players: entity({
name: str(24), hats: u32, bestStrokes: list(u8, 9), x: f32, y: f32,
}, {
perPlayer: true,
persist: ['hats', 'bestStrokes'], // these follow the player; name, x and y do not
}), The rules:
| Property | Behavior |
|---|---|
| Who gets it | Clients that join with a player identity. An anonymous client gets defaults and stores nothing |
| When it is read | On join, into the record the join creates. A reconnect keeps what the player already has |
| When it is written | When the player leaves, including when the room hibernates |
| Two devices at once | The last leave wins. There is no merge |
| Size | 4 KB per player per project, measured as the worst case the declared fields can encode. A schema over the cap is refused at compile |
| How long it is kept | 90 days from the last write |
| How many players | 10,000 per project |
persist is part of the schema hash, so adding or changing it is a schema change and clients
reconnect against the new schema. A stored field whose type no longer matches the schema is
dropped, and that field starts at its default.
Room code has a wider surface for the same job: see player storage.
Validation and access
For custom validation, RPCs, or simulation, deploy room code using the same schema.
Clients choose their own role unless the join is authorized by a trusted mechanism. Use a JWT role claim or room code to control access to hidden state. In an unrestricted relay, role visibility is a traffic filter.
Relay state is capped at 512 KB, with 1024 records per collection and 35 write operations per client per second. Persisted player fields are capped at 4 KB per player, for 10,000 players per project, and are kept for 90 days after the last write. See limits.