From Colyseus

Your rooms carry over. A Colyseus room becomes an irtio room type, its state class becomes a schema, and onJoin and onLeave keep their names. What changes is writes: clients write what they own, and the server judges it.

What maps directly

ColyseusirtioDifference
Room classA room type: irtio/room.ts, or one file per type in irtio/rooms/The filename is the type name, and each file has one default export.
Room instanceroomA room exists because someone joined its id. Your game never constructs one.
State class and its typed fieldsdefineSchema in irtio/schema.tsField types are values from @irtio/schema, not classes. Your game and your room import the same module.
A keyed collection of records in stateentity(fields, options?)Ids are strings you choose. Iteration is insertion order, on the server and on every client.
The rest of the room’s statesingleton(fields, options?)Exactly one instance and no id.
State sync and patchesdelta sync, generated from the schemaOnly changed fields go out. There is no patch callback: you read room.state or room.render when you draw.
onJoin and onLeaveonJoin(state, ctx) and onLeave(state, ctx, reason)ctx.reconnecting is true on a resumed session. reason is left, timeout, kicked or closed.
Message handlersRPCs, declared in irtio/rpc.tsParams and returns are declared field types. The caller awaits the result.
The room’s simulation intervalmode: 'tick' and tick(state, dt, room)tickRate is 1 to 240 and defaults to 20. dt is fixed at 1 / tickRate.
Matchmakingroom codes, and matchRoom for a queuejoinRoom with no room option creates a room and writes its code into the address bar.

What has no equivalent

Not in irtio

  • Async room handlers. onJoin, onLeave, RPC handlers, validators and tick are all synchronous, and defineRoom refuses an async handler when it runs. Work that needs a promise is written as “ask, and carry on”: start it, and write state in the continuation.
  • Free-form message payloads. RPC params and returns are records of declared field types. There is no JSON blob channel. For bytes you encode yourself there is room.message, and nothing sent that way is retained or replayed.
  • Per-field filtering. visibility is an option on a whole collection. To hide one value, give it its own collection or do not write it into state until it should be visible.
  • A Node process of your own inside the room. A room has no fetch, no fs and no process. Use room.now instead of Date.now() and room.random() instead of Math.random(), so a run can be replayed.
  • Element-level array diffs. list(str(48), 20) is bounded, and writing one element sends the whole list. Anything that grows without a bound is an entity collection.

New here

  • Ownership. Every instance has one owner, a client or the server. An owned instance is a writable object on the client, and the write syncs on the next flush.
  • Validators. A validate entry runs on the server for every owner write.
  • Hibernation. A quiet room is saved and stopped, and the next join resumes it at the tick it stopped on. There is nothing to write.
  • An interpolated read path. room.render has the same shape as room.state, with non-owned entities smoothed for drawing.
  • Role and spatial visibility, and client-side prediction for physics rooms.

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. Keep your existing game code where it is. Only the networking moves.

2. Translate the state class

Every typed field in your Colyseus state gets a field type here. A per-player map becomes one entity collection; everything that exists once becomes a singleton.

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

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

export const schema = defineSchema(
  {
    players: entity({ x: f32, y: f32, name: str(24), score: u16 }),
    match: singleton(
      { phase: enumOf('lobby', 'play', 'over'), round: u8, deadline: f64 },
      { 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,
  },
);

str(24) is 24 UTF-8 bytes and u16 is 0 to 65535. Both are checked on write. See State and schema for the full type list.

3. Move join and leave

// 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 },
      { owner: ctx.clientId },                    // this is what makes it writable by that client
    );
  },

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

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

irtio creates no per-player record for you. The id has to be ctx.clientId so the client can find itself as room.state.players[room.me].

4. Turn message handlers into RPCs

Wherever your room registered a handler for a named client message, declare that name as an RPC and implement it in the room’s rpc map. A handler that returned something to the sender declares a returns shape.

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

export const rpc = {
  start: server({}),
  answer: server({ params: { choice: 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';
  },

  answer(state, { choice }, ctx) {
    const player = state.players.get(ctx.clientId);
    if (!player) return;
    if (choice === 2) player.score += 1;
  },
},

The map has to match the schema exactly. A missing implementation is a compile error and an unknown key throws when defineRoom runs.

5. Move the loop

Whatever your room ran on its own interval goes in tick. Client writes and RPCs that arrived since the last tick are already applied when it runs.

// irtio/room.ts, inside defineRoom
tick(state, _dt, room) {
  // room.now, not Date.now(). Both the clock and room.random() are recorded, so a run replays.
  if (state.match.phase === 'play' && room.now >= state.match.deadline) {
    state.match.phase = 'over';
  }
},

Projectiles, decay and AI movement go here too, stepped by dt rather than by measured time.

A game that only changes when someone does something wants mode: 'event' instead. There is no loop, and the room hibernates after idleMs (30 000 by default).

6. Replace the client’s state callbacks

Wherever your client subscribed to state changes and mirrored them into local objects, delete that layer and read the state directly in your draw loop.

// 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;
});

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

room.state is authoritative and is what game logic and tests read. room.render is the same shape with non-owned entities interpolated.

7. Decide ownership per collection

Anything a lying client could use to change the outcome gets serverOwned: true in irtio/schema.ts and is written only by an RPC handler. Anything a client reports about itself stays client owned and gets a validator.

// 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
  },
},

8. Run it, then deploy

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

irtio/room.test.ts runs under your own test runner. testRoom gives you the room with fake clients, so the port is checkable before it goes anywhere.

// irtio/room.test.ts
import { testRoom } from '@irtio/testing';
import room from './room.js';

const t = await testRoom(room);
await t.join(2);
await t.tick(10);
expect(t.state.players.size).toBe(2);

The part people get wrong

A client write is not applied because the client sent it

In Colyseus a client asks and the server moves the state. Here a client’s own instance is writable and assigning to it looks local, which reads as “the client is in charge”. It is not. The server sees every owner write before anyone else does, and validate decides what is actually stored. Return prev to reject, return a new object to correct, and the owner is snapped back.

Two ported mistakes follow from missing that:

  • Everything becomes an RPC. A position sent as an RPC costs a round trip per input, which is the latency you were trying to avoid. Frequent, self-reported values belong in an owned write with a validator.
  • Nothing gets a validator. A collection with no validate entry accepts whatever its owner sends. That is fine for a cursor and wrong for anything that decides the game.

Schema types are budgets, not hints

str(24), u8, list(str(48), 20): each bound is the wire size, and a value past it is refused at add() and at encode time rather than truncated. Pick each bound from your game. A chat line that can be 200 bytes has to say str(200), and a score that can pass 255 cannot be u8.

The schema is hashed, so a client built against an older shape is refused with a version-skew error instead of decoding nonsense. Redeploy the room and reload the page together. See Migrations.

Next steps