From Playroom

Per-player state and the join callback carry over almost directly. The host does not. Playroom elects one player’s browser to run the game. irtio runs your room code on a server, so host-only logic becomes server logic.

What maps directly

PlayroomirtioDifference
The host, and the flag your code checks to find itThe room file, irtio/room.tsThere is no host. tick, RPC handlers and validate run on the server for everyone.
Host migration when the host leavesnothing to migrateAuthority never moves, so no player leaving can take it away.
Per-player state, set by that playerAn entity collection, one instance per client, owned by that clientFields are declared with a type and a bound. Assign to them and the write syncs.
Shared game stateA singleton with serverOwned: trueOnly the room writes it. On the client it is read-only at compile time.
The player-joined callbackonJoin(state, ctx)Runs on the server. You add the player’s instance yourself; irtio adds nothing for you.
The player-quit callbackonLeave(state, ctx, reason)reason is left, timeout, kicked or closed.
RPC-style calls between playersRPCs to the room, plus room.broadcastA call goes to the server, which decides and writes state. Every client sees the result through the normal sync.
The lobby screen and its room code<irt-lobby> from @irtio/lobby, and room.linkA custom element with the code, the share link, a QR code and a connection dot.
A persistent id for a returning playeridentity: true on the join, then ctx.playerIdAn anonymous account that survives closing the tab, with no sign-in screen.

What has no equivalent

Not in irtio

  • Player profiles. There is no name service, no avatar and no third-party sign-in. identity: true gives an anonymous id that persists in the browser and can be linked to a second device. A display name is the name you pass to joinRoom, plus whatever fields you declare.
  • Anything running in a player’s browser as the authority. There is no host, and no way to elect one.
  • A browsable list of open games. Players get in two ways: someone shares a room code, or quick match queues them into one.
  • Async room handlers. onJoin, RPC handlers, validators and tick are synchronous. A room has no fetch, no fs and no process.
  • Free-form per-player values. A field is str(24) or u8 or a list with a bound. There is no dynamic key and no JSON blob.

New here

  • A server that runs whether or not anyone is on it. tick(state, dt, room) steps at a fixed rate. Timers, projectiles and decay stop depending on somebody’s tab staying open.
  • Validators. Every write from a client passes validate before anyone else sees it.
  • Hibernation and saves. A quiet room is saved and stopped, and the next join resumes it at the tick it stopped on.
  • Per-player storage. room.kv holds values keyed to a player, across rooms and across matches.
  • Visibility. A collection can reach only certain roles, or only clients near enough to see it.
  • Physics with client-side prediction, and a test harness that runs your room with fake clients.

The migration, step by step

1. Scaffold the project

npx irtio init --tick

That writes irtio/schema.ts, irtio/rpc.ts, irtio/room.ts, irtio/room.test.ts and irtio.json. Your rendering, input and asset code does not move.

2. Declare per-player state and shared state

Every value you set on a player becomes a field on one collection. Every value you kept as shared game state becomes a singleton the room owns.

// irtio/schema.ts
import { bool, defineSchema, entity, enumOf, f32, singleton, str, u8, u16 } from '@irtio/schema';

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

export const schema = defineSchema(
  {
    // One instance per player. The player owns their own.
    players: entity({ x: f32, y: f32, name: str(24), colour: u8, ready: bool }),
    // Scores decide the game, so the room owns them.
    scores: entity({ points: u16 }, { serverOwned: true }),
    match: singleton({ phase: enumOf('lobby', 'play', 'over'), round: u8 }, { serverOwned: true }),
  },
  {
    project: 'p_c0ffee1234abcd56',   // written by `irtio init`
    roles: ['player', 'host'] as const,   // the first declared role is what a client gets by default
    rpc,
  },
);

Split by who decides the value, not by what it is about. A player’s position is a report, so the player owns it. A player’s score is an outcome, so the room owns it. See Ownership.

3. Move the join and quit callbacks

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

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

export default defineRoom(schema, {
  mode: 'tick',
  tickRate: 20,
  maxClients: 8,

  onJoin(state, ctx) {
    if (ctx.reconnecting) return;      // a resumed session already has its records
    state.players.add(
      ctx.clientId,
      { x: 0, y: 0, name: ctx.name || 'anon', colour: 0, ready: false },
      { owner: ctx.clientId },         // this is what makes it writable by that client
    );
    state.scores.add(ctx.clientId, { points: 0 });   // no owner option, so the room owns it
  },

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

  tick(_state, _dt, _room) {},
});

4. Move every host-only block into the room

Find each place your code checks whether it is the host and take the body of that branch here. A block that ran on an interval becomes tick. A block that ran in response to a player action becomes an RPC handler. A block that ran after a delay becomes a durable alarm.

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

export const rpc = {
  start: server({}),
  claim: server({ params: { tile: u8 } }),
};
// irtio/room.ts, inside defineRoom
rpc: {
  start(state, _params, ctx) {
    if (ctx.role !== 'host') throw new Error('start: host only');   // rejects the caller's promise
    state.match.phase = 'play';
    ctx.room.alarm('roundOver', ctx.room.now + 60_000);
  },

  claim(state, { tile }, ctx) {
    if (state.match.phase !== 'play') throw new Error('claim: round is not open');
    if (tile > 8) throw new Error('claim: no such tile');
    const score = state.scores.get(ctx.clientId);
    if (score) score.points += 1;
  },
},

alarms: {
  roundOver(state, _room) {
    state.match.phase = 'over';
  },
},

The host check is now a role check, and a client cannot promote itself into a role the schema gates. The difference is where the check runs: on the server, where the player cannot reach it.

5. Join the room and read state

// main.ts
import { joinRoom } from '@irtio/client';
import '@irtio/lobby';                  // registers <irt-lobby>

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

// No ?room= in the URL? This creates a room and writes ?room=CODE into the address bar.
const room = await joinRoom(schema, { name: 'you', identity: true });
document.querySelector('irt-lobby')?.attach(room);

canvas.addEventListener('pointermove', (e) => {
  const me = room.state.players[room.me];   // undefined for a frame or two after load
  if (!me) return;
  me.x = e.clientX;                         // owned, so this is a local write that syncs
  me.y = e.clientY;
});

// Rejects with 'start: host only' unless this client joined as { role: 'host' }.
startButton.onclick = () => room.call.start();

function frame() {
  for (const [, p] of room.render.players) draw(p);   // render: interpolated, for drawing
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

There is no subscription callback for state. Read room.state for logic and room.render in your draw loop.

6. Guard what players still own

// irtio/room.ts, inside defineRoom
validate: {
  players(prev, next, _ctx) {
    if (Math.hypot(next.x - prev.x, next.y - prev.y) > 60) return prev;   // reject: teleport
    return { ...next, x: Math.min(800, Math.max(0, next.x)) };            // accept, clamped
  },
},

7. Run it, then deploy

npx irtio dev      # your room on ws://localhost:7070
npx irtio deploy

The part people get wrong

There is no host, so a host check is a server check

The trap is porting the host branch to the client and keeping it there: pick the first player in the roster, call them the host, and let their browser run the round. That is the model you just left, without the machinery that made it work. Nothing stops another client writing the same values, and the round ends when one particular player closes their tab.

The room file is the host now. If a decision matters, it happens in an RPC handler, in tick, or in an alarm. A “host” in your UI is a role: roles: ['player', 'host'] in the schema, and ctx.role !== 'host' at the top of the handler.

The same swap fixes the shared-state writes. In a host model, if (isHost) setSharedState(...) was the guard. Here the guard is the schema: serverOwned: true makes the collection read-only on every client at compile time, so there is no branch to get wrong.

Per-player state is declared, not a bag

You cannot set a key you did not declare, and you cannot put an object into a field. Every field has a type and a bound, both checked on write: str(24) is 24 UTF-8 bytes, u8 is 0 to 255, and list(str(48), 20) holds 20 entries. A value past its bound is refused rather than truncated.

Two consequences worth planning for. A list is whole-replace, so anything that grows without a bound belongs in an entity collection instead. And changing the shape of a field is a schema change, so redeploy the room and reload the page together.

Next steps