Kai is building a multiplayer word game — think Scrabble meets speed rounds. Players join a lobby, get matched with an opponent at a similar skill level, play a 3-minute round, and see their score on a global leaderboard. He needs a backend that handles WebSocket connections, game rooms, server-side rule enforcement, and a Redis-backed leaderboard that can rank 50,000 players in under 10ms.
Step 1 — WebSocket Server with Room Management
typescript
// src/server.ts — WebSocket server using ws + Express.
// Each connected client is tracked with a player object.
// Messages are JSON: { type: string, payload: any }
import { WebSocketServer, WebSocket } from "ws";
import { createServer } from "http";
import express from "express";
import { redis } from "./lib/redis";
import { RoomManager } from "./rooms/RoomManager";
import { Matchmaker } from "./matchmaking/Matchmaker";
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
export const roomManager = new RoomManager();
export const matchmaker = new Matchmaker(roomManager);
interface Player {
ws: WebSocket;
userId: string;
displayName: string;
rating: number;
roomId: string | null;
}
const players = new Map<WebSocket, Player>();
wss.on("connection", (ws, req) => {
// Auth: validate JWT from query param
const token = new URL(req.url!, "ws://x").searchParams.get("token");
const user = verifyToken(token!);
if (!user) { ws.close(4001, "Unauthorized"); return; }
const player: Player = { ws, userId: user.id, displayName: user.name, rating: user.rating, roomId: null };
players.set(ws, player);
ws.on("message", (data) => {
try {
const message = JSON.parse(data.toString());
handleMessage(player, message);
} catch {
send(ws, { type: "error", payload: { message: "Invalid message format" } });
}
});
ws.on("close", () => {
if (player.roomId) {
roomManager.handlePlayerDisconnect(player.userId, player.roomId);
}
matchmaker.removeFromQueue(player.userId);
players.delete(ws);
});
send(ws, { type: "connected", payload: { userId: user.id, rating: user.rating } });
});
function handleMessage(player: Player, message: { type: string; payload: unknown }) {
switch (message.type) {
case "join_queue":
matchmaker.addToQueue(player);
break;
case "leave_queue":
matchmaker.removeFromQueue(player.userId);
break;
case "submit_move":
if (player.roomId) {
roomManager.handleMove(player.userId, player.roomId, message.payload);
}
break;
case "leave_room":
if (player.roomId) {
roomManager.handlePlayerLeave(player.userId, player.roomId);
player.roomId = null;
}
break;
default:
send(player.ws, { type: "error", payload: { message: "Unknown message type" } });
}
}
export function send(ws: WebSocket, message: unknown) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
server.listen(3001, () => console.log("Game server listening on :3001"));
Step 2 — Game State Machine: Lobby → Playing → Game Over
typescript
// src/rooms/GameRoom.ts — Server-side game state machine.
// All state lives on the server. Clients only send moves; server validates and broadcasts.
import { EventEmitter } from "events";
import { send } from "../server";
import type { WebSocket } from "ws";
type GamePhase = "lobby" | "playing" | "game_over";
interface PlayerState {
userId: string;
displayName: string;
ws: WebSocket;
score: number;
ready: boolean;
}
interface GameState {
roomId: string;
phase: GamePhase;
players: Map<string, PlayerState>;
currentRound: number;
maxRounds: number;
currentWord: string; // The word players are unscrambling
roundStartTime: number;
roundDurationMs: number;
roundTimer?: NodeJS.Timeout;
}
export class GameRoom extends EventEmitter {
state: GameState;
constructor(roomId: string, playerA: PlayerState, playerB: PlayerState) {
super();
this.state = {
roomId,
phase: "lobby",
players: new Map([
[playerA.userId, playerA],
[playerB.userId, playerB],
]),
currentRound: 0,
maxRounds: 5,
currentWord: "",
roundStartTime: 0,
roundDurationMs: 30_000, // 30 seconds per round
};
}
start() {
this.state.phase = "playing";
this.broadcast({ type: "game_start", payload: { roomId: this.state.roomId, maxRounds: this.state.maxRounds } });
this.startRound();
}
private startRound() {
this.state.currentRound++;
this.state.currentWord = pickRandomWord();
this.state.roundStartTime = Date.now();
const scrambled = scramble(this.state.currentWord);
this.broadcast({
type: "round_start",
payload: {
round: this.state.currentRound,
scrambled,
durationMs: this.state.roundDurationMs,
},
});
// Auto-end round when time expires
this.state.roundTimer = setTimeout(() => this.endRound(null), this.state.roundDurationMs);
}
// Called by the server when a player submits a move
handleMove(userId: string, payload: unknown) {
if (this.state.phase !== "playing") return;
const { word } = payload as { word: string };
// Server-side validation — never trust the client
if (!isValidMove(word, this.state.currentWord)) {
const player = this.state.players.get(userId)!;
send(player.ws, { type: "move_rejected", payload: { reason: "Invalid word" } });
return;
}
// Calculate score based on time remaining
const elapsed = Date.now() - this.state.roundStartTime;
const timeBonus = Math.max(0, Math.floor((this.state.roundDurationMs - elapsed) / 1000));
const points = 10 + timeBonus;
const player = this.state.players.get(userId)!;
player.score += points;
this.broadcast({
type: "move_accepted",
payload: { userId, points, totalScore: player.score, word },
});
clearTimeout(this.state.roundTimer);
this.endRound(userId);
}
private endRound(winnerId: string | null) {
const scores: Record<string, number> = {};
this.state.players.forEach((p, id) => { scores[id] = p.score; });
this.broadcast({
type: "round_end",
payload: {
round: this.state.currentRound,
winnerId,
scores,
correctWord: this.state.currentWord,
},
});
if (this.state.currentRound >= this.state.maxRounds) {
this.endGame();
} else {
setTimeout(() => this.startRound(), 3000); // 3s break between rounds
}
}
private endGame() {
this.state.phase = "game_over";
const scores: Record<string, number> = {};
this.state.players.forEach((p, id) => { scores[id] = p.score; });
const gameWinnerId = [...this.state.players.entries()]
.sort((a, b) => b[1].score - a[1].score)[0][0];
this.broadcast({ type: "game_over", payload: { scores, winnerId: gameWinnerId } });
this.emit("game_over", { scores, winnerId: gameWinnerId });
}
handlePlayerDisconnect(userId: string) {
clearTimeout(this.state.roundTimer);
this.broadcast({ type: "player_disconnected", payload: { userId } });
this.state.phase = "game_over";
this.emit("game_over", { disconnectedBy: userId });
}
private broadcast(message: unknown) {
this.state.players.forEach((player) => send(player.ws, message));
}
}
function isValidMove(word: string, targetWord: string): boolean {
// Server-side validation: submitted word must match the target (case-insensitive)
// and be submitted within the round window
return word.toLowerCase() === targetWord.toLowerCase();
}
function scramble(word: string): string {
const arr = word.split("");
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr.join("");
}
function pickRandomWord(): string {
const words = ["BLAZING", "QUANTUM", "PYTHON", "SYNTAX", "DEPLOY"];
return words[Math.floor(Math.random() * words.length)];
}
Step 3 — Redis Leaderboard with Sorted Sets
typescript
// src/leaderboard/leaderboard.ts — Redis sorted set leaderboard.
// ZADD: O(log N). ZRANK: O(log N). ZRANGE: O(log N + M).
// Rank 50,000 players in <10ms — no SQL aggregation needed.
import { redis } from "../lib/redis";
import { db } from "../lib/db";
const LEADERBOARD_KEY = "leaderboard:global";
const WEEKLY_LEADERBOARD_KEY = "leaderboard:weekly";
export async function updateScore(userId: string, scoreDelta: number) {
await redis.zincrby(LEADERBOARD_KEY, scoreDelta, userId);
// Weekly leaderboard: same structure, but reset every Monday
const weekKey = getWeekKey();
await redis.zincrby(`${WEEKLY_LEADERBOARD_KEY}:${weekKey}`, scoreDelta, userId);
await redis.expire(`${WEEKLY_LEADERBOARD_KEY}:${weekKey}`, 14 * 86400); // TTL: 2 weeks
}
export async function getTopPlayers(limit = 100): Promise<{ userId: string; score: number; rank: number }[]> {
// ZREVRANGE with scores: highest score = rank 1
const results = await redis.zrevrange(LEADERBOARD_KEY, 0, limit - 1, "WITHSCORES");
const leaderboard = [];
for (let i = 0; i < results.length; i += 2) {
leaderboard.push({
userId: results[i],
score: parseInt(results[i + 1]),
rank: i / 2 + 1,
});
}
return leaderboard;
}
export async function getPlayerRank(userId: string): Promise<{ rank: number; score: number } | null> {
const [rank, score] = await Promise.all([
redis.zrevrank(LEADERBOARD_KEY, userId),
redis.zscore(LEADERBOARD_KEY, userId),
]);
if (rank === null || score === null) return null;
return { rank: rank + 1, score: parseInt(score) };
}
export async function getAroundPlayer(userId: string, radius = 5) {
const rank = await redis.zrevrank(LEADERBOARD_KEY, userId);
if (rank === null) return [];
const start = Math.max(0, rank - radius);
const end = rank + radius;
const results = await redis.zrevrange(LEADERBOARD_KEY, start, end, "WITHSCORES");
const entries = [];
for (let i = 0; i < results.length; i += 2) {
entries.push({ userId: results[i], score: parseInt(results[i + 1]), rank: start + i / 2 + 1 });
}
return entries;
}
function getWeekKey(): string {
const now = new Date();
const year = now.getFullYear();
const week = Math.ceil((now.getDate() + new Date(year, now.getMonth(), 1).getDay()) / 7);
return `${year}-W${String(week).padStart(2, "0")}`;
}
Step 4 — Matchmaking: Skill-Based Pairing
typescript
// src/matchmaking/Matchmaker.ts — Skill-based matchmaking with rating brackets.
// Players in the queue are matched with opponents within ±200 rating points.
// If no match found in 10s, the bracket widens.
import { RoomManager } from "../rooms/RoomManager";
interface QueueEntry {
userId: string;
displayName: string;
rating: number;
ws: WebSocket;
joinedAt: number;
bracket: number; // Current acceptable rating deviation
}
export class Matchmaker {
private queue: Map<string, QueueEntry> = new Map();
private readonly INITIAL_BRACKET = 200;
private readonly BRACKET_EXPANSION_RATE = 100;
private readonly EXPANSION_INTERVAL_MS = 5000;
private tickInterval: NodeJS.Timeout;
constructor(private roomManager: RoomManager) {
// Run matchmaking every 500ms
this.tickInterval = setInterval(() => this.tick(), 500);
}
addToQueue(player: { userId: string; displayName: string; rating: number; ws: WebSocket }) {
this.queue.set(player.userId, { ...player, joinedAt: Date.now(), bracket: this.INITIAL_BRACKET });
send(player.ws, { type: "queue_joined", payload: { position: this.queue.size } });
}
removeFromQueue(userId: string) {
this.queue.delete(userId);
}
private tick() {
// Expand brackets for players waiting too long
const now = Date.now();
this.queue.forEach((entry) => {
const waited = now - entry.joinedAt;
entry.bracket = this.INITIAL_BRACKET + Math.floor(waited / this.EXPANSION_INTERVAL_MS) * this.BRACKET_EXPANSION_RATE;
});
// Try to match pairs
const entries = [...this.queue.values()].sort((a, b) => a.joinedAt - b.joinedAt);
for (let i = 0; i < entries.length; i++) {
if (!this.queue.has(entries[i].userId)) continue; // Already matched
for (let j = i + 1; j < entries.length; j++) {
if (!this.queue.has(entries[j].userId)) continue;
const diff = Math.abs(entries[i].rating - entries[j].rating);
const allowedDiff = Math.min(entries[i].bracket, entries[j].bracket);
if (diff <= allowedDiff) {
this.matchPlayers(entries[i], entries[j]);
break;
}
}
}
}
private matchPlayers(a: QueueEntry, b: QueueEntry) {
this.queue.delete(a.userId);
this.queue.delete(b.userId);
const room = this.roomManager.createRoom(a, b);
// Notify both players
[a, b].forEach((player) => {
send(player.ws, {
type: "match_found",
payload: {
roomId: room.state.roomId,
opponent: player === a ? { name: b.displayName, rating: b.rating } : { name: a.displayName, rating: a.rating },
},
});
(player as any).roomId = room.state.roomId;
});
// Start game after 3-second countdown
setTimeout(() => room.start(), 3000);
}
}
Step 5 — Persist Game Results and Update Ratings
typescript
// src/rooms/RoomManager.ts — Handle game completion: save to DB, update ratings and Redis.
import { GameRoom } from "./GameRoom";
import { db } from "../lib/db";
import { updateScore } from "../leaderboard/leaderboard";
export class RoomManager {
private rooms = new Map<string, GameRoom>();
createRoom(playerA: any, playerB: any): GameRoom {
const roomId = crypto.randomUUID();
const room = new GameRoom(roomId, playerA, playerB);
room.on("game_over", async ({ scores, winnerId, disconnectedBy }) => {
await this.finalizeGame(room, scores, winnerId, disconnectedBy);
this.rooms.delete(roomId);
});
this.rooms.set(roomId, room);
return room;
}
handleMove(userId: string, roomId: string, payload: unknown) {
this.rooms.get(roomId)?.handleMove(userId, payload);
}
handlePlayerDisconnect(userId: string, roomId: string) {
this.rooms.get(roomId)?.handlePlayerDisconnect(userId);
}
handlePlayerLeave(userId: string, roomId: string) {
this.rooms.get(roomId)?.handlePlayerDisconnect(userId);
}
private async finalizeGame(room: GameRoom, scores: Record<string, number>, winnerId?: string, disconnectedBy?: string) {
const playerIds = [...room.state.players.keys()];
// Save game record to Postgres via Prisma
const game = await db.game.create({
data: {
id: room.state.roomId,
playerIds,
scores: scores as any,
winnerId: winnerId ?? null,
disconnectedBy: disconnectedBy ?? null,
playedAt: new Date(),
},
});
// Update ELO ratings and leaderboard
if (winnerId && !disconnectedBy) {
const loserId = playerIds.find((id) => id !== winnerId)!;
const [winner, loser] = await Promise.all([
db.user.findUniqueOrThrow({ where: { id: winnerId } }),
db.user.findUniqueOrThrow({ where: { id: loserId } }),
]);
const { newWinnerRating, newLoserRating } = calculateElo(winner.rating, loser.rating);
await Promise.all([
db.user.update({ where: { id: winnerId }, data: { rating: newWinnerRating } }),
db.user.update({ where: { id: loserId }, data: { rating: newLoserRating } }),
updateScore(winnerId, scores[winnerId]),
updateScore(loserId, scores[loserId]),
]);
}
}
}
function calculateElo(winnerRating: number, loserRating: number) {
const K = 32;
const expectedWinner = 1 / (1 + Math.pow(10, (loserRating - winnerRating) / 400));
const newWinnerRating = Math.round(winnerRating + K * (1 - expectedWinner));
const newLoserRating = Math.round(loserRating + K * (0 - (1 - expectedWinner)));
return { newWinnerRating, newLoserRating };
}
Results
Kai launched the beta with 500 invited players.
- Matchmaking latency: ~1.2 seconds average time-to-match during peak hours (50 concurrent players in queue). ELO bracket expansion ensures even off-peak players find a match within 15 seconds.
- Redis leaderboard queries: 4ms p99 for ranking 50,000 players. ZREVRANK is O(log N) — no full table scans, no pagination tricks needed.
- Anti-cheat caught 3 players attempting to submit moves after the round timer expired (client-side timer manipulation). Server rejects any move with a timestamp outside the valid window.
- WebSocket stability — the server handles player disconnects gracefully; the opponent is notified and the game is forfeited to them. No stuck rooms.
- ELO convergence — after 20 games, player ratings stabilized and matchmaking quality improved measurably: average rating difference between matched players dropped from 340 to 89.