You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
All four services run as Docker containers on the same Compose network. The ws-server is stateless — all shared game state lives in Redis, so you can run multiple instances behind a load balancer.
Features
Auth
Signup / signin over HTTP (POST /auth/signup, POST /auth/signin)
Passwords hashed with bcryptjs (configurable rounds)
JWT sessions (HS256, 7-day expiry) stored in localStorage
WebSocket auth: token passed as URL query param, verified before upgrade
Matchmaking
Redis FIFO queue (LPUSH / atomic Lua RPOP pair)
O(1) dequeue — no polling loops
Players are matched and notified over pub/sub, never need to poll
Gameplay
Move validation on the server via a chess.js wrapper (shared package)
All standard rules: castling, en passant, pawn promotion
Clocks: 5 minutes per side, 100 ms server tick, TIME_UPDATE push
Resign, draw offer / accept / decline
Game over: checkmate, timeout, resignation, mutual agreement
Ratings
ELO algorithm (K-factor 32) applied after every game
Rating snapshot recorded at game start; delta shown in game-over modal
Leaderboard sorted by current rating (GET /leaderboard)
Spectating
GET /games/active lists games in progress
Spectators join via ?spectate=<gameId> WebSocket param
Board hydrated from HTTP move history, then live moves stream over WebSocket
Reconnection
Same user can open a new socket mid-game
Server swaps the socket reference and sends current FEN + remaining clocks
No move is lost; clock continues without interruption
Frontend
Next.js 16 / React 19 with Tailwind CSS and Framer Motion
Click-to-move with valid-move indicators (dots for empty squares, rings for captures)
Move history panel (SAN notation)
Sound effects via Web Audio API (no external files)
Dark mode
Request Flows
Matchmaking
sequenceDiagram
participant A as Player A
participant B as Player B
participant WS as ws-server
participant R as Redis
A->>WS: WS connect + JWT
A->>WS: { type: "init_game" }
WS->>R: LPUSH matchmaking:queue "userA"
B->>WS: WS connect + JWT
B->>WS: { type: "init_game" }
WS->>R: LPUSH matchmaking:queue "userB"
WS->>R: Lua RPOP pair → ["userA","userB"]
WS->>R: SET game:id:state (FEN, clocks)
WS->>R: PUBLISH user:userA:notify
WS->>R: PUBLISH user:userB:notify
R-->>A: INIT_GAME { color:"white", gameId }
R-->>B: INIT_GAME { color:"black", gameId }
Loading
Making a Move
sequenceDiagram
participant P as Player (White)
participant WS as ws-server
participant ENG as chess.js engine
participant R as Redis
participant DB as PostgreSQL
P->>WS: { type:"move", from:"e2", to:"e4" }
WS->>WS: checkRateLimit (token bucket)
WS->>ENG: tryMove({ from, to })
ENG-->>WS: move result (SAN, FEN)
WS->>R: SET game:state + RPUSH game:moves + PUBLISH game:live
WS-->>P: { type:"move", payload }
WS-->>Opponent: { type:"move", payload }
WS--)DB: saveMove() fire-and-forget
Loading
Tech Stack
Layer
Technology
Version
Frontend
Next.js + React
16 / 19
Styling
Tailwind CSS + Framer Motion
4 / 12
Backend runtime
Node.js
20
WebSocket
ws
8.18
Auth
jsonwebtoken + bcryptjs
9 / 2.4
Validation
Zod
3.24
Database
PostgreSQL + Prisma
15
Cache / pub-sub
Redis + ioredis
7 / 5.4
Chess rules
chess.js (wrapped in @chess/chess-engine)
—
Monorepo
Turborepo + pnpm workspaces
—
Tests
Vitest + @vitest/coverage-v8
4.1
Containers
Docker + Docker Compose
—
Performance
Characteristic
Value
Notes
Rate limit
10 msg/s per socket
Token bucket; socket closed after 5 consecutive overages
Clock resolution
100 ms
Server-authoritative TIME_UPDATE every 100 ms
Matchmaking
O(1)
Lua atomic RPOP from Redis list
DB writes
Fire-and-forget
.catch() logged; Redis is the synchronous source of truth
Redis throughput
~50 k msg/s
Single Redis 7 node (official benchmark)
Messages per active game
~20 msg/s
10 TIME_UPDATE ticks × 2 players
Concurrent games (1 node)
~2 500
50 k ÷ 20 msg/game/s
Horizontal scaling
Yes
Stateless ws-server; add nodes behind a load balancer
Database Schema
erDiagram
Profile {
uuid id PK
string username
int rating
int wins
int losses
int draws
}
Credentials {
uuid id PK
uuid profileId FK
string passwordHash
}
Game {
uuid id PK
uuid whitePlayerId FK
uuid blackPlayerId FK
int whiteRating
int blackRating
enum winner
string reason
string pgn
int whiteRatingChange
int blackRatingChange
}
GameMove {
uuid id PK
uuid gameId FK
int moveNumber
string moveSan
string moveUci
string fen
int whiteTime
int blackTime
}
Profile ||--|| Credentials : "has"
Profile ||--o{ Game : "white"
Profile ||--o{ Game : "black"
Game ||--o{ GameMove : "has"
git clone <repo-url> chess
cd chess
docker compose up --build
Open http://localhost:3000. Register two accounts in separate tabs and play.
Option B — Hot reload dev
# Prerequisites: Node 20, pnpm, Docker (for Postgres + Redis only)
git clone <repo-url> chess
cd chess
docker compose up -d db redis
pnpm install
cp apps/ws-server/.env.example apps/ws-server/.env
# Fill in JWT_SECRET in the .env file
pnpm --filter @repo/db exec prisma migrate dev
pnpm dev