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.

RuleWho gets it
freeAnyone who asks. The last taker wins
closerThe 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
askThe owner, if they say yes within five seconds. No answer refuses
neverNobody. 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:

CaseWhat happens
The owner answers true inside five secondsThe requester gets the instance
The owner answers anything else, or the handler throwsRefused
Five seconds pass with no answerRefused. An answer arriving later is ignored
The owner registered no handlerRefused at once, without waiting out the window
Somebody else is already asking for this instanceRefused at once. One request at a time per instance
The owner is disconnected, or the instance is held by onLeaveRefused. Nobody is asked
Nothing owns the instanceGranted. 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.

thenWhat happens
releaseNothing owns it, so any transfer rule can hand it out
nearestThe closest remaining player by the position declaration, or release if nobody is left
randomA random connected player, or release if nobody is left
removeThe 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.