āļø 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.
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 management2model User {
3 id String @id@default(cuid())4 handle String @unique5 email String? @unique6 createdAt DateTime@default(now())7 deletedAt DateTime?
89// Relationships10 whiteGames Game[]@relation("WhitePlayer")11 blackGames Game[]@relation("BlackPlayer")12 ratings Rating[]13 sessions Session[]14}
1516// Separate ratings for each time control17model 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 deviation22 vol Float@default(0.06)// Volatility23 updatedAt DateTime@default(now())2425userUser@relation(fields: [userId],references: [id])26 @@id([userId, tc])27}
2829// Complete game records30model Game {
31 id String @id@default(cuid())32 whiteId String
33 blackId String
34 tc String // Time control35 rated Boolean@default(true)36 result String? // "1-0", "0-1", "1/2-1/2"37 startedAt DateTime@default(now())38 endedAt DateTime?
3940// Time tracking41 whiteTimeMs Int// Milliseconds used42 blackTimeMs Int4344// Relationships45 white User@relation("WhitePlayer",fields: [whiteId],references: [id])46 black User@relation("BlackPlayer",fields: [blackId],references: [id])47 moves Move[]48}
4950// Move-by-move storage for analysis51model Move {
52 gameId String
53 ply Int// Move number (starts at 1)54 uci String // e2e4, etc.55 serverTs BigInt// Server timestamp56 clientTs BigInt? // Client timestamp for latency calculation57 byColor String // "w" or "b"5859 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 messages2exportinterfaceClientMessage{3// Matchmaking4'queue.join':{ tc:string; rated:boolean;}5'queue.leave':{}67// Game actions8'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}1819// Server to Client messages20exportinterfaceServerMessage{21// Matchmaking responses22'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}2930// Game updates31'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
1exportclassRedisMatchmakingQueue{2constructor(private redis:Redis){}34asyncjoinQueue(userId:string, tc:string, rating:number, rated:boolean):Promise<string|null>{5const queueKey =`queue:${tc}:${rated ?'rated':'casual'}`;6const userKey =`user:${userId}`;7const joinTime =Date.now();89// Add user to sorted set with join time as score10awaitthis.redis.zadd(queueKey, joinTime, userId);1112// Store user's rating and preferences13awaitthis.redis.hset(userKey,{14 rating,15 tc,16 rated: rated.toString(),17 joinTime
18});1920// Try to find a match immediately21returnthis.findMatch(userId, queueKey, rating);22}2324privateasyncfindMatch(userId:string, queueKey:string, userRating:number):Promise<string|null>{25const waitTime =Date.now()-awaitthis.redis.zscore(queueKey, userId);2627// Dynamic rating spread - gets wider as wait time increases28const maxDiff =Math.min(400,100+Math.floor(waitTime /3000)*50);2930// Find opponents within rating range31const candidates =awaitthis.redis.zrange(queueKey,0,-1);3233for(const opponentId of candidates){34if(opponentId === userId)continue;3536const opponentData =awaitthis.redis.hgetall(`user:${opponentId}`);37const ratingDiff =Math.abs(userRating -parseInt(opponentData.rating));3839if(ratingDiff <= maxDiff){40// Found a match! Remove both players from queue41awaitthis.redis.zrem(queueKey, userId, opponentId);42awaitthis.redis.del(`user:${userId}`,`user:${opponentId}`);4344returnthis.createGame(userId, opponentId);45}46}4748returnnull;// No match found yet49}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
1exportclassGameEngine{2private games =newMap<string,GameState>();34asynchandleMove(ws:WebSocket, data:MoveMessage):Promise<void>{5const game =this.games.get(data.gameId);6if(!game)return;78const player = game.players.get(ws);9if(!player || game.currentPlayer!== player.color)return;1011// Validate move with chess.js12const chess =newChess(game.fen);13const move = chess.move(data.uci);14if(!move)return;// Illegal move1516const now =Date.now();1718// Calculate time elapsed since last move (with lag compensation)19const timeSinceLastMove = now - game.lastMoveTime;20const lagCompensation =Math.min(100, now - data.clientTs);// Cap at 100ms21const actualTimeUsed =Math.max(0, timeSinceLastMove - lagCompensation);2223// Update player's time24 game.timeLeft[player.color]-= actualTimeUsed;2526// Check for time forfeit27if(game.timeLeft[player.color]<=0){28returnthis.endGame(data.gameId, player.color==='white'?'0-1':'1-0','time');29}3031// Add increment after move is made32if(game.timeControl.increment>0){33 game.timeLeft[player.color]+= game.timeControl.increment*1000;34}3536// Update game state37 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.clientTs45});4647// Broadcast move to both players48const 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};5556this.broadcastToGame(data.gameId,'move.made', moveMessage);5758// Check for game end conditions59if(chess.isGameOver()){60const result = chess.isDraw()?'1/2-1/2':61 chess.turn()==='w'?'0-1':'1-0';62this.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
1exportclassFairPlayAnalyzer{2private stockfish:Stockfish;34asyncanalyzeGame(gameId:string, moves:Move[]):Promise<FairPlayReport>{5const analysis ={6 accuracyScore:0,7 timingConsistency:0,8 complexPositionSpeed:0,9 engineCorrelation:0,10 suspicionLevel:'clean'as'clean'|'suspicious'|'likely_cheating'11};1213let totalAccuracy =0;14let timingVariance =0;15const moveTimes:number[]=[];1617for(let i =0; i < moves.length; i++){18const move = moves[i];19const moveTime = i >0?Number(move.serverTs- moves[i-1].serverTs):1000;20 moveTimes.push(moveTime);2122// Analyze position complexity vs. move speed23const fen =this.getFenAtMove(moves, i);24const complexity =awaitthis.analyzePosition(fen);2526// Fast moves in complex positions are suspicious27if(complexity >8&& moveTime <2000){28 analysis.complexPositionSpeed+=1;29}3031// Get engine evaluation of the move32const engineMove =awaitthis.getBestMove(fen);33const playerMove = move.uci;3435// Calculate move accuracy (engine agreement)36const accuracy =this.calculateMoveAccuracy(engineMove, playerMove, fen);37 totalAccuracy += accuracy;38}3940// Calculate final scores41 analysis.accuracyScore= totalAccuracy / moves.length;42 analysis.timingConsistency=this.calculateTimingConsistency(moveTimes);43 analysis.engineCorrelation= analysis.accuracyScore>0.9?1:0;4445// Determine suspicion level46const 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);5152if(suspicionScore >=6) analysis.suspicionLevel='likely_cheating';53elseif(suspicionScore >=3) analysis.suspicionLevel='suspicious';5455return analysis;56}5758privateasyncgetBestMove(fen:string):Promise<string>{59returnnewPromise((resolve)=>{60this.stockfish.postMessage(`position fen ${fen}`);61this.stockfish.postMessage('go depth 15');6263this.stockfish.addEventListener('message',functionhandler(event){64const line = event.data;65if(line.startsWith('bestmove')){66const move = line.split(' ')[1];67this.removeEventListener('message', handler);68resolve(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 Vercel2vercel deploy --prod
34# Deploy WebSocket server to Fly.io5fly deploy --config apps/realtime/fly.toml
67# Database migrations (run once)8pnpm db:migrate
910# Health check endpoints11curl 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.
š¬ Comments & Discussion
Share your thoughts, ask questions, or discuss this post. Comments are powered by GitHub Discussions.
š” Tip: You need a GitHub account to comment. This helps reduce spam and keeps discussions high-quality.