| name | engineering-game-backend-architecture |
| description | Use when building game servers, WebSocket connections, room management, game tick loops, or server-authoritative architecture with Elysia. Triggers: game server, WebSocket, rooms, tick loop, server architecture, multiplayer backend. |
Game Backend Architecture
Purpose
WebSocket + REST patterns, room/session management, game tick loops, genre-agnostic server design using Elysia + Bun. Covers server-authoritative architecture, fixed-timestep simulation, room lifecycle, typed message protocols, and horizontal scaling via Redis pub/sub.
When to Use
Trigger: game server, backend architecture, WebSocket, room management, game loop, tick system, server-authoritative, real-time, Elysia game server, multiplayer backend, session management, game tick, fixed timestep server
Prerequisites
postgres-game-schema — database layer for persistent game state
redis-game-patterns — caching, pub/sub, presence, and ephemeral state
Core Principles
"I'm not interested in creating a game that does things for the player. I want to create a simulation that responds to the player." — Will Wright
"A game is a series of interesting decisions." — Sid Meier
The server is a simulation system that responds to player intentions and produces meaningful state changes. Every design decision should preserve determinism, enforce authority, and keep the door open for any genre.
- Server-authoritative: the client sends intentions (actions), the server validates and executes. Never trust the client.
- Separate simulation loop from I/O: the game tick runs on a fixed timestep independent of network or render cycles.
- Fixed timestep for deterministic game logic: same inputs at the same tick always produce the same output, enabling replays, rollback, and offline catchup.
- Room-based architecture for multiplayer isolation: each room encapsulates its own state, tick loop, and player set. Rooms are the unit of scaling.
- Stateless REST for CRUD, WebSocket for real-time state: use REST for durable operations (create account, save inventory, fetch leaderboard) and WebSocket for ephemeral, high-frequency updates (game state, actions, presence).
- Event-driven: game actions produce events, systems consume them. Decouple producers from consumers for testability and extensibility.
- Horizontal scaling via Redis pub/sub: multiple Elysia instances share state through Redis. Any instance can handle any player; cross-server communication uses pub/sub channels.
Step-by-Step Instructions
1. Define the Message Protocol
Start with templates/message-types.ts. Define discriminated unions for every client-to-server and server-to-client message. This is the contract between frontend and backend — get it right first.
import type { ClientMessage, ServerMessage } from './templates/message-types';
2. Set Up the Elysia Server
Use boilerplate/server.ts as the entry point. It wires together:
- REST routes for CRUD operations (players, sessions, inventory)
- A WebSocket endpoint with JWT authentication
- Message routing to the room manager
bun run boilerplate/server.ts
3. Implement Room Management
Use boilerplate/room-manager.ts. The room manager handles:
- Creating and destroying rooms
- Joining and leaving players
- Broadcasting messages to room members
- Room lifecycle transitions (created -> active -> closing -> closed)
Each room owns its game state and tick loop instance.
4. Configure the Game Loop
Use boilerplate/game-loop.ts. The GameLoop class provides:
- Fixed-timestep accumulator pattern
- Configurable tick rate (default 20 TPS for most genres)
- Speed multiplier for fast-forward or slow-motion
- Delta time overflow protection (caps accumulated time to prevent spiral of death)
Attach one GameLoop per room. The tick callback receives dt in seconds and updates room state.
5. Wire Room State to WebSocket Broadcasts
After each tick, diff the room state and broadcast deltas to all room members:
room.gameLoop.onTick = (dt) => {
room.state = updateGameState(room.state, dt);
const delta = computeDelta(room.previousState, room.state);
if (delta) {
room.broadcast({ type: 'state_update', roomId: room.id, state: delta, tick: room.tick });
}
room.previousState = structuredClone(room.state);
room.tick++;
};
6. Add Redis Pub/Sub for Scaling
When running multiple Elysia instances behind a load balancer:
import { Redis } from 'ioredis';
const pub = new Redis(process.env.REDIS_URL!);
const sub = new Redis(process.env.REDIS_URL!);
sub.subscribe('game:broadcast', 'game:direct');
sub.on('message', (channel, message) => {
const parsed = JSON.parse(message);
if (channel === 'game:broadcast') {
server.publish(parsed.channel, parsed.data);
}
if (channel === 'game:direct') {
const ws = connections.get(parsed.userId);
if (ws) ws.send(parsed.data);
}
});
7. Implement Presence and Heartbeat
- Client sends
ping every 30 seconds
- Server responds with
pong + server timestamp
- If no ping within 90 seconds, mark player offline and clean up room membership
- Store presence in Redis with TTL for automatic expiry
Code Examples
Fixed-Timestep Server Loop
const TICK_RATE = 20;
const TICK_DURATION_MS = 1000 / TICK_RATE;
const loop = new GameLoop(TICK_RATE, (dt) => {
for (const room of roomManager.getActiveRooms()) {
room.update(dt);
room.broadcastState();
}
});
loop.start();
Server-Authoritative Action Processing
const processAction = (
room: Room,
playerId: string,
action: { type: string; payload: Record<string, unknown> },
): ActionResult => {
const validation = validateAction(room.state, playerId, action);
if (!validation.valid) {
return { success: false, error: validation.reason };
}
const events = applyAction(room.state, playerId, action);
for (const event of events) {
room.broadcast({
type: 'game_event',
event: event.type,
data: event.data,
tick: room.tick,
});
}
return { success: true, events };
};
Room-Scoped State Update
const updateRoomState = (room: Room, dt: number) => {
for (const system of room.systems) {
system.update(room.state, dt);
}
while (room.actionQueue.length > 0) {
const { playerId, action } = room.actionQueue.shift()!;
processAction(room, playerId, action);
}
room.tick++;
};
WebSocket Message Routing
const handleMessage = (ws: GameWebSocket, data: ClientMessage) => {
const { playerId } = ws.data;
switch (data.type) {
case 'join_room':
roomManager.joinRoom(data.roomId, playerId, ws);
break;
case 'leave_room':
roomManager.leaveRoom(data.roomId, playerId);
break;
case 'action':
roomManager.queueAction(data.roomId, playerId, data.action);
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong', serverTime: Date.now() }));
break;
}
};
Delta State Synchronization
const computeDelta = (
previous: Record<string, unknown>,
current: Record<string, unknown>,
): Record<string, unknown> | null => {
const delta: Record<string, unknown> = {};
let hasChanges = false;
for (const key of Object.keys(current)) {
if (JSON.stringify(previous[key]) !== JSON.stringify(current[key])) {
delta[key] = current[key];
hasChanges = true;
}
}
return hasChanges ? delta : null;
};
Cross-References
redis-game-patterns — presence storage, pub/sub for cross-server communication, room state caching, rate limiting
bullmq-game-queues — offload heavy computations (matchmaking, leaderboard recalc, scheduled events) to background workers
game-state-sync — client-side state reconciliation, interpolation, optimistic updates, rollback
postgres-game-schema — persistent storage for player profiles, inventory, match history, room configurations
Pitfalls & Anti-Patterns
1. Client-Authoritative State
Wrong: client sends "my position is (100, 200)" and server trusts it.
Right: client sends "move north" intention, server validates movement speed, collision, and updates position.
2. Variable Timestep for Game Logic
Wrong: using wall-clock delta time directly in game formulas.
Right: fixed-timestep accumulator — game logic always sees the same dt, regardless of how fast the server runs.
3. Global State Instead of Room Isolation
Wrong: single shared game state object for all players.
Right: each room owns its state, tick loop, and player set. Rooms are independent simulation contexts.
4. Synchronous Database Writes in the Tick Loop
Wrong: await db.update(players).set(...) inside the tick callback.
Right: batch state changes and persist asynchronously outside the tick loop (e.g., every N ticks or on room close).
5. Broadcasting Full State Every Tick
Wrong: sending the entire game state to every player on every tick.
Right: compute deltas and only send what changed. Use interest management to filter updates per player.
6. No Overflow Protection
Wrong: accumulator grows unbounded when server lags, causing hundreds of catch-up ticks.
Right: cap accumulated time (e.g., 1 second max) to prevent spiral of death.
7. Tight Coupling Between Message Types and Game Logic
Wrong: game systems import WebSocket types and send messages directly.
Right: game systems produce events, a separate broadcast layer translates events to WebSocket messages.
8. No Graceful Room Shutdown
Wrong: rooms disappear instantly when the last player leaves, losing unsaved state.
Right: transition through closing state — persist state, notify players, clean up resources, then destroy.
Designer Philosophy
Will Wright's Simulation Thinking: the server is not a script executor — it is a simulation. Players interact with systems that have emergent behavior. The server defines rules and constraints; the interesting outcomes arise from player interactions within those rules. Design your tick loop as a simulation step, not a sequence of hardcoded events.
Sid Meier's Interesting Decisions: every message from the client represents a decision. The server's job is to make that decision meaningful by enforcing constraints (can you afford this action?), producing consequences (what happens as a result?), and communicating outcomes (what changed?). If an action has no validation and no consequence, it is not an interesting decision — remove it or make it matter.
Practical Implications:
- Validation is gameplay. Rejection messages should explain why an action failed, giving players information for their next decision.
- Events are the language of consequence. When an action produces events that other systems consume, you get emergent complexity without writing combinatorial logic.
- The tick loop is your simulation clock. Everything that "happens" in the game happens during a tick. Between ticks, the game world is frozen and consistent.
Sources