From Socket.IO
Your game logic carries over. Your protocol does not. Events, rooms and acks all have somewhere to go, but shared state stops being a stream of messages you send and becomes a schema you declare once.
The same applies to a plain ws server. Anywhere below that says “event”, read “the message type
you dispatch on”.
What maps directly
| Socket.IO | irtio | Difference |
|---|---|---|
| An event carrying a client’s own position or input | An owned write on that client’s instance | Assign to the field. Every field written inside one flush window leaves as one frame. |
| An event asking the server to do something | An RPC in irtio/rpc.ts | Params are declared field types, not a JSON argument. |
| A room | a room | A room is the unit you join, by code. One joinRoom is one room. |
| Broadcast to a room | delta sync from the schema | You write state and every client gets the changed fields. There is no send call in the middle. |
| An ack callback | An RPC with a returns shape | await room.call.name(params) resolves with the declared record, or rejects with the message the handler threw. |
| Sticky sessions and a shared bus between processes | one room, in one place | Every client in a room reaches the same room, so there is nothing to configure for fan-out. |
| Reconnect and resync code | reconnectGraceMs and ctx.reconnecting | The seat is held for 30 seconds by default, state is still there, and the client resyncs itself. |
| A connected-sockets list you keep yourself | room.clients | Built in on both sides, one record per client: { clientId, role, name, connected }. |
| Separate namespaces for separate features | room types | One project, several kinds of room, each with its own schema and budget. |
What has no equivalent
Not in irtio
- Event names invented at runtime. Every RPC is declared in
irtio/rpc.tsand is part of the schema hash. A client built against a different set of names is refused at join. - Arbitrary payloads. Params, returns and state fields are typed and bounded:
str(24)is 24 UTF-8 bytes,u8is 0 to 255. There is no free-form object. - Connection middleware. A join carries the public project key and, optionally, a JWT or an anonymous identity. Your checks go in the handler, against
ctx.roleandctx.playerId. - A transport fallback. irtio speaks a binary WebSocket protocol and only that.
- Async server handlers.
onJoin,onLeave, RPC handlers, validators andtickare synchronous, and a room has nofetch, nofsand noprocess.
New here
- Declared state. The wire format is generated from the schema, so the client and the server cannot disagree about a field’s shape.
- Ownership and validators. Every instance has one owner, and a
validateentry gets the last word on that owner’s writes. - Hibernation. A quiet room is saved and stopped, and the next join resumes it where it was.
- An interpolated read path.
room.rendersmooths non-owned entities for drawing. - Room codes and share links.
room.linkis a URL that puts someone else in this room. - Visibility filtering by role or position, and a test harness that fails on leaks.
The migration, step by step
1. Scaffold, and sort your events into three piles
npx irtio init --tick That writes irtio/schema.ts, irtio/rpc.ts, irtio/room.ts, irtio/room.test.ts and irtio.json. Before touching them, put every event you send into one of three piles.
| The event | Where it goes |
|---|---|
| Reports a value the sender owns, sent often | An owned write, plus a validator |
| Asks the server for a decision, or for a value back | An RPC |
| Is a one-off signal nobody needs to replay | room.message or room.broadcast |
Most protocols are two thirds pile one, and pile one is the part that disappears.
2. Declare the state
// 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, owned by that player.
players: entity({ x: f32, y: f32, name: str(24), score: u16, ready: bool }),
// Written only by the room.
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,
},
); Anything you were keeping in a Map on the server and mirroring to clients by hand belongs here.
See State and schema for the field types.
3. Declare the calls
// irtio/rpc.ts
import { list, server, str, u8 } from '@irtio/schema';
export const rpc = {
start: server({}), // no params, no return
answer: server({ params: { choice: u8 } }),
dealCards: server({ params: { count: u8 }, returns: { cards: list(str(8), 52) } }),
}; An event whose ack callback carried a result becomes the returns shape. The caller awaits it.
4. Replace connect and disconnect
// 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 record
state.players.add(
ctx.clientId,
{ x: 0, y: 0, name: ctx.name || 'anon', score: 0, ready: false },
{ owner: ctx.clientId }, // this is what makes it writable by that client
);
},
onLeave(state, ctx, reason) {
// reason: 'left' | 'timeout' | 'kicked' | 'closed'
state.players.remove(ctx.clientId);
},
rpc: {
start(state, _params, ctx) {
if (ctx.role !== 'host') throw new Error('start: host only'); // rejects the caller
state.match.phase = 'play';
},
answer(state, { choice }, ctx) {
const player = state.players.get(ctx.clientId);
if (!player) return;
if (choice === 2) player.score += 1;
},
dealCards(state, { count }, _ctx) {
return { cards: draw(state, count) };
},
},
tick(_state, _dt, _room) {},
}); The rpc map has to match the schema exactly. A missing implementation is a compile error.
5. Replace emit and listen on the client
// main.ts
import { joinRoom } from '@irtio/client';
import { schema } from './irtio/schema.js';
const room = await joinRoom(schema, { name: 'you' });
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();
const { cards } = await room.call.dealCards({ count: 5 }); // typed return, awaited
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. You read room.state for logic and room.render in
your draw loop. Delete the listener that unpacked each event into a local object, and delete the
resync you wrote for reconnects.
6. Guard the writes you just made client owned
// 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
},
}, Return next to accept, prev to reject, or a new object to correct. Anything you would have
checked inside the old event handler goes here.
7. Keep the events that really are events
// main.ts
room.message('all', bytes); // everyone but you
room.message({ role: 'host' }, bytes); // by role
room.onMessage((from, bytes) => apply(from, bytes)); Nothing sent this way is retained, nothing replays on reconnect, and a late joiner sees none of it.
The room can drop or rewrite these in its own onMessage handler. Server to client, a void RPC and room.broadcast.<name>(params) do the same job with types.
8. Run it, then deploy
npx irtio dev # your room on ws://localhost:7070
npx irtio deploy The part people get wrong
There is no handler for a state message
The reflex is to port socket.on('move', ...) into an RPC called move. That works, and it is the
wrong shape: every input now costs a round trip before the player sees their own movement, and you
have rebuilt the protocol you were replacing.
Position, aim, colour, “is typing” and “is ready” are not messages here. They are fields on an instance the client owns. The client assigns to them, irtio batches and sends them, and the only handler you write is the validator. Reach for an RPC when the server has to decide something, when the caller must not be able to skip the check, or when the client needs a value back.
Broadcast is not a call you make
Writing server-owned state is the broadcast. A room that sets state.match.phase = 'play' has told
every client, and calling room.broadcast alongside it sends the same news twice. Keep room.broadcast for signals with no state behind them, such as a countdown buzz.
The related habit worth dropping is sending state on a timer. Only changed fields go out, so a field nobody touched costs nothing. A flush that changes nothing sends nothing.
Next steps
- Your first room for the whole path once, end to end.
- RPCs for the events that stay calls.
- Ownership for who may write each instance.
- Server authority and validation for validators and the tick.
- Presence and lifecycle for
room.clients, reconnects and status. - irtio and Socket.IO for how the two compare before you commit.