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.

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.

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);
  });
});

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:

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.

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:

PropertyBehavior
Who gets itClients that join with a player identity. An anonymous client gets defaults and stores nothing
When it is readOn join, into the record the join creates. A reconnect keeps what the player already has
When it is writtenWhen the player leaves, including when the room hibernates
Two devices at onceThe last leave wins. There is no merge
Size4 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 kept90 days from the last write
How many players10,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.