mcpField
C
colyseus

colyseus

github.com/colyseusDevelopment3243 installsUpdated 10w ago
Repository

About colyseus

colyseus is a skill that guides coding agents in using the authoritative multiplayer framework Colyseus for Node.js. It emphasizes checking installed versions first and provides version-specific guidance, especially for APIs changed in 0.17 and 0.18.

Key features

  1. 01Version-aware API guidance
  2. 02Colyseus server and room patterns
  3. 03Schema and synchronized state guidance
  4. 04Client callbacks and reconnection patterns
  5. 05Migration pitfalls and reference pointers

Use cases

  1. 01Checking a project's Colyseus version before editing
  2. 02Implementing rooms and synchronized state
  3. 03Updating code for Colyseus 0.18
  4. 04Handling client state callbacks and reconnection

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/colyseus/skill --skill colyseus

After installing, type/colyseusin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

.gitignore
sources.json
README.md
package.json
LICENSE
.github/workflows/
sync.yml
references/
netcode.md
schema.md
room.md
client-sdk.md
tools/
sync.mjs

Details

From the project README

Colyseus

Colyseus is an authoritative multiplayer framework for Node.js. The server owns the state; clients send input and receive binary delta patches.

The APIs moved substantially in 0.17 and again in 0.18, and most model training data predates both. Assume your recall of Colyseus is out of date, and check before you write.

Step 1: establish the version. Do not skip this.

Read the project's package.json (or pnpm-lock.yaml / yarn.lock) for colyseus and @colyseus/schema.

InstalledWhat to do
colyseus 0.18.x, @colyseus/schema 5.xUse this skill's guidance directly.
0.17.xRead https://docs.colyseus.io/migrating/0.18.md to see what does not yet apply, then follow 0.17 idioms.
0.16.x or olderFollow the version's own docs. Do not apply anything below. Version-specific sites: https://0-16-x.docs.colyseus.io/.
Nothing installed yetScaffold with npm create colyseus-app@latest, which is 0.18.

If the version is ambiguous, ask rather than guess. Writing 0.18 APIs into a 0.16 project produces code that fails at import time, and writing 0.16 APIs into a 0.18 project produces code that fails at runtime or silently desynchronizes.

Step 2: what memory gets wrong

Left column is what an out-of-date recall produces. Check your own output for it, and recognize it in existing projects.

Do not writeWrite insteadWhy
import { Client } from "colyseus.js"import { Client } from "@colyseus/sdk"The JS client package was renamed.
class MyRoom extends Room<MyState>class MyRoom extends Room with state = new MyState()The generic is now an options object, Room<{ state, metadata, client }>. A bare state type no longer type-checks.
Client<UserData, AuthData>Client<{ userData: UserData, auth: AuthData }>Same change on the client type.
onCreate() { this.onMessage("x", fn) }messages = { x: fn } as a class propertyThe declarative map is the primary API. this.onMessage() still works and returns an unbind function.
@type("string") name: string onlyschema({ name: t.string() }, "MyState")The builder is the primary style and needs no compiler flags. Decorators remain supported.
schema({ name: "string" })schema({ name: t.string() })Raw string field types throw at definition time in schema 5. They remain valid as collection child types: t.array("string").
client.idclient.sessionIdclient.id was removed in 0.18.
async onLeave(client, consented: boolean)onLeave(client, code: CloseCode)The second argument is a close code. 4000 is truthy, so if (!consented) never runs and reconnection is silently never allowed. See the reconnection shape below.
Protocol.WS_CLOSE_CONSENTEDCloseCode.CONSENTED from "colyseus"Protocol.WS_* moved to CloseCode. 4000–4010 are reserved; custom codes use 4011–4999.
this.setSeatReservationTime(15)this.seatReservationTimeout = 15The method became a property.
this.setMetadata({ status: "x" }) expecting a mergethis.setMetadata({ ...this.metadata, status: "x" })0.18 replaces metadata wholesale instead of shallow-merging. Same for setMatchmaking({ metadata }).
@filter() / @filterChildren()@view() on the field, client.view = new StateView() in onJoinPer-client filtering is opt-in through views.
this.setSimulationInterval(fn)this.setTimestep(fn)Renamed. The old name forwards, so it is not broken, but new code should use setTimestep(). For predicted games use setFixedTimestep(step, tickRate).
A Schema with 64+ fieldsMove the overflow onto a nested child Schema0.18 caps a Schema at 63 fields and throws at definition time. Inherited fields count.
@colyseus/fossil-delta-serializer@colyseus/schemaRemoved in 0.18.

Two more that bite in production:

  • Playground returns 404 on production mounts (NODE_ENV=production) unless you pass a guard middleware via its use option.
  • @colyseus/auth password hashing changed in 0.18. Pre-0.18 hashes cannot be verified by the new hasher, so existing email/password users must reset their passwords. The legacy scrypt hasher and its global AUTH_SALT are deprecated.
Shapes that changed, not just names

Three places where pre-0.17 code has a different structure, so a rename table cannot show the fix. The left side is what to recognize.

Server definition. new Server() + gameServer.define(), and the @colyseus/tools config({ initializeGameServer }) wrapper, both became defineServer():

-import config from "@colyseus/tools";
-export default config({
-    initializeGameServer: (gameServer) => {
-        gameServer.define("my_room", MyRoom);
-    },
-    initializeExpress: (app) => {},
-});
+import { defineServer, defineRoom } from "colyseus";
+export default defineServer({
+    rooms: { my_room: defineRoom(MyRoom) },
+    express: (app) => {},
+});

State callbacks. The handler is no longer a callable wrapper around state objects. It is an object whose methods take the collection name, or the instance:

-import { Client, getStateCallbacks } from "colyseus.js";
-const $ = getStateCallbacks(room);
-$(room.state).players.onAdd((player, sessionId) => {
-    $(player).listen("x", (value, previous) => { /* ... */ });
+import { Client, Callbacks } from "@colyseus/sdk";
+const callbacks = Callbacks.get(room);
+callbacks.onAdd("players", (player, sessionId) => {
+    callbacks.listen(player, "x", (value, previous) => { /* ... */ });
 });

callbacks.onRemove("players", fn), callbacks.onChange(player, fn), callbacks.bindTo(player, sprite), and callbacks.listen("phase", fn) for a root property all follow the same form. Every registration returns an unbind function. onAdd fires immediately for items already present.

Reconnection. It moved out of onLeave into its own hooks, and the client no longer reconnects by hand:

-async onLeave(client, consented: boolean) {
-    try {
-        if (consented) throw new Error("consented leave");
-        await this.allowReconnection(client, 20);
-    } catch (e) {
-        this.state.players.delete(client.sessionId);
-    }
-}
+onDrop(client: Client, code: number) {
+    this.allowReconnection(client, 30); // no await; the outcome routes to the hooks below
+}
+onReconnect(client: Client) { /* mark the player connected again */ }
+onLeave(client: Client, code: number) {
+    this.state.players.delete(client.sessionId); // gone for good
+}
-room.onLeave(async () => {
-    room = await client.reconnect(room.reconnectionToken); // new instance: re-attach everything
-});
+room.onDrop(() => showReconnectingUI());      // the SDK is already retrying
+room.onReconnect(() => hideReconnectingUI()); // same instance, callbacks intact
+room.onLeave((code) => {
+    if (code === CloseCode.FAILED_TO_RECONNECT) offerRejoin();
+});

client.reconnect(token) still exists for a page reload. It returns a new Room, so re-attach callbacks in that one case.

How the lifecycle runs

Room: onCreate → … → onDispose. Per client: onAuth → onJoin → (onDrop → onReconnect)* → onLeave.

  1. Any non-consented close fires onDrop on both sides. The server calls allowReconnection(client, seconds); the SDK retries with backoff on the same room instance.
  2. Success fires onReconnect on both sides. The server sends a full snapshot that is reconciled in place: existing callbacks stay attached, onAdd and onRemove fire for what changed while offline, queued room.send() calls flush.
  3. Timeout or denial fires onLeave on both sides; the client receives FAILED_TO_RECONNECT (4003). onLeave means gone for good: remove the player there, never in onDrop.

room.leave() (CONSENTED, 4000) skips onDrop. A room without onDrop receives every disconnect in onLeave and may call allowReconnection() there when code !== CloseCode.CONSENTED.

Step 3: the canonical 0.18 shapes

Each block ends with the reference section that documents it in full. The references/<file> § "Heading" form names a heading in a bundled reference; jump to it with grep -n rather than reading the whole file.

Server

src/index.ts:

import { listen } from "@colyseus/tools";
import app from "./app.config.js";

listen(app);

src/app.config.ts:

import { defineServer, defineRoom } from "colyseus";
import { MyRoom } from "./rooms/MyRoom.js";

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

export default server;

The named export is what a TypeScript client imports for end-to-end types. Reference: references/room.md § "Defining a Room"; server options, transport, presence and driver: https://docs.colyseus.io/server.md.

State

src/rooms/schema/MyRoomState.ts:

import { schema, t, type SchemaType } from "@colyseus/schema";

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

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

schema() returns a real class, so new MyRoomState() and instanceof both work; the export type alias makes the same name usable as a type. Pass the name argument for every structure: it is what the serializer, debug output and code generation identify the class by. Collections start empty without a default. Reference: references/schema.md § "Defining a Schema structure"; field types and bandwidth: references/schema.md § "Data Types"; per-client filtering: references/schema.md § "View tags".

Room

src/rooms/MyRoom.ts:

import { Room, Client, CloseCode, validate } from "colyseus";
import { z } from "zod";
import { MyRoomState, Player } from "./schema/MyRoomState.js";

export class MyRoom extends Room {
    maxClients = 4;
    state = new MyRoomState();

    messages = {
        // validate() rejects a malformed payload before it reaches the handler
        move: validate(z.object({ x: z.number(), y: z.number() }), (client: Client, { x, y }) => {
            const player = this.state.players.get(client.sessionId);
            player.x = x;
            player.y = y;
        }),
        // return a value to answer a client's room.request()
        "get-profile": async (client: Client, { userId }: { userId: number }) => {
            return await db.profiles.findById(userId);
        },
    };

    onCreate(options: any) {}

    onJoin(client: Client, options: any) {
        this.state.players.set(client.sessionId, new Player());
    }

    onDrop(client: Client, code: CloseCode) {
        this.allowReconnection(client, 30);
        this.state.players.get(client.sessionId).connected = false;
    }

    onReconnect(client: Client) {
        this.state.players.get(client.sessionId).connected = true;
    }

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

    onDispose() {}
}

Reference: references/room.md § "Message Handling"; reconnection: references/room.md § "Complete Server Example"; timers: references/room.md § "Clock".

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

// typeof server types room names, options, state and messages end to end
const client = new Client<typeof server>("http://localhost:2567");
const room = await client.joinOrCreate("my_room");

const callbacks = Callbacks.get(room);
callbacks.onAdd("players", (player, sessionId) => {
    callbacks.listen(player, "x", (x) => { /* move the sprite */ });
});
callbacks.onRemove("players", (player, sessionId) => { /* destroy the sprite */ });

room.send("move", { x: 1, y: 2 });
const profile = await room.request("get-profile", { userId: 42 });

room.onDrop(() => { /* show reconnecting UI */ });
room.onReconnect(() => { /* hide it */ });
room.onLeave((code) => {
    if (code === CloseCode.FAILED_TO_RECONNECT) { /* offer a rejoin */ }
});

Reference: references/client-sdk.md § "State Sync Callbacks (Recommended)"; references/client-sdk.md § "Request/Response"; references/client-sdk.md § "Automatic Reconnection"; the page-reload case: references/client-sdk.md § "Manual Reconnection".

Step 4: where to look

Read the bundled reference before writing non-trivial code in that area. They are the full documentation text, generated from the site, 1,200–2,200 lines each; the section pointers above land you in the right part.

TaskRead
Defining or changing synchronized state, collections, filteringreferences/schema.md
Room lifecycle, messages, request/response, timers, reconnectionreferences/room.md
Connecting, joining, sending, state callbacks, reconnection on the clientreferences/client-sdk.md
Client prediction, reconciliation, lag compensation, fixed timestepreferences/netcode.md

Everything else is on the site, and every page is available as raw markdown by appending .md:

TopicURL
Matchmaking, lobby, queue, visibilityhttps://docs.colyseus.io/matchmaker.md
HTTP routes: createEndpoint, createRouter, middleware, CORShttps://docs.colyseus.io/server/http-routes.md
Authenticationhttps://docs.colyseus.io/auth.md
Database and persistencehttps://docs.colyseus.io/database.md
Server config, transport, presence, driverhttps://docs.colyseus.io/server.md
Scaling outhttps://docs.colyseus.io/scalability.md
Deploymenthttps://docs.colyseus.io/deployment.md
Colyseus Cloudhttps://docs.colyseus.io/cloud.md
Unit testing with @colyseus/testinghttps://docs.colyseus.io/tools/unit-testing.md
Playground, monitor, load testinghttps://docs.colyseus.io/tools.md
Command pattern (@colyseus/command)https://docs.colyseus.io/recipes/command-pattern.md
Upgrading from an earlier versionhttps://docs.colyseus.io/migrating/0.18.md
Full index of every pagehttps://docs.colyseus.io/llms.txt

When a task needs a page that is neither bundled nor listed, fetch https://docs.colyseus.io/llms.txt and pick from it rather than guessing a URL.

Symptoms in an existing project

What pre-0.17 code looks like when it runs against 0.18. The silent ones are the ones to look for.

You seeCauseFix
Players never get to reconnect; no erroronLeave's second argument is a close code, so if (!consented) never runsonDrop(), or compare code !== CloseCode.CONSENTED
Metadata fields vanish after setMetadata({ x })0.18 replaces instead of mergingSpread this.metadata
client.id is undefinedRemovedclient.sessionId
Cannot find module "colyseus.js", or getStateCallbacks is not exportedPackage renamed, handler API changed@colyseus/sdk, Callbacks.get(room)
Throws at schema definition mentioning "string" or another type nameRaw string field typest.string() and friends
Throws at schema definition about the field count63-field cap, parents includedNest a child schema
Room<MyState> no longer type-checksThe generic is an options objectDrop it and assign state = new MyState()
Playground 404 in productionLocked down in 0.18Pass a guard middleware via use

Working rules

  • The server is authoritative. Client code must never be the only thing enforcing a rule; validate in the room.
  • Only the server mutates state. Clients send messages or input and react to callbacks.
  • Prefer the declarative messages map over imperative onMessage() calls, and validate payloads. Never trust a sessionId supplied in a message body; use the client argument the handler receives.
  • Schema is for synchronized state only. Messages and request payloads are plain objects.
  • Keep synchronized state small. It is broadcast on every patch, so anything a client does not need belongs outside the schema.
  • After changing state structure, check the 63-field cap on every affected class, parents included.
  • Use this.clock.setTimeout() and this.clock.setInterval(), not the globals; room timers are cleared on dispose.
  • Remove a player in onLeave, never in onDrop; a dropped client may come back.

Comments

Markdown supported