Colyseus with TypeScript: Matchmaking and State Sync Without Rolling Your Own Netcode

Colyseus with TypeScript: Matchmaking and State Sync Without Rolling Your Own Netcode

The moment a game grows a second player, the boring single-process server turns into a networking project. You need a transport, a message format, a game loop, authoritative state, a matchmaker, reconnection handling, and client code in two or three engines at once. Reach for the most famous tools and you assemble that stack from parts: Socket.IO moves messages, Photon or Mirror platform the game, Nakama and PlayFab provide the backend, Agones hosts the fleet. None of them is the whole answer on its own, and that gap is exactly where Colyseus sits.

Colyseus is an open source (MIT) multiplayer framework for Node.js. It gives you authoritative rooms, schema-based state synchronization that sends binary delta patches, and matchmaking, plus client SDKs for the web, Unity, Godot, Defold, GameMaker, Construct, Haxe, and more.

This is the TypeScript tour: where it fits in the framework landscape, why the architecture is different, and a minimal server and client you can copy.

The short version

  • Colyseus is a server-authoritative framework, not a transport. Rooms own the state, clients request changes with messages, and the framework synchronizes the deltas.
  • State sync is schema-based. You define types with schema() and t.* from @colyseus/schema, the server mutates them, and clients receive compact binary patches instead of JSON snapshots.
  • Matchmaking is built in. One room definition spawns many instances, with visibility flags, filtering, queueing, lobbies, and reconnection. No separate lobby server.
  • TypeScript runs end to end. The server is TypeScript, the client SDK is typed, and 0.18 added typed messages, typed join options, and typed state shared with the client.
  • The SDK list is the point. One server, and clients in JavaScript and TypeScript, Unity (C#), Godot, Defold (Lua), GameMaker, Construct, and Haxe.
  • It is not a whole platform. The surrounding pieces are first-party, but separate packages: @colyseus/auth shipped in 0.15.15 (December 2023), and the @colyseus/monitor admin panel has existed since 2018 — 0.18 wires them into the default project scaffolding, which is closer default integration, not new features.
  • Self-hosting is free. MIT licensed, even for commercial games, with no per-player pricing attached.
Verified
  • colyseusv0.18.5
  • @colyseus/corev0.18.12
  • @colyseus/redis-presencev0.18.4
  • @colyseus/databasev0.18.2
  • colyseus-unity-sdk0.18.4

Checked 2026-09-16 against the colyseus releases page on GitHub and the 0.18 documentation. The 0.18 line is releasing actively and its API (schema() builder, defineServer, messages) differs from older guides, so pin exact versions and read the migration guide before upgrading.

The multiplayer framework landscape

Most famously, the choices split the problem instead of solving it.

  • Socket.IO and raw WebSocket libraries are transports. You get messages both ways and then rebuild matchmaking, authoritative state, a game loop, reconnection, and a per-engine client from scratch. It is the most flexible and the most expensive in time.
  • Photon is commercial networking centered on Unity, with cloud hosting and per-CCU pricing. It solves realtime well and hands you a vendor relationship.
  • Mirror is open source C# networking for Unity only. If your whole team lives inside Unity, it is a strong fit; the moment you want a web or mobile client from the same server code, it is not.
  • Nakama (open source, paid cloud on top) is more of a game backend: realtime, plus authentication, storage, and social features. The netcode and state sync layer is still yours.
  • PlayFab is Microsoft's commercial backend: auth, leaderboards, analytics, and hosting. It is not a realtime game simulation framework.
  • Agones is Kubernetes for game servers: fleet management, allocation, and autoscaling. You bring your own game server and netcode to run inside it.

The pattern: every famous name covers one slice, and you glue the slices together and write the delta protocol yourself. Colyseus is the option that treats the room, the state, and the matchmaking as the product.

Framework

What it gives you

What you still build

Socket.IO / ws

Bidirectional messaging over WebSocket

Matchmaking, authoritative state, game loop, reconnection, per-engine clients

Photon

Commercial cloud realtime, Unity-centric, per-CCU pricing

Nothing to operate, everything to pay for; engine lock-in

Mirror

Open source C# networking for Unity

Everything outside Unity, plus hosting and ops

Nakama

Open source backend: realtime, auth, storage, social

The state sync and simulation layer

PlayFab

Commercial backend services: auth, leaderboards, analytics

Realtime state sync and simulation

Agones

Dedicated game server fleets on Kubernetes

The netcode and the game server itself

Colyseus

Authoritative rooms, schema state sync, matchmaking, SDKs

Ops: the server is yours to run

How Colyseus is different

  • Authoritative by design. The server owns the state and the client cannot mutate it. Clients send messages that your room code validates, and the server applies the change. Cheating is not prevented, it is structurally harder.
  • Rooms as the unit of everything. Room is the core abstraction: one class, many instances, each instance holding its own clients, state, and logic. Players in room A do not see or interact with room B.
  • State sync is a schema, not an event bus. You declare the shape of the state, and Colyseus tracks property-level changes and sends only what changed, binary-encoded, on a patch interval. You never write a "send this update to everyone" line.
  • Engine-agnostic SDKs. The server speaks one protocol and official SDKs implement it for the web (TypeScript and JavaScript), Unity, MonoGame, Godot, Defold, GameMaker, Construct 3, Cocos Creator, Haxe, Flutter, Swift, and a native C SDK. The same server serves a web lobby and a Unity client.
  • One language on both sides. This is the TypeScript point: the server and the JS client share types, and 0.18 exports them across the boundary, so join options, messages, and state are checked instead of guessed.

The architecture in one paragraph

A client connects to the server and joins a room. On join, the server sends the room's schema types followed by the full state; after that it sends binary patches describing only what changed, at a configurable patch rate (default 50 ms, 20 fps). The client cannot mutate state: it sends messages, your room's handlers validate them and mutate the state, and the framework re-synchronizes. On the client you subscribe to collections and instances with onAdd, onChange, and onRemove, or to individual properties with listen, and the UI stays in sync without you diffing anything.

A minimal TypeScript server

Scaffold the project the official way:

scaffold and run
npm create colyseus-app@latest ./my-server
cd my-server
npm install
npm start

# the dev server binds a WebSocket transport to port 2567
# the playground page lists live rooms while the server runs

The state is where the schema rules apply. The modern API is the schema() builder, which needs no compiler flags (the legacy @type() decorator style requires two tsconfig.json options). SchemaType derives the TypeScript type from the value:

// src/rooms/MyState.ts
import { schema, t, type SchemaType } from "@colyseus/schema";

export const Player = schema(
  {
    name: t.string().default(""),
    x: t.number().default(0),
    y: t.number().default(0),
  },
  "Player"
);
export type Player = SchemaType<typeof Player>;

export const MyState = schema(
  {
    players: t.map(Player),
  },
  "MyState"
);
export type MyState = SchemaType<typeof MyState>;

The room extends the Room generic, which you can type for full safety. Messages are declared as a messages map; each handler receives the client and the payload. Never reassign state, mutate it, because the sync engine tracks the changes:

// src/rooms/MyRoom.ts
import { Room, type Client } from "colyseus";
import { MyState, Player } from "./MyState";

export class MyRoom extends Room<{ state: MyState }> {
  maxClients = 4;

  state = new MyState();

  messages = {
    move: (client: Client, data: { x: number; y: number }) => {
      const player = this.state.players.get(client.sessionId);
      if (player && Number.isFinite(data.x) && Number.isFinite(data.y)) {
        player.x = data.x;
        player.y = data.y;
      }
    },
  };

  onCreate() {}

  onJoin(client: Client, options: { name?: string }) {
    const player = new Player();
    player.name = options.name || "player-" + client.sessionId;
    this.state.players.set(client.sessionId, player);
  }

  onLeave(client: Client) {
    this.state.players.delete(client.sessionId);
  }

  onDispose() {}
}

maxClients fills and auto-locks the room at four players, autoDispose disposes it when the last player leaves, and the lifecycle hooks run the show: onCreate, onAuth, onJoin, onDrop, onReconnect, onLeave, and onDispose.

Expose the room type so clients can reach it:

// src/app.config.ts
import { defineServer, defineRoom } from "colyseus";
import { MyRoom } from "./rooms/MyRoom";

export const server = defineServer({
  rooms: {
    my_room: defineRoom(MyRoom),
  },
});

A minimal TypeScript client

The client SDK is @colyseus/sdk, and 0.18 lets you import the server's types for full-stack safety:

// client.ts
import { Client, Callbacks } from "@colyseus/sdk";
import type { server } from "../server/src/app.config";

const client = new Client<typeof server>("http://localhost:2567");

const room = await client.joinOrCreate("my_room", { name: "systhoughts" });

const callbacks = Callbacks.get(room);

callbacks.onAdd("players", (player, sessionId) => {
  // spawn a sprite for sessionId at player.x, player.y
  callbacks.onChange(player, () => {
    // update the sprite transform
  });
});

callbacks.onRemove("players", (player, sessionId) => {
  // despawn the sprite for sessionId
});

room.send("move", { x: 10, y: 20 });

Messages are MsgPack-encoded and can be string or number typed. For ad-hoc data there is sendBytes with raw Uint8Array, and request() for request/response calls such as "get-profile", with a 10-second default timeout.

Matchmaking without writing a lobby

The client SDK exposes the four join modes, all on the same Client:

  • joinOrCreate(roomName, options) joins an existing room or creates one; locked and private rooms are ignored.
  • create(roomName, options) always creates a new room.
  • join(roomName, options) joins an existing room and fails if none is available.
  • joinById(roomId, options) joins a specific room by ID, which is how invite links work; private rooms are joinable this way.

Room visibility is controlled by the locked, private, and unlisted flags plus lock(), unlock(), and setMatchmaking(). Rooms fill up to maxClients and auto-lock, seatReservationTimeout (15 s default) waits for a client to actually join after reserving a seat, and maxMessagesPerSecond caps client spam, disconnecting anyone who exceeds it. Queue rooms, lobby rooms, and ranked queues are documented patterns built on the same primitives. Reconnection is part of the flow: a dropped client gets a reconnection token and can come back to the same room state.

State sync: what the schema costs

Sync is a handshake plus deltas. On join the server sends the schema types that compose the room's state and then the full state; reconnecting clients skip the full handshake. After that, every mutation is tracked per property through an internal change tree, and the changed properties are encoded and sent at the patch interval. Each schema instance gets a refId over the wire, which is how the client knows what was added, removed, or updated.

The constraints are real and worth knowing before the schema grows:

  • A single schema type holds up to 63 serialized fields. Past that, nest types.
  • Multi-dimensional arrays are not supported; encode them as flat arrays.
  • NaN encodes as 0, Infinity as the largest safe integer, and null strings as an empty string.
  • Encoding order is declaration order, and the server and client definitions must match exactly, because the protocol has no field names per patch.

What 0.18 adds for netcode

The headline feature of the current line is prediction-ready netcode: client prediction, rollback, interpolation, and lag compensation are now documented primitives instead of folklore. setTimestep(callback, delay?) runs a variable-step game loop at the measured wall-clock delta, while setFixedTimestep(step, tickRate) advances at a constant step and advertises the tick rate to predicting clients. Both live on the room. The old setSimulationInterval() still works as a forwarder.

Around that, 0.18 deepens the first-party tooling: typed messages and join options flowing from app.config.ts to the SDK, a validate() helper that checks incoming messages against a Zod schema (invalid input disconnects the sender with close code 4002), request/response with explicit ctx.reject() for business-logic no answers, database services, and a Bun transport (@colyseus/bun-websockets). The auth module and the monitoring panel are not new arrivals — they are first-party packages from earlier lines that 0.18 now sets up by default.

What breaks first

  • Version churn. Colyseus has migrated APIs between 0.15 and 0.18: decorators versus the schema() builder, client callbacks versus the newer Callbacks.get(room), defineServer and defineRoom. Old guides and tutorials will not compile. Pin versions and follow releases the way the release-tracking workflow describes.
  • The event loop is shared. Rooms run on one Node.js process by default, and a room loop defaults to 60 fps on the same thread as everything else. Long physics ticks stall the whole process; the simulation has to be bounded or moved off the loop.
  • Scaling is your job. Vertical scaling is easy; horizontal scaling means a Redis presence driver and a load balancer, and the project's own story goes from a single process to fleets of them. The docs cover Traefik and Colyseus Cloud, but you still operate the boxes.
  • SDK and server must agree. The Unity SDK, Haxe SDK, and others track the server line; mismatch them and schema decoding breaks (the field-order rule from above bites in practice).
  • It is not your whole backend. Auth and storage are first-party modules, but you still install and configure them alongside the server, the secrets, and the deployment. For deployment sizing, the Compose vs Kubernetes post is the same decision this server faces.

When not to use Colyseus

  • Your team is Unity-only and wants C# end to end. Mirror is the honest fit; Colyseus's C# SDK is fine, but you are choosing a Node.js server you will have to operate.
  • You want a managed backend with auth, storage, and social out of the box. Nakama's cloud or PlayFab is the answer if you will not self-host.
  • You are operating fleets at scale on Kubernetes. Agones manages the servers; pair it with Colyseus's room logic or your own netcode.
  • The game is two players over WebRTC. A P2P data channel plus a tiny signaling server beats any authoritative server for that shape.
  • You only need leaderboards and queues, not realtime. That is a database problem, not a game server problem.

Which should you pick?

  • Choose Colyseus when you want an authoritative server, control the stack, and ship clients to more than one engine from one server codebase. The TypeScript story is the differentiator: the same types on both sides, no codegen to babysit.
  • Choose a transport plus your own netcode when the game is small, the team already ships one engine, and you have weeks to spend on the delta protocol. Most projects do not.
  • Choose a commercial platform when time-to-market beats ownership and you want to pay instead of operate.
  • Choose nothing when the realtime requirement is smaller than the hype: a leaderboard, a chat channel, or a turn-based room that can rest on plain messages.

For a self-hosted, TypeScript-first multiplayer backend, Colyseus is the rare framework where matchmaking and state sync are features, not a to-do list. One room class, one schema, one process, and SDKs for whatever engine ships next.

Official sources

  • Colyseus documentation: https://docs.colyseus.io/
  • Getting started: https://docs.colyseus.io/getting-started
  • Rooms reference: https://docs.colyseus.io/room
  • State synchronization: https://docs.colyseus.io/state
  • Client SDK: https://docs.colyseus.io/sdk
  • Netcode and prediction: https://docs.colyseus.io/netcode
  • Scalability: https://docs.colyseus.io/scalability
  • Source and releases: https://github.com/colyseus/colyseus and https://github.com/colyseus/colyseus/releases
  • Unity SDK: https://github.com/colyseus/colyseus-unity-sdk
  • Our Compose vs Kubernetes post: https://systhoughts.com/posts/docker-compose-vs-kubernetes-self-hosted-apps
  • Our release-tracking workflow: https://systhoughts.com/posts/tracking-software-releases-across-forges

Are you running Colyseus, and did the schema-based sync or the matchmaking surprise you first? Drop it in the comments.

Until next time, keep your systems thoughtful.

No comments yet