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
| Handler | Signature | Runs |
|---|---|---|
onCreate | (state, room) => void | Once, when the room is first created |
onJoin | (state, ctx) => void | On every join, including a resumed one |
onLeave | (state, ctx, reason) => void | On every departure |
onSleep | (state, room) => void | Just before the room hibernates |
onWake | (state, room) => void | After the room comes back |
tick | (state, dt, room) => void | Every tick. dt is seconds. Required in tick mode |
validate.<entity> | (prev, next, ctx) => instance | Before an owner write is accepted |
rpc.<name> | (state, params, ctx) => result | When a client calls it |
alarms.<name> | (state, room) => void | When a durable alarm fires |
onOwnershipRequest | (state, entity, id, ctx) => boolean | On the built-in requestOwnership RPC |
onChat | (state, line, ctx) => boolean \| void | Before chat is delivered; false or a throw refuses the line |
onMessage | (state, from, target, bytes, ctx, typed?) => boolean \| void | On 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
stateand 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 messagedeny: <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
throwfrom an RPC is treated as a bug: the caller’s promise rejects withE_INTERNAL: rpc <name>, the thrown message is withheld, the room counts it as a handler error, and the room keeps running. - A throw in
tickskips 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:
| Field | Type | Meaning |
|---|---|---|
clientId | string | The client this join, call or write came from |
playerId | string | The 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 |
role | a role your schema declares | The role this client holds. See role views |
name | string | The display name it joined with |
tick | number | The server tick this is applied at |
clientTick | number \| undefined | RPCs 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 |
reconnecting | boolean | Joins only: this is a resumed session |
npc | boolean | This 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 |
room | Room<S> | The room object, same as the one tick receives |
deny(reason) | (reason: string) => never | RPCs only: refuse this call with a reason. See ctx.deny |
require(condition, reason) | (condition: unknown, reason: string) => asserts condition | Check 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-rpcshows it asdeny:<reason>;irtio simulatereports denials separately; itsdenial-ratecheck 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:
| form | why 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
| Member | Notes |
|---|---|
room.state | The room’s shared state. |
room.seed | Seed used by the room’s deterministic random generator. |
room.messages | Typed peer messages declared in the schema. See messages. |
room.broadcastExcept(clientId) | Client RPC broadcast excluding one client. |
room.bus | Send and receive messages between rooms. See room messages. |
room.lobby | Lobby lifecycle and readiness. See lobby reference. |
room.ratings | Report match results. See matchmaking. |
room.backfill | Control whether matchmaking offers this room’s open seats. |
room.spawnNPC / room.despawnNPC | Manage simulated players. See NPCs. |
room.id | The room code |
room.link | Shareable URL carrying ?room=<id> |
room.tick | Current server tick |
room.now | Server 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.clients | Presence, 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) / setInterval | Live-only timers. The callback is cancelled by hibernation, but the room is woken at the due time |
room.clearTimeout(handle) / clearInterval | Cancel 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.kv | Per-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.physics | The Rapier world and its bodies, in a rapier3d room. See physics |
room.physicsRapier2d | The Rapier world and its bodies, in a rapier2d room |
room.physics2d | The matter.js engine and its bodies, in a matter2d room |
room.physicsCustom | Your 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 logsroom.sleep() is event-mode only; ignored in tick modeand carries on. It is not an error, so it is easy to miss when porting an event-mode room to tick.room.setTimeoutandroom.setIntervalare live only. Hibernation cancels the callback — it is a closure, and there is nothing to persist. What the platform does promise is residency for asetTimeout: a room that hibernates with one pending is woken at, or shortly after, its due time. Handle the deadline inonWake, using a value saved in room state. Store that deadline as wall clock (Date.now()):room.nowis monotonic within one sitting and starts again in the worker the wake spawns, so aroom.nowdeadline 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
setIntervalgets 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 — androom.sleep()on a room holding one really does put the room down. Re-arm the interval inonWakewhen 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.
| Property | Meaning |
|---|---|
physicsStepMs | Wall 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. |
physicsStepMsAvg | An 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 due | Typical lateness |
|---|---|
| Live | milliseconds |
| Hibernated, your project’s server still running | milliseconds |
| Your project’s server stopped | seconds: 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:
| Rejection | When |
|---|---|
E_CLIP_COOLDOWN | Called again inside 1 second of the last accepted call |
E_CLIP_DISARMED | A single keyframe was too big for the ring’s 8 MB bound; recording stopped for the life of the room |
E_CLIP_EMPTY | The ring holds no footage yet |
E_CLIP_TOO_LARGE | The 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.
| Option | Type | Default | Meaning |
|---|---|---|---|
seconds | number | Required | Positive lookback window, at most 30 seconds |
collections | readonly string[] | All entity collections | Limit output to these collections; singleton names are refused |
ids | readonly string[] | All entity ids | Limit 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:
| Rejection | When |
|---|---|
E_REPLAY_UNARMED | The room type did not declare replay: |
E_EXCERPT_BAD_ARGS | seconds 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_LONG | seconds is over 30. The window is refused, never shortened silently |
E_EXCERPT_COOLDOWN | More than 4 calls without waiting; the room refills one token per second |
E_EXCERPT_TOO_LARGE | The frames would be over 4 MB. Ask for fewer seconds or name collections |
E_EXCERPT_DISARMED | Recording 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.