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.IOirtioDifference
An event carrying a client’s own position or inputAn owned write on that client’s instanceAssign to the field. Every field written inside one flush window leaves as one frame.
An event asking the server to do somethingAn RPC in irtio/rpc.tsParams are declared field types, not a JSON argument.
A rooma roomA room is the unit you join, by code. One joinRoom is one room.
Broadcast to a roomdelta sync from the schemaYou write state and every client gets the changed fields. There is no send call in the middle.
An ack callbackAn RPC with a returns shapeawait room.call.name(params) resolves with the declared record, or rejects with the message the handler threw.
Sticky sessions and a shared bus between processesone room, in one placeEvery client in a room reaches the same room, so there is nothing to configure for fan-out.
Reconnect and resync codereconnectGraceMs and ctx.reconnectingThe seat is held for 30 seconds by default, state is still there, and the client resyncs itself.
A connected-sockets list you keep yourselfroom.clientsBuilt in on both sides, one record per client: { clientId, role, name, connected }.
Separate namespaces for separate featuresroom typesOne 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.ts and 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, u8 is 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.role and ctx.playerId.
  • A transport fallback. irtio speaks a binary WebSocket protocol and only that.
  • Async server handlers. onJoin, onLeave, RPC handlers, validators and tick are synchronous, and a room has no fetch, no fs and no process.

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 validate entry 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.render smooths non-owned entities for drawing.
  • Room codes and share links. room.link is 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 eventWhere it goes
Reports a value the sender owns, sent oftenAn owned write, plus a validator
Asks the server for a decision, or for a value backAn RPC
Is a one-off signal nobody needs to replayroom.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