ā™Ÿļø Building a Bullet Chess Platform: From Monorepo to Production

After building my Nikolas Dev Journey blog and learning the fundamentals of Next.js, I decided to tackle my most ambitious project yet: a complete bullet chess platform. This wasn't just a simple chess game,I wanted to build something that could handle real-time gameplay, user authentication, rating systems, and fair-play detection. In this post, I'll walk through how I structured the project as a monorepo, implemented WebSocket communication, and deployed a production-ready chess platform with sub-120ms latency.

Why Bullet Chess and Why It's Technically Challenging

Bullet chess is the fastest time control in chess,players get just one minute (sometimes with a one-second increment) to complete an entire game. This creates unique technical challenges that don't exist in other web applications:

  • Ultra-low latency requirements: Every millisecond counts when players have seconds left on their clock.
  • Real-time state synchronization: Both players must see moves instantly and accurately.
  • Time precision: Clock management must be server-authoritative but feel responsive.
  • Fair-play concerns: Fast time controls make engine assistance more obvious, requiring detection systems.
  • Scalable matchmaking: Players expect to find opponents within seconds.

Coming from building static websites, this project pushed me into real-time systems, database design, and production deployment,exactly the challenge I needed to grow as a developer.

Project Architecture: Monorepo with pnpm Workspaces

One of my first decisions was using a monorepo structure with pnpm workspaces. After managing separate repositories for my blog components, I realized how much easier it would be to share code between the frontend and backend of a chess platform.

Bullet Chess Monorepo Structurebash
1bullet-chess/
2ā”œā”€ā”€ apps/
3│   ā”œā”€ā”€ web/              # Next.js frontend
4│   └── realtime/         # WebSocket server
5ā”œā”€ā”€ packages/
6│   ā”œā”€ā”€ db/               # Prisma schema & client
7│   ā”œā”€ā”€ proto/            # WebSocket message types
8│   ā”œā”€ā”€ rating/           # Glicko-2 rating system
9│   ā”œā”€ā”€ stockfish/        # Chess engine integration
10│   ā”œā”€ā”€ redis-client/     # Redis utilities
11│   ā”œā”€ā”€ timer/            # Time management
12│   ā”œā”€ā”€ streaming/        # Real-time updates
13│   └── utils/            # Shared utilities
14ā”œā”€ā”€ package.json          # Root workspace config
15└── pnpm-workspace.yaml   # Workspace definition

This structure lets me share TypeScript types between frontend and backend, reuse utilities across packages, and maintain consistent dependencies. The packages/ directory contains domain-specific logic that both applications can import.

Setting Up the Tech Stack

Choosing the right technologies was crucial for meeting my performance targets. Here's what I selected and why:

Core Dependenciesjson
1{
2  "name": "bullet-chess",
3  "workspaces": ["apps/*", "packages/*"],
4  "scripts": {
5    "dev": "pnpm run --parallel dev",
6    "build": "pnpm run build:deps && pnpm --filter @bullet-chess/realtime build",
7    "db:generate": "pnpm --filter @bullet-chess/db prisma:generate",
8    "db:migrate": "pnpm --filter @bullet-chess/db prisma migrate dev"
9  },
10  "dependencies": {
11    "next": "^15.0.0"
12  }
13}
  • Next.js 15 for the frontend with React 18 and the App Router
  • WebSocket server in Node.js for real-time communication
  • PostgreSQL with Prisma ORM for data persistence
  • Redis for matchmaking queues and caching
  • TypeScript throughout for type safety
  • chess.js for move validation and game logic
  • Stockfish for position analysis and fair-play detection

Database Design: Users, Games, and Ratings

Designing the database schema required thinking about how chess games flow and what data I needed to track. Here's the core schema I developed:

Chess Platform Database Schemasql
1// Core user management
2model User {
3  id        String   @id @default(cuid())
4  handle    String   @unique
5  email     String?  @unique
6  createdAt DateTime @default(now())
7  deletedAt DateTime?
8
9  // Relationships
10  whiteGames  Game[] @relation("WhitePlayer")
11  blackGames  Game[] @relation("BlackPlayer")
12  ratings     Rating[]
13  sessions    Session[]
14}
15
16// Separate ratings for each time control
17model Rating {
18  userId    String
19  tc        String  // "1+0", "1+1", etc.
20  rating    Float   @default(1500)
21  rd        Float   @default(350)  // Glicko-2 rating deviation
22  vol       Float   @default(0.06) // Volatility
23  updatedAt DateTime @default(now())
24
25  user User @relation(fields: [userId], references: [id])
26  @@id([userId, tc])
27}
28
29// Complete game records
30model Game {
31  id        String   @id @default(cuid())
32  whiteId   String
33  blackId   String
34  tc        String   // Time control
35  rated     Boolean  @default(true)
36  result    String?  // "1-0", "0-1", "1/2-1/2"
37  startedAt DateTime @default(now())
38  endedAt   DateTime?
39
40  // Time tracking
41  whiteTimeMs Int    // Milliseconds used
42  blackTimeMs Int
43
44  // Relationships
45  white User  @relation("WhitePlayer", fields: [whiteId], references: [id])
46  black User  @relation("BlackPlayer", fields: [blackId], references: [id])
47  moves Move[]
48}
49
50// Move-by-move storage for analysis
51model Move {
52  gameId   String
53  ply      Int     // Move number (starts at 1)
54  uci      String  // e2e4, etc.
55  serverTs BigInt  // Server timestamp
56  clientTs BigInt? // Client timestamp for latency calculation
57  byColor  String  // "w" or "b"
58
59  game Game @relation(fields: [gameId], references: [id])
60  @@id([gameId, ply])
61}

This schema separates ratings by time control (important for chess), stores complete move history for analysis, and tracks precise timing data for fair-play detection. The Glicko-2 rating fields (rating, RD, volatility) let me implement a proper chess rating system.

WebSocket Protocol: Real-Time Game Communication

The heart of any real-time chess platform is the communication protocol. I designed a message-based system that handles everything from matchmaking to game moves:

WebSocket Message Protocoltypescript
1// Client to Server messages
2export interface ClientMessage {
3  // Matchmaking
4  'queue.join': { tc: string; rated: boolean; }
5  'queue.leave': {}
6
7  // Game actions
8  'move.make': {
9    gameId: string;
10    uci: string;
11    clientTs: number;
12    seq: number;
13  }
14  'draw.offer': { gameId: string; }
15  'draw.accept': { gameId: string; }
16  'resign': { gameId: string; }
17}
18
19// Server to Client messages
20export interface ServerMessage {
21  // Matchmaking responses
22  'queue.joined': { position: number; }
23  'match.found': {
24    gameId: string;
25    color: 'white' | 'black';
26    opponent: { handle: string; rating: number; }
27    timeControl: { initial: number; increment: number; }
28  }
29
30  // Game updates
31  'move.made': {
32    gameId: string;
33    uci: string;
34    by: 'w' | 'b';
35    serverTs: number;
36    timeLeft: { white: number; black: number; }
37  }
38  'game.end': {
39    gameId: string;
40    result: string;
41    reason: string;
42    newRatings?: { white: number; black: number; }
43  }
44}

Each message includes the information needed for that specific action. The seq field prevents duplicate moves, clientTs helps with lag compensation, and timeLeft keeps clocks synchronized.

Redis Matchmaking: Fast and Persistent Queues

For bullet chess, players expect to find opponents within seconds. I implemented a Redis-based matchmaking system that persists across server restarts and can handle hundreds of concurrent players:

Redis Matchmaking Implementationtypescript
1export class RedisMatchmakingQueue {
2  constructor(private redis: Redis) {}
3
4  async joinQueue(userId: string, tc: string, rating: number, rated: boolean): Promise<string | null> {
5    const queueKey = `queue:${tc}:${rated ? 'rated' : 'casual'}`;
6    const userKey = `user:${userId}`;
7    const joinTime = Date.now();
8
9    // Add user to sorted set with join time as score
10    await this.redis.zadd(queueKey, joinTime, userId);
11
12    // Store user's rating and preferences
13    await this.redis.hset(userKey, {
14      rating,
15      tc,
16      rated: rated.toString(),
17      joinTime
18    });
19
20    // Try to find a match immediately
21    return this.findMatch(userId, queueKey, rating);
22  }
23
24  private async findMatch(userId: string, queueKey: string, userRating: number): Promise<string | null> {
25    const waitTime = Date.now() - await this.redis.zscore(queueKey, userId);
26
27    // Dynamic rating spread - gets wider as wait time increases
28    const maxDiff = Math.min(400, 100 + Math.floor(waitTime / 3000) * 50);
29
30    // Find opponents within rating range
31    const candidates = await this.redis.zrange(queueKey, 0, -1);
32
33    for (const opponentId of candidates) {
34      if (opponentId === userId) continue;
35
36      const opponentData = await this.redis.hgetall(`user:${opponentId}`);
37      const ratingDiff = Math.abs(userRating - parseInt(opponentData.rating));
38
39      if (ratingDiff <= maxDiff) {
40        // Found a match! Remove both players from queue
41        await this.redis.zrem(queueKey, userId, opponentId);
42        await this.redis.del(`user:${userId}`, `user:${opponentId}`);
43
44        return this.createGame(userId, opponentId);
45      }
46    }
47
48    return null; // No match found yet
49  }
50}

This system uses Redis sorted sets to maintain queue order and hash maps to store player data. The dynamic rating spread ensures that players find matches quickly,the longer you wait, the wider the rating range becomes.

Real-Time Game Server with Lag Compensation

The WebSocket game server handles the core game logic, time management, and state synchronization. Here's how I implemented lag compensation for responsive gameplay:

Game Engine with Time Managementtypescript
1export class GameEngine {
2  private games = new Map<string, GameState>();
3
4  async handleMove(ws: WebSocket, data: MoveMessage): Promise<void> {
5    const game = this.games.get(data.gameId);
6    if (!game) return;
7
8    const player = game.players.get(ws);
9    if (!player || game.currentPlayer !== player.color) return;
10
11    // Validate move with chess.js
12    const chess = new Chess(game.fen);
13    const move = chess.move(data.uci);
14    if (!move) return; // Illegal move
15
16    const now = Date.now();
17
18    // Calculate time elapsed since last move (with lag compensation)
19    const timeSinceLastMove = now - game.lastMoveTime;
20    const lagCompensation = Math.min(100, now - data.clientTs); // Cap at 100ms
21    const actualTimeUsed = Math.max(0, timeSinceLastMove - lagCompensation);
22
23    // Update player's time
24    game.timeLeft[player.color] -= actualTimeUsed;
25
26    // Check for time forfeit
27    if (game.timeLeft[player.color] <= 0) {
28      return this.endGame(data.gameId, player.color === 'white' ? '0-1' : '1-0', 'time');
29    }
30
31    // Add increment after move is made
32    if (game.timeControl.increment > 0) {
33      game.timeLeft[player.color] += game.timeControl.increment * 1000;
34    }
35
36    // Update game state
37    game.fen = chess.fen();
38    game.currentPlayer = player.color === 'white' ? 'black' : 'white';
39    game.lastMoveTime = now;
40    game.moveHistory.push({
41      uci: data.uci,
42      by: player.color,
43      serverTs: now,
44      clientTs: data.clientTs
45    });
46
47    // Broadcast move to both players
48    const moveMessage: ServerMessage['move.made'] = {
49      gameId: data.gameId,
50      uci: data.uci,
51      by: player.color === 'white' ? 'w' : 'b',
52      serverTs: now,
53      timeLeft: { ...game.timeLeft }
54    };
55
56    this.broadcastToGame(data.gameId, 'move.made', moveMessage);
57
58    // Check for game end conditions
59    if (chess.isGameOver()) {
60      const result = chess.isDraw() ? '1/2-1/2' :
61                    chess.turn() === 'w' ? '0-1' : '1-0';
62      this.endGame(data.gameId, result, 'checkmate');
63    }
64  }
65}

The lag compensation subtracts estimated network latency from time calculation, making moves feel responsive even with some network delay. Server-authoritative time management prevents cheating while maintaining fairness.

Stockfish Integration for Fair-Play Detection

One unique challenge in online chess is detecting when players use computer assistance. I integrated Stockfish to analyze games and flag suspicious patterns:

Fair-Play Detection with Stockfishtypescript
1export class FairPlayAnalyzer {
2  private stockfish: Stockfish;
3
4  async analyzeGame(gameId: string, moves: Move[]): Promise<FairPlayReport> {
5    const analysis = {
6      accuracyScore: 0,
7      timingConsistency: 0,
8      complexPositionSpeed: 0,
9      engineCorrelation: 0,
10      suspicionLevel: 'clean' as 'clean' | 'suspicious' | 'likely_cheating'
11    };
12
13    let totalAccuracy = 0;
14    let timingVariance = 0;
15    const moveTimes: number[] = [];
16
17    for (let i = 0; i < moves.length; i++) {
18      const move = moves[i];
19      const moveTime = i > 0 ? Number(move.serverTs - moves[i-1].serverTs) : 1000;
20      moveTimes.push(moveTime);
21
22      // Analyze position complexity vs. move speed
23      const fen = this.getFenAtMove(moves, i);
24      const complexity = await this.analyzePosition(fen);
25
26      // Fast moves in complex positions are suspicious
27      if (complexity > 8 && moveTime < 2000) {
28        analysis.complexPositionSpeed += 1;
29      }
30
31      // Get engine evaluation of the move
32      const engineMove = await this.getBestMove(fen);
33      const playerMove = move.uci;
34
35      // Calculate move accuracy (engine agreement)
36      const accuracy = this.calculateMoveAccuracy(engineMove, playerMove, fen);
37      totalAccuracy += accuracy;
38    }
39
40    // Calculate final scores
41    analysis.accuracyScore = totalAccuracy / moves.length;
42    analysis.timingConsistency = this.calculateTimingConsistency(moveTimes);
43    analysis.engineCorrelation = analysis.accuracyScore > 0.9 ? 1 : 0;
44
45    // Determine suspicion level
46    const suspicionScore =
47      (analysis.accuracyScore > 0.85 ? 3 : 0) +
48      (analysis.timingConsistency > 0.8 ? 2 : 0) +
49      (analysis.complexPositionSpeed > 3 ? 2 : 0) +
50      (analysis.engineCorrelation > 0.5 ? 3 : 0);
51
52    if (suspicionScore >= 6) analysis.suspicionLevel = 'likely_cheating';
53    else if (suspicionScore >= 3) analysis.suspicionLevel = 'suspicious';
54
55    return analysis;
56  }
57
58  private async getBestMove(fen: string): Promise<string> {
59    return new Promise((resolve) => {
60      this.stockfish.postMessage(`position fen ${fen}`);
61      this.stockfish.postMessage('go depth 15');
62
63      this.stockfish.addEventListener('message', function handler(event) {
64        const line = event.data;
65        if (line.startsWith('bestmove')) {
66          const move = line.split(' ')[1];
67          this.removeEventListener('message', handler);
68          resolve(move);
69        }
70      });
71    });
72  }
73}

This system analyzes multiple patterns: move accuracy compared to engine suggestions, timing consistency, and fast play in complex positions. It's not perfect, but it flags obvious cases for human review.

Deployment: Vercel + Fly.io Architecture

Deploying a real-time application requires different considerations than static sites. I ended up with a split architecture:

  • Vercel hosts the Next.js frontend (great for static optimization and edge caching)
  • Fly.io runs the WebSocket server (needed for persistent connections and low latency)
  • Neon provides managed PostgreSQL (automatic backups and scaling)
  • Upstash offers managed Redis (global replication and low latency)
Production Deploymentbash
1# Deploy web application to Vercel
2vercel deploy --prod
3
4# Deploy WebSocket server to Fly.io
5fly deploy --config apps/realtime/fly.toml
6
7# Database migrations (run once)
8pnpm db:migrate
9
10# Health check endpoints
11curl https://bullet-chess-realtime.fly.dev/health
12curl https://bullet-chess.vercel.app/api/health

Performance Results and Lessons Learned

After months of development and optimization, here's what I achieved:

  • Match time: < 5 seconds (usually under 3 seconds)
  • Move latency: 80-120ms average (target was < 120ms)
  • Concurrent games: Tested up to 50 simultaneous games
  • Fair-play detection: Flags obvious engine use with 85%+ accuracy
  • Rating system: Proper Glicko-2 implementation with separate pools

What I Learned Building This Platform

This project pushed me far beyond my previous experience with static websites. Here are the key technical skills I developed:

  • Real-time systems: WebSocket communication, state synchronization, and lag compensation
  • Database design: Proper relational schemas, indexing, and query optimization
  • Monorepo management: Code sharing, dependency management, and build orchestration
  • Performance optimization: Profiling bottlenecks, caching strategies, and latency reduction
  • Production deployment: Multi-service architecture, monitoring, and scaling considerations
  • Algorithm implementation: Glicko-2 rating system, matchmaking logic, and fair-play detection

Challenges I Had to Overcome

Building a real-time platform presented challenges I'd never faced before:

  • WebSocket connection management: Handling disconnections, reconnections, and state recovery
  • Race conditions: Multiple players acting simultaneously required careful state management
  • Time synchronization: Server and client clocks never match perfectly
  • Fair-play detection: Balancing false positives vs. catching actual cheaters
  • Scaling concerns: Redis clustering, database connection pooling, and server resources

What's Next for the Platform

The platform is production-ready, but there's always room for improvement:

  • Mobile app: React Native version for better mobile experience
  • Tournament system: Swiss and round-robin tournament formats
  • Advanced analysis: Deep Stockfish integration for post-game analysis
  • Social features: Friend lists, private games, and spectator mode
  • Multi-region deployment: Edge servers for global low latency
"Building this bullet chess platform taught me that the difference between a toy project and a real application isn't just features,it's handling edge cases, optimizing performance, and creating systems that work reliably under pressure. Every millisecond matters when you're building for speed."

šŸ’¬ Comments & Discussion

Share your thoughts, ask questions, or discuss this post. Comments are powered by GitHub Discussions.