| name | dev-multiplayer-server-authoritative |
| description | Server-authoritative multiplayer architecture principles. Use when designing multiplayer features. |
| category | multiplayer |
Server-Authoritative Architecture
"All gameplay logic belongs on the server. Clients only send inputs."
When to Use
Use for EVERY gameplay feature in a multiplayer game. Server authority is not optional for real-time multiplayer.
Critical Architecture Principle
┌─────────────────────────────────────────────────────────────────┐
│ SERVER-AUTHORITATIVE │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Client │ │ Client │ │ Client │ │
│ │ A │ │ B │ │ C │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ INPUT ONLY │ INPUT ONLY │ INPUT ONLY │
│ └──────────┬────────┴──────────┬────────┘ │
│ │ │ │
│ ┌───▼───────────────────▼────┐ │
│ │ COLYSEUS SERVER │ │
│ │ (SOURCE OF TRUTH) │ │
│ │ - Validates all inputs │ │
│ │ - Runs game simulation │ │
│ │ - Broadcasts state │ │
│ └───┬───────────────────┬────┘ │
│ │ STATE UPDATE │ │
│ ┌──────────┴────────┬──────────┴────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Client │ │ Client │ │ Client │ │
│ │ A │ │ B │ │ C │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ ✓ Anti-cheat built-in ✗ Client-authoritative = cheatable │
└─────────────────────────────────────────────────────────────────┘
Quick Start: Server-Authoritative Player Movement
Server Side (GameRoom.ts)
import { Room, Client } from 'colyseus';
import { Schema, type } from '@colyseus/schema';
class PlayerState extends Schema {
@type('number') x = 0;
@type('number') y = 0;
@type('number') z = 0;
@type('number') rotation = 0;
}
class GameRoomState extends Schema {
@type({ map: PlayerState }) players = new MapSchema<PlayerState>();
}
export class GameRoom extends Room<GameRoomState> {
onCreate() {
this.setState(new GameRoomState());
this.setSimulationInterval( .(dt));
}
() {
player = ();
player. = .() * ;
player. = .() * ;
...(client., player);
}
() {
player = ...(client.);
(!player) ;
(data.) {
:
player. = data.;
;
}
}
() {
deltaTime = dt / ;
( [sessionId, player] ..) {
(!player.) ;
speed = ;
input = player.;
(input.) player. -= speed * deltaTime;
(input.) player. += speed * deltaTime;
(input.) player. -= speed * deltaTime;
(input.) player. += speed * deltaTime;
player. = .(-, .(, player.));
player. = .(-, .(, player.));
player. = ;
}
}
() {
...(client.);
}
}
Client Side (NetworkManager.ts)
import { Client } from 'colyseus.js';
class NetworkManager {
private client: Client;
private room: any;
private inputSequence: number = 0;
async connect() {
this.client = new Client('ws://localhost:2567');
this.room = await this.client.joinOrCreate('game_room');
this.room.state.players.onAdd((player: any, sessionId: string) => {
if (sessionId === this.room.sessionId) {
this.setupLocalPlayerPrediction();
} else {
.(player, sessionId);
}
});
..( {
.(state);
});
}
() {
.++;
..({
: ,
: {
: input.,
: input.,
: input.,
: input.,
: input.,
: .,
},
});
}
}
Decision Framework
| Question | Answer |
|---|
| Who calculates player position? | Server only - client sends input (WASD) |
| Who validates shooting? | Server only - client sends aim direction |
| Who determines score? | Server only - clients just see the result |
| Can client trust its own state? | No - server is source of truth |
| What about latency? | Client-side prediction + server reconciliation |
Progressive Guide
Level 1: Basic Room Setup
import { Server } from 'colyseus';
import { WebSocketTransport } from '@colyseus/ws-transport';
import { GameRoom } from './rooms/GameRoom';
const port = Number(process.env.PORT) || 2567;
const gameServer = new Server({
transport: new WebSocketTransport({ port }),
});
gameServer.define('game_room', GameRoom);
gameServer.listen(port);
console.log(`Colyseus server listening on ws://localhost:${port}`);
Level 2: State Schema Definition
import { Schema, type, MapSchema, ArraySchema } from '@colyseus/schema';
class PlayerState extends Schema {
@type('number') x: number = 0;
@type('number') y: number = 0;
@type('number') z: number = 0;
@type('number') rotation: number = 0;
@type('string') team: string = 'orange';
@type('number') score: number = 0;
}
class PaintData extends Schema {
@type('number') x: number;
@type('number') z: number;
@type('string') : ;
}
{
({ : }) players = <>();
([]) paintSplats = <>();
() : = ;
() : = ;
() : = ;
}
Level 3: Input Validation (Anti-Cheat)
function validateInput(input: PlayerInput, player: PlayerState): boolean {
if (input.movementSpeed > 20) return false;
if (input.jumpHeight > 10) return false;
const dx = input.targetX - player.x;
const dz = input.targetZ - player.z;
const distance = Math.sqrt(dx * dx + dz * dz);
if (distance > 2) return false;
return true;
}
onMessage(client: Client, data: any) {
const player = this.state.players.get(client.sessionId);
if (!player) return;
(data. === ) {
((data., player)) {
player. = data.;
} {
.();
}
}
}
Level 4: Shooting Validation
onMessage(client: Client, data: any) {
if (data.type !== 'shoot') return;
const shooter = this.state.players.get(client.sessionId);
if (!shooter) return;
if (shooter.ink <= 0) return;
if (Date.now() - shooter.lastShotTime < 100) return;
const aim = data.aimDirection;
const aimLength = Math.sqrt(aim.x ** 2 + aim.y ** 2 + aim.z ** 2);
if (aimLength > 1.0) return;
const projectile = {
x: shooter.x,
: shooter. + ,
: shooter.,
: aim. * ,
: aim. * ,
: aim. * ,
: client.,
: shooter.,
};
..(projectile);
shooter. -= ;
shooter. = .();
}
Level 5: Hit Detection with Lag Compensation
function checkHit(shooter: PlayerState, targetId: string, aim: Vector3): boolean {
const target = this.state.players.get(targetId);
if (!target) return false;
const shotTime = Date.now();
const latency = this.getClientLatency(shooter.sessionId);
const rewindTime = shotTime - latency;
const historicalPosition = this.getPositionHistory(targetId, rewindTime);
if (!historicalPosition) return false;
return this.raycastHits(shooter, historicalPosition, aim);
}
private positionHistory: Map<string, Array<{time: number, x: , : , : }>> = ();
() {
now = .();
( [sessionId, player] ..) {
(!..(sessionId)) {
..(sessionId, []);
}
history = ..(sessionId)!;
history.({ : now, : player., : player., : player. });
(history. > && history[]. < now - ) {
history.();
}
}
}
Client-Side Prediction (for responsiveness)
Client still feels responsive by predicting locally:
class LocalPlayerController {
private pendingInputs: Array<{ input: PlayerInput; sequence: number }> = [];
update(deltaTime: number) {
const input = this.getCurrentInput();
this.predictedPosition.x += input.forward * this.speed * deltaTime;
this.pendingInputs.push({
input: input,
sequence: this.nextSequence++,
});
networkManager.sendInput(input);
}
reconcile(serverState: PlayerState) {
this.pendingInputs = this.pendingInputs.filter(
(p) => p.sequence > serverState.
);
reconciledX = serverState.;
reconciledZ = serverState.;
( pending .) {
reconciledX += pending.. * ;
reconciledZ += pending.. * ;
}
.. = .(.., reconciledX, );
}
}
Testing Checklist
For EVERY gameplay feature:
Common Mistakes
| ❌ Wrong | ✅ Right |
|---|
| Client sends absolute position | Client sends input (WASD, aim) |
| Client reports "I hit player X" | Client sends aim, server validates hit |
| Server trusts client score | Server calculates score |
player.x = data.x (from client) | player.x += input.forward * speed * dt |
| Client determines paint coverage | Server tracks paint state |
Anti-Cheat Best Practices
- Validate all inputs - Reject impossible values
- Rate limit actions - Prevent spam exploits
- Track position history - Detect teleportation
- Checksum game state - Detect tampering
- Log suspicious activity - For analysis/banning
Reference