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
| Playroom | irtio | Difference |
|---|---|---|
| The host, and the flag your code checks to find it | The room file, irtio/room.ts | There is no host. tick, RPC handlers and validate run on the server for everyone. |
| Host migration when the host leaves | nothing to migrate | Authority never moves, so no player leaving can take it away. |
| Per-player state, set by that player | An entity collection, one instance per client, owned by that client | Fields are declared with a type and a bound. Assign to them and the write syncs. |
| Shared game state | A singleton with serverOwned: true | Only the room writes it. On the client it is read-only at compile time. |
| The player-joined callback | onJoin(state, ctx) | Runs on the server. You add the player’s instance yourself; irtio adds nothing for you. |
| The player-quit callback | onLeave(state, ctx, reason) | reason is left, timeout, kicked or closed. |
| RPC-style calls between players | RPCs to the room, plus room.broadcast | A 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.link | A custom element with the code, the share link, a QR code and a connection dot. |
| A persistent id for a returning player | identity: true on the join, then ctx.playerId | An 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: truegives an anonymous id that persists in the browser and can be linked to a second device. A display name is thenameyou pass tojoinRoom, 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 andtickare synchronous. A room has nofetch, nofsand noprocess. - Free-form per-player values. A field is
str(24)oru8or alistwith 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
validatebefore 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.kvholds 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
- Your first room for the whole path once, end to end.
- The mental model for rooms, owners and the server’s last word.
- Server authority and validation for validators and the tick.
- Player identity for a player id that outlives the tab.
- Player storage for
room.kv. - Quick match for getting players in without a code.