name: dev-multiplayer-colyseus-server description: Colyseus server setup, room handlers, lifecycle events, and scaling. Use when setting up multiplayer server. category: multiplayer
Colyseus Server Setup
Node.js multiplayer framework - authoritative game server with real-time state sync.
When to Use
Use when:
- Setting up Colyseus server
- Creating room handlers
- Implementing matchmaking
- Configuring server transport
Server Setup (ESM Required)
// server/index.ts - MUST use ESM
import { Server } from 'colyseus';
import { createServer } from 'http';
import express from 'express';
import { WebSocketTransport } from '@colyseus/ws-transport';
import { GameRoom } from './rooms/GameRoom';
const port = Number(process.env.PORT) || 2567;
const app = express();
// CORS middleware
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
next();
});
const httpServer = createServer(app);
const gameServer = new Server({
transport: new WebSocketTransport({ server: httpServer }),
});
gameServer.define('game_room', GameRoom);
gameServer.listen(port);
console.log(`Colyseus server listening on wss://localhost:${port}`);
CRITICAL: Server MUST use "type": "module" in package.json. Do NOT use CommonJS.
Room Handler Definition
import { Room, Client } from 'colyseus';
import { Schema, type, MapSchema } from '@colyseus/schema';
export class GameRoom extends Room<GameRoomState> {
onCreate(options: any) {
this.setState(new GameRoomState());
console.log(`[GameRoom] Created: ${this.roomId}`);
// Simulation tick (deltaTime in seconds)
this.setSimulationInterval((deltaTime) => {
this.update(deltaTime);
});
// One-time event
this.clock.setTimeout(() => this.endMatch(), 5000);
// Repeated event
this.clock.setInterval(() => this.checkCondition(), 100);
}
onJoin(client: Client, options: any) {
const player = new PlayerState();
player.clientId = client.sessionId;
this.state.players.set(client.sessionId, player);
client.send('welcome', { playerId: client.sessionId });
}
onLeave(client: Client, consented: boolean) {
this.state.players.delete(client.sessionId);
}
onMessage(client: Client, data: any) {
switch (data.type) {
case 'player_input':
this.handleInput(client, data);
break;
}
}
onDispose() {
console.log(`[GameRoom] Disposed: ${this.roomId}`);
}
}
Message Broadcasting
// Broadcast to all clients
this.broadcast('game_event', { data: 'value' });
// Send to specific client
client.send('personal_event', { data: 'value' });
// Send to all except sender
this.broadcast('game_event', { data: 'value' }, [client.sessionId]);
Server Definition Options
// Filter rooms by options for matchmaking
gameServer
.define('battle', BattleRoom)
.filterBy(['mode', 'map']);
// Sort rooms for matchmaking priority
gameServer
.define('battle', BattleRoom)
.sortBy({ clients: -1 }); // Most players first
// Enable realtime listing
gameServer
.define('battle', BattleRoom)
.enableRealtimeListing();
// Listen to lifecycle events
gameServer
.define('chat', ChatRoom)
.on('create', (room) => console.log('Room created'))
.on('dispose', (room) => console.log('Room disposed'))
.on('join', (room, client) => console.log('Client joined'))
.on('leave', (room, client) => console.log('Client left'));
Transport Configuration
import { WebSocketTransport } from '@colyseus/ws-transport';
new WebSocketTransport({
server, // HTTP server instance
pingInterval: 30000, // Ping interval (ms)
pingMaxRetries: 3, // Max retries before disconnect
});
// Development: simulate latency
if (process.env.NODE_ENV !== 'production') {
gameServer.simulateLatency(200);
}
Scaling with Redis
import { Server, RedisPresence, RedisDriver } from 'colyseus';
const gameServer = new Server({
presence: new RedisPresence(), // Multi-process communication
driver: new RedisDriver(), // Room storage across processes
});
Best Practices
- Always use ESM -
"type": "module"in package.json - Validate all inputs - Never trust client data
- Rate limit actions - Prevent spam exploits
- Log room lifecycle - For debugging
- Use Schema for state - Efficient binary serialization
Common Mistakes
| ❌ Wrong | ✅ Right |
|---|---|
require/module.exports | import/export with "type": "module" |
Server using colyseus.js | Server uses colyseus package |
| Trusting client positions | Validate all inputs server-side |
Reference
Room Lifecycle Best Practices (Updated 2026-01-28)
From arch-003 retrospective - proven patterns for Colyseus room implementation.
Lifecycle Event Order
onCreate (once) → onAuth (per client) → onJoin (per client) → onMessage (loop)
→ onLeave (per client) → onDispose (once, when no clients)
onCreate Implementation Pattern
import { Room, Client } from 'colyseus';
import { MyRoomState } from '../schema/MyRoomState';
export class MyRoom extends Room<MyRoomState> {
maxClients = 64; // Set on class for 64-player FFA
onCreate(options: any) {
// 1. Initialize state
this.setState(new MyRoomState());
// 2. Set patch rate (20Hz = 50ms for multiplayer games)
this.setPatchRate(50); // 50ms = 20 ticks/sec
// 3. Set up simulation interval (optional game loop)
this.setSimulationInterval((deltaTime) => {
this.gameLoop(deltaTime);
});
// 4. Register message handlers
this.onMessage('input', (client, data) => {
this.handlePlayerInput(client, data);
});
console.log(`[Room ${this.roomId}] Created with options:`, options);
}
onAuth(client: Client, options: any): boolean | Promise<any> {
// Return true to allow, false/error to reject
// Can return Promise for async validation
return true;
}
onJoin(client: Client, options: any) {
console.log(`[Room ${this.roomId}] Client joined: ${client.sessionId}`);
// Create player state
const player = new PlayerState();
player.sessionId = client.sessionId;
this.state.players.set(client.sessionId, player);
// Send welcome message
client.send('welcome', { sessionId: client.sessionId });
}
onLeave(client: Client, consented: boolean) {
console.log(`[Room ${this.roomId}] Client left: ${client.sessionId} (consented: ${consented})`);
// Remove player from state
this.state.players.delete(client.sessionId);
}
onDispose() {
console.log(`[Room ${this.roomId}] Disposed`);
}
private gameLoop(deltaTime: number) {
// Game logic here
}
}
Room Configuration Best Practices
| Configuration | Value | Purpose |
|---|---|---|
maxClients | 64 | FFA mode capacity |
patchRate | 50ms | 20Hz state sync for multiplayer |
autoDispose | true | Cleanup when empty (default) |
setSimulationInterval | 16.6ms | 60Hz game loop (optional) |
Message Handler Pattern
// Specific message type
this.onMessage('move', (client, data) => {
const player = this.state.players.get(client.sessionId);
if (player) {
player.x = data.x;
player.y = data.y;
}
});
// Catch-all for other messages
this.onMessage('*', (client, type, data) => {
console.log(`Unhandled message: ${type}`, data);
});
Sources:
- https://docs.colyseus.io/colyseus/server/room/
- Learned from arch-003 retrospective (2026-01-28)