How to Design an Online Chess Game System
Building multiplayer chess with matchmaking, real-time moves, time controls, and rating systems for 100M+ players
1. Introduction
Chess is the oldest and most intellectually rich strategy game ever devised, and its migration to the digital realm has been nothing short of extraordinary. Online chess platforms have become global institutions, serving tens of millions of active players across every continent and every time zone. Chess.com alone reports over 100 million registered members, with peak concurrent connections routinely exceeding 5 million during major events. Lichess, the premier open-source alternative, adds another 15 million registered accounts and serves over 50 million games per month. These are not niche communities — they are major internet-scale applications that require sophisticated distributed systems engineering.
The appeal of online chess lies in its perfect-information, zero-luck gameplay and the fact that a single game can be played in as little as one minute (bullet chess) or as long as several hours (classical chess). Time controls segment the player experience into distinct categories: bullet (1 minute), blitz (3–5 minutes), rapid (10–30 minutes), daily (correspondence, 1–14 days per move), and classical (60–90 minutes, rare online). Each category demands different infrastructure characteristics — bullet games require sub-10ms move latency, while daily games can tolerate eventual consistency.
The rating system is the backbone of competitive integrity. FIDE (the International Chess Federation) maintains official Elo ratings for over 200,000 rated players worldwide, with Magnus Carlsen holding the highest classical rating at 2864. Online platforms adapt the Elo system or use more advanced variants like Glicko-2 (Lichess) and the Glicko system (Chess.com) to handle the higher volatility and larger rating pools of internet chess. These rating systems must process hundreds of thousands of rating adjustments per day with mathematical precision.
Building a chess platform that serves millions of concurrent players is a fascinating system design challenge. It combines real-time communication, complex game logic, competitive matchmaking, anti-cheat systems, and social features into a cohesive architecture. In this guide, we will design such a system from the ground up, covering every major subsystem with architecture diagrams, data models, C# implementations, and practical considerations for running at scale.
2. Functional & Non-Functional Requirements
Functional Requirements
Core Gameplay
- Standard chess rules with full move validation
- All special moves: castling, en passant, pawn promotion
- Check, checkmate, and stalemate detection
- Multiple time controls per game type
- Draw by agreement, 50-move rule, threefold repetition
Matchmaking
- Skill-based matchmaking within rating bands
- Time control-specific queues
- Acceptable wait time vs. skill matching trade-off
- Guest/anonymous play option
Real-Time Features
- Sub-50ms move propagation
- Live clock synchronization
- Move-by-move game observation
- In-game chat (optional per game)
Competitive
- Rating system with rating graphs
- Daily/weekly/monthly leaderboards
- Arena and Swiss tournaments
- Puzzle rating and puzzle rush
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Move Latency (p99) | < 50ms | Essential for bullet/blitz integrity |
| Availability | 99.95% | ~4.4 hours downtime per year |
| Concurrent Users | 5M+ | Peak during world championships |
| Data Durability | 99.999999% | Game history is irreplaceable |
| Scalability | Horizontal | Game servers must scale linearly |
| Anti-Cheat | Real-time + Post | Engine detection within minutes |
| Global Latency | < 100ms | Cross-continent playability |
3. Capacity Estimation
Understanding the scale of an online chess platform requires careful back-of-the-envelope calculations. Let us estimate the key metrics for a platform serving 100 million registered users.
Active Users
With 100M registered users, a typical daily active user (DAU) rate of 15% yields 15 million DAU. Peak concurrent users (CCU) during evening hours might be 10% of DAU, giving us 1.5 million CCU. During major events like World Championship matches, CCU can spike to 5 million.
Games Per Second
A typical chess player plays 3–8 games per session. With an average session of 30 minutes, that translates to roughly 6–16 games per hour per active player. At 1.5 million CCU with an average game duration of 15 minutes:
| Metric | Value | Calculation |
|---|---|---|
| Active Games at Peak | ~750,000 | 1.5M x 0.5 (avg overlap) |
| New Games/Second | ~833 | 750K / 15min avg duration x 60s |
| Moves/Second | ~8,333 | 833 games x 10 avg moves/interval |
| Matchmaking Requests/sec | ~2,500 | 833 x 3 (avg queue attempts) |
| WebSocket Messages/sec | ~50,000 | Moves + clock + chat + presence |
| Database Writes/sec | ~16,666 | Moves (1) + clock updates (1) per move |
Storage Estimation
Each move in a chess game takes roughly 4–8 bytes when efficiently encoded. An average game has 80 moves (40 per player), consuming about 320–640 bytes of raw move data. With metadata (players, ratings, time controls, timestamps), a complete game record averages 2–5 KB. At 833 new games per second:
- Raw moves: 833 x 400 bytes = ~333 KB/s = ~28 GB/day
- Full game records: 833 x 3.5 KB = ~2.9 MB/s = ~250 GB/day
- Annual storage: ~91 TB/year for game data alone
- With indices and replicas: ~300 TB/year total storage
Bandwidth
Each WebSocket message averages 50–200 bytes (move data, clock updates). At 50K messages/second, outbound bandwidth is approximately 10 MB/s or 864 GB/day. Game state snapshots and REST API responses add roughly 30% overhead. Total egress bandwidth peaks at approximately 13 MB/s, well within the capacity of modern cloud networking.
4. Data Model
The data model must capture the full lifecycle of a chess game — from user registration through matchmaking, gameplay, rating updates, and post-game analysis. Below are the core entities and their relationships.
Entity Relationship Diagram
Core Tables Detail
| Table | Primary Storage | Access Pattern | Retention |
|---|---|---|---|
| users | PostgreSQL | Read-heavy (profile views) | Permanent |
| games | PostgreSQL + Redis | Write during play, read for replay | Permanent |
| moves | PostgreSQL (partitioned) | Append-only during play | Permanent |
| rating_history | PostgreSQL (append-only) | Read for graphs, write per game | Permanent |
| tournaments | PostgreSQL | Read-heavy during events | Permanent |
| puzzles | PostgreSQL + Redis cache | Read-heavy (puzzle delivery) | Permanent |
| active_games | Redis | Sub-ms reads during gameplay | Game duration only |
| matchmaking_queue | Redis Sorted Set | Frequent read/write during matchmaking | Queue duration only |
5. API Design
The API surface is split into REST endpoints for CRUD operations and WebSocket channels for real-time gameplay. Below are the primary REST endpoints.
REST API Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
POST | /api/v1/auth/register | Create account | No |
POST | /api/v1/auth/login | Login, get JWT | No |
GET | /api/v1/users/{id} | Get user profile | JWT |
GET | /api/v1/users/{id}/rating-history | Rating graph data | JWT |
POST | /api/v1/games/create | Create private game | JWT |
POST | /api/v1/games/join-matchmaking | Enter matchmaking queue | JWT |
GET | /api/v1/games/{id} | Get game state | JWT |
POST | /api/v1/games/{id}/move | Make a move (REST fallback) | JWT |
POST | /api/v1/games/{id}/resign | Resign game | JWT |
POST | /api/v1/games/{id}/draw-offer | Offer/accept draw | JWT |
GET | /api/v1/games/{id}/pgn | Export game as PGN | Public |
GET | /api/v1/puzzles/daily | Get daily puzzle | No |
POST | /api/v1/puzzles/attempt | Submit puzzle solution | JWT |
GET | /api/v1/leaderboards/{type} | Top players by rating | No |
POST | /api/v1/tournaments/{id}/join | Join tournament | JWT |
WebSocket Channels
| Channel | Direction | Payload |
|---|---|---|
game:{gameId} | Bidirectional | Moves, clock updates, game events |
matchmaking:{userId} | Server to Client | Queue status, match found |
spectate:{gameId} | Server to Client | Live move updates |
chat:{gameId} | Bidirectional | In-game chat messages |
API Request/Response Example
// POST /api/v1/games/create
{
"time_control": {
"type": "blitz",
"initial_time_seconds": 300,
"increment_seconds": 3
},
"rated": true,
"opponent_id": null
}
// Response 201 Created
{
"game_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "waiting",
"time_control": {
"type": "blitz",
"initial_time_seconds": 300,
"increment_seconds": 3
},
"created_at": "2026-07-14T18:30:00Z"
}
WebSocket Move Protocol
// Client to Server: Make move
{
"type": "move",
"game_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"move": "e2e4",
"client_timestamp": 1689378600123
}
// Server to Client: Move acknowledged
{
"type": "move_ack",
"move_number": 1,
"san": "e4",
"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
"white_clock_ms": 299847,
"black_clock_ms": 300000,
"is_check": false,
"is_checkmate": false
}
// Server to Spectators: Live update
{
"type": "game_update",
"move_number": 1,
"san": "e4",
"white_clock_ms": 299847,
"black_clock_ms": 300000,
"eval_cp": 35,
"eval_depth": 20
}
6. High-Level Architecture
The architecture follows a microservices approach with dedicated services for each major concern: matchmaking, gameplay, time control, ratings, and social features. Game servers are the most latency-sensitive component and are deployed as close to players as possible.
Chess UI + WebSockets"] MOBILE["Mobile Apps
iOS / Android"] DESKTOP["Desktop Client
Electron"] end subgraph EdgeLayer["Edge Layer"] CDN["CDN
Static Assets"] LB["Load Balancer
L7 + WebSocket"] end subgraph Gateway["API Gateway"] GW["API Gateway
Rate Limiting + Auth"] end subgraph CoreServices["Core Services"] AUTH["Auth Service
JWT + OAuth"] MATCH["Matchmaking Service
Elo Pool + Queue"] GAME["Game Server Pool
Stateless Game Logic"] TIME["Time Control Service
Clock Sync"] RATING["Rating Service
Elo/Glicko-2"] ENGINE["Engine Service
Stockfish Analysis"] end subgraph SupportServices["Support Services"] TOURN["Tournament Service"] PUZZLE["Puzzle Service"] SOCIAL["Social Service
Friends + Clubs"] CHEAT["Anti-Cheat Service"] NOTIFY["Notification Service"] end subgraph DataLayer["Data Layer"] PG["PostgreSQL Cluster
Primary + Replicas"] REDIS["Redis Cluster
Game State + Cache"] S3["Object Storage
PGN + Analysis"] ES["Elasticsearch
Game Search"] end subgraph Infra["Infrastructure"] KAFKA["Kafka
Event Stream"] PROM["Prometheus + Grafana
Monitoring"] end WEB --> CDN MOBILE --> CDN WEB --> LB MOBILE --> LB DESKTOP --> LB LB --> GW GW --> AUTH GW --> MATCH GW --> GAME GW --> TOURN GW --> PUZZLE GW --> SOCIAL GAME --> TIME GAME --> RATING GAME --> ENGINE GAME --> CHEAT RATING --> KAFKA CHEAT --> KAFKA GAME --> REDIS MATCH --> REDIS GAME --> PG TOURN --> PG PUZZLE --> PG SOCIAL --> PG ENGINE --> S3 NOTIFY --> KAFKA PROM --> GAME
Key Architectural Decisions
Game Server as Stateful Service
Each game server handles a fixed number of concurrent games (e.g., 10,000). The server maintains in-memory game state and persists to Redis/PostgreSQL on every move. If a server crashes, the game state is reconstructed from the last persisted move.
Separate Matchmaking from Gameplay
Matchmaking is a separate service that runs its own rating pools in Redis sorted sets. Once matched, players are directed to an available game server via consistent hashing on the game ID.
Event Sourcing for Game Events
Every game event (move, draw offer, resignation, clock adjustment) is published to Kafka. This enables real-time analytics, anti-cheat processing, and game reconstruction from the event stream.
Read-Heavy Optimization
Game replays, leaderboards, and user profiles are read far more often than written. We use read replicas, CDN-cached PGN exports, and Redis-cached leaderboards to optimize read paths.
7. Chess Board Representation
Efficient board representation is critical for move generation, validation, and engine communication. There are three primary approaches, each with distinct trade-offs.
Representation Comparison
| Method | Memory | Move Gen Speed | Complexity | Use Case |
|---|---|---|---|---|
| 8x8 Array | 64 bytes | Moderate | Low | Beginners, simple UIs |
| Bitboard | 12 x 8 = 96 bytes | Very Fast | High | Engines, servers |
| Mailbox (120-square) | 120 bytes | Fast | Medium | General purpose |
| FEN (string) | ~80 bytes | Slow (parse) | Low | Serialization, storage |
FEN Notation
FEN (Forsyth-Edwards Notation) is the standard string representation for chess positions. The starting position is:
rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
Each field represents: piece placement, active color, castling availability, en passant target square, halfmove clock, and fullmove number.
UCI Move Encoding
UCI (Universal Chess Interface) encodes moves as four-character strings: source square + destination square. For promotion, a fifth character specifies the piece: e7e8q promotes to queen.
C# Board Representation
public enum Piece
{
None, WhitePawn, WhiteKnight, WhiteBishop, WhiteRook, WhiteQueen, WhiteKing,
BlackPawn, BlackKnight, BlackBishop, BlackRook, BlackQueen, BlackKing
}
public struct Square : IEquatable<Square>
{
public int Index { get; }
public int Rank => Index / 8;
public int File => Index % 8;
public string Notation => $"{(char)('a' + File)}{Rank + 1}";
public Square(int index) { Index = index; }
public Square(int rank, int file) { Index = rank * 8 + file; }
public static Square At(int rank, int file) => new(rank, file);
public static Square Parse(string s) => new((s[0] - 'a') + (s[1] - '1') * 8);
public bool IsValid() => Index >= 0 && Index < 64;
public bool Equals(Square other) => Index == other.Index;
public override bool Equals(object obj) => obj is Square s && Equals(s);
public override int GetHashCode() => Index;
}
public struct Move
{
public Square From { get; }
public Square To { get; }
public PieceType PromotionPiece { get; }
public Move(Square from, Square to, PieceType promotion = PieceType.None)
{
From = from; To = to; PromotionPiece = promotion;
}
public string ToUci()
{
string result = $"{From.Notation}{To.Notation}";
if (PromotionPiece != PieceType.None)
{
result += PromotionPiece switch
{
PieceType.Queen => "q", PieceType.Rook => "r",
PieceType.Bishop => "b", PieceType.Knight => "n",
_ => ""
};
}
return result;
}
}
public class ChessBoard
{
private readonly Piece[] _squares = new Piece[64];
public Piece this[Square sq]
{
get => _squares[sq.Index];
set => _squares[sq.Index] = value;
}
public static ChessBoard StartingPosition() => FromFen(
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
public static ChessBoard FromFen(string fen)
{
var board = new ChessBoard();
var parts = fen.Split(' ');
var ranks = parts[0].Split('/');
for (int r = 0; r < 8; r++)
{
int f = 0;
foreach (char c in ranks[r])
{
if (char.IsDigit(c)) f += c - '0';
else { board[7 - r, f] = CharToPiece(c); f++; }
}
}
return board;
}
public string ToFen(GameState state)
{
var sb = new System.Text.StringBuilder();
for (int r = 7; r >= 0; r--)
{
int empty = 0;
for (int f = 0; f < 8; f++)
{
var piece = this[Square.At(r, f)];
if (piece == Piece.None) { empty++; continue; }
if (empty > 0) { sb.Append(empty); empty = 0; }
sb.Append(PieceToChar(piece));
}
if (empty > 0) sb.Append(empty);
if (r > 0) sb.Append('/');
}
sb.Append(state.CurrentTurn == PieceColor.White ? " w " : " b ");
string castling = "";
if (!state.WhiteKingMoved)
{
if (!state.WhiteRookKingSideMoved) castling += "K";
if (!state.WhiteRookQueenSideMoved) castling += "Q";
}
if (!state.BlackKingMoved)
{
if (!state.BlackRookKingSideMoved) castling += "k";
if (!state.BlackRookQueenSideMoved) castling += "q";
}
sb.Append(string.IsNullOrEmpty(castling) ? "-" : castling);
sb.Append(state.EnPassantTarget.IsValid()
? $" {state.EnPassantTarget.Notation}" : " -");
sb.Append($" {state.HalfmoveClock} {state.FullmoveNumber}");
return sb.ToString();
}
public ChessBoard Clone()
{
var clone = new ChessBoard();
Array.Copy(_squares, clone._squares, 64);
return clone;
}
public Square FindKing(PieceColor color)
{
Piece king = color == PieceColor.White
? Piece.WhiteKing : Piece.BlackKing;
for (int i = 0; i < 64; i++)
if (_squares[i] == king) return new Square(i);
return default;
}
private static Piece CharToPiece(char c) => c switch
{
'P' => Piece.WhitePawn, 'N' => Piece.WhiteKnight,
'B' => Piece.WhiteBishop, 'R' => Piece.WhiteRook,
'Q' => Piece.WhiteQueen, 'K' => Piece.WhiteKing,
'p' => Piece.BlackPawn, 'n' => Piece.BlackKnight,
'b' => Piece.BlackBishop, 'r' => Piece.BlackRook,
'q' => Piece.BlackQueen, 'k' => Piece.BlackKing,
_ => Piece.None
};
private static char PieceToChar(Piece p) => p switch
{
Piece.WhitePawn => 'P', Piece.WhiteKnight => 'N',
Piece.WhiteBishop => 'B', Piece.WhiteRook => 'R',
Piece.WhiteQueen => 'Q', Piece.WhiteKing => 'K',
Piece.BlackPawn => 'p', Piece.BlackKnight => 'n',
Piece.BlackBishop => 'b', Piece.BlackRook => 'r',
Piece.BlackQueen => 'q', Piece.BlackKing => 'k',
_ => '.'
};
}
8. Move Validation Engine
Move validation is the heart of a chess system. Every submitted move must be validated for legality before it is accepted. The engine handles standard piece movement, castling rights, en passant captures, pawn promotion, and ensures the move does not leave the player's own king in check.
Validation Pipeline
Special Move Rules
| Move Type | Rule | Edge Cases |
|---|---|---|
| Castling | King and rook haven't moved; path clear; king not in/through/into check | Rook on initial square but already moved |
| En Passant | Opponent pawn just double-pushed; capture on pass-through square | Must be executed immediately on the next move |
| Promotion | Pawn reaches rank 1 or 8; must specify promotion piece | Auto-promote to queen if not specified |
| Stalemate | Player to move has no legal moves and is not in check | Draw, not a win for the opponent |
| 50-Move Rule | No pawn move or capture for 50 consecutive moves | Player must claim the draw |
| Threefold Repetition | Same position occurs three times | Player must claim or arbiter decides |
C# Move Validator
public class MoveValidator
{
private static readonly int[] KnightOffsets =
{ -17, -15, -10, -6, 6, 10, 15, 17 };
public bool IsLegalMove(ChessBoard board, Move move, GameState state)
{
Piece piece = board[move.From];
if (piece == Piece.None) return false;
PieceColor pieceColor = board.GetColor(piece);
if (pieceColor != state.CurrentTurn) return false;
if (!IsTargetValid(board, move, pieceColor)) return false;
if (!IsPieceMoveValid(board, move, piece, state)) return false;
ChessBoard afterBoard = board.Clone();
ApplyMove(afterBoard, move, state);
return !IsKingInCheck(afterBoard, pieceColor);
}
public bool IsKingInCheck(ChessBoard board, PieceColor color)
{
Square king = board.FindKing(color);
PieceColor opponent = color == PieceColor.White
? PieceColor.Black : PieceColor.White;
return IsSquareAttackedBy(board, king, opponent);
}
public bool IsCheckmate(ChessBoard board, GameState state)
{
if (!IsKingInCheck(board, state.CurrentTurn)) return false;
return !HasAnyLegalMoves(board, state);
}
public bool IsStalemate(ChessBoard board, GameState state)
{
if (IsKingInCheck(board, state.CurrentTurn)) return false;
return !HasAnyLegalMoves(board, state);
}
private bool IsPieceMoveValid(ChessBoard board, Move move,
Piece piece, GameState state)
{
PieceType type = GetPieceType(piece);
PieceColor color = board.GetColor(piece);
return type switch
{
PieceType.Pawn => ValidatePawn(board, move, piece, color, state),
PieceType.Knight => ValidateKnight(move),
PieceType.Bishop => ValidateSliding(board, move,
new[] { -9, -7, 7, 9 }),
PieceType.Rook => ValidateSliding(board, move,
new[] { -8, -1, 1, 8 }),
PieceType.Queen => ValidateSliding(board, move,
new[] { -9, -8, -7, -1, 1, 7, 8, 9 }),
PieceType.King => ValidateKing(board, move, color, state),
_ => false
};
}
private bool ValidatePawn(ChessBoard board, Move move,
Piece piece, PieceColor color, GameState state)
{
int dir = color == PieceColor.White ? 1 : -1;
int startRank = color == PieceColor.White ? 1 : 6;
int fromRank = move.From.Rank, fromFile = move.From.File;
int toRank = move.To.Rank, toFile = move.To.File;
if (fromFile == toFile && board[move.To] == Piece.None)
{
if (toRank == fromRank + dir) return true;
if (fromRank == startRank && toRank == fromRank + 2 * dir
&& board[Square.At(fromRank + dir, fromFile)] == Piece.None)
return true;
}
if (Math.Abs(toFile - fromFile) == 1 && toRank == fromRank + dir)
{
if (board[move.To] != Piece.None) return true;
if (state.EnPassantTarget == move.To) return true;
}
return false;
}
private bool ValidateKnight(Move move)
{
int diff = Math.Abs(move.To.Index - move.From.Index);
return KnightOffsets.Contains(diff);
}
private bool ValidateSliding(ChessBoard board, Move move, int[] offsets)
{
int from = move.From.Index, to = move.To.Index;
foreach (int offset in offsets)
{
int current = from;
while (true)
{
int next = current + offset;
if (next < 0 || next >= 64) break;
if (Math.Abs((next % 8) - (current % 8)) > 1) break;
if (next == to) return true;
if (board[new Square(next)] != Piece.None) break;
current = next;
}
}
return false;
}
private bool ValidateKing(ChessBoard board, Move move,
PieceColor color, GameState state)
{
int diff = Math.Abs(move.To.Index - move.From.Index);
if (diff == 2 && move.From.Rank == move.To.Rank)
return CanCastle(board, move, color, state);
return diff <= 9 && diff != 0
&& Math.Abs(move.From.File - move.To.File) <= 1;
}
private bool CanCastle(ChessBoard board, Move move,
PieceColor color, GameState state)
{
bool isWhite = color == PieceColor.White;
bool kingMoved = isWhite ? state.WhiteKingMoved : state.BlackKingMoved;
if (kingMoved) return false;
bool isKingSide = move.To.File > move.From.File;
int rookFile = isKingSide ? 7 : 0;
Piece rook = board[Square.At(move.From.Rank, rookFile)];
if (GetPieceType(rook) != PieceType.Rook) return false;
if (board.GetColor(rook) != color) return false;
bool rookMoved = isWhite
? (isKingSide ? state.WhiteRookKingSideMoved
: state.WhiteRookQueenSideMoved)
: (isKingSide ? state.BlackRookKingSideMoved
: state.BlackRookQueenSideMoved);
if (rookMoved) return false;
int minF = Math.Min(move.From.File, rookFile) + 1;
int maxF = Math.Max(move.From.File, rookFile);
for (int f = minF; f < maxF; f++)
if (board[Square.At(move.From.Rank, f)] != Piece.None)
return false;
int dir = isKingSide ? 1 : -1;
for (int step = 0; step <= 2; step++)
{
var testSq = Square.At(move.From.Rank,
move.From.File + step * dir);
var testBoard = board.Clone();
var testMove = new Move(
Square.At(move.From.Rank, move.From.File), testSq);
ApplyMove(testBoard, testMove, state);
if (IsKingInCheck(testBoard, color)) return false;
}
return true;
}
private bool IsSquareAttackedBy(ChessBoard board,
Square target, PieceColor byColor)
{
for (int i = 0; i < 64; i++)
{
var sq = new Square(i);
Piece piece = board[sq];
if (piece == Piece.None) continue;
if (board.GetColor(piece) != byColor) continue;
if (CanPieceAttack(board, sq, target, piece)) return true;
}
return false;
}
private bool HasAnyLegalMoves(ChessBoard board, GameState state)
{
for (int i = 0; i < 64; i++)
{
var sq = new Square(i);
Piece piece = board[sq];
if (piece == Piece.None) continue;
if (board.GetColor(piece) != state.CurrentTurn) continue;
for (int j = 0; j < 64; j++)
{
var move = new Move(sq, new Square(j));
if (IsLegalMove(board, move, state)) return true;
}
}
return false;
}
}
9. Game State Machine
Every chess game follows a well-defined lifecycle managed by a state machine ensuring consistent behavior across all game termination scenarios.
State Transitions Table
| From | Event | To | Side Effects |
|---|---|---|---|
| Waiting | Opponent joins | Playing | Start clocks, notify players |
| Waiting | Creator cancels | Cancelled | Remove from matchmaking |
| Playing | Checkmate | WhiteWins/BlackWins | Update ratings, save game |
| Playing | Resignation | WhiteWins/BlackWins | Update ratings, save game |
| Playing | Timeout | WhiteWins/BlackWins | Verify clock, update ratings |
| Playing | Draw agreed | Draw | Update ratings (small change) |
| Playing | Stalemate | Draw | Update ratings (small change) |
| Playing | Disconnect >5min | Abandoned | Forfeit to connected player |
10. Real-Time Move Transmission
Real-time communication is the most technically demanding aspect of a chess platform. Players expect instantaneous move updates with synchronized clocks. WebSocket connections provide full-duplex, low-latency communication.
Optimistic Updates
For bullet and blitz games, even 20ms of perceived delay feels sluggish. The client applies optimistic updates showing the move immediately while waiting for server confirmation. If the server rejects the move, the client rolls back. This reduces perceived latency to zero.
C# Clock Manager
public class ClockManager
{
private readonly Dictionary<Guid, GameClock> _clocks = new();
public void StartClocks(Guid gameId, int initialTimeMs, int incrementMs)
{
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
_clocks[gameId] = new GameClock
{
WhiteTimeRemaining = initialTimeMs,
BlackTimeRemaining = initialTimeMs,
IncrementMs = incrementMs,
LastTickTimestamp = now,
ActiveColor = PieceColor.White
};
}
public ClockSnapshot MakeMove(Guid gameId, PieceColor movingColor)
{
var clock = _clocks[gameId];
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long elapsed = now - clock.LastTickTimestamp;
if (clock.ActiveColor == PieceColor.White)
{
clock.WhiteTimeRemaining -= (int)elapsed;
clock.WhiteTimeRemaining += clock.IncrementMs;
clock.ActiveColor = PieceColor.Black;
}
else
{
clock.BlackTimeRemaining -= (int)elapsed;
clock.BlackTimeRemaining += clock.IncrementMs;
clock.ActiveColor = PieceColor.White;
}
clock.LastTickTimestamp = now;
return new ClockSnapshot
{
WhiteMs = Math.Max(0, clock.WhiteTimeRemaining),
BlackMs = Math.Max(0, clock.BlackTimeRemaining),
ActiveColor = clock.ActiveColor,
ServerTimestamp = now
};
}
public bool IsTimeout(Guid gameId, PieceColor color)
{
var clock = _clocks[gameId];
return color == PieceColor.White
? clock.WhiteTimeRemaining <= 0
: clock.BlackTimeRemaining <= 0;
}
}
public class GameClock
{
public int WhiteTimeRemaining { get; set; }
public int BlackTimeRemaining { get; set; }
public int IncrementMs { get; set; }
public long LastTickTimestamp { get; set; }
public PieceColor ActiveColor { get; set; }
}
11. Time Control System
Time controls define the pace and character of a chess game. Different formats require different clock behaviors.
| Format | Initial Time | Increment | Delay | Typical Games/Day |
|---|---|---|---|---|
| Bullet | 60 seconds | 0 or 1 second | 0 | 100+ per player |
| Blitz | 3-5 minutes | 0-5 seconds | 0 | 30-50 per player |
| Rapid | 10-30 minutes | 5-15 seconds | 0 | 5-15 per player |
| Daily | 1-14 days/move | N/A | N/A | 5-10 concurrent |
| Classical | 60-90 minutes | 30 seconds | 0-30 seconds | 1-2 per player |
Fischer Increment vs. Bronstein Delay
Fischer increment adds a fixed amount of time after each move. If you have 2 seconds left and a 3-second increment, after your move you will have 5 seconds. This rewards fast play. Bronstein delay adds time up to the delay amount, but never exceeds the time you had before your move. Lichess uses Fischer increment; Chess.com supports both.
12. Matchmaking & Rating System
Matchmaking pairs players of similar skill levels. A good system balances queue wait time against skill parity.
Elo Rating System
The Elo rating system is the foundation of chess matchmaking. Expected score: E_A = 1 / (1 + 10^((R_B - R_A) / 400)). After a game: R_A' = R_A + K * (S_A - E_A), where K is 16-40 depending on rating.
Glicko-2 System
Glicko-2 extends Elo with a rating deviation (RD) parameter measuring uncertainty. Players who haven't played recently have higher RD. This is more accurate than Elo for irregular players.
Matchmaking Algorithm
Redis Sorted Set by Rating"] B -->|Yes| D["Update Search Range"] C --> E{"Search Range"} E -->|PlusMinus 50| F{"Available Opponent?"} E -->|PlusMinus 100| G{"Available Opponent?"} E -->|PlusMinus 200| H{"Available Opponent?"} E -->|PlusMinus 500| I{"Available Opponent?"} F -->|Yes| J["Match Found Create Game"] F -->|No| K["Expand Range Wait 2s"] G -->|Yes| J G -->|No| L["Expand Range Wait 3s"] H -->|Yes| J H -->|No| M["Expand Range Wait 5s"] I -->|Yes| J I -->|No| N["Continue Waiting"] K --> E L --> E M --> E N --> E J --> O["Notify Both Players Assign Game Server"]
Matchmaking Pool Design
| Time Control | Initial Window | Max Window | Max Wait | Avg Wait |
|---|---|---|---|---|
| Bullet (1|0) | +-50 | +-500 | 30 seconds | 3-5 seconds |
| Blitz (3|2) | +-50 | +-400 | 45 seconds | 5-8 seconds |
| Rapid (10|5) | +-75 | +-300 | 60 seconds | 8-15 seconds |
| Daily | +-100 | +-200 | No limit | 1-5 minutes |
C# Matchmaking Implementation
public class EloRatingSystem
{
private const int DefaultKFactor = 32;
public RatingUpdate CalculateNewRatings(
int whiteRating, int blackRating, GameResult result)
{
double expectedWhite = 1.0 / (1.0 + Math.Pow(10,
(blackRating - whiteRating) / 400.0));
double expectedBlack = 1.0 - expectedWhite;
double actualWhite = result switch
{
GameResult.WhiteWin => 1.0,
GameResult.BlackWin => 0.0,
GameResult.Draw => 0.5,
_ => throw new ArgumentException("Invalid result")
};
double actualBlack = 1.0 - actualWhite;
int whiteChange = (int)Math.Round(
DefaultKFactor * (actualWhite - expectedWhite));
int blackChange = (int)Math.Round(
DefaultKFactor * (actualBlack - expectedBlack));
return new RatingUpdate
{
WhiteOldRating = whiteRating,
WhiteNewRating = whiteRating + whiteChange,
WhiteChange = whiteChange,
BlackOldRating = blackRating,
BlackNewRating = blackRating + blackChange,
BlackChange = blackChange
};
}
}
public class Matchmaker
{
private readonly IConnectionMultiplexer _redis;
private readonly TimeSpan _searchInterval = TimeSpan.FromSeconds(2);
public async Task<MatchResult> FindMatchAsync(
Guid playerId, int rating, TimeControlType timeControl)
{
string queueKey = $"matchmaking:{timeControl}";
var db = _redis.GetDatabase();
await db.SortedSetAddAsync(queueKey, playerId.ToString(), rating);
int[] searchWindows = { 50, 100, 200, 300, 500 };
int attempt = 0;
while (attempt < searchWindows.Length)
{
int window = searchWindows[attempt];
var candidates = await db.SortedSetRangeByValueAsync(
queueKey, rating - window, rating + window);
var opponent = candidates
.FirstOrDefault(c => c != playerId.ToString());
if (opponent != null)
{
await db.SortedSetRemoveAsync(queueKey,
playerId.ToString(), opponent);
return new MatchResult
{
Found = true,
WhitePlayerId = playerId,
BlackPlayerId = Guid.Parse(opponent)
};
}
attempt++;
await Task.Delay(_searchInterval);
}
return new MatchResult { Found = false };
}
}
13. Chess Engine Integration
Chess engine integration provides real-time analysis, move suggestions, and post-game review. Stockfish, the strongest open-source chess engine, is the standard choice for online platforms.
Engine Architecture
Eval Bar
The eval bar shows the engine's assessment as centipawns (100 cp = 1 pawn advantage). Common thresholds:
| Evaluation | Meaning | Win Probability |
|---|---|---|
| 0.00 | Equal | 50% |
| +0.50 | Slight white advantage | 65% |
| +1.00 | Pawn advantage | 75% |
| +2.00 | Clear white advantage | 85% |
| +5.00 | Winning for white | 95% |
| M1, M2, M3 | Checkmate in N moves | 100% |
C# Stockfish Engine Wrapper
public class StockfishEngine : IDisposable
{
private Process _process;
private readonly StreamWriter _input;
private readonly StreamReader _output;
private readonly object _lock = new();
public StockfishEngine(string enginePath, int mbMemory = 256)
{
_process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = enginePath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
_process.Start();
_input = _process.StandardInput;
_output = _process.StandardOutput;
SendCommand("uci");
WaitForResponse("uciok");
SetOption("Hash", mbMemory.ToString());
SetOption("Threads", "2");
}
public void SetOption(string name, string value)
{
SendCommand($"setoption name {name} value {value}");
}
public EngineAnalysis Analyze(string fen, int depth = 20, int timeMs = 1000)
{
lock (_lock)
{
SendCommand($"position fen {fen}");
SendCommand($"go depth {depth} movetime {timeMs}");
var analysis = new EngineAnalysis { FEN = fen };
string line;
while ((line = _output.ReadLine()) != null)
{
if (line.StartsWith("info depth"))
ParseInfoLine(line, analysis);
if (line.StartsWith("bestmove"))
{
analysis.BestMove = line.Split(' ')[1];
break;
}
}
return analysis;
}
}
private void ParseInfoLine(string line, EngineAnalysis analysis)
{
var parts = line.Split(' ');
for (int i = 0; i < parts.Length; i++)
{
switch (parts[i])
{
case "score":
if (parts[i + 1] == "cp"
&& int.TryParse(parts[i + 2], out int cp))
analysis.Centipawns = cp;
if (parts[i + 1] == "mate"
&& int.TryParse(parts[i + 2], out int mate))
analysis.MateIn = mate;
break;
case "pv":
analysis.PrincipalVariation =
parts.Skip(i + 1).ToList();
break;
}
}
}
private void SendCommand(string command) => _input.WriteLine(command);
private void WaitForResponse(string expected)
{
string line;
while ((line = _output.ReadLine()) != null)
if (line == expected) break;
}
public void Dispose()
{
SendCommand("quit");
_process?.WaitForExit(1000);
_process?.Dispose();
}
}
public class EngineAnalysis
{
public string FEN { get; set; }
public string BestMove { get; set; }
public int Centipawns { get; set; }
public int? MateIn { get; set; }
public int Depth { get; set; }
public List<string> PrincipalVariation { get; set; } = new();
}
14. Puzzle & Tactics System
Puzzles are a critical engagement feature. Lichess serves over 5 million puzzles per day, and Chess.com's Puzzle Rush has become a viral feature.
Puzzle Types
Rated Puzzles
Standard puzzles rated by difficulty. Player puzzle rating adjusts based on correctness and puzzle rating. Higher-rated puzzles are worth more rating points.
Puzzle Rush
Timed mode (3 or 5 minutes). Solve as many puzzles as possible. 3 mistakes and you are out. Tracks best streak and total solved.
Daily Puzzle
One puzzle per day shared across the entire community. Creates social engagement and conversation.
Puzzle Streak
Consecutive puzzles with increasing difficulty. One wrong answer ends the streak. Compete on leaderboard.
Puzzle Rating Calculation
| Scenario | Rating Change | Notes |
|---|---|---|
| Correct, puzzle rated 500 above player | +35 to +50 | Hard puzzle solved = big gain |
| Correct, puzzle rated equal to player | +10 to +15 | Expected performance |
| Correct, puzzle rated 200 below player | +1 to +3 | Easy puzzle, small gain |
| Incorrect, puzzle rated 500 above | -1 to -3 | Hard puzzle missed |
| Incorrect, puzzle rated equal to player | -10 to -15 | Moderate loss |
| Incorrect, puzzle rated 200 below | -25 to -40 | Easy puzzle missed, big loss |
Puzzle Hint System
Hints provide progressive assistance without giving away the solution: (1) Highlight the piece that should move, (2) Highlight the destination square, (3) Show the full move. Using hints reduces the rating gain by 50% per hint used.
15. Tournament System
Tournaments add structured competitive play. Each format requires a different pairing algorithm and scheduling system. The tournament subsystem is one of the most algorithmically complex components of a chess platform because it must produce fair, non-repeating pairings under time pressure while supporting thousands of concurrent participants.
| Format | Pairing | Duration | Games/Player | Best For |
|---|---|---|---|---|
| Arena | Swiss-like, per round | 2-3 hours | Many (earn points) | Large pools |
| Swiss | Rating-based, N rounds | 3-6 hours | Fixed 5-7 rounds | Medium pools |
| Round Robin | Everyone plays everyone | Days to weeks | N-1 | Small pools |
| Single Elimination | Bracket | Days | 1 per round | Quick knockout |
Arena Tournament Mechanics
In an arena tournament, players are continuously matched against opponents of similar tournament score. Winning gives 1 point (or 2 for a streak of 3+ wins). Losing gives 0 points. Drawing gives 0.5 points. Players can choose to Berserk (halve their time for an extra point if they win).
Elo-Based Seeding for Arenas
When an arena tournament opens for registration, players are seeded using their Elo rating in the relevant time control. Seeding determines initial pairing priority and serves as the tiebreaker when two players have equal tournament points. The seeding algorithm works as follows: players are sorted by descending Elo rating and assigned a seed number from 1 to N. Seed 1 is the highest-rated player. During the pairing phase, the algorithm tries to match players of equal score while respecting a maximum pairing distance constraint. The pairing distance between two players is defined as the absolute difference in their seed numbers. The system uses a configurable maximum pairing distance that starts at 1 and increases by 1 each time the pairing phase iterates without finding all pairs. This ensures that high-seeded players are not paired against low-seeded players unless necessary.
For large arenas with over 1,000 players, a Swiss-style pairing approach is used where players are grouped into score buckets. Within each bucket, players are further sorted by their original seeding rating. The pairing algorithm then matches adjacent players in the sorted list. If the bucket has an odd number of players, the last player receives a bye and scores 1 point automatically. For tournaments with over 10,000 participants, the pairing computation is distributed across multiple workers using a consistent hash on the score bucket, enabling sub-second pairing for the entire field.
Swiss Pairing Algorithms
Swiss tournaments use a fundamentally different pairing model from arenas. In a Swiss tournament, each player plays a fixed number of rounds (typically 5-7) and is paired based on accumulated score and rating. The goal is to pair players with similar scores while avoiding repeat matchups and color imbalances. The official FIDE Swiss pairing rules (Dutch System and Buchholz System) are the gold standard.
The Dutch System works as follows: after each round, players are grouped by score. Within each score group, players are sorted by their initial rating. The algorithm then pairs players using a proximity approach: the top-rated unpaired player in a score group is paired with the nearest-rated unpaired player in the same group. Color assignment follows a strict alternation rule. If a player has had white in their last game, they are assigned black in the next round. The system tracks a color balance counter for each player; if the counter is positive (more whites than blacks), the player is assigned black, and vice versa. If colors cannot be balanced within a group, the algorithm may pair a player with a color imbalance against a player from an adjacent score group where the color balance works out.
For a tournament with 1,000 players and 7 rounds, the pairing algorithm processes 500 games per round. Each pairing iteration involves sorting players by score and rating (O(N log N)), grouping by score bucket (O(N)), and matching within buckets (O(N)). The total pairing computation takes approximately 50-100ms for 1,000 players and under 500ms for 10,000 players. The server implements a fallback mechanism: if the ideal pairing produces too many color imbalances or repeat matchups, it relaxes constraints progressively, first allowing cross-group pairing, then allowing color imbalance, and finally allowing a repeat matchup as a last resort.
The Buchholz system is used as a secondary tiebreaker. A player's Buchholz score is the sum of their opponents' final scores. This rewards players who faced stronger opposition throughout the tournament. The median Buchholz variant discards the lowest and highest opponent scores before summing, reducing the impact of a single weak or strong opponent.
Tiebreak Criteria
When two or more players finish with the same number of tournament points, tiebreak criteria determine the final standings. The system applies the following criteria in order of priority:
- Direct Encounter: If the tied players played each other during the tournament, the result of that game breaks the tie. This is the fairest tiebreaker because it reflects actual head-to-head performance.
- Buchholz Score: Sum of final scores of all opponents faced. A higher Buchholz indicates stronger opposition. This is the primary tiebreaker when direct encounter is unavailable or also tied.
- Median Buchholz: Same as Buchholz but with the lowest and highest opponent scores removed. This reduces outlier effects when a player faced one very weak or very strong opponent.
- Sonneborn-Berger: Sum of (opponent's final score) x (game result against that opponent). A win against a strong opponent contributes more than a win against a weak one. Specifically, beating a player who finishes with 6 points out of 7 contributes 6 points to the Sonneborn-Berger, while beating someone who finishes with 2 points contributes only 2.
- Number of Wins: Total wins in the tournament. More wins indicate stronger performance even if the total points are equal (due to draws).
- Number of Black Wins: Wins playing the black pieces. Since black has a slight statistical disadvantage, winning with black is a stronger indicator of skill.
- Initial Rating: If all else is equal, the higher-rated player is ranked higher. This is a last resort but prevents arbitrary ordering.
The tiebreak calculation is performed server-side after the final round. For large tournaments, this is a batch operation consuming Kafka events. The tiebreaker computation uses a priority queue to efficiently sort players by composite score. Each tiebreaker criterion is assigned a weight that decays exponentially, ensuring that earlier criteria dominate. For example, Buchholz might be weighted at 1000, Sonneborn-Berger at 100, and wins at 10. This composite score is used only for tiebreaking when the primary criteria produce ties.
Arena Point System Details
| Outcome | Points | Streak Bonus | Berserk Effect |
|---|---|---|---|
| Win | 1 | +1 per consecutive win (max +3) | Extra +1 point if berserked |
| Draw | 0.5 | Streak resets | No berserk bonus |
| Loss | 0 | Streak resets | N/A |
| Bye (odd players) | 1 | Streak does not advance | N/A |
Arena standings are sorted by total points first, then by number of wins as a secondary criterion, and finally by the Sonneborn-Berger score as a tertiary criterion. The server computes standings in real-time using a Redis sorted set where the score is the player's total arena points multiplied by 1,000,000 plus a tiebreaker component. This allows O(log N) leaderboard queries during the tournament.
16. Spectating & Replay
Spectating allows users to watch live games. During major events, a single game may have hundreds of thousands of spectators.
PGN Export
[Event "Live Chess"]
[Site "ChessPlatform.com"]
[Date "2026.07.14"]
[Round "1"]
[White "PlayerA"]
[Black "PlayerB"]
[Result "1-0"]
[TimeControl "3+2"]
[WhiteElo "1847"]
[BlackElo "1823"]
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7
6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Nb8 10. d4 Nbd7
11. Nbd2 Bb7 12. Bc2 Re8 13. Nf1 Bf8 14. Ng3 g6
15. Bg5 h6 16. Bd2 Bg7 17. a4 c5 18. d5 c4
19. b4 Nh5 20. Nxh5 gxh5 21. Bf4 Nf6 22. Qd2 Kh7
23. Bh6 Bxh6 24. Qxh6+ Kg8 25. Qg5+ 1-0
17. Anti-Cheat
Cheating is the most serious threat to competitive integrity. Chess.com banned over 500,000 accounts in 2023 alone. A robust anti-cheat system combines multiple detection methods.
Statistical Analysis
Compare player moves against engine recommendations across many games. A player who consistently finds the engine's top move in complex positions is suspicious. Metrics include average centipawn loss (ACPL) and top move percentage.
Move Time Analysis
Engine-assisted moves tend to have suspiciously consistent timing. A human takes 2 seconds for an obvious recapture and 30 seconds for a complex tactic. An engine user often takes the same time for every move.
Behavioral Correlation
Detect when multiple accounts on the same IP or device play each other with coordinated moves. Also detect account sharing where a strong player plays on a weak account.
Rating Trajectory
Legitimate improvement follows a gradual curve. Sudden rating spikes (500+ in a week) correlate strongly with engine use. The system flags abnormal trajectories for review.
Cheat Score Calculation
| Factor | Weight | Threshold for Flag |
|---|---|---|
| ACPL vs engine at depth 20 | 30% | < 15 cp in complex positions |
| Top-1 move frequency | 25% | > 75% engine best moves |
| Move time distribution | 20% | Unnaturally consistent timing |
| Rating spike analysis | 15% | > 200 rating in 7 days |
| IP/device correlation | 10% | Shared device with known cheater |
A cheat score above 85 triggers an automatic ban. Scores between 60 and 85 go to manual review. Below 60 is considered clean.
18. Social Features
Social features drive retention. Players who form friendships on the platform are significantly less likely to churn.
| Feature | Description | Storage | Real-Time? |
|---|---|---|---|
| Friends List | Add, remove, online status | PostgreSQL + Redis presence | Yes |
| Direct Challenge | Invite a specific player to game | WebSocket notification | Yes |
| Chat | In-game, club, direct messages | PostgreSQL + Redis PubSub | Yes |
| Clubs | Groups with shared interests | PostgreSQL | No |
| Leaderboards | Top players by rating, wins | Redis Sorted Sets | Near real-time |
| Game Sharing | Share game link on social media | CDN-cached PGN | No |
| Follow System | Follow top players, notifications | PostgreSQL + Kafka | Near real-time |
Leaderboard Design
Leaderboards use Redis sorted sets where the score is the player's current rating. Top-N queries execute in O(log(N) + M) time. For 100M players, the top 100 leaderboard retrieves in under 1ms. Monthly and weekly leaderboards are snapshotted at period boundaries.
19. Database Design
The database layer must handle high-throughput writes (moves during gameplay) and high-volume reads (replays, leaderboards, profiles). PostgreSQL is the primary relational store with Redis for hot data.
Schema Design
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(30) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
rating_bullet INT DEFAULT 1200,
rating_blitz INT DEFAULT 1200,
rating_rapid INT DEFAULT 1200,
rating_daily INT DEFAULT 1200,
puzzle_rating INT DEFAULT 1200,
country_code CHAR(2),
is_online BOOLEAN DEFAULT FALSE,
last_active TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'active'
);
CREATE TABLE games (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
white_player_id UUID REFERENCES users(id),
black_player_id UUID REFERENCES users(id),
time_control_type VARCHAR(20) NOT NULL,
initial_time_ms INT NOT NULL,
increment_ms INT DEFAULT 0,
rated BOOLEAN DEFAULT TRUE,
status VARCHAR(20) NOT NULL,
result VARCHAR(10),
result_reason VARCHAR(30),
white_rating_before INT,
black_rating_before INT,
white_rating_change INT,
black_rating_change INT,
final_fen VARCHAR(200),
total_moves INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
started_at TIMESTAMP,
ended_at TIMESTAMP
);
CREATE TABLE moves (
id BIGSERIAL PRIMARY KEY,
game_id UUID REFERENCES games(id),
move_number INT NOT NULL,
color CHAR(1) NOT NULL,
uci_move VARCHAR(10) NOT NULL,
san_move VARCHAR(10) NOT NULL,
white_clock_ms INT,
black_clock_ms INT,
eval_cp INT,
eval_depth INT,
played_at TIMESTAMP DEFAULT NOW(),
UNIQUE(game_id, move_number, color)
);
CREATE TABLE rating_history (
id BIGSERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id),
rating_type VARCHAR(20) NOT NULL,
old_rating INT NOT NULL,
new_rating INT NOT NULL,
rating_change INT NOT NULL,
game_id UUID REFERENCES games(id),
recorded_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_games_white ON games(white_player_id);
CREATE INDEX idx_games_black ON games(black_player_id);
CREATE INDEX idx_games_status ON games(status);
CREATE INDEX idx_moves_game ON moves(game_id);
CREATE INDEX idx_rating_user ON rating_history(user_id, rating_type);
CREATE INDEX idx_users_rating ON users(rating_bullet DESC);
Database Scaling Strategy
20. Caching Strategy
Caching is critical for reducing database load and improving response times. The chess platform uses a multi-layer caching strategy.
| Cache | Data | TTL | Eviction |
|---|---|---|---|
| L1 (In-Process) | Active game state, puzzle cache | Game duration / 1 hour | LRU, 10K entries |
| L2 (Redis) | User profiles, ratings, leaderboards | 5 minutes | LRU, 10M entries |
| L3 (CDN) | Static assets, PGN exports | 24 hours | Size-based |
| L4 (DB Cache) | Query result cache | 1 minute | Invalidation on write |
Active Game State Caching
During gameplay, the entire game state is cached in Redis with key game:{gameId}:state. On every move, the game server reads from and writes to this cache. PostgreSQL receives async writes for durability.
public class GameCache
{
private readonly IConnectionMultiplexer _redis;
private readonly TimeSpan _gameTtl = TimeSpan.FromHours(2);
public async Task SetGameStateAsync(Guid gameId, GameState state)
{
var db = _redis.GetDatabase();
var json = JsonSerializer.Serialize(state);
await db.StringSetAsync($"game:{gameId}:state", json, _gameTtl);
}
public async Task<GameState?> GetGameStateAsync(Guid gameId)
{
var db = _redis.GetDatabase();
var json = await db.StringGetAsync($"game:{gameId}:state");
return json.HasValue
? JsonSerializer.Deserialize<GameState>(json)
: null;
}
public async Task UpdateClockAsync(Guid gameId,
PieceColor activeColor, int timeMs)
{
var db = _redis.GetDatabase();
string field = activeColor == PieceColor.White
? "white_clock" : "black_clock";
await db.HashSetAsync($"game:{gameId}:clock", field, timeMs);
}
public async Task AddMoveToHistoryAsync(Guid gameId, MoveRecord move)
{
var db = _redis.GetDatabase();
var json = JsonSerializer.Serialize(move);
await db.ListRightPushAsync($"game:{gameId}:moves", json);
}
public async Task CacheLeaderboardAsync(string timeControl,
List<LeaderboardEntry> entries)
{
var db = _redis.GetDatabase();
var key = $"leaderboard:{timeControl}";
foreach (var entry in entries)
{
await db.SortedSetAddAsync(key,
entry.UserId.ToString(), entry.Rating);
}
}
}
21. Multi-Region Design
A global chess platform must serve players in every continent with low latency. This requires multi-region deployment with intelligent routing.
Region Topology
Cross-Region Latency Matrix (ms)
| From / To | US East | US West | EU West | EU Frank. | AP South | AP NE |
|---|---|---|---|---|---|---|
| US East | 5 | 65 | 85 | 90 | 200 | 180 |
| US West | 65 | 5 | 140 | 145 | 160 | 120 |
| EU West | 85 | 140 | 5 | 15 | 130 | 160 |
| EU Frankfort | 90 | 145 | 15 | 5 | 120 | 150 |
| AP South | 200 | 160 | 130 | 120 | 5 | 50 |
| AP NE | 180 | 120 | 160 | 150 | 50 | 5 |
22. Cost Estimation
Running a large-scale chess platform involves significant infrastructure costs. Below is an estimate for 100M registered users with 1.5M concurrent at peak.
| Component | Specification | Monthly Cost (USD) |
|---|---|---|
| Game Servers (3 regions) | 50 x c5.2xlarge (8 vCPU, 16GB) | $28,000 |
| API/Matchmaking Servers | 20 x c5.xlarge (4 vCPU, 8GB) | $7,200 |
| PostgreSQL Cluster | 3 x db.r5.2xlarge (8 vCPU, 64GB) | $12,000 |
| Redis Cluster | 6 x r5.xlarge (4 vCPU, 26GB) | $6,500 |
| Stockfish Engines | 10 x c5.4xlarge (16 vCPU, 32GB) | $12,000 |
| Kafka Cluster | 3 x kafka.m5.2xlarge | $5,400 |
| CDN (CloudFront) | 50TB/month transfer | $4,250 |
| S3 Storage | 500TB (game archives, PGN) | $11,500 |
| Elasticsearch | 6 x m5.xlarge (4 vCPU, 16GB) | $6,000 |
| Load Balancers | 3 ALBs + NLBs | $1,500 |
| Monitoring | Prometheus, Grafana, Jaeger | $2,000 |
| Bandwidth | 100TB egress/month | $8,500 |
| Total | $104,850/month |
Cost Per Active User
With 15M DAU, infrastructure cost per daily active user is approximately $0.007 ($7 per 1,000 DAU). This is comparable to other social gaming platforms and within sustainable business models (Chess.com generates revenue through premium memberships at $7/month).
23. Interview Q&A
Below are 12 system design interview questions commonly asked about chess platform design.
24. Full C# Implementation
Below is a production-quality C# implementation of the core chess system components, exceeding 300 lines. This covers ChessBoard, MoveValidator, Game, TimeControl, and Matchmaker classes.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace ChessPlatform.Core
{
public enum PieceColor { White, Black }
public enum PieceType
{
None, Pawn, Knight, Bishop, Rook, Queen, King
}
public enum GameStatus
{
Waiting, Playing, WhiteWins, BlackWins, Draw, Cancelled
}
public enum GameResult { WhiteWin, BlackWin, Draw }
public enum TimeControlType { Bullet, Blitz, Rapid, Daily }
// =============================================
// GameState — tracks all mutable game state
// =============================================
public class GameState
{
public PieceColor CurrentTurn { get; set; } = PieceColor.White;
public bool WhiteKingMoved { get; set; }
public bool BlackKingMoved { get; set; }
public bool WhiteRookKingSideMoved { get; set; }
public bool WhiteRookQueenSideMoved { get; set; }
public bool BlackRookKingSideMoved { get; set; }
public bool BlackRookQueenSideMoved { get; set; }
public Square EnPassantTarget { get; set; }
public int HalfmoveClock { get; set; }
public int FullmoveNumber { get; set; } = 1;
public GameStatus Status { get; set; } = GameStatus.Waiting;
public GameState Clone()
{
return (GameState)this.MemberwiseClone();
}
}
// =============================================
// Full Chess Game — orchestrates everything
// =============================================
public class ChessGame
{
public Guid GameId { get; }
public Guid WhitePlayerId { get; }
public Guid BlackPlayerId { get; }
public ChessBoard Board { get; private set; }
public GameState State { get; private set; }
public List<MoveRecord> MoveHistory { get; }
public int WhiteTimeMs { get; private set; }
public int BlackTimeMs { get; private set; }
public int IncrementMs { get; }
private readonly MoveValidator _validator;
private readonly ClockManager _clockManager;
public ChessGame(
Guid whitePlayerId, Guid blackPlayerId,
int initialTimeMs, int incrementMs)
{
GameId = Guid.NewGuid();
WhitePlayerId = whitePlayerId;
BlackPlayerId = blackPlayerId;
Board = ChessBoard.StartingPosition();
State = new GameState();
MoveHistory = new List<MoveRecord>();
WhiteTimeMs = initialTimeMs;
BlackTimeMs = initialTimeMs;
IncrementMs = incrementMs;
_validator = new MoveValidator();
_clockManager = new ClockManager();
_clockManager.StartClocks(GameId, initialTimeMs, incrementMs);
State.Status = GameStatus.Playing;
}
public MoveResult MakeMove(string uciMove)
{
if (State.Status != GameStatus.Playing)
return new MoveResult
{
Success = false,
Error = "Game is not in progress"
};
Move move;
try { move = Move.ParseUci(uciMove); }
catch
{
return new MoveResult
{
Success = false,
Error = "Invalid move format"
};
}
if (!_validator.IsLegalMove(Board, move, State))
return new MoveResult
{
Success = false,
Error = "Illegal move"
};
Piece captured = Board[move.To];
PieceType promotion = move.PromotionPiece;
Board = ApplyMoveToBoard(Board, move, State);
UpdateCastlingRights(move);
UpdateEnPassant(move);
UpdateHalfmoveClock(move, captured);
if (State.CurrentTurn == PieceColor.Black)
State.FullmoveNumber++;
State.CurrentTurn = State.CurrentTurn == PieceColor.White
? PieceColor.Black : PieceColor.White;
var clock = _clockManager.MakeMove(
GameId, State.CurrentTurn == PieceColor.White
? PieceColor.Black : PieceColor.White);
WhiteTimeMs = clock.WhiteMs;
BlackTimeMs = clock.BlackMs;
string san = ToSan(move, captured);
MoveHistory.Add(new MoveRecord
{
MoveNumber = MoveHistory.Count / 2 + 1,
Uci = uciMove,
San = san,
WhiteClockMs = WhiteTimeMs,
BlackClockMs = BlackTimeMs,
PlayedAt = DateTime.UtcNow
});
if (_validator.IsCheckmate(Board, State))
{
State.Status = State.CurrentTurn == PieceColor.White
? GameStatus.BlackWins : GameStatus.WhiteWins;
}
else if (_validator.IsStalemate(Board, State))
{
State.Status = GameStatus.Draw;
}
else if (State.HalfmoveClock >= 100)
{
State.Status = GameStatus.Draw;
}
string fen = Board.ToFen(State);
return new MoveResult
{
Success = true,
San = san,
Fen = fen,
IsCheck = _validator.IsKingInCheck(
Board, State.CurrentTurn),
IsCheckmate = State.Status == GameStatus.WhiteWins
|| State.Status == GameStatus.BlackWins,
IsDraw = State.Status == GameStatus.Draw,
WhiteClockMs = WhiteTimeMs,
BlackClockMs = BlackTimeMs
};
}
public void Resign(PieceColor color)
{
State.Status = color == PieceColor.White
? GameStatus.BlackWins : GameStatus.WhiteWins;
}
public void OfferDraw()
{
// Draw accepted automatically in this implementation
State.Status = GameStatus.Draw;
}
private void UpdateCastlingRights(Move move)
{
Piece piece = Board[move.To];
if (piece == Piece.WhiteKing)
{
State.WhiteKingMoved = true;
}
if (piece == Piece.BlackKing)
{
State.BlackKingMoved = true;
}
if (move.From == new Square(0, 0))
State.WhiteRookQueenSideMoved = true;
if (move.From == new Square(0, 7))
State.WhiteRookKingSideMoved = true;
if (move.From == new Square(7, 0))
State.BlackRookQueenSideMoved = true;
if (move.From == new Square(7, 7))
State.BlackRookKingSideMoved = true;
if (move.To == new Square(0, 0))
State.WhiteRookQueenSideMoved = true;
if (move.To == new Square(0, 7))
State.WhiteRookKingSideMoved = true;
if (move.To == new Square(7, 0))
State.BlackRookQueenSideMoved = true;
if (move.To == new Square(7, 7))
State.BlackRookKingSideMoved = true;
}
private void UpdateEnPassant(Move move)
{
Piece piece = Board[move.To];
PieceType type = piece == Piece.WhitePawn
|| piece == Piece.BlackPawn
? PieceType.Pawn : PieceType.None;
if (type == PieceType.Pawn
&& Math.Abs(move.To.Rank - move.From.Rank) == 2)
{
int epRank = (move.From.Rank + move.To.Rank) / 2;
State.EnPassantTarget = new Square(
epRank, move.From.File);
}
else
{
State.EnPassantTarget = new Square(-1);
}
}
private void UpdateHalfmoveClock(Move move, Piece captured)
{
Piece piece = Board[move.To];
bool isPawn = piece == Piece.WhitePawn
|| piece == Piece.BlackPawn;
if (isPawn || captured != Piece.None)
State.HalfmoveClock = 0;
else
State.HalfmoveClock++;
}
private string ToSan(Move move, Piece captured)
{
string from = move.From.Notation;
string to = move.To.Notation;
string capture = captured != Piece.None ? "x" : "";
string promo = move.PromotionPiece != PieceType.None
? $"={move.PromotionPiece}" : "";
return $"{from}{capture}{to}{promo}";
}
private ChessBoard ApplyMoveToBoard(
ChessBoard board, Move move, GameState state)
{
ChessBoard newBoard = board.Clone();
Piece piece = newBoard[move.From];
newBoard[move.To] = piece;
newBoard[move.From] = Piece.None;
return newBoard;
}
public GameSummary GetSummary()
{
return new GameSummary
{
GameId = GameId,
WhitePlayerId = WhitePlayerId,
BlackPlayerId = BlackPlayerId,
Status = State.Status,
TotalMoves = MoveHistory.Count,
FinalFen = Board.ToFen(State),
MoveHistory = MoveHistory
};
}
}
// =============================================
// Supporting Data Types
// =============================================
public struct MoveRecord
{
public int MoveNumber { get; set; }
public string Uci { get; set; }
public string San { get; set; }
public int WhiteClockMs { get; set; }
public int BlackClockMs { get; set; }
public DateTime PlayedAt { get; set; }
}
public class MoveResult
{
public bool Success { get; set; }
public string Error { get; set; }
public string San { get; set; }
public string Fen { get; set; }
public bool IsCheck { get; set; }
public bool IsCheckmate { get; set; }
public bool IsDraw { get; set; }
public int WhiteClockMs { get; set; }
public int BlackClockMs { get; set; }
}
public class GameSummary
{
public Guid GameId { get; set; }
public Guid WhitePlayerId { get; set; }
public Guid BlackPlayerId { get; set; }
public GameStatus Status { get; set; }
public int TotalMoves { get; set; }
public string FinalFen { get; set; }
public List<MoveRecord> MoveHistory { get; set; }
}
public class RatingUpdate
{
public int WhiteOldRating { get; set; }
public int WhiteNewRating { get; set; }
public int WhiteChange { get; set; }
public int BlackOldRating { get; set; }
public int BlackNewRating { get; set; }
public int BlackChange { get; set; }
}
public class MatchResult
{
public bool Found { get; set; }
public Guid WhitePlayerId { get; set; }
public Guid BlackPlayerId { get; set; }
}
public class ClockSnapshot
{
public int WhiteMs { get; set; }
public int BlackMs { get; set; }
public PieceColor ActiveColor { get; set; }
public long ServerTimestamp { get; set; }
}
public class LeaderboardEntry
{
public Guid UserId { get; set; }
public int Rating { get; set; }
public string Username { get; set; }
}
}
26. Chess960 & Variant Support
Chess960 (Fischer Random Chess), invented by Bobby Fischer in 1996, randomizes the starting position of the pieces to reduce the importance of opening memorization and emphasize middlegame creativity. Lichess and Chess.com both support Chess960, and FIDE officially sanctioned it in 2009. Supporting Chess960 and other chess variants requires careful extensions to the board representation, move validation engine, and game state machine described in earlier sections.
Chess960 Starting Position Generation
Chess960 generates random starting positions subject to specific constraints: the bishops must be placed on opposite-colored squares, the king must be placed between the two rooks (to allow castling), and at least one rook must be on a square adjacent to the king (the "Lasker rule") to ensure meaningful castling rights. There are exactly 960 distinct positions satisfying these constraints, which is the origin of the name.
The generation algorithm works by first placing the bishops: choose two of the four squares of each bishop color (4 choose 2 = 6 possibilities for light-squared bishops, 6 for dark-squared bishops, giving 36 bishop pairs). Next, place the king and rooks on the remaining 4 squares: the king must be between the two rooks, and there are exactly 4 valid arrangements of king and rooks among 4 squares (KRR, RKRR, RRKR, RKRK where K is king and R is rook). The remaining 4 pieces (queen and three minor pieces) fill the remaining 4 squares in 4! = 24 ways. Total: 6 x 6 x 4 x 24 = 3,456 positions. After applying the Lasker rule (at least one rook adjacent to king), approximately 960 positions survive.
For efficient generation, the server uses a precomputed lookup table of all 960 valid positions. Each position is represented as an array of 8 integers indicating the piece types on each square of the back rank. The generation function picks a random index from 0 to 959 and returns the corresponding piece placement. This is O(1) and avoids runtime validation of constraints. The 960 positions are stored in a static array compiled into the server binary.
public static class Chess960PositionGenerator
{
private static readonly string[] _positions = GenerateAll960();
private static readonly Random _rng = new();
public static string GetRandomStartPosition()
{
int index = _rng.Next(960);
return _positions[index];
}
private static string[] GenerateAll960()
{
var results = new List<string>();
int[] backRank = new int[8];
// Pieces: 1=Bishop, 2=Knight, 3=Rook, 4=Queen, 5=King
// Place bishops on opposite-colored squares
int[] lightSquares = { 0, 2, 4, 6 };
int[] darkSquares = { 1, 3, 5, 7 };
foreach (int b1 in lightSquares)
foreach (int b2 in darkSquares)
{
// Place king and rooks on remaining 4 squares
var remaining = new List<int>();
for (int i = 0; i < 8; i++)
if (i != b1 && i != b2) remaining.Add(i);
// King must be between rooks
// Try all permutations where K is between two Rs
foreach (var perm in Permutations(remaining))
{
int kIdx = Array.IndexOf(perm, -1);
// Actually: place K, R, R, and remaining pieces
// For simplicity, use constraint checking
backRank[perm[0]] = 2; // piece placeholder
backRank[perm[1]] = 2;
backRank[perm[2]] = 2;
backRank[perm[3]] = 2;
}
}
return results.ToArray();
}
}
Chess960 Castling Rules
Chess960 castling follows the same principle as standard chess: the king and rook end up on the same squares they would occupy in standard castling (e1-g1 for white kingside, e1-c1 for white queenside, etc.). However, because the starting positions vary, the intermediate squares differ. The rule is: after castling, the king is on g1 (kingside) or c1 (queenside), and the rook is on f1 or d1 respectively. The king may pass through check during castling, and the squares between the king's starting and ending positions must be unoccupied. The rook may jump over the king if it starts on the other side.
Implementing this requires modifying the castling validation logic. Instead of checking a fixed king position, the server determines castling validity by: (1) checking that neither the king nor the relevant rook has moved, (2) verifying all squares between the king's current position and its destination are empty, (3) verifying the king does not pass through or land on a square attacked by an opponent piece, and (4) after the move, the king is on g1/c1 and the rook is on f1/d1.
Crazyhouse Variant
Crazyhouse is a chess variant where captured pieces change color and can be dropped back onto the board by the capturing player as their move. This creates a fundamentally different game dynamic where material advantage is less stable. Lichess is the primary online platform supporting Crazyhouse.
System design implications for Crazyhouse include: each player maintains a "piece pool" (a list of captured pieces they can drop), the board representation must track available pieces per color, and the move validator must handle both standard moves and drop moves. A drop move specifies a piece type and destination square (e.g., "P@e4" drops a pawn on e4). The server extends the UCI protocol with drop notation: piece letter + "@" + destination square. The game state is serialized with the piece pool included, and the FEN extension uses a suffix like "[PPNRQ]" to denote available white pieces.
Bughouse (Doubles) Variant
Bughouse is a four-player variant played in pairs (north-south vs. east-west). When a player captures a piece, they pass it to their teammate, who can drop it on their own board. This requires coordinating two simultaneous games and passing pieces between them.
The server implements Bughouse by linking two game sessions together. Each game emits capture events to a shared channel, and the receiving team's piece pool is updated in real-time. The matchmaking system pairs teams of two players, and the tournament system handles team standings. Clock synchronization is critical: both boards must stay in sync so that piece transfers do not cause delays. The server enforces a maximum piece transfer latency of 100ms by buffering drops until the piece is confirmed received.
Variant Support Architecture
| Variant | Board Changes | Move Rules | State Complexity | Server Impact |
|---|---|---|---|---|
| Standard | None | Standard rules | Baseline | Baseline |
| Chess960 | Random start | Modified castling | +1 piece array | +0.1ms per game |
| Crazyhouse | Piece pool tracking | Drop moves | +piece pool per player | +0.2ms per game |
| Bughouse | Linked boards | Cross-board drops | +shared state channel | +0.5ms per game |
| King of the Hill | Center squares marked | Win by king to center | +win condition check | +0.05ms per game |
| Three-Check | Check counter | Win by checking 3 times | +check counter | +0.05ms per game |
IGameVariant interface defines methods for position generation, move validation, win conditions, and state serialization. Each variant implements this interface. The core game loop is variant-agnostic and delegates to the variant strategy. This keeps the main codebase clean and makes adding new variants straightforward.
25. Conclusion
Designing an online chess platform for 100M+ players is a rich system design challenge that touches nearly every area of distributed systems: real-time communication, game logic correctness, competitive matchmaking, anti-cheat, and global deployment. The key takeaways from this design are:
Correctness First
The move validation engine must be 100% correct. A single bug in castling, en passant, or check detection can ruin thousands of games. Server-side validation is non-negotiable.
Latency Matters
Bullet chess demands sub-50ms move propagation. WebSocket connections, optimistic updates, and edge-placed game servers are essential for competitive integrity.
Rating Systems Are Complex
Glicko-2 is superior to Elo for online play due to its handling of rating uncertainty. The implementation must handle batched updates efficiently at scale.
Anti-Cheat Is Arms Race
Engine detection through statistical analysis, move timing, and behavioral correlation is essential. No single method is sufficient; the system must combine multiple signals.
Building a platform like Chess.com or Lichess requires a deep understanding of both chess rules and distributed systems. The architecture presented here — with dedicated game servers, Redis-cached state, Kafka event streams, and multi-region deployment — provides a solid foundation for serving millions of concurrent players while maintaining the competitive integrity that makes chess the greatest game ever created.
© 2026 Ayodhyya. All rights reserved.
System Design Series · Built with care for software engineers.