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
| Colyseus | irtio | Difference |
|---|---|---|
| Room class | A 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 instance | room | A room exists because someone joined its id. Your game never constructs one. |
| State class and its typed fields | defineSchema in irtio/schema.ts | Field types are values from @irtio/schema, not classes. Your game and your room import the same module. |
| A keyed collection of records in state | entity(fields, options?) | Ids are strings you choose. Iteration is insertion order, on the server and on every client. |
| The rest of the room’s state | singleton(fields, options?) | Exactly one instance and no id. |
| State sync and patches | delta sync, generated from the schema | Only changed fields go out. There is no patch callback: you read room.state or room.render when you draw. |
onJoin and onLeave | onJoin(state, ctx) and onLeave(state, ctx, reason) | ctx.reconnecting is true on a resumed session. reason is left, timeout, kicked or closed. |
| Message handlers | RPCs, declared in irtio/rpc.ts | Params and returns are declared field types. The caller awaits the result. |
| The room’s simulation interval | mode: 'tick' and tick(state, dt, room) | tickRate is 1 to 240 and defaults to 20. dt is fixed at 1 / tickRate. |
| Matchmaking | room codes, and matchRoom for a queue | joinRoom 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 andtickare all synchronous, anddefineRoomrefuses anasynchandler 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.
visibilityis 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, nofsand noprocess. Useroom.nowinstead ofDate.now()androom.random()instead ofMath.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 anentitycollection.
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
validateentry 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.renderhas the same shape asroom.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
validateentry 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
- Your first room for the whole path once, end to end.
- Ownership for who may write each instance.
- Server authority and validation for validators and the tick.
- RPCs for the calls your message handlers become.
- Room types for a project with more than one kind of room.
- irtio and Colyseus for how the two compare before you commit.