Skip to content

🗄️ Chess Game — Database Schema & Relationships

Database: PostgreSQL 16 + Redis 7
Purpose: Online chess platform — game state, player profiles, matchmaking, tournaments, analytics
Scale: 1M+ users, 100K concurrent games, 500K games/day
Tables: 12 tables (game_moves is hash-partitioned)


📊 Entity Relationship Diagram (Textual)

┌──────────────┐     ┌───────────────┐     ┌───────────────┐
│    users     │1───N│   user_stats  │     │  game_sessions│
└──────┬───────┘     └───────────────┘     └───────┬───────┘
       │                                           │
       │1                                          │1
       │                                           │
┌──────▼────────┐     ┌───────────────┐     ┌──────▼────────┐
│ user_ratings  │     │  game_        │     │  game_moves   │
│ (history)     │     │  sessions     │     │               │
└───────────────┘     └───────┬───────┘     └───────────────┘
                              │
                    ┌─────────▼──────────┐     ┌────────────────┐
                    │   tournament_      │     │  tournament_   │
                    │   registrations    │1───N│  rounds        │
                    └────────────────────┘     └───────┬────────┘
                                                        │1
                                                        │
                    ┌───────────────┐     ┌──────────────▼────┐
                    │  game_        │     │  tournament_      │
                    │  analysis     │     │  matches          │
                    └───────────────┘     └───────────────────┘

┌───────────────┐     ┌───────────────┐
│ user_         │     │  user_sessions│
│ friendships   │     │               │
└───────────────┘     └───────────────┘

Figure: tables and foreign keys, generated from the DDL below (one-to-many from parent to child).

erDiagram
  game_sessions ||--o{ game_analysis : "game_id"
  game_sessions ||--o{ game_moves : "game_id"
  game_sessions ||--o{ tournament_matches : "game_id"
  game_sessions ||--o{ user_rating_history : "game_id"
  tournament_rounds ||--o{ tournament_matches : "round_id"
  tournaments ||--o{ tournament_matches : "tournament_id"
  tournaments ||--o{ tournament_registrations : "tournament_id"
  tournaments ||--o{ tournament_rounds : "tournament_id"
  users ||--o{ game_sessions : "black_player_id"
  users ||--o{ game_sessions : "white_player_id"
  users ||--o{ game_sessions : "winner_id"
  users ||--o{ tournament_matches : "player1_id"
  users ||--o{ tournament_matches : "player2_id"
  users ||--o{ tournament_registrations : "user_id"
  users ||--o{ tournaments : "created_by"
  users ||--o{ user_friendships : "addressee_id"
  users ||--o{ user_friendships : "requester_id"
  users ||--o{ user_rating_history : "opponent_id"
  users ||--o{ user_rating_history : "user_id"
  users ||--o{ user_sessions : "user_id"
  users ||--o{ user_stats : "user_id"

🏛️ Complete DDL

-- ============================================================
-- Chess Game Platform - Production Database Schema
-- Database: PostgreSQL 16
-- Scale: 1M+ users, 100K concurrent games, 500K games/day
-- ============================================================

-- gen_random_uuid() is built in since PostgreSQL 13; no extension needed.

-- -----------------------------------------------------------
-- 1. USERS
-- -----------------------------------------------------------
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    display_name VARCHAR(100),
    avatar_url TEXT,
    bio TEXT,
    country VARCHAR(100),
    language VARCHAR(10) DEFAULT 'en',
    is_verified BOOLEAN DEFAULT false,
    is_bot BOOLEAN DEFAULT false,                    -- Distinguish human vs AI players
    status VARCHAR(20) DEFAULT 'OFFLINE'
        CHECK (status IN ('ONLINE', 'IN_GAME', 'AWAY', 'OFFLINE', 'BANNED')),
    last_seen_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- username/email already have indexes via UNIQUE; don't duplicate them.
CREATE INDEX idx_users_status ON users(status) WHERE status != 'OFFLINE';
CREATE INDEX idx_users_last_seen ON users(last_seen_at DESC);
CREATE INDEX idx_users_country ON users(country);

-- -----------------------------------------------------------
-- 2. USER STATS (Performance metrics)
-- -----------------------------------------------------------
CREATE TABLE user_stats (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID UNIQUE NOT NULL REFERENCES users(id),
    rating_rapid INT DEFAULT 1200,
    rating_blitz INT DEFAULT 1200,
    rating_classical INT DEFAULT 1200,
    rating_bullet INT DEFAULT 1200,
    peak_rating INT DEFAULT 1200,
    games_played INT DEFAULT 0,
    wins INT DEFAULT 0,
    losses INT DEFAULT 0,
    draws INT DEFAULT 0,
    win_streak INT DEFAULT 0,                        -- Current win streak
    longest_win_streak INT DEFAULT 0,
    total_time_played_seconds BIGINT DEFAULT 0,
    average_move_time_seconds DECIMAL(6,1),
    puzzles_solved INT DEFAULT 0,
    puzzle_rating INT DEFAULT 1200,
    last_rating_change INT DEFAULT 0,
    last_rating_change_at TIMESTAMPTZ,
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_stats_rating_rapid ON user_stats(rating_rapid DESC);
CREATE INDEX idx_stats_rating_blitz ON user_stats(rating_blitz DESC);
CREATE INDEX idx_stats_games ON user_stats(games_played DESC);

-- -----------------------------------------------------------
-- 3. GAME SESSIONS (~180M rows/year at 500K games/day)
-- Not partitioned on purpose: PostgreSQL requires the partition key in every
-- PRIMARY KEY / UNIQUE constraint and in every FK that references the table,
-- and five tables reference game_sessions(id). An unpartitioned table with the
-- indexes below handles this size; archive old rows to S3 instead.
-- -----------------------------------------------------------
CREATE TABLE game_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    white_player_id UUID NOT NULL REFERENCES users(id),
    black_player_id UUID NOT NULL REFERENCES users(id),
    winner_id UUID REFERENCES users(id),              -- NULL for draws/unfinished
    result VARCHAR(20) DEFAULT 'IN_PROGRESS'           -- WHO won
        CHECK (result IN ('IN_PROGRESS', 'WHITE_WIN', 'BLACK_WIN', 'DRAW', 'ABORTED')),
    termination_reason VARCHAR(30)                     -- HOW it ended
        CHECK (termination_reason IN (
            'CHECKMATE', 'RESIGNATION', 'TIMEOUT', 'STALEMATE', 'REPETITION',
            'FIFTY_MOVE', 'INSUFFICIENT_MATERIAL', 'AGREEMENT', 'ABANDONED')),
    time_control VARCHAR(30) NOT NULL,                 -- '60+0', '180+2', '600+5', 'UNLIMITED'
    initial_time_seconds INT NOT NULL,                 -- Base time
    increment_seconds INT DEFAULT 0,                   -- Increment per move
    rated BOOLEAN DEFAULT true,

    -- Game state (compressed for storage)
    starting_fen VARCHAR(100) DEFAULT 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
    final_fen VARCHAR(100),
    pgn TEXT,                                          -- Full game in PGN format
    move_count INT DEFAULT 0,

    -- Timing
    white_time_remaining_seconds INT,
    black_time_remaining_seconds INT,
    started_at TIMESTAMPTZ DEFAULT NOW(),
    ended_at TIMESTAMPTZ,
    duration_seconds INT,                              -- Computed: ended_at - started_at

    -- Metadata
    eco_code VARCHAR(10),                              -- Encyclopedia of Chess Openings
    accuracy_white DECIMAL(5,2),                       -- 0-100% accuracy
    accuracy_black DECIMAL(5,2),
    idempotency_key VARCHAR(64) UNIQUE,
    version INT DEFAULT 1,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
,
    CHECK (white_player_id <> black_player_id)
);

CREATE INDEX idx_games_white ON game_sessions(white_player_id);
CREATE INDEX idx_games_black ON game_sessions(black_player_id);
CREATE INDEX idx_games_status ON game_sessions(result) WHERE result = 'IN_PROGRESS';
CREATE INDEX idx_games_started ON game_sessions(started_at DESC);
CREATE INDEX idx_games_eco ON game_sessions(eco_code);
CREATE INDEX idx_games_players ON game_sessions(white_player_id, black_player_id, started_at DESC);

-- -----------------------------------------------------------
-- 4. USER RATING HISTORY (Time-series for ELO tracking)
-- -----------------------------------------------------------
CREATE TABLE user_rating_history (
    id BIGSERIAL,
    user_id UUID NOT NULL REFERENCES users(id),
    game_id UUID NOT NULL REFERENCES game_sessions(id),
    rating_type VARCHAR(20) NOT NULL                  -- 'rapid', 'blitz', 'classical', 'bullet'
        CHECK (rating_type IN ('rapid', 'blitz', 'classical', 'bullet')),
    rating_before INT NOT NULL,
    rating_after INT NOT NULL,
    rating_change INT NOT NULL,
    opponent_id UUID REFERENCES users(id),
    result VARCHAR(10) NOT NULL                       -- 'win', 'loss', 'draw'
        CHECK (result IN ('win', 'loss', 'draw')),
    recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id),
    UNIQUE (user_id, game_id, rating_type)          -- a redelivered game-over event can't double-apply
);

CREATE INDEX idx_rating_history_user ON user_rating_history(user_id, recorded_at DESC);
CREATE INDEX idx_rating_history_game ON user_rating_history(game_id);

-- -----------------------------------------------------------
-- 5. GAME MOVES (~40M rows/day; billions per year)
-- The source of truth for active games: one INSERT per move, before ack.
-- PK (game_id, move_number) doubles as the "one move per ply" guard.
-- -----------------------------------------------------------
CREATE TABLE game_moves (
    game_id UUID NOT NULL REFERENCES game_sessions(id),
    move_number INT NOT NULL,                          -- 1-indexed half-move
    from_square VARCHAR(2) NOT NULL,                   -- e.g., 'e2'
    to_square VARCHAR(2) NOT NULL,                     -- e.g., 'e4'
    piece VARCHAR(2) NOT NULL,                         -- 'P', 'N', 'B', 'R', 'Q', 'K'
    captured_piece VARCHAR(2),                         -- NULL if no capture
    promotion_piece VARCHAR(2),                        -- NULL if no promotion
    is_castling BOOLEAN DEFAULT false,
    is_en_passant BOOLEAN DEFAULT false,
    is_check BOOLEAN DEFAULT false,
    is_checkmate BOOLEAN DEFAULT false,
    fen_after VARCHAR(100) NOT NULL,                   -- Position after the move (fen_before = previous row's fen_after)
    time_taken_seconds DECIMAL(5,1),                   -- Seconds player took for this move
    evaluation_cp INT,                                 -- Centipawn evaluation (engine)
    best_move VARCHAR(5),                              -- Engine's best move at this position
    annotations TEXT,                                   -- Commentary / analysis
    lease_epoch BIGINT NOT NULL,                       -- fencing token of the writing pod
    created_at TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (game_id, move_number)
) PARTITION BY HASH (game_id);

-- 64 hash partitions keep each index/vacuum unit small. The PK already covers
-- "all moves of a game in order", so no extra index is needed.
CREATE TABLE game_moves_p00 PARTITION OF game_moves FOR VALUES WITH (MODULUS 64, REMAINDER 0);
-- ... game_moves_p01 .. game_moves_p63

-- -----------------------------------------------------------
-- 6. GAME ANALYSIS (Stockfish/Engine evaluations)
-- -----------------------------------------------------------
CREATE TABLE game_analysis (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    game_id UUID UNIQUE NOT NULL REFERENCES game_sessions(id),
    engine_name VARCHAR(50) DEFAULT 'Stockfish_16',
    engine_depth INT DEFAULT 20,
    analysis_depth INT,                                -- Depth reached
    nodes_searched BIGINT,
    evaluation_cp INT,                                 -- Final evaluation in centipawns
    best_line TEXT,                                    -- Best continuation (moves)
    -- Blunder detection
    white_blunders INT DEFAULT 0,
    black_blunders INT DEFAULT 0,
    white_mistakes INT DEFAULT 0,
    black_mistakes INT DEFAULT 0,
    white_inaccuracies INT DEFAULT 0,
    black_inaccuracies INT DEFAULT 0,
    -- Accuracy scores
    white_accuracy DECIMAL(5,2),
    black_accuracy DECIMAL(5,2),
    -- JSON analysis per move
    move_analysis JSONB,                               -- [{move_number, evaluation, best, blunder}, ...]
    opening_name VARCHAR(255),
    opening_moves TEXT,
    status VARCHAR(20) DEFAULT 'PENDING'
        CHECK (status IN ('PENDING', 'ANALYZING', 'COMPLETED', 'FAILED')),
    completed_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_analysis_status ON game_analysis(status) WHERE status = 'PENDING';

-- -----------------------------------------------------------
-- 7. TOURNAMENTS
-- -----------------------------------------------------------
CREATE TABLE tournaments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    description TEXT,
    tournament_type VARCHAR(30) NOT NULL
        CHECK (tournament_type IN (
            'SWISS', 'ROUND_ROBIN', 'KNOCKOUT', 'ARENA', 'MATCH'
        )),
    time_control VARCHAR(30) NOT NULL,
    rating_min INT DEFAULT 0,
    rating_max INT DEFAULT 9999,
    max_participants INT DEFAULT 64,
    current_participants INT DEFAULT 0,
    rounds INT DEFAULT 7,
    current_round INT DEFAULT 0,
    status VARCHAR(20) DEFAULT 'REGISTRATION'
        CHECK (status IN ('REGISTRATION', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED')),
    start_at TIMESTAMPTZ NOT NULL,
    end_at TIMESTAMPTZ,
    prize_pool DECIMAL(12,2),
    sponsorship_info TEXT,
    created_by UUID REFERENCES users(id),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_tournaments_status ON tournaments(status);
CREATE INDEX idx_tournaments_start ON tournaments(start_at);

-- -----------------------------------------------------------
-- 8. TOURNAMENT REGISTRATIONS
-- -----------------------------------------------------------
CREATE TABLE tournament_registrations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tournament_id UUID NOT NULL REFERENCES tournaments(id),
    user_id UUID NOT NULL REFERENCES users(id),
    seed_rating INT NOT NULL,                          -- Rating at time of registration
    current_score DECIMAL(5,2) DEFAULT 0.00,          -- Tournament score
    current_rank INT,
    status VARCHAR(20) DEFAULT 'REGISTERED'
        CHECK (status IN ('REGISTERED', 'ACTIVE', 'ELIMINATED', 'WITHDREW', 'DISQUALIFIED')),
    registered_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(tournament_id, user_id)
);

CREATE INDEX idx_tournament_reg ON tournament_registrations(tournament_id, current_rank);

-- -----------------------------------------------------------
-- 9. TOURNAMENT ROUNDS
-- -----------------------------------------------------------
CREATE TABLE tournament_rounds (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tournament_id UUID NOT NULL REFERENCES tournaments(id),
    round_number INT NOT NULL,
    status VARCHAR(20) DEFAULT 'PENDING'
        CHECK (status IN ('PENDING', 'PAIRING', 'IN_PROGRESS', 'COMPLETED')),
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    UNIQUE(tournament_id, round_number)
);

-- -----------------------------------------------------------
-- 10. TOURNAMENT MATCHES (bridge between tournaments and games)
-- -----------------------------------------------------------
CREATE TABLE tournament_matches (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tournament_id UUID NOT NULL REFERENCES tournaments(id),
    round_id UUID NOT NULL REFERENCES tournament_rounds(id),
    game_id UUID REFERENCES game_sessions(id),
    player1_id UUID NOT NULL REFERENCES users(id),
    player2_id UUID NOT NULL REFERENCES users(id),
    player1_score DECIMAL(3,1) DEFAULT 0,              -- 0, 0.5, or 1
    player2_score DECIMAL(3,1) DEFAULT 0,
    status VARCHAR(20) DEFAULT 'SCHEDULED'
        CHECK (status IN ('SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'BYE', 'FORFEIT')),
    board_number INT,
    scheduled_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    UNIQUE(tournament_id, round_id, player1_id, player2_id)
);

CREATE INDEX idx_tournament_matches_round ON tournament_matches(round_id);
CREATE INDEX idx_tournament_matches_tournament ON tournament_matches(tournament_id);

-- -----------------------------------------------------------
-- 11. USER FRIENDSHIPS
-- -----------------------------------------------------------
CREATE TABLE user_friendships (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    requester_id UUID NOT NULL REFERENCES users(id),
    addressee_id UUID NOT NULL REFERENCES users(id),
    status VARCHAR(20) DEFAULT 'PENDING'
        CHECK (status IN ('PENDING', 'ACCEPTED', 'BLOCKED', 'DECLINED')),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(requester_id, addressee_id),
    CONSTRAINT no_self_friend CHECK (requester_id != addressee_id)
);

CREATE INDEX idx_friends_requester ON user_friendships(requester_id, status);
CREATE INDEX idx_friends_addressee ON user_friendships(addressee_id, status);

-- -----------------------------------------------------------
-- 12. USER SESSIONS (For WebSocket management)
-- -----------------------------------------------------------
CREATE TABLE user_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id),
    session_token VARCHAR(255) UNIQUE NOT NULL,
    device_type VARCHAR(30),                           -- 'WEB', 'MOBILE', 'DESKTOP'
    device_name VARCHAR(100),
    ip_address INET,
    user_agent TEXT,
    ws_connection_id VARCHAR(100),                     -- WebSocket connection ID
    is_active BOOLEAN DEFAULT true,
    last_heartbeat TIMESTAMPTZ,
    logged_in_at TIMESTAMPTZ DEFAULT NOW(),
    logged_out_at TIMESTAMPTZ,
    metadata JSONB DEFAULT '{}'
);

CREATE INDEX idx_sessions_user ON user_sessions(user_id);
CREATE INDEX idx_sessions_active ON user_sessions(user_id) WHERE is_active = true;
CREATE INDEX idx_sessions_ws ON user_sessions(ws_connection_id) WHERE ws_connection_id IS NOT NULL;

-- -----------------------------------------------------------
-- KEY QUERY EXAMPLES
-- -----------------------------------------------------------

-- 1. Find active games for a player (for reconnect)
SELECT gs.id, gs.white_player_id, gs.black_player_id,
       gs.starting_fen, gs.move_count, gs.time_control,
       gs.white_time_remaining_seconds, gs.black_time_remaining_seconds,
       u1.username AS white_name, u2.username AS black_name
FROM game_sessions gs
JOIN users u1 ON gs.white_player_id = u1.id
JOIN users u2 ON gs.black_player_id = u2.id
WHERE (gs.white_player_id = 'user-uuid' OR gs.black_player_id = 'user-uuid')
  AND gs.result = 'IN_PROGRESS'
ORDER BY gs.started_at DESC;

-- 2. Get game replay with all moves
SELECT gm.move_number, gm.from_square, gm.to_square,
       gm.piece, gm.captured_piece, gm.is_castling,
       gm.is_check, gm.is_checkmate,
       gm.fen_after,
       gm.time_taken_seconds, gm.evaluation_cp
FROM game_moves gm
WHERE gm.game_id = 'game-uuid'
ORDER BY gm.move_number;

-- 3. Leaderboard: top 50 rapid players
SELECT u.username, us.rating_rapid, us.games_played,
       us.wins, us.win_streak, us.peak_rating
FROM user_stats us
JOIN users u ON us.user_id = u.id
WHERE u.is_bot = false
ORDER BY us.rating_rapid DESC
LIMIT 50;

-- 4. Find opponent: users near matching rating who are online
SELECT u.id, u.username, us.rating_rapid
FROM users u
JOIN user_stats us ON us.user_id = u.id
WHERE u.status = 'ONLINE'
  AND u.is_bot = false
  AND u.id != 'current-user'
  AND us.rating_rapid BETWEEN 1150 AND 1250     -- ±50 of current rating
  AND NOT EXISTS (
      SELECT 1 FROM game_sessions gs
      WHERE (gs.white_player_id = u.id OR gs.black_player_id = u.id)
        AND gs.result = 'IN_PROGRESS'
  )
ORDER BY ABS(us.rating_rapid - 1200) ASC
LIMIT 10;

-- 5. Get player's opening statistics
SELECT gs.eco_code, COUNT(*) AS games_played,
       SUM(CASE WHEN gs.winner_id = 'user-uuid' THEN 1 ELSE 0 END) AS wins,
       COUNT(*) FILTER (WHERE gs.result = 'DRAW') AS draws,
       ROUND(AVG(gs.accuracy_white), 1) AS avg_accuracy
FROM game_sessions gs
WHERE gs.white_player_id = 'user-uuid'
  AND gs.eco_code IS NOT NULL
GROUP BY gs.eco_code
ORDER BY games_played DESC
LIMIT 10;

-- 6. Tournament standing with tie-breaks
SELECT tr.current_rank, u.username, tr.current_score,
       us.rating_rapid, tr.seed_rating
FROM tournament_registrations tr
JOIN users u ON tr.user_id = u.id
JOIN user_stats us ON us.user_id = u.id
WHERE tr.tournament_id = 'tournament-uuid'
ORDER BY tr.current_rank ASC;

-- 7. Detect engine-like play (anti-cheat heuristic, games as White)
-- (Joining game_moves here would multiply rows per move and make COUNT(*)
--  count moves, not games.)
SELECT u.id, u.username,
       AVG(gs.accuracy_white) AS avg_accuracy,
       COUNT(*) AS games
FROM game_sessions gs
JOIN users u ON gs.white_player_id = u.id
WHERE gs.started_at >= CURRENT_DATE - 30
  AND gs.accuracy_white IS NOT NULL
  AND u.is_bot = false
GROUP BY u.id, u.username
HAVING AVG(gs.accuracy_white) > 95 AND COUNT(*) > 20
ORDER BY avg_accuracy DESC;

🔑 Redis Schema (Real-Time Game State)

# Redis is a hot CACHE for active games (fast reads, pub/sub fan-out).
# PostgreSQL game_moves is the source of truth: every move is written there
# before it is acknowledged, so losing Redis loses no moves.

# === Active Game State ===
game:{game_id}:state           → HASH
  white_player_id, black_player_id
  fen                          → Current board position (FEN string)
  turn                         → "white" or "black"
  status                       → "active", "check", "checkmate", etc.
  white_time_ms                → Remaining time in milliseconds
  black_time_ms                → Remaining time in milliseconds
  last_move_at                 → Unix timestamp of last move
  move_count                   → Total half-moves played
  TTL: 24h (or until game ends)

# === Recent moves (cache for reconnect/spectators; PG has the full log) ===
game:{game_id}:moves           → LIST (capped with LTRIM to the last 50)
  Each element: JSON {ply, from, to, promotion, time_taken_ms}

# === WebSocket Connections ===
ws:user:{user_id}              → SET of ws_connection_ids (supports multi-device)
ws:game:{game_id}              → SET of user_ids currently watching this game

# === Matchmaking Queues ===
queue:rapid                    → ZSET (user_id → rating)
queue:blitz                    → ZSET (user_id → rating)
queue:bullet                   → ZSET (user_id → rating)
  # Matchmaking: ZRANGEBYSCORE queue:rapid (rating-range) (rating-range) LIMIT 0 2

# === Rate Limiting ===
ratelimit:user:{user_id}:moves_per_sec  → STRING (counter, TTL 1s)
ratelimit:user:{user_id}:games_per_min  → STRING (counter, TTL 60s)

# === Active Game Count (for scaling metrics) ===
active_games_count             → STRING (atomically INCR/DECR on game start/end)
connected_users_count          → STRING (updated via WebSocket connect/disconnect)

📐 Table Relationships Summary

# Table Parent FK Child References Key Indexes
1 users — user_stats(user_id), user_rating_history(user_id), game_sessions(white/black), tournament_registrations(user_id), user_friendships(requester/addressee), user_sessions(user_id) username, email, status, last_seen
2 user_stats user_id → users — rating_rapid DESC, games DESC
3 game_sessions white_player_id → users, black_player_id → users, winner_id → users game_moves(game_id), game_analysis(game_id), tournament_matches(game_id), user_rating_history(game_id) white, black, active(filter), started DESC
4 user_rating_history user_id → users, game_id → game_sessions, opponent_id → users — (user, recorded_at DESC)
5 game_moves game_id → game_sessions — (game_id, move_number) PK
6 game_analysis game_id → game_sessions — game_id UNIQUE, pending(filter)
7 tournaments created_by → users tournament_registrations(tournament_id), tournament_rounds(tournament_id), tournament_matches(tournament_id) status, start_at
8 tournament_registrations tournament_id → tournaments, user_id → users — (tournament, rank)
9 tournament_rounds tournament_id → tournaments tournament_matches(round_id) (tournament, round_number) UNIQUE
10 tournament_matches tournament_id → tournaments, round_id → tournament_rounds, game_id → game_sessions, player1/2_id → users — round, tournament
11 user_friendships requester_id → users, addressee_id → users — (requester, status), (addressee, status)
12 user_sessions user_id → users — user, active(filter), ws_connection

⚡ Concurrency & Consistency at Scale

Concern Solution
Game state consistency One writer per game (the owning engine pod, holding a lease). Each move is inserted into game_moves before ack; Redis is updated after. If Redis fails, replay game_moves.
Race condition: double move Single writer serialises moves; the client's expected_ply rejects stale submissions; PRIMARY KEY (game_id, move_number) rejects a second write for the same ply even if two pods briefly both think they own the game.
Race condition: simultaneous game start Redis SETNX for game lock. Only one matchmaking worker wins. Loser picks next available opponent.
Game recovery on crash New owner takes the lease (higher epoch), replays game_moves, resumes. Writes carrying an older lease_epoch are rejected.
ELO rating update Idempotent: insert into user_rating_history and update user_stats in one transaction; UNIQUE(user_id, game_id, rating_type) turns a redelivered event into a no-op.
Read-after-write consistency Moves are read from the owning pod (routed by game_id), not from Redis replicas, which lag.

📊 Data Volume Estimates (at 100K concurrent games / 1M users)

Table Row Count Growth Rate Storage
users 1M +10K/month ~500 MB
user_stats 1M +10K/month ~200 MB
game_sessions ~180M +15M/month (500K/day) ~1 KB/row with PGN → ~180 GB
user_rating_history ~360M +30M/month (2 rows/rated game) ~35 GB
game_moves ~15B +1.2B/month (~80 plies/game) ~150 B/row → ~2 TB
game_analysis 5M +250K/month (analysed on request) ~20 GB
tournament* 100K +5K/month ~500 MB

After 12 months: ~2–3 TB, dominated by game_moves. That's why the per-move row stores only fen_after (not before and after), and why finished games older than a few months move to S3 as PGN and their move rows are dropped (the PGN is the archive format).