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.
Installs into .claude/skills of the current project.
Are you the author of Game Backend Architecture?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/aibot88-game-backend-architecture)
---
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.
1. **Server-authoritative**: the client sends intentions (actions), the server validates and executes. Never trust the client.
2. **Separate simulation loop from I/O**: the game tick runs on a fixed timestep independent of network or render cycles.
3. **Fixed timestep for deterministic game logic**: same inputs at the same tick always produce the same output, enabling replays, rollback, and offline catchup.
4. **Room-based architecture for multiplayer isolation**: each room encapsulates its own state, tick loop, and player set. Rooms are the unit of scaling.
5. **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).
6. **Event-driven**: game actions produce events, systems consume them. Decouple producers from consumers for testability and extensibility.
7. **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.
```typescript
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
```bash
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:
```typescript
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:
```typescript
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
```typescript
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
```typescript
const processAction = (
room: Room,
playerId: string,
action: { type: string; payload: Record<string, unknown> },
): ActionResult => {
// 1. Validate: is this action legal in current state?
const validation = validateAction(room.state, playerId, action);
if (!validation.valid) {
return { success: false, error: validation.reason };
}
// 2. Execute: apply to authoritative state
const events = applyAction(room.state, playerId, action);
// 3. Broadcast: notify all room members of resulting events
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
```typescript
const updateRoomState = (room: Room, dt: number) => {
// Run all registered systems in order
for (const system of room.systems) {
system.update(room.state, dt);
}
// Process queued player actions
while (room.actionQueue.length > 0) {
const { playerId, action } = room.actionQueue.shift()!;
processAction(room, playerId, action);
}
room.tick++;
};
```
### WebSocket Message Routing
```typescript
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
```typescript
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
- Glenn Fiedler, "Fix Your Timestep!" — https://gafferongames.com/post/fix_your_timestep/
- Gabriel Gambetta, "Fast-Paced Multiplayer" — https://www.gabrielgambetta.com/client-server-game-architecture.html
- Elysia documentation — https://elysiajs.com/
- Redis pub/sub documentation — https://redis.io/docs/manual/pubsub/
- Will Wright, GDC talks on simulation design
- Sid Meier, GDC 2012, "Interesting Decisions"