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:

ReturnMeaning
nextAccept the write as sent.
prevReject it. The owner is snapped back to the previous value.
A new objectAccept 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 serverOwned collection or a singleton throws when defineRoom runs, because there are no owner writes to validate.
  • The validator sees whole instances, not single fields. prev and next are the full instance.
  • ctx is the client attempting the write, so ctx.clientId and ctx.role are 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';
  },
});
  • tickRate is an integer from 1 to 240 and defaults to 20. defineRoom rejects anything else.
  • dt is the fixed step in seconds, 1 / tickRate, not measured wall-clock time. Two runs of the same room see the same dt every tick, which is what makes a recorded run replayable.
  • Client writes and RPCs that arrived since the previous tick are already applied when tick runs. You are looking at settled state, never a half-applied frame.
  • Everything you write in tick is tracked and leaves in that tick’s delta.
  • tick is 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.