RPCs

RPCs are typed calls between client and server. Use them for actions such as playing a card, submitting an answer, or starting a round. The handler updates shared state and returns a result to the caller.

Declare them

Both directions live in irtio/rpc.ts, which the schema imports. Both halves of your game import that file, and the names and signatures are part of the schema hash, so a signature change shows up as version skew rather than as a runtime surprise.

// irtio/rpc.ts
import { client, list, server, str, u8, u16 } from '@irtio/schema';

export const rpc = {
  // client to server, no params, no return
  start: server({}),

  // client to server, params only
  answer: server({ params: { choice: u8 } }),

  // client to server with a typed return
  dealCards: server({ params: { count: u8 }, returns: { cards: list(str(8), 52) } }),

  // server to client, void
  buzz: client({ params: { ms: u16 } }),
};
// irtio/schema.ts
import { defineSchema, entity, str, u8, u16 } from '@irtio/schema';

import { rpc } from './rpc.js';

export const schema = defineSchema(
  {
    players: entity({ name: str(24), score: u16, choice: u8 }, { serverOwned: true }),
  },
  { project: 'p_c0ffee1234abcd56', roles: ['controller', 'host'] as const, rpc },
);

Params and returns are records of the same field types the state uses. There is no free-form JSON payload. The wire format is generated from these declarations.

Implement them in the room

Every server(...) RPC gets an entry in the room config’s rpc map. The map has to match the schema exactly: a missing implementation is a compile error, and an unknown key throws when defineRoom runs.

// irtio/room.ts
import { defineRoom } from '@irtio/server';

import { schema } from './schema.js';

export default defineRoom(schema, {
  mode: 'event',

  onJoin(state, ctx) {
    if (ctx.reconnecting) return;
    // Everyone joins as a controller. The room grants host to the first client, the shared
    // screen; a client cannot pick the role for itself.
    if (!ctx.room.clients.some((c) => c.role === 'host')) {
      ctx.room.setRole(ctx.clientId, 'host');
      return;
    }
    state.players.add(ctx.clientId, { name: ctx.name || 'player', score: 0, choice: 0 });
  },

  onLeave(state, ctx) {
    state.players.remove(ctx.clientId);
  },

  rpc: {
    start(state, _params, ctx) {
      // ctx.deny(reason) refuses the call: the caller's promise rejects with `deny: <reason>`.
      if (ctx.role !== 'host') ctx.deny('start: host only');
      openRound(state, ctx.room);
    },

    answer(state, { choice }, ctx) {
      if (ctx.role !== 'controller') ctx.deny('answer: controller only');
      // Mashing the button after the round closed is normal input, not an error worth showing.
      const player = state.players.get(ctx.clientId);
      if (!player) return;
      player.choice = choice;
      if (choice === correctChoice(state)) player.score += 1;
    },

    dealCards(state, { count }, _ctx) {
      return { cards: draw(state, count) };
    },
  },
});

A handler receives (state, params, ctx). ctx.clientId is the caller, ctx.role is its resolved role, ctx.name is the display name it joined with, and ctx.room is the same room object the lifecycle handlers get. Mutate state directly and the changes go out with the next frame. Return the declared result, or nothing for a void RPC.

Handlers are synchronous. defineRoom refuses an async handler at import time rather than at run time, so you find out on deploy.

ctx.tick and ctx.clientTick

ctx.tick is the server’s own tick, the one this call is being applied at. ctx.clientTick is the newest authoritative tick the caller had applied when it sent the call, which at any real latency is a round trip behind: it is the tick whose world the player was looking at when they pressed the button.

Most handlers never need it. The one that does is the shot:

fire(state, { x, y }, ctx) {
  return ctx.room.rewind(ctx.clientTick ?? ctx.tick, (past) => { … });
}

It is undefined for a join, for a write, and for a client that sends no stamp, so ?? ctx.tick is the fallback to write. And it is a number the client chose, which matters if your game is competitive: see lag compensation.

Call them from the client

// main.ts
import { joinRoom } from '@irtio/client';

import { schema } from './irtio/schema.js';

const room = await joinRoom(schema, {
  role: 'controller',
  name: 'you',
  // Implementations of every server-to-client RPC. Exhaustive by type.
  rpc: {
    buzz({ ms }) {
      startCountdownBar(ms);
    },
  },
});

await room.call.start(); // void
await room.call.answer({ choice: 2 });
const { cards } = await room.call.dealCards({ count: 5 });

room.call.<name> exists for every server(...) RPC in the schema, typed from the declaration. When a handler refuses an expected call with ctx.deny(reason), the caller’s promise rejects with an Error whose message is deny: <reason>, so catch it where a failure is meaningful to the player. A handler that throws is treated as a bug instead: the message is E_INTERNAL: rpc <name> and the thrown text is withheld. There is a 10 second client-side timeout.

You do not apply the RPC’s effects yourself. Everything the handler wrote to the state arrives through the normal sync, the same as any other change.

A resolved call does not mean your state has caught up

The reply and the state change are two frames, and the reply goes first. The room encodes the result the moment your handler returns, while the delta carrying what the handler wrote waits for the end of the tick in tick mode, and for the flush right after the frame in event mode. The client may therefore still read the value from before the call:

// main.ts
await room.call.play({ card: '7H' });
render(room.state.match.top); // may still be the old card

Two ways to write it correctly. Return the value you need, which also gets it to the caller alone:

const { top } = await room.call.play({ card: '7H' });
render(top);

Or read the state where it changes rather than where you asked. A game with a render loop already does this, because the loop reads room.state every frame and picks the change up on arrival. room.onAdd(collection, cb) and room.onRemove(collection, cb) cover rows appearing and going, and room.onChange(collection, cb) fires when a server frame updates a row that is already there. A singleton has no change event, so a field on one is something the render loop sees.

Refusing a call

Use ctx.deny(reason) to refuse a call when the caller lacks permission, the round is in the wrong phase, or a move is out of turn. It stops the handler and rejects the caller’s promise with an Error whose message is deny: <reason>.

OutcomeUseThe caller receivesirtio simulate records
An expected refusalctx.deny(reason)A rejection with the message deny: <reason>deny:<reason>; counts toward denial-rate
A bug in the handlerthrowA rejection with the message E_INTERNAL: rpc <name>; the thrown text is withheldA handler error

When testing with simulate --bots N, use ctx.deny for expected refusals so invalid calls do not count against the handler-error invariant.

See ctx.deny for the full reference.

Calling a client from the room

Two shapes, depending on whether you want a reply.

// One client, awaiting a typed result.
void ctx.room.call(ctx.clientId).pickCard({ options }).then((res) => {
  state.hands.get(ctx.clientId)!.picked = res.picked;
}).catch((error) => ctx.room.log('pickCard failed', String(error)));

// Every connected client, fire and forget. Void RPCs only, no role filter.
room.broadcast.buzz({ ms: 15_000 });

room.call(clientId) on an RPC that declares returns gives you a promise with a 5 second default timeout. It rejects if the client disconnects first, if the client has no implementation for that RPC, and if the client answers with an error. A rejection nobody catches is logged by the room and counted, not fatal, but attach a .catch anyway: it is where you decide what the room does when the answer never comes.

A directed call of a client(...) RPC that declares no returns is fire and forget, exactly like the broadcast of that same RPC: it sends one frame, waits for nothing, and never rejects — not on a missing implementation, not on a recipient who has already gone. That matters for the notify shape (ctx.room.call(to).invited({ from })), because a testRoom or simulate bot registers no client RPC implementations at all, and before this a single invite in a test run surfaced as an unhandled rejection on the server side. You do not need to .catch() a void directed call.

room.broadcast only carries the client(...) RPCs that return nothing, and reaches every connected client regardless of role.

A call made from onJoin to the client that is joining is delivered after the join completes. irtio holds it until the client’s welcome frame has gone out, then sends it, so the client sees the welcome first and the call second, and the promise settles on the reply as usual. If that join is cut short before the welcome, the call rejects with the same error a disconnect produces. Nothing needs deferring on your side.

The reply is not available in the handler that asked for it

Room handlers are synchronous and none of them can block on I/O, so the continuation of room.call() runs as its own event between ticks, after your handler has already returned. State you mutate in the continuation is tracked and flushed exactly like state mutated in a handler, so the pattern is “ask, and carry on”:

rpc: {
  chooseCard(state, _params, ctx) {
    const options = topThree(state);
    void ctx.room.call(ctx.clientId).pickCard({ options }).then(({ picked }) => {
      // Its own event, some time later. This write syncs normally.
      state.hands.get(ctx.clientId)!.picked = picked;
    }).catch(() => {
      // The client never answered: no implementation, disconnected, or out of time. Decide here
      // rather than leaving the hand half-dealt.
      const hand = state.hands.get(ctx.clientId);
      if (hand) hand.picked = options[0]!;
    });
    // Nothing to read here yet, and nothing will be.
  },
}

The same rule applies to every promise-returning room API, including saves and player storage.

RPC or an owned write

Use an owned write when the client is reporting something about itself and the server only needs to relay it, possibly with a guardrail. Use an RPC when the server has to decide, when the caller must not be able to skip the check, or when the client needs a value back.

SituationUse
Cursor, position, camera, emoteOwned write
Owned write that needs clampingOwned write plus a validator
Play a card, submit an answer, buy an itemRPC
The client needs a result backRPC with returns
Score, loot, turn order, hit resolutionRPC writing server-owned state
A one-off signal to clients, not worth syncingroom.broadcast

An RPC costs a round trip. Anything you send every frame belongs in an owned write.

Adding an RPC later

An RPC is addressed on the wire by its position in your rpc object, so add to the bottom, never insert in the middle. Appending is an additive deploy: nothing already deployed moves, and live rooms keep their sockets across it. Reordering is breaking, and the deploy names the RPCs whose ids moved. See declaration order.

Reserved names

requestOwnership is built in and cannot be declared in your rpc map. It is documented under Ownership. Built-ins live in a reserved range at the top of the id space, separate from the ids assigned to your declarations.