A full-stack online chess platform built with Next.js 15, Express, Socket.IO, chess.js, and MongoDB. It supports realtime two-player matches, spectators, live chat, server-authoritative move validation, and — with an account — persistent match history and win/loss/draw stats.
Realtime gameplay runs entirely in memory for speed; MongoDB is used only for durable data (accounts, completed games, stats). See Architecture.
- Realtime chess gameplay via WebSockets (server-authoritative move validation)
- Shareable
gameIdURLs with automatic White / Black / Spectator assignment - Stable per-browser identity — keep your color across refresh / reconnect
- Spectator mode for public viewing
- Chat with room-scoped typing indicators
- Clickable move history viewer
- Check sound effects & background music
- User accounts (register / login / logout) with JWT auth in httpOnly cookies
- Persistent match history and win/loss/draw stats for logged-in players
- TailwindCSS styling, dynamic routing
Guests can play without an account; only logged-in players have their games and stats saved.
| Frontend | Backend | Data / Auth |
|---|---|---|
| Next.js 15 (App Router) + TypeScript | Node.js + Express | MongoDB + Mongoose |
| React 19 + React-Chessboard | Socket.IO (realtime WebSockets) | JWT (httpOnly cookies) |
| Shared typed socket/API contract | chess.js for move logic | bcrypt password hashing |
The app is split into two processes:
- Next.js frontend (
src/app) — the board UI, chat, move history, and a small auth panel. Presentational pieces are split intosrc/app/components/(MoveHistory,Chat,GameOverScreen,GameInfoBar), and the whole client shares one typed contract insrc/app/types.ts. - Express + Socket.IO backend (
server.js+server/) — realtime gameplay plus a small REST API for auth and history.
Backend modules (server.js is a thin entry point that wires these together):
server/app.js— the Express app: CORS, JSON/cookie parsing, REST routes, and a central error handler.server/socketHandlers.js— all Socket.IO event handlers (join, move, chat, typing, presence, disconnect).server/gameStore.js— the in-memory active-game map and seat/presence helpers. Nothing here touches Mongo.server/chessRules.js— server-authoritative move validation (applyMove): validates with chess.js and classifies the outcome.utils/persistGame.js— one-time persistence of a finished game plus stat updates.
Realtime vs. persistence — the key design decision:
- Active games live in memory on the server (
games{}map, onechess.jsinstance per room). Every move is validated server-side and broadcast over Socket.IO. This is the hot path and never touches the database, so gameplay stays fast. - MongoDB stores only durable, lifecycle data: user accounts, and a
Gamerecord written once when a game ends (checkmate or draw). Completed-game persistence also updates each registered player's aggregate stats.
MongoDB is deliberately not used as the realtime transport — live board updates are never written per-move. If the backend restarts, in-progress games are lost (acceptable for a portfolio app); completed games and stats are safe in Mongo.
A stable playerId (stored in the browser's localStorage) identifies a player across reconnects, so refreshing the page keeps your seat. When you log in, the socket reconnects so its handshake carries the auth cookie, letting the server attach your seat to your account.
git clone https://github.com/harshit-ojha0324/Realtime-Multiplayer-Chess-Game.git
cd Realtime-Multiplayer-Chess-Gamenpm installcp .env.example .envThen edit .env. The app runs without a database (auth/history disabled) if MONGO_URI is empty, so you can try the game immediately. To enable accounts and match history, create a free MongoDB Atlas cluster and paste its connection string into MONGO_URI, and set a strong JWT_SECRET. See Environment Variables.
npm run server # or: node server.jsRuns at:
http://localhost:5001(the Socket.IO + Express server runs as a separate process from Next.js)
npm run devRuns at:
http://localhost:3000. Run the backend and frontend in two terminals.
Copy .env.example to .env and fill in values.
Backend (server.js)
| Variable | Purpose |
|---|---|
PORT |
Backend port (default 5001). |
CLIENT_ORIGIN |
Allowed CORS origin(s) for the frontend (comma-separated). Required for cookies. |
MONGO_URI |
MongoDB Atlas connection string. Empty = run without persistence. |
JWT_SECRET |
Secret used to sign auth JWTs. Use a long random string. |
JWT_EXPIRE |
JWT lifetime (e.g. 7d). |
COOKIE_EXPIRE |
Auth cookie lifetime in days. |
NODE_ENV |
production makes the auth cookie Secure + SameSite=None. |
Frontend (Next.js, must be NEXT_PUBLIC_)
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_SOCKET_URL |
URL of the Socket.IO backend. |
NEXT_PUBLIC_API_URL |
Base URL of the REST API (same backend). |
For a copy-paste, checkbox-style version of everything below — plus the exact Git commands to push to GitHub — see DEPLOYMENT.md.
The app is three separately-deployed pieces, because the realtime backend is a long-lived WebSocket process and can't run as a serverless function alongside the Next.js frontend:
| Piece | Host | Why |
|---|---|---|
| Frontend (Next.js) | Vercel (Hobby, free) | First-class Next.js hosting, instant Git deploys, global CDN. |
| Backend (Socket.IO + Express) | Render (free web service) | Real free tier that keeps a process running and supports WebSockets. |
| Database | MongoDB Atlas (M0, free) | Managed Mongo, free 512 MB cluster. |
Why Render for the backend (not Vercel/Fly/Railway)? Vercel's serverless functions can't hold an open WebSocket, so the Socket.IO server needs a normal always-on host. As of 2026, Render still has a genuine free web-service tier that supports WebSockets, while Fly.io no longer offers a free tier and Railway is trial-credits only. Render's free instance spins down after ~15 min idle and cold-starts in ~30–60 s — fine for a portfolio demo. Any always-on Node host (Railway paid, Fly paid, a small VPS) works the same way.
- Create a free account at mongodb.com/atlas and create an M0 (free) cluster.
- Database Access → Add New Database User. Choose password auth, give it a username/password, and "Read and write to any database". Save the password.
- Network Access → Add IP Address. For a hosted backend whose IP you don't control, add
0.0.0.0/0(allow from anywhere). The database is still protected by the user credentials. - Clusters → Connect → Drivers → copy the connection string. It looks like
mongodb+srv://<user>:<password>@cluster0.xxxx.mongodb.net/chess?retryWrites=true&w=majority. Replace<password>and add a database name (e.g./chess) before the?. - You'll paste this into the backend's
MONGO_URIenv var (Step 2). It is never committed to the repo.
Persistence model reminder: active games stay in server memory; Atlas only stores accounts, one record per completed game, and aggregate stats. A backend restart loses in-progress games but never completed history.
-
Push this repo to GitHub.
-
In Render: New + → Blueprint, and select the repo. Render reads
render.yamland provisions a free web service that runsnpm run serverwith a health check at/api/health. (Or create a Web Service manually: Buildnpm install, Startnpm run server.) -
Set the secret/environment variables in the Render dashboard:
Variable Value NODE_ENVproductionMONGO_URIthe Atlas connection string from Step 1 JWT_SECRETa long random string JWT_EXPIRE7dCOOKIE_EXPIRE7CLIENT_ORIGINyour Vercel URL, e.g. https://your-app.vercel.app(set after Step 3)You do not set
PORT— Render injects it andserver.jsreadsprocess.env.PORT. -
Deploy. Your backend will be at something like
https://chess-backend.onrender.com. Confirmhttps://<backend>/api/healthreturns{"ok":true}.
-
In Vercel: Add New → Project, import the same GitHub repo. Vercel auto-detects Next.js (build
next build, no config needed). -
Add Environment Variables (both must point at the Render backend URL):
Variable Value NEXT_PUBLIC_SOCKET_URLhttps://chess-backend.onrender.comNEXT_PUBLIC_API_URLhttps://chess-backend.onrender.comThese are baked in at build time, so redeploy after changing them.
-
Deploy. Note the resulting URL (e.g.
https://your-app.vercel.app). -
Go back to Render and set
CLIENT_ORIGINto that exact Vercel URL (no trailing slash), then redeploy the backend so CORS and cookies allow the frontend origin. If you use multiple frontend URLs (preview + prod), setCLIENT_ORIGINto a comma-separated list.
| Service | Variables |
|---|---|
| Render (backend) | NODE_ENV, MONGO_URI, JWT_SECRET, JWT_EXPIRE, COOKIE_EXPIRE, CLIENT_ORIGIN (+ PORT auto-injected) |
| Vercel (frontend) | NEXT_PUBLIC_SOCKET_URL, NEXT_PUBLIC_API_URL |
| Atlas | — (provides the MONGO_URI consumed by Render) |
Earlier the app imported Geist via next/font/google, which fetches the font from Google's servers at build time. That makes the production build fail in any environment without outbound access to fonts.googleapis.com. The app now uses a native system font stack (defined as --font-geist-sans / --font-geist-mono CSS variables in globals.css), so the build has zero network dependencies and no extra font payload. This was chosen over keeping next/font/google because a portfolio project should build reliably anywhere, and the body text already rendered with a system font — so there's no visual regression, just a more robust, faster-loading build.
| Symptom | Likely cause & fix |
|---|---|
| CORS error in browser console | CLIENT_ORIGIN on Render doesn't exactly match the Vercel URL (scheme, no trailing slash). With credentialed CORS the origin must be concrete — * is invalid. Update and redeploy backend. |
| Login "works" but you're logged out on refresh (cookie not set) | Cookie needs Secure + SameSite=None across domains — ensure NODE_ENV=production on Render. trust proxy is enabled in prod so Express sees HTTPS behind Render's proxy. Also confirm the frontend calls use credentials: "include" (they do). |
| WebSocket / socket won't connect | NEXT_PUBLIC_SOCKET_URL must be the https:// Render URL (not localhost), and must be set before the Vercel build (redeploy after changing). Render supports WebSockets on the free tier by default. |
| First request after idle is slow (~30–60 s) | Render free instances spin down after ~15 min idle and cold-start on the next request. Expected; the page reconnects automatically once the backend wakes. |
503 on auth/profile endpoints |
No MONGO_URI set (or bad string). Gameplay still works; auth/history need the DB. Check Render logs and the Atlas connection string / Network Access (0.0.0.0/0). |
| An in-progress game vanished after a deploy/restart | Expected — active games are in memory only. Completed games and stats persist in Atlas. |
- Render free spins down after idle (~15 min) → cold starts; ~750 instance-hours/month; not for production traffic.
- Atlas M0 is 512 MB shared — plenty for accounts + completed games here.
- Vercel Hobby is for non-commercial/personal use; fine for a portfolio.
- In-memory live games don't survive a backend restart and can't scale horizontally without a shared store (e.g. a Socket.IO Redis adapter) — a deliberate scope choice.
.
├── src/app/
│ ├── page.tsx # Game container: socket wiring + board + state
│ ├── types.ts # Shared types: socket events, roles, moves, auth
│ ├── AuthPanel.tsx # Login / register / logout widget + session state
│ ├── SearchParamsComponent.tsx # Reads `gameId` from the URL (Suspense child)
│ ├── layout.tsx # Root layout (metadata, Tailwind base)
│ ├── globals.css # Tailwind & global styles
│ └── components/
│ ├── MoveHistory.tsx # Clickable move list
│ ├── Chat.tsx # Room chat + typing indicator
│ ├── GameOverScreen.tsx # End-of-game art (next/image)
│ └── GameInfoBar.tsx # Role, connection & opponent presence status
├── server.js # Thin entry: wires DB, Express app, and Socket.IO
├── server/
│ ├── app.js # Express app: middleware, routes, error handler
│ ├── socketHandlers.js # Socket.IO realtime gameplay handlers
│ ├── gameStore.js # In-memory active games + seat/presence helpers
│ └── chessRules.js # Server-authoritative move validation (applyMove)
├── config/
│ └── db.js # MongoDB connection (optional / graceful)
├── models/
│ ├── User.js # Account + stats schema
│ └── Game.js # Completed-game / match-history schema
├── middleware/
│ └── auth.js # protect() for REST + socketAuth() for sockets
├── controllers/
│ ├── authController.js # register / login / logout / me
│ └── profileController.js # profile + match history
├── routes/
│ ├── authRoutes.js # /api/auth/*
│ └── profileRoutes.js # /api/profile/*, /api/games/*
├── utils/
│ ├── token.js # JWT sign/verify + cookie helpers
│ └── persistGame.js # Save completed game + update stats
├── public/ # audios/ (sfx) and images/ (game-over art)
├── render.yaml # Render Blueprint for the backend web service
├── .env.example # Environment variable template
├── DEPLOYMENT.md # GitHub push + Vercel/Render/Atlas deploy checklist
├── LEARNING_GUIDE.md # Topic-by-topic study guide + 7-day plan
├── INTERVIEW_PREP.md # 50 mock Q&A + set-piece answers
├── RESUME_BULLETS.md # Honest resume bullets (FDE + SDE versions)
└── package.json
User — username (unique), email (unique), passwordHash (select:false, bcrypt), totalGames, wins, losses, draws, and createdAt / updatedAt timestamps.
Game (one document per completed game) — gameId, white / black seats ({ user, username }, user is null for guests), result (white | black | draw), winner, reason (checkmate | stalemate | threefold repetition | insufficient material), finalFen, moves[] (SAN + coordinates), startedAt, endedAt, durationMs.
| Method | Route | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register |
— | Create account, set auth cookie. |
| POST | /api/auth/login |
— | Log in (identifier = email or username). |
| POST | /api/auth/logout |
— | Clear auth cookie. |
| GET | /api/auth/me |
✅ | Current user. |
| GET | /api/profile/me |
✅ | Current user + recent games. |
| GET | /api/profile/:username |
— | Public profile + stats + recent games. |
| GET | /api/games/:gameId |
— | A completed game record. |
| GET | /api/health |
— | Health check. |
Realtime gameplay (join, move, chat, typing, presence, game-over) is handled over Socket.IO, not REST.
All realtime events are typed on both ends — the client maps live in src/app/types.ts (ServerToClientEvents / ClientToServerEvents).
Client → server
| Event | Payload | Meaning |
|---|---|---|
joinGame |
{ gameId, playerId } |
Join/reclaim a seat (sent on every connect). |
move |
{ gameId, move, playerId } |
Attempt a move; server validates it. |
chatMessage |
{ gameId, user, text } |
Send a room chat message. |
typing |
{ gameId, user } |
Broadcast a typing indicator. |
Server → client
| Event | Payload | Meaning |
|---|---|---|
playerColor |
"w" | "b" |
You hold the White/Black seat. |
spectator |
{ reason? } |
Both seats taken — you're watching. |
gameState |
fen (string) |
Authoritative board position. |
moveHistory |
MoveData[] |
Full ordered history (on join/reconnect). |
moveMade |
MoveData |
A single new accepted move. |
invalidMove |
{ move, reason? } |
Move rejected (not_your_turn/illegal_move). |
gameOver |
{ winner, reason } |
Game ended (checkmate or draw). |
presence |
PresenceState |
Who holds each seat and who's online. |
opponentDisconnected |
{ color } |
The other player's socket dropped. |
typing |
user (string) |
Opponent is typing. |
A move round-trip: the client applies the move optimistically and emits move. The server re-validates with chess.js (applyMove), and on success broadcasts the new gameState + moveMade to the room; on rejection it replies invalidMove and re-sends the authoritative gameState, which reverts the client. The server's chess instance is always the single source of truth.
npm run lint # ESLint — passes with no warnings or errors
npx tsc --noEmit # TypeScript type-check — passesUses ESLint with the Next.js recommended rules, TypeScript support, and accessible JSX (jsx-a11y). The production build no longer ignores ESLint or TypeScript errors (next.config.ts), so type or lint regressions fail the build.
- Open the site – it generates a unique
gameId. - Share the URL for someone to join as Black.
- The third+ user becomes a spectator.
- Chat in real-time, view move history, and listen for check alerts!
Done
- User accounts (register / login / logout) with JWT auth
- Persistent match history + win/loss/draw stats
- Server-authoritative move validation & reconnect-safe seats
- Production-ready deployment config (Vercel + Render + Atlas)
Planned
- Timers for competitive play
- Resign / draw-offer controls
- Chess engine integration (e.g. Stockfish)
- Matchmaking / lobby
- In-progress games are in-memory only. If the backend restarts, active games are lost. Only completed games (checkmate/draw) are persisted. This is an intentional trade-off for realtime speed on a portfolio app.
- Identity is per-browser. A player's seat is tied to a
playerIdinlocalStorage, so opening the game in a different browser or clearing storage starts a fresh identity. Logging in attaches the account to the seat for stat-crediting but does not migrate an existing guest seat across browsers. - No in-game lifecycle controls yet. There is no resign, draw offer, or clock; games end only on checkmate or an automatic draw (stalemate, threefold repetition, insufficient material).
- CORS / cookies must be configured for production. Credentialed CORS requires a concrete
CLIENT_ORIGIN(not*), andNODE_ENV=productionis required forSecure+SameSite=Nonecookies across domains. - Auth/history degrade gracefully without a DB. With no
MONGO_URI, gameplay still works but auth and persistence endpoints return503.
A short, honest way to talk through the design:
What it is. A realtime two-player chess platform: two browsers play over WebSockets, extra viewers join as spectators, and logged-in players get persistent match history and win/loss/draw stats.
Why Socket.IO. Chess is bidirectional and latency-sensitive — the server has to push the opponent's move the instant it happens, which is a poor fit for request/response REST. Socket.IO gives a room abstraction (one room per gameId) and broadcasts, plus automatic reconnection, which I lean on for the reconnect-into-your-seat flow.
Why active games stay in memory. The hot path is move validation and broadcast, which happens many times per game. I keep one chess.js instance per room in a plain in-memory map so a move is validate-and-broadcast with no database round-trip. Writing every board state to Mongo would add latency and write load for data I don't need to keep. The trade-off is that an in-progress game is lost if the server restarts — acceptable for this app, and documented.
Why MongoDB stores completed games and stats. Those are the durable, lifecycle facts worth keeping: accounts, a single record per finished game, and aggregate stats. A finished game is written exactly once (guarded by a saved flag), and each seated, logged-in player's stats are incremented with a $inc update. Guests are recorded on the game but have no stats to update.
How server-authoritative validation works. The client is never trusted. It applies a move optimistically for responsiveness and emits it, but the server re-validates with chess.js (server/chessRules.js). On success it broadcasts the new FEN and move; on rejection it replies invalidMove and re-sends the authoritative FEN, which reverts the client. The server's chess instance is the single source of truth — this prevents illegal or out-of-turn moves regardless of what a client sends.
How auth connects to stats/history. Auth is a JWT in an httpOnly cookie. REST routes read it via protect(). For sockets, a handshake-cookie middleware (socketAuth) attaches the user to the connection without rejecting guests. When a player logs in, the client reconnects so the new handshake carries the cookie, and the server re-attaches their seat to their account — so the finished-game stats land on the right user.
Trade-offs I'd call out. In-memory state means no horizontal scaling without a shared store (e.g. Redis adapter) and no crash recovery for live games; identity is per-browser via localStorage; and there are no clocks/resign/draw-offer controls yet. These are deliberate scope choices for a portfolio project, and they're the natural next steps.
MIT License © 2025


