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.
71
89%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Use this skill for server-authoritative multiplayer game backends using Colyseus.
Do not use this skill for peer-to-peer networking or single-player game architecture.
onCreate, onJoin, onLeave, onDrop).allowReconnection restores session — verify the player is not ejected prematurely.npm create colyseus-app@latest serverExpected result: runnable Colyseus project in server/.
cd server && bun install && bun run src/index.tsExpected result: server listening on configured port.
cd server && npm install && npm run startExpected result: room handlers and matchmaker initialized.
sh skills/agentic-harness/skill-quality-auditor/scripts/evaluate.sh colyseus-multiplayer --jsonExpected result: updated dimension score breakdown.
bunx markdownlint-cli2 "skills/colyseus-multiplayer/**/*.md"Expected result: no markdownlint violations.
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;
});
}
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");
}
}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.
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.
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.
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.
allowReconnection, metadata filters, lobby patterns