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.
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 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);
});
}); 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.
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. See limits.