SuperAPI AI Directory

engineering-matchmaking-system

General & other

View repository

Updated 10-15 21:41

About this skill

Matchmaking System

Purpose

Rank-based and skill-based player matching, lobby lifecycle management, and queue orchestration for multiplayer games.

When to Use

Trigger: matchmaking, lobby, queue, ELO, Glicko, skill-based matchmaking, rank, match players, pairing, bracket, queue management, wait time, search radius, lobby state

Prerequisites

  • bullmq-game-queues — priority queues, delayed jobs, worker patterns
  • redis-game-patterns — Redis connection, pub/sub for lobby events
  • postgres-game-schema — persisting match history, player ratings

Core Principles

Raph Koster: "Matchmaking is the invisible hand that shapes every player's experience. Get it wrong, and no amount of great game design can save you." Will Wright: "The best systems match complexity to the player — beginners face beginners, experts face experts, and everyone stays in flow."

  1. ELO/Glicko-2 for skill estimation — track rating AND deviation. A player with 1500 rating and low deviation is well-calibrated; 1500 with high deviation means uncertain. Factor in performance metrics (damage dealt, objectives captured), not just win/loss.
  2. Match quality score = sum(skill_delta^2 x weight) — minimize skill gap — a match is only as good as its closest pairing. Compute quality across all player pairs, not just averages. Weight recent performance higher than lifetime rating.
  3. Expand search radius over time — start tight (+-50 rating) and widen every 10s. After 30s, widen aggressively. Players prefer a slightly uneven match over waiting 3 minutes. Use BullMQ delayed jobs to trigger radius expansion.
  4. Separate queues by game mode, region, and optional parameters — 1v1 ranked, 2v2 casual, battle royale, and co-op each have different matching criteria. Never cross-pollinate queues unless the player opts in.
  5. Lobby state machine: WAITING -> ALL_READY -> COUNTDOWN -> IN_PROGRESS -> COMPLETE — every lobby transition is explicit, logged, and reversible (until IN_PROGRESS). If a player disconnects during COUNTDOWN, revert to WAITING.
  6. BullMQ priority queue for matchmaking — players waiting > 30s get priority 1 (highest). This prevents starvation where popular skill brackets drain the queue while edge cases wait forever.
  7. Never match new players against experienced players — players with < 10 games are in a protected pool. Players with > 100 games are in the general pool. The 10-100 range transitions gradually. This prevents new player churn from stomps.
  8. Pre-match validation: all players still connected before starting — after a match is found, verify every player is still online via WebSocket heartbeat. If anyone dropped, return remaining players to the front of the queue (not the back).
  9. Anti-abuse: leave penalties and queue cooldowns — track abandonment rate. Players who leave > 20% of matches in the last 20 games get increasing queue delays (30s, 2m, 10m). Cooldown decays over time with completed matches.

Step-by-Step Instructions

1. Install Dependencies

bun add bullmq ioredis
bun add -d @types/node

2. Set Up Matchmaking Queues

Create dedicated BullMQ queues for matchmaking. Use the patterns from bullmq-game-queues.

import { Queue, Worker } from 'bullmq';
import type { Redis } from 'ioredis';

const matchmakingQueue = new Queue('matchmaking', {
  connection: redis,
  defaultJobOptions: {
    removeOnComplete: 100,
    removeOnFail: 500,
    attempts: 3,
    backoff: { type: 'exponential', delay: 1000 },
  },
});

// Separate queue for search radius expansion
const radiusExpansionQueue = new Queue('matchmaking:radius-expand', {
  connection: redis,
});

3. Configure Rating System

Choose ELO for simplicity or Glicko-2 for accuracy. Glicko-2 tracks rating deviation (confidence) and volatility.

interface PlayerRating {
  rating: number;      // ELO/Glicko-2 rating (default: 1500)
  deviation: number;   // Glicko-2 RD (default: 350, lower = more confident)
  volatility: number;  // Glicko-2 sigma (default: 0.06)
  gamesPlayed: number;
}

// K-factor decreases as confidence increases
function getKFactor(gamesPlayed: number): number {
  if (gamesPlayed < 10) return 40;  // New players: high adjustment
  if (gamesPlayed < 30) return 24;  // Learning players
  return 16;                         // Established players
}

4. Implement Queue Entry

See boilerplate/matchmaker.ts for the full MatchmakingEngine class. Players enter the queue with their rating, region, and game mode.

import { MatchmakingEngine } from './matchmaker';

const engine = new MatchmakingEngine(redis);

await engine.enqueue({
  playerId: 'player_123',
  rating: 1500,
  deviation: 100,
  gameMode: 'ranked-1v1',
  region: 'us-east',
  queuedAt: Date.now(),
  searchRadius: 50,
});

5. Implement Match Finding

The engine scores potential matches and picks the best one. Quality score considers rating difference, deviation overlap, wait time, and region latency.

// Inside BullMQ worker
const worker = new Worker('matchmaking', async (job) => {
  const player = job.data as PlayerQueueEntry;
  const match = await engine.findMatch(player);

  if (match) {
    await lobbyManager.createLobby(match);
  } else {
    // Schedule radius expansion after 10s
    await radiusExpansionQueue.add('expand', {
      playerId: player.playerId,
    }, { delay: 10_000 });
  }
}, { connection: redis, concurrency: 10 });

6. Implement Lobby Lifecycle

See boilerplate/lobby-manager.ts for the full LobbyManager class. Lobbies use Redis for state storage and pub/sub for real-time events.

import { LobbyManager } from './lobby-manager';

const lobbyManager = new LobbyManager(redis);

// Match found -> create lobby
const lobby = await lobbyManager.createLobby(match);

// Players ready up
await lobbyManager.readyUp(lobby.lobbyId, 'player_123');

// All ready -> countdown starts automatically
// Countdown complete -> IN_PROGRESS
// Game ends -> COMPLETE

7. Handle Edge Cases

// Player disconnects during countdown
await lobbyManager.leaveLobby(lobbyId, disconnectedPlayerId);
// Remaining players return to queue front with priority boost

// Player cancels queue
await engine.dequeue('player_123');

// Search radius expansion (called by delayed job)
engine.widenSearchRadius('player_123');

Code Examples

See boilerplate files:

  • boilerplate/matchmaker.ts — Core MatchmakingEngine class with enqueue, dequeue, match finding, and quality scoring
  • boilerplate/lobby-manager.ts — LobbyManager class with state machine, Redis storage, and pub/sub events
  • templates/matchmaking-config.md — Configuration template for queue settings, rating parameters, and anti-abuse rules

Cross-References

  • bullmq-game-queues — Queue creation, worker patterns, retry strategies, delayed jobs. Matchmaking queues build directly on these patterns.
  • redis-game-patterns — Redis connection, pub/sub for lobby events, sorted sets for leaderboards. Lobby state is stored in Redis hashes with TTL.
  • postgres-game-schema — Match history, player rating persistence, abandonment tracking. Ratings are cached in Redis but persisted to PostgreSQL after each match.
  • game-state-sync — Once a match starts, state sync takes over. The lobby hands off to the game server with the match configuration.

Pitfalls & Anti-Patterns

  • Don't match purely on average team rating — a team of [1000, 2000] vs [1500, 1500] has the same average but terrible match quality. Minimize variance within AND between teams.
  • Don't reset search radius on re-queue — if a player was returned to queue after a failed lobby, preserve their accumulated wait time and expanded radius.
  • Don't use a single global queue — separate by game mode and region. A global queue means a 1v1 player in Asia competes for worker time with a 4v4 player in Europe.
  • Don't ignore ELO inflation — over time, ratings drift upward as new players inject points. Implement seasonal resets or rating decay for inactive players.
  • Don't skip pre-match validation — the #1 source of "ghost matches" is starting a game with a player who disconnected 2 seconds before launch. Always verify presence.
  • Don't match solo players against premade teams at the same rating — premade teams have coordination advantage worth ~100-200 rating points. Apply a handicap or separate the queues.
  • Don't ignore region latency — a "perfect" skill match across continents produces a terrible gameplay experience. Prioritize region over rating within reasonable bounds.

Designer Philosophy

Raph Koster: Matchmaking defines the difficulty curve more than any level designer ever could. A good matchmaker keeps players in Csikszentmihalyi's "flow channel" — challenged enough to be engaged, but never so outmatched that they feel helpless. The system is invisible when it works, infuriating when it doesn't.

Will Wright: The matchmaker is a simulation of social dynamics. It must model not just skill but confidence, frustration tolerance, and play patterns. A player on a losing streak needs an easier match, even if their rating says otherwise. Systems that serve the math instead of the human always fail.

Sources