Colyseus Multiplayer
Build authoritative real-time multiplayer servers with Colyseus 0.17+. Use when implementing rooms, schema state sync, client message validation, matchmaking, authentication, reconnection handling, or server-side anti-cheat constraints. Keywords: colyseus, room lifecycle, schema, multiplayer, websocket, matchmaking, onJoin, onLeave, onDrop, allowReconnection.
npx skills add github:pantheon-org/tekhne --skill specialized--colyseus-multiplayerPhilosophy
- Treat every client as untrusted: the server decides, the client only asks.
- Validate before you mutate: parse the payload, check ownership and rules, then change state.
- Plan for dropped connections as the normal case, not the exception.
- Keep synchronised state in Schema and keep matchmaking metadata honest about the room.
When to Use
- Building server-authoritative multiplayer game backends with Colyseus 0.17+.
- Defining rooms, Schema state and lifecycle hooks (
onCreate,onJoin,onLeave,onDrop). - Writing message handlers that must validate client payloads and resist cheating.
- Configuring matchmaking metadata, authentication or reconnection policy.
When Not to Use
- Peer-to-peer networking, where there is no authoritative server.
- Single-player game architecture with no networked state.
- Client-side rendering or prediction code that never touches room logic.
Core Principles
- Server is authoritative for all game state.
- Room state changes must be validated before applying.
- Reconnection and drop handling must be explicit.
- Matchmaking metadata must reflect real room capability.
Deterministic Workflow
- Define state models in Schema.
- Implement room lifecycle hooks (
onCreate,onJoin,onLeave,onDrop). - Register message handlers with payload validation.
- Checkpoint: Verify schema sync with a test client before proceeding — confirm field changes propagate and no plain properties are used for game-critical state.
- Wire matchmaking and metadata filters.
- Add reconnection policy and timeout behavior.
- Checkpoint: Simulate a transient disconnect and confirm
allowReconnectionrestores session — verify the player is not ejected prematurely. - Verify full room behavior with local multi-client runs.
Quick Commands
Scaffold a Colyseus app
npm create colyseus-app@latest server
Expected result: runnable Colyseus project in server/.
Install and run with Bun
cd server && bun install && bun run src/index.ts
Expected result: server listening on configured port.
Run with Node scripts
cd server && npm install && npm run start
Expected result: room handlers and matchmaker initialized.
Evaluate this skill quality
pantheon-skill-auditor evaluate specialized/colyseus-multiplayer --json
Expected result: updated dimension score breakdown.
Lint this skill docs
rumdl check specialized/colyseus-multiplayer
Expected result: no rumdl violations.
Room Implementation Example
The following shows lifecycle hooks, Schema state, and reconnection handling together:
import { Room, Client } from "@colyseus/core";
import { Schema, type, MapSchema } from "@colyseus/schema";
class Player extends Schema {
@type("string") sessionId: string = "";
@type("number") x: number = 0;
@type("number") score: number = 0;
}
class GameState extends Schema {
@type({ map: Player }) players = new MapSchema<Player>();
}
export class GameRoom extends Room<GameState> {
maxClients = 4;
onCreate(options: any) {
this.setState(new GameState());
this.onMessage("move", (client, payload: { dx: number; dy: number }) => {
const player = this.state.players.get(client.sessionId);
if (!player) return;
// Server-side validation: cap movement delta
const dx = Math.max(-5, Math.min(5, payload.dx));
const dy = Math.max(-5, Math.min(5, payload.dy));
player.x += dx;
player.y += dy;
});
}
onJoin(client: Client, options: any) {
const player = new Player();
player.sessionId = client.sessionId;
this.state.players.set(client.sessionId, player);
}
async onLeave(client: Client, consented: boolean) {
if (!consented) {
// Hold the slot for up to 20 seconds on transient disconnect
const reconnection = await this.allowReconnection(client, 20);
if (!reconnection) {
this.state.players.delete(client.sessionId);
}
} else {
this.state.players.delete(client.sessionId);
}
}
onDispose() {
console.log("Room disposed");
}
}
Anti-Patterns
NEVER trust client position or score updates directly
WHY: Clients are untrusted and can be modified for cheating.
BAD: Apply payload.x and payload.score without server checks.
GOOD: Validate movement delta and compute score on server.
// BAD
this.onMessage("move", (client, payload) => {
player.x = payload.x;
player.score = payload.score;
});
// GOOD
this.onMessage("move", (client, payload: { dx: number }) => {
const player = this.state.players.get(client.sessionId);
if (!player) return;
const dx = Math.max(-5, Math.min(5, payload.dx)); // clamp server-side
player.x += dx;
// score computed exclusively by server logic, never from client
});
Consequence: Competitive integrity is lost and leaderboard data is corrupted.
NEVER mutate non-Schema fields expecting automatic sync
WHY: Only Schema-decorated fields are synchronized to clients.
BAD: Store gameplay-critical values in plain class properties.
GOOD: Keep synchronized values in Schema fields and collections.
// BAD
class Player extends Schema {
hp: number = 100; // plain property — not synced
}
// GOOD
class Player extends Schema {
@type("number") hp: number = 100; // decorated — synced automatically
}
Consequence: Clients desync and render stale or inconsistent state.
NEVER skip reconnection handling for transient disconnects
WHY: Mobile and unstable networks frequently drop short-lived connections.
BAD: Remove player immediately in onLeave for all disconnects.
GOOD: Use allowReconnection with a bounded timeout, falling back to removal only on expiry.
// BAD
async onLeave(client: Client, consented: boolean) {
this.state.players.delete(client.sessionId);
}
// GOOD
async onLeave(client: Client, consented: boolean) {
if (!consented) {
const reconnection = await this.allowReconnection(client, 20);
if (!reconnection) {
this.state.players.delete(client.sessionId);
}
} else {
this.state.players.delete(client.sessionId);
}
}
Consequence: Players are ejected from active matches unnecessarily.
NEVER expose privileged room messages to all clients
WHY: Admin or host-only actions must be authorization-gated.
BAD: Let any client trigger startMatch or kickPlayer.
GOOD: Verify role/ownership before privileged actions.
// BAD
this.onMessage("startMatch", (client) => {
this.startMatch();
});
// GOOD
this.onMessage("startMatch", (client) => {
if (client.sessionId !== this.hostSessionId) return; // guard
this.startMatch();
});
Consequence: Match flow can be hijacked by unauthorized clients.
NEVER skip payload validation on message handlers
WHY: Malformed or hostile payloads can crash the handler or corrupt shared state.
BAD: Read payload.dx without checking its shape.
GOOD: Parse the payload with a schema validator and return early when it fails.
const parsed = moveSchema.safeParse(payload);
if (!parsed.success) return;
Consequence: One bad message can break the room for every player.
NEVER accept absolute client coordinates
WHY: Absolute positions let a modified client teleport; deltas can be clamped and range-checked on the server.
BAD: player.x = payload.x.
GOOD: Accept a delta, clamp it server-side and apply it to the stored position.
Consequence: Players can move anywhere instantly and bypass level geometry.
NEVER hardcode matchmaking metadata
WHY: Metadata drives room discovery, so values that do not reflect the room’s real mode and capacity send players to the wrong rooms.
BAD: this.setMetadata({ mode: "ranked" }) regardless of the options the room was created with.
GOOD: Derive mode, region or skill values from the creation options in onCreate and keep them consistent with runtime state.
Consequence: Players are matched into rooms that do not match their request.
NEVER leave spam-prone messages without rate limiting
WHY: Unthrottled message types allow one client to flood the room and starve other players.
BAD: Process every move message the moment it arrives.
GOOD: Enforce a cooldown or per-client message budget on spam-prone message types.
Consequence: A single client can degrade latency for the whole room.
NEVER use an unbounded or mismatched reconnection window
WHY: Seats held too long block other players, and a window too short for the mode ejects players unfairly.
BAD: Hold a ranked slot for minutes, or pass no timeout to allowReconnection.
GOOD: Use a bounded grace period for the mode: 20-30 seconds casual, 10-15 seconds ranked, strict for tournaments.
Consequence: Rooms stay half empty, or players lose matches to brief network drops.
References
| Topic | Reference | When to Use |
|---|---|---|
| Room lifecycle and state | references/room-lifecycle-and-state.md | Schema setup, lifecycle hook order, state synchronisation |
| Message validation and security | references/message-validation-and-security.md | Server-side payload validation and anti-cheat patterns |
| Matchmaking and reconnection | references/matchmaking-and-reconnection.md | allowReconnection, metadata filters and timeout policy |
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
No eval scenarios available.
Matchmaking and Reconnection
Use this reference for room discovery, filtering, and reconnect workflows.
Matchmaking Rules
- Set room metadata in
onCreatefor mode/region/skill filters. - Use metadata and seat availability in matchmaking selection.
- Keep metadata consistent with room runtime state.
Reconnection Pattern
- In
onDrop, mark player as disconnected. - Allow reconnection for a bounded timeout.
- Restore session if token is valid.
- Expire and remove player if timeout passes.
Example Timeout Policy
- Casual match: 20-30 seconds grace.
- Ranked match: 10-15 seconds grace.
- Tournament: strict policy with admin override.
Operational Checks
- Reconnected clients rebind to prior player state.
- Duplicate sessions for same player are rejected.
- Expired reconnection tokens are invalidated.
Message Validation and Security
Use this reference when defining room message handlers.
Validation Pattern
- Parse payload with schema validation (for example Zod).
- Enforce role or ownership constraints.
- Enforce gameplay constraints (cooldowns, ranges, turn order).
- Apply validated mutation to server state.
Handler Skeleton
room.onMessage("move", (client, payload) => {
const parsed = moveSchema.safeParse(payload);
if (!parsed.success) return;
const player = room.state.players.get(client.sessionId);
if (!player) return;
if (!isValidDelta(player, parsed.data)) return;
player.x = parsed.data.x;
player.y = parsed.data.y;
});
Security Rules
- Never trust client-derived score, inventory, or authority flags.
- Keep anti-cheat checks on server.
- Rate-limit spam-prone message types.
Room Lifecycle and State
Use this reference for room hook sequencing and synchronized state design.
Lifecycle Sequence
onCreate(options)initializes state and room metadata.onAuth(client, options, context)validates join eligibility.onJoin(client, options, auth)adds player state.onDrop(client)handles transient disconnect.onLeave(client, code)removes player permanently.onDispose()performs cleanup/persistence.
Schema Guidance
- Use Schema classes for every client-visible gameplay value.
- Prefer
MapSchema<Player>keyed bysessionIdfor per-player state. - Keep non-synced operational details outside gameplay state.
Verification Checklist
- Joining a room creates exactly one player entry.
- Leaving removes that entry.
- Client receives updates only for Schema mutations.