Server room API

The server runs your room callbacks and gives them access to shared state and runtime methods. For defineRoom options, see Room configuration.

Room handlers are synchronous. NPC scripts are the exception and may be async. Storage and client-call promises complete between ticks.

Handlers

HandlerSignatureRuns
onCreate(state, room) => voidOnce, when the room is first created
onJoin(state, ctx) => voidOn every join, including a resumed one
onLeave(state, ctx, reason) => voidOn every departure
onSleep(state, room) => voidJust before the room hibernates
onWake(state, room) => voidAfter the room comes back
tick(state, dt, room) => voidEvery tick. dt is seconds. Required in tick mode
validate.<entity>(prev, next, ctx) => instanceBefore an owner write is accepted
rpc.<name>(state, params, ctx) => resultWhen a client calls it
alarms.<name>(state, room) => voidWhen a durable alarm fires
onOwnershipRequest(state, entity, id, ctx) => booleanOn the built-in requestOwnership RPC
onChat(state, line, ctx) => boolean \| voidBefore chat is delivered; false or a throw refuses the line
onMessage(state, from, target, bytes, ctx, typed?) => boolean \| voidOn a peer message, raw or typed

reason in onLeave is 'left', 'timeout', 'kicked' or 'closed'.

onJoin fires again for a resumed session. Guard with ctx.reconnecting so a reconnect does not spawn a second entity.

onOwnershipRequest defaults to granting when the instance is unowned. Its return value is not what the client sees as granted. The server checks ownerOf(id) === ctx.clientId after your handler returns, and calls setOwner for you when you return true and have not already. If you call state.<entity>.setOwner(id, ctx.clientId) yourself, return false; the client is still told true, because by then it does own the instance. Setting the owner and returning a disagreeing boolean is the mistake to avoid.

Collections can also declare transfer policies in the schema. A room-level onOwnershipRequest overrides that policy. See Ownership.

onMessage returning false drops the message. Anything else relays it.

typed is present exactly when the message is one of the shapes the schema declares, and carries { name, value } — the declared name and the decoded value, narrowed by name. It is absent for a client’s raw room.message(target, bytes), where bytes is all there is. The decode happens before the handler runs, so a payload that does not fit its shape never reaches you: it is counted and dropped. See peer messages.

room.messages.<name>.send(target, value) is the server side of the same channel.

By the time tick runs, the queued writes and RPCs for that tick have already been applied.

validate

validate: {
  players(prev, next, ctx) {
    return next;                            // accept as written
    // return prev;                         // reject the whole write
    // return { ...next, x: clamp(next.x) }; // accept a clamped version
  },
}

prev and next are both read only. Return the instance you want applied. The writer gets a correction, which fires room.on('correct', ...) on their client, for each field it wrote whose applied value differs from what it sent. The comparison is by value, so { ...next, x: clamp(next.x) } sends nothing while x is in range, and a validator that throws rejects the write as if it returned prev.

validate may only name instance-owned entity collections. Server-owned collections and singletons are not client writable in the first place, so a validator for them is a definition-time error.

Delete a validator and anything in range is accepted. This is where cheating stops, so irtio simulate --cheat warns loudly when a room takes every illegal write.

Return and throw semantics

  • A server RPC mutates state and returns its declared result, or nothing for a void RPC.
  • To refuse a call, use ctx.deny(reason) (see Denying a call): the caller’s promise rejects with the message deny: <reason> and the runtime records it as a refusal, not an error. That is how you write authorization: if (ctx.role !== 'host') ctx.deny('host only').
  • A plain throw from an RPC is treated as a bug: the caller’s promise rejects with E_INTERNAL: rpc <name>, the thrown message is withheld, the room counts it as a handler error, and the room keeps running.
  • A throw in tick skips that tick and logs. Three consecutive throws restart the room from its last snapshot.
  • Nothing a handler does can take down your project’s server.

The state object

state mirrors your schema. Entity collections are writable on the server:

state.players.add(id, values, { owner: ctx.clientId });  // owner defaults to the server
state.players.remove(id);
state.players.get(id);            // T | undefined
state.players.has(id);
state.players.setOwner(id, ctx.clientId);
state.players.ownerOf(id);        // client id, or '' for the server
state.players.size;
state.players.ids();
for (const [id, p] of state.players) { /* ... */ }

add fills in any missing .opt or .default() field and normalizes values, so the returned instance always has every field. Adding an id that already exists replaces it.

Singletons are plain objects: state.match.phase = 'playing'.

Ownership is the instance-level rule that decides who may write what. '' means the server, and SERVER_OWNER from @irtio/schema is that constant if you prefer a name. See ownership.

ownerOf(id) returns undefined only when id names no instance at all. Check with .get(id) or .has(id) first if that distinction matters. Do not test ownerOf(id) === undefined for server-owned; test ownerOf(id) === '' instead.

Body fields of a physics collection are read only on the server too. The simulation owns them, and you steer a body through forces or intents rather than by assigning a position.

Everything you mutate in a handler is tracked and goes out in that tick’s delta. There is nothing to commit.

The ctx object

Handlers that act on behalf of one client get ctx:

FieldTypeMeaning
clientIdstringThe client this join, call or write came from
playerIdstringThe identity room.kv is keyed by
auth'key' \| 'jwt' \| 'assertion'The credential this client joined with: 'key' for an anonymous project-key join, 'jwt' for your own auth, 'assertion' for an irtio identity. Grant privileged roles on it rather than on the role a client asked for
rolea role your schema declaresThe role this client holds. See role views
namestringThe display name it joined with
ticknumberThe server tick this is applied at
clientTicknumber \| undefinedRPCs only: the newest tick the caller had applied when it sent the call, for lag compensation. undefined on a join, on a write, and for a client that sends no stamp
reconnectingbooleanJoins only: this is a resumed session
npcbooleanThis session was opened by room.spawnNPC rather than by a connecting client. Read from the seat the room allocated, so a client cannot claim it
roomRoom<S>The room object, same as the one tick receives
deny(reason)(reason: string) => neverRPCs only: refuse this call with a reason. See ctx.deny
require(condition, reason)(condition: unknown, reason: string) => asserts conditionCheck this or refuse the call. A falsy condition denies with reason. See Checking a value

What ctx.playerId is depends on how the player joined. On a plain key join, the default, it is the client id, so it is stable only as long as the resume token is. A player who comes back after their token expires arrives as a new playerId, with none of their old room.kv rows. On a JWT join (token in joinRoom), it is "<iss>:<sub>", the token’s verified subject namespaced by the issuer, a durable identity that follows the same person across devices and time. See Authentication and Player storage below.

Denying a call

Refuse a call and say why:

rpc: {
  fire(state, params, ctx) {
    if (tooSoon(state, ctx.clientId)) ctx.deny('rate-limit');
    if (ctx.tick - (ctx.clientTick ?? ctx.tick) > 60) ctx.deny('stale-stamp');
    // …
  },
},

ctx.deny never returns, so nothing after it in the handler runs. The caller’s call promise rejects with deny: <reason>, carried on the same reply an error uses — a refusal costs no extra frame.

A denial is not an error, and the tools keep them apart:

  • it does not count towards the room’s handler errors and is not logged as a throw;
  • irtio dev --trace-rpc shows it as deny:<reason>;
  • irtio simulate reports denials separately; its denial-rate check can fail a run with too many refusals.

Reasons are your own strings. Short, stable ones read best in a trace: rate-limit, not-your-turn, out-of-ammo.

Outside an RPC handler there is no reply to carry it, so ctx.deny behaves like any other throw: caught, logged, and counted.

Checking a value

Most denials guard a value the handler is about to use. requireOr is the same refusal written as a check, and it tells TypeScript the value is good below the call:

import { requireOr } from '@irtio/server';

rpc: {
  fire(state, params, ctx) {
    const weapon = state.weapons.get(ctx.playerId);
    requireOr(ctx, weapon !== undefined, 'no-weapon');
    weapon.ammo -= 1; // weapon is narrowed to the entity here
  },
},

A falsy condition is refused exactly as ctx.deny(reason) refuses it: the same rejection on the caller’s promise, the same deny:<reason> in a trace, and not a handler error. A truthy condition does nothing at all. It works in onAdmit too, where a denial fails the join.

ctx.require(condition, reason) is the same check as a method, for a handler that spells out its own ctx type:

onAdmit(state, ctx: AdmitCtx<typeof schema>) {
  ctx.require(ctx.subject !== undefined, 'identity required');
  if (state.bans.includes(ctx.subject)) ctx.deny('banned');
},

The annotation is required. TypeScript only applies an assertion signature when every name in the call target has an explicit type annotation, and a handler’s ctx is normally typed by inference from defineRoom, so ctx.require(…) on an unannotated parameter is error TS2775, Assertions require every name in the call target to be declared with an explicit type annotation. This is a TypeScript rule, not something the room API can work around.

Two forms typecheck as written, with no annotation on ctx:

formwhy it works
ctx.deny(reason)Returns never rather than asserting, so the rule does not apply
requireOr(ctx, condition, reason)An imported function carries the annotation TypeScript wants

Reach for those first, and annotate ctx only when you want ctx.require’s narrowing.

The room object

MemberNotes
room.stateThe room’s shared state.
room.seedSeed used by the room’s deterministic random generator.
room.messagesTyped peer messages declared in the schema. See messages.
room.broadcastExcept(clientId)Client RPC broadcast excluding one client.
room.busSend and receive messages between rooms. See room messages.
room.lobbyLobby lifecycle and readiness. See lobby reference.
room.ratingsReport match results. See matchmaking.
room.backfillControl whether matchmaking offers this room’s open seats.
room.spawnNPC / room.despawnNPCManage simulated players. See NPCs.
room.idThe room code
room.linkShareable URL carrying ?room=<id>
room.tickCurrent server tick
room.nowServer milliseconds on a monotonic clock. In testRoom it is the fake clock, which a test controls; Date.now() is always the real one. It starts again after a wake, so store deadlines in state as Date.now()
room.clientsPresence, ordered by join: { clientId, role, name, connected }[]
room.random()Seeded from room.seed, so a test with the same seed gets the same numbers
room.metrics{ physicsStepMs, physicsStepMsAvg }, live. See Metrics
room.writerStaleness(clientId)Ticks since this client’s last applied write, or undefined if it is not joined. See Stale writers
room.staleWriters(ticks)The connected clients at or past that staleness, in join order
room.send(target, bytes)Raw message. 'all', a client id, or { role }. Throws on anything else
room.setRole(clientId, role)Move a client to another role
room.kick(clientId, reason?)Disconnect a client with E_KICKED
room.close(reason?)Close the room with E_ROOM_CLOSED
room.sleep()Ask to hibernate now. Event mode only
room.log(...args)Lands in irtio logs
room.setTimeout(ms, fn) / setIntervalLive-only timers. The callback is cancelled by hibernation, but the room is woken at the due time
room.clearTimeout(handle) / clearIntervalCancel one
room.call(clientId).<rpc>(params)Server-to-client RPC. With returns: a promise, 5 second timeout, rejects on disconnect, on an error reply, and when the client has no implementation for that RPC. With no returns: fire and forget, like broadcast — it never rejects
room.broadcast.<rpc>(params)Void client RPC to every connected client
room.save()Promise of a save id. See saves
room.kvPer-player storage that outlives the room
room.leaderboard.submit(board, playerId, score)Post a score. The only way a score reaches a board, since there is no client-side submit. See leaderboards
room.alarm(name, atMs) / cancelAlarm(name)Durable alarms
room.physicsThe Rapier world and its bodies, in a rapier3d room. See physics
room.physicsRapier2dThe Rapier world and its bodies, in a rapier2d room
room.physics2dThe matter.js engine and its bodies, in a matter2d room
room.physicsCustomYour stepper and its bodies, in a room with engine: 'custom'. See custom physics
room.rewind(tick, fn)Run fn against the world as it stood at tick. Needs physics.history. See lag compensation
room.replay.clip(seconds)Promise of { id }, a clip of the recent past. See Replay clips below
room.replay.excerpt(options)Promise of the recent past decoded into plain frames the room can read. Experimental. See room.replay.excerpt() below

Two behaviours that catch people out:

  • room.sleep() in tick mode is a no-op. It logs room.sleep() is event-mode only; ignored in tick mode and carries on. It is not an error, so it is easy to miss when porting an event-mode room to tick.

  • room.setTimeout and room.setInterval are live only. Hibernation cancels the callback — it is a closure, and there is nothing to persist. What the platform does promise is residency for a setTimeout: a room that hibernates with one pending is woken at, or shortly after, its due time. Handle the deadline in onWake, using a value saved in room state. Store that deadline as wall clock (Date.now()): room.now is monotonic within one sitting and starts again in the worker the wake spawns, so a room.now deadline written before a hibernation means nothing after it.

    onWake: (s, room) => {
      if (s.roundEndsAt === 0) return;
      const left = s.roundEndsAt - Date.now();
      if (left <= 0) endRound(s, room);
      else room.setTimeout(left, () => endRound(s, room));
    }

    A pending timer does not keep the room resident. A setInterval gets no wake at all: it is a cadence, not a deadline, so the room sleeps through it and comes back on the next join, message or alarm — and room.sleep() on a room holding one really does put the room down. Re-arm the interval in onWake when the room only needs it while it is up; the departure logs a warning naming both options so this is never silent. When you want a schedule that fires whether or not the room is awake, and survives a tenant stop as well, that is a durable alarm.

Reading room.physics in a room whose config declares no physics throws, which is a mistake worth naming at the call site.

Promises resolve between ticks

Room handlers are synchronous, and there is no way to block one on I/O. Every promise-returning room API works the same way, so it is worth stating once:

The continuation runs as its own event, between ticks, after the handler that started it has already returned.

The consequence is the part people trip over: a value you asked for is not available in the handler that asked for it. There is no blocking API and no synchronous-looking accessor, because either would be a lie.

rpc: {
  loadProfile(state, _params, ctx) {
    // Ask, and carry on. Nothing below waits.
    ctx.room.kv.get(ctx.playerId, 'profile').then((raw) => {
      // Its own event, one or more ticks later. State written here is tracked and flushed
      // exactly like state written in a handler.
      const player = state.players.get(ctx.clientId);
      if (player && raw) player.score = JSON.parse(raw).score;
    });
  },
}

This applies to room.call(clientId), room.save() and every method on room.kv. Alarm handlers are scheduled the same way, as their own event between ticks, so a tick-mode room never runs a half-alarmed tick.

Because the continuation is a separate event, the room may have moved on. Re-read the instance you care about rather than closing over it, and check that the phase you were in still holds.

A room.call(clientId) promise for an RPC that declares returns rejects when the client disconnects, when it answers with an error, when it has no implementation for that RPC, and when the 5 second timeout expires. The room logs and counts a rejection you do not catch, so a fire-and-forget call cannot take the room down, but attach a .catch to decide what happens when the answer never arrives.

A directed call of a client RPC with no returns waits for nothing and never rejects, exactly like the broadcast of that same RPC — including when the recipient is a testRoom or simulate bot, which implements no client RPCs.

Metrics

room.metrics reports what this room’s own tick cost. It is a live view, so you can hold the object and every read is current.

PropertyMeaning
physicsStepMsWall milliseconds the last world step took, measured around the engine’s step() and nothing else. 0 in a room with no physics: config, and 0 before the first step.
physicsStepMsAvgAn exponential moving average of physicsStepMs over roughly ten ticks.
tick(state, dt, room) {
  if (room.metrics.physicsStepMsAvg > 8) room.log('physics budget exceeded');
}

physicsStepMs excludes the body reconcile, the write-back into schema state, and your own tick(). It answers one question: is the engine the thing that is slow. A single step’s timing on a shared machine is mostly noise, so compare physicsStepMsAvg against a budget rather than the raw value. Both numbers also ride the room’s stats, so the values you read here are the ones the dev tooling shows.

Stale writers

A client can be connected and silent. A browser tab that goes to the background stops sending input while its socket stays open, so unless the room checks, it goes on treating the last position that client reported as current: the avatar stands still in the open and the game keeps aiming at it.

Two reads answer that question:

room.writerStaleness(clientId); // ticks since this client's last applied write, or undefined
room.staleWriters(ticks);       // connected clients at or past that staleness, in join order

writerStaleness returns 0 for a client that wrote on this tick, and undefined when no client with that id is joined. A client that has joined and never written measures from its join, so a client arriving into a room that has been up for an hour starts at 0 and climbs from there. A reconnect restarts the same clock. Writes that the room rejected still count: they are evidence the client is awake, which is the only thing being measured.

This is not disconnection. connected on the presence row answers that, and a client can be stale, disconnected, both, or neither. There is no callback and no threshold to configure, because the right response is per game. Read it in tick() and decide:

tick(state, dt, room) {
  for (const clientId of room.staleWriters(60)) {
    const p = state.players.get(clientId);
    if (p) p.idle = true; // or stop targeting it, or hand it to an NPC driver, or kick it
  }
}

Pick a threshold from your tick rate and the input rate you expect. At 20 Hz a client sending input every frame is stale by three ticks during an ordinary network hiccup, so a threshold under about a second of ticks will fire on healthy clients.

Player storage

room.kv is per-player key/value storage scoped to the project. It outlives the room, and another room in the same project reads back what this one wrote.

await room.kv.get(playerId, key);          // Promise<string | undefined>, a miss is not an error
await room.kv.set(playerId, key, value);
await room.kv.delete(playerId, key);       // idempotent

Values are strings, so JSON-encode structured data yourself. The limits are exact keys only: no listing, no prefix scans, no secondary indexes, and no cross-project reads. Values are at most 16 KiB of UTF-8, keys and player ids at most 256 bytes, and one player holds at most 128 keys per project.

Confinement is per project rather than per player, so a room may pass any playerId and read another player’s row.

playerId itself is only as durable as how the player joined. See ctx.playerId above. A key join’s playerId dies with its resume token; a JWT join’s is durable. Adopting JWT partway through a game’s life starts a player’s storage over, since rows written under the old client-id identity have no mapping to the new subject-based one. Ship JWT before your game accumulates KV state players would miss.

A rejection names the limit it hit and the room keeps running. irtio dev has no storage behind it, so room.kv rejects there with E_KV_UNAVAILABLE: expect that in local development and handle it.

Durable alarms

An alarm is a named timer that survives hibernation and a full stop of your project’s server. Handlers live in the config rather than in a call you make at runtime, because an alarm outlives the process a callback would have lived in.

room.alarm('round', room.now + 15_000);   // compute from room.now, never from Date.now()
room.cancelAlarm('round');

Arming a name that is already armed replaces its due time, which is what makes re-arming from inside an alarm handler the way to build a repeating timer. Arming a name with no entry in alarms is refused at arm time, with a log line saying so. Cancelling an unarmed name is a no-op.

What an alarm guarantees is worth reading carefully. It fires at or after atMs, never before, and the guaranteed resolution is seconds, not milliseconds.

Room state when the alarm comes dueTypical lateness
Livemilliseconds
Hibernated, your project’s server still runningmilliseconds
Your project’s server stoppedseconds: irtio checks every ten seconds, starts the server, and the room fires what is overdue

So a 15 second round can end at 15.2 seconds, or at 20 if the server had to be started first. Write the handler so a late alarm is harmless, which usually means checking the phase before acting. A server that was stopped for an hour still owes the room its overdue alarm and delivers it on the next start rather than dropping it.

If you need sub-second precision while the room is awake, use room.setTimeout for the precision and an alarm for the durability.

Replay clips

Add replay: { seconds: 60 } to the room config to record the recent past. Recording costs memory in the room and nothing else until a clip is taken:

replay: {
  seconds: 60,    // footage the ring holds, 1 to 300, default 60
  view: 'seeker', // optional: narrows the clip to one role's collection visibility
}

room.replay.armed is true once the room type declares replay:. room.replay.clip(seconds) takes a clip of the last seconds of footage (omitted, the whole ring) and resolves with { id }, the clip’s whole access control. It rejects rather than throwing:

RejectionWhen
E_CLIP_COOLDOWNCalled again inside 1 second of the last accepted call
E_CLIP_DISARMEDA single keyframe was too big for the ring’s 8 MB bound; recording stopped for the life of the room
E_CLIP_EMPTYThe ring holds no footage yet
E_CLIP_TOO_LARGEThe assembled clip is over 16 MB

Relay rooms cannot record: replay is a room-file setting, and a relay room has no room file. See Replays.

room.replay.excerpt() (experimental)

room.replay.excerpt({ seconds, collections, ids }) decodes the last seconds of the ring back into plain per-tick frames the room can read, without storing anything. Use it for a killcam you send as a message, a post-round summary, or a trace of a contested moment.

This API is experimental; its frame shape may change.

OptionTypeDefaultMeaning
secondsnumberRequiredPositive lookback window, at most 30 seconds
collectionsreadonly string[]All entity collectionsLimit output to these collections; singleton names are refused
idsreadonly string[]All entity idsLimit output to these ids within the selected collections

Resolves to { startTick, endTick, tickRate, frames }. tickRate is in ticks per second; each frame contains a tick and entities, indexed by collection name and entity id.

const cam = await room.replay.excerpt({
  seconds: 3,                // up to 30
  collections: ['players'],  // optional: only these collections
  ids: [victimId, killerId], // optional: only these entities
});

// cam is { startTick, endTick, tickRate, frames }, and each frame is
// { tick, entities: { players: { p1: { x, y } } } }
room.messages.killcam.send(victimId, { json: JSON.stringify(cam.frames) });

frames is in tick order, one entry per recorded tick inside the window. A tick in which nothing changed was never recorded, so the ticks ascend but are not always contiguous. Each frame is plain data: writing to it changes neither room state nor the other frames.

An excerpt frame carries entity collections only. Singleton collections are never part of one, so a room that needs a singleton reads it from state directly, and naming a singleton in collections is refused rather than ignored.

collections and ids limit what is materialized, not what is decoded. A delta can only be applied against whole state, so the walk always decodes the whole recorded world and the filters decide what is copied out of it. That is also what the size limit measures, so naming the collections you need is the way to stay under it.

Excerpt output is not filtered by interest management. The ring records the recorder view: public collections by default, a role’s collections with replay.view, everything but server collections with replay.omniscient (see What a clip contains). Collections restricted by position are recorded whole, so an excerpt can contain entities a given client would never receive normally. Deciding who may see which part of it is the room’s decision: filter before you send. Naming a collection the recorder does not record is refused.

It rejects rather than throwing:

RejectionWhen
E_REPLAY_UNARMEDThe room type did not declare replay:
E_EXCERPT_BAD_ARGSseconds is missing, zero, negative, or not a number, or a name in collections is not an entity collection in the schema, or is one the recorder does not record
E_EXCERPT_TOO_LONGseconds is over 30. The window is refused, never shortened silently
E_EXCERPT_COOLDOWNMore than 4 calls without waiting; the room refills one token per second
E_EXCERPT_TOO_LARGEThe frames would be over 4 MB. Ask for fewer seconds or name collections
E_EXCERPT_DISARMEDRecording stopped in this room because one keyframe was larger than the ring

A room that is armed but has recorded nothing yet resolves with no frames rather than rejecting.

Room runtime

Room code runs in a sandbox. Import @irtio/server, @irtio/schema, the supported physics packages, and local files. Use synchronous room handlers, schema visibility rules, and the room APIs for storage, messaging, and alarms. The bundler reports unsupported imports.