Server authority and validation
Authority is per collection, and you take as much or as little of it as a value deserves. A validator keeps the instance client-owned and refuses writes that are not possible. A tick loop puts the server in charge of the simulation itself, and the clients only send input.
Both live in the same room file, and adding either one is an edit rather than a migration.
Validators
A validator runs on the server for every owner write to one collection, before anyone else sees the value. It receives the previous value, the value the owner wants, and the context of the client attempting the write. What you return is what gets applied.
// 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;
state.players.add(
ctx.clientId,
{ x: 0, y: 0, name: ctx.name || 'anon', color: 0 },
{ owner: ctx.clientId },
);
},
onLeave(state, ctx) {
state.players.remove(ctx.clientId);
},
validate: {
players(prev, next, _ctx) {
// Crossed more than MAX_STEP in one write: that is a teleport. Reject the whole write.
if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;
// Otherwise accept it, clamped to the canvas.
return {
...next,
x: Math.min(WIDTH, Math.max(0, next.x)),
y: Math.min(HEIGHT, Math.max(0, next.y)),
};
},
},
tick(_state, _dt, _room) {},
}); A validator returns one of three things:
| Return | Meaning |
|---|---|
next | Accept the write as sent. |
prev | Reject it. The owner is snapped back to the previous value. |
| A new object | Accept a corrected version. The owner is snapped to what you returned. |
The owner gets a correction for each field it wrote whose accepted value differs from what it sent, and the correction overwrites its local copy of that field. The comparison is by value, so returning a new object with the same values sends nothing. Everyone else only ever sees the value you approved.
Rules for validators:
- Only instance-owned collections can have a validator. Naming a
serverOwnedcollection or a singleton throws whendefineRoomruns, because there are no owner writes to validate. - The validator sees whole instances, not single fields.
prevandnextare the full instance. ctxis the client attempting the write, soctx.clientIdandctx.roleare available for rules that depend on who is asking.- A validator filters client writes only. The server can write the same collection directly and never passes through it.
- Validators are synchronous, like every room handler. There is no way to await anything here.
Write the check as a statement about what is physically possible in your game: a speed limit, a board boundary, a legal move. A validator that guesses at intent and is too strict punishes players on bad connections. One that only rejects the impossible does not.
The tick loop
Set mode: 'tick' (the default) and give the room a tick function, and the server runs it at a
fixed rate whether or not anyone is sending anything. This is where continuous simulation lives:
projectiles, timers, decay, AI movement, scoring rules that depend on time passing.
export default defineRoom(schema, {
mode: 'tick',
tickRate: 30,
tick(state, dt, room) {
for (const [id, b] of state.bullets) {
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < 0 || b.x > 800 || b.y < 0 || b.y > 500) state.bullets.remove(id);
}
if (room.now >= state.match.deadline) state.match.phase = 'over';
},
}); tickRateis an integer from 1 to 240 and defaults to 20.defineRoomrejects anything else.dtis the fixed step in seconds,1 / tickRate, not measured wall-clock time. Two runs of the same room see the samedtevery tick, which is what makes a recorded run replayable.- Client writes and RPCs that arrived since the previous tick are already applied when
tickruns. You are looking at settled state, never a half-applied frame. - Everything you write in
tickis tracked and leaves in that tick’s delta. tickis required in tick mode and forbidden in event mode. Both are checked at import time.
Inside a room, use room.random() instead of Math.random(): it is seeded, so a test that passes
the same seed gets the same numbers. Measure elapsed time with room.now, which runs on the fake
clock in testRoom; Date.now() is always the real clock. room.now starts again when a
hibernated room wakes, so a deadline kept in state across a sleep is stored as Date.now(). A room
has no fetch, no fs, no process, and no async: handlers return, and irtio encodes what
changed.
Event mode
If your game only changes when someone does something (a quiz, a card game, a turn-based board), use mode: 'event' instead. There is no loop. RPCs and writes are applied as they arrive, and the room
hibernates after idleMs with no traffic. See Hibernation.
Physics needs a fixed timestep, so it needs tick mode. See Physics.
When a handler throws
To refuse an RPC, call ctx.deny(reason). It stops the handler, rejects the caller’s promise with deny: <reason>, and the room carries on. That is how authorization is written.
if (ctx.role !== 'host') ctx.deny('start: host only'); A plain throw from an RPC handler is treated as a bug: the caller gets E_INTERNAL: rpc <name>,
the thrown message goes to the room log, and the room carries on. A throw inside tick is
different. That tick is skipped and logged, and three consecutive throwing
ticks stop the room, which is then restarted from its last snapshot. Nothing a handler does affects
another room.
What the client sees
A client whose write was corrected receives a correction frame, which overwrites the fields the server overrode and leaves everything else alone. The client SDK re-applies any local writes newer than the one the server judged, so a correction does not throw away input the player has made since. You can observe it:
room.on('correct', ({ collection, id, fields, patch, previous }) => {
// fields: what the server overrode. patch: the server's values, already applied.
// previous: what this client had before the snap.
}); For most games you never need to listen. It matters when you are measuring misprediction or tuning a simulation, which Prediction covers.
How much authority to take
Start with client ownership and no validator. Add a validator to a collection the first time a lie about it would matter, and it usually stays there. Move a value to server-owned state and RPCs when the server has to decide the outcome rather than approve a report. Add a tick loop when something has to keep happening with nobody touching it.
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.
Related
- RPCs for moving a decision to the server.
- Presence and lifecycle for joins, leaves, and reconnection.