Choosing an authority model
Every multiplayer game answers one question per value: who decides it. irtio gives you three answers today. A relay room decides nothing and forwards messages. Ownership with a validator lets the client write and lets the server refuse. A tick room decides everything on the server and takes input from clients.
You pick per collection, not once per game, and moving up is an edit to your room file rather than a rewrite.
Picking by game
| Your game | Model | Why |
|---|---|---|
| A jam prototype, a shared whiteboard, a party game on one couch | Relay room | Nothing to cheat at, nothing to deploy |
| Cursors, avatars, emotes, camera angles | Ownership, no validator | A lie costs nothing, and input is instant |
| A platformer, a racer, a top-down shooter’s movement | Ownership plus validate | The client keeps the feel, the server refuses the impossible |
| Scores, decks, turn order, loot, hit results | Server-owned state plus RPCs | The outcome is not the client’s to report |
| Physics, projectiles, timers, AI, anything that keeps happening | Tick room | Something has to run with nobody sending input |
| A leaderboard-backed competitive match | Tick room, server-owned everything | The result is worth lying about |
Most games use more than one row. A shooter owns its own aim locally, validates its movement, and keeps damage server-owned.
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' }.
What you get. Presence, a room code, a raw message channel, and a round-trip reading. The presence list is written down when the room empties and comes back when someone rejoins.
What you do not get. No schema, no room.state, no RPCs, no validation, no tick, and no
state of any kind beyond the presence list. A relay room cannot hold a score, and it cannot stop
a client sending anything it likes. Every client is trusted completely, because there is nothing
running that could disagree.
A project gets relay rooms while it has deployed no room code. Deploy a room file and the project runs your rooms instead. Nothing is migrated, because a presence list is not room state.
Cost. A relay room holds up to 64 players, or 16 on the free plan. It runs no code of yours, so it costs less than any size class.
Ownership with a validator
Write a schema, deploy a room file, and give each player an instance they own. Clients write
their own instance and read everyone else’s. Add a validate entry for the collection when a lie
starts costing something.
// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';
const WIDTH = 800;
const HEIGHT = 500;
const MAX_STEP = 60;
export default defineRoom(schema, {
mode: 'tick',
tickRate: 20,
onJoin(state, ctx) {
if (ctx.reconnecting) return;
// `{ owner: ctx.clientId }` is what makes the instance writable by that client.
state.players.add(ctx.clientId, { x: 0, y: 0, name: ctx.name || 'anon' }, { owner: ctx.clientId });
},
onLeave(state, ctx) {
state.players.remove(ctx.clientId);
},
validate: {
players(prev, next) {
// Further than MAX_STEP in one write is a teleport. Reject the whole write.
if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;
return {
...next,
x: Math.min(WIDTH, Math.max(0, next.x)),
y: Math.min(HEIGHT, Math.max(0, next.y)),
};
},
},
tick() {},
}); A validator returns next to accept, prev to reject, or a new object to correct. Rejecting or
correcting sends the owner a correction that overwrites the fields you changed.
What you get. The owner’s own value answers input with no round trip, because the client applies its own write locally and sends it on the next flush. Everyone else sees only what the server approved.
What you do not get. A validator sees one write at a time against the previous value. It
cannot see the future, it cannot await anything, and it cannot run on a serverOwned collection
or a singleton. It judges plausibility, not outcomes: a validator can refuse a 400-unit step, and
it cannot decide who won a race.
Cost. One validator call per owner write. The client feels nothing, because the write is already applied locally when the server judges it.
Write the check as a statement about what is physically possible in your game. A validator that is too strict punishes players on bad connections.
Server-owned state and RPCs
Mark the collection serverOwned: true and no client can write it, at compile time as well as at
runtime. Clients ask through typed RPCs and the handler decides what happened.
// irtio/schema.ts
scores: entity({ name: str(24), points: u16 }, { serverOwned: true }), // irtio/room.ts
rpc: {
claim(state, { id }, ctx) {
if (state.match.phase !== 'play') throw new Error('not in play');
const row = state.scores.get(ctx.clientId);
if (row) row.points += 10;
},
}, Throwing rejects the caller’s promise with your message. The room carries on.
Cost. A round trip. The caller learns the outcome when the reply arrives, so anything on this model shows up in the UI one round trip after the button press. That is right for a score and wrong for a cursor.
Tick rooms
Set mode: 'tick' and give the room a tick function, and the server runs it at a fixed rate
whether or not anyone is sending anything.
// irtio/room.ts
export default defineRoom(schema, {
mode: 'tick',
tickRate: 30,
tick(state, dt, room) {
for (const [id, b] of state.bullets) {
b.x += b.vx * dt; // dt is 1 / tickRate, not wall clock
if (b.x < 0 || b.x > 800) state.bullets.remove(id);
}
if (room.now >= state.match.deadline) state.match.phase = 'over';
},
}); tickRate is an integer from 1 to 240 and defaults to 20, in irtio/room.ts. tick is required
in tick mode and rejected in event mode.
What you get. The server is the simulation. Clients write intent fields, the server steps the world, and nothing a client sends can produce a state the server did not compute.
Cost. Input answers one round trip late unless you turn on client prediction, and a tick room keeps a server busy whether anyone
is playing or not. Use mode: 'event' instead when your game only changes when someone acts, so
the room hibernates between inputs. See Hibernation.
Moving between models
Start low. Each step below is a local edit:
- Relay room to owned state: write a schema, deploy a room file with
onJoin. - Owned state to validated: add a
validateentry for one collection. - Validated to server-owned: set
serverOwned: trueand move the write into an RPC. - Event mode to tick mode: set
mode: 'tick'and addtick.
Steps 1 and 3 change the schema, so redeploy the room and reload the page together. Steps 2 and 4 are room-file only.
Do not start at the top. A tick loop for a game that only changes on a button press keeps a server busy for nothing, and server-owned state for a cursor buys you a round trip of input lag.
Next steps
- Ownership for owners, transfers and
requestOwnership. - Server authority and validation for the validator contract.
- RPCs for typed calls and their replies.
- Latency and what players feel for what each model costs a player.