Problem and users
8.1 Market reality
Online chess is a mature, winner-take-most consumer category with two dominant destinations: a large commercial platform and a strong open-source community. A new entrant should not try to win by having "a chessboard and a queue." That game is already over.
The realistic opportunity is not "better Chess.com." It is:
- Private communities that want their own branded, ad-free space.
- Clubs, schools, and academies that need member management, ratings, and game archives.
- Coaches who want a simple way to review games with students.
- Tournament organisers who need a lightweight hosted bracket/arena.
- Campus and corporate leagues that want a recurring social ritual.
GambitCore already has the primitives those customers need: accounts, friends, private rooms, ratings, history, and analysis. The missing pieces are packaging, admin tools, and trust and safety.
8.2 Customer segments and jobs to be done
| Segment | Job to be done | Willingness to pay | What they need first |
|---|---|---|---|
| Chess clubs | Run a weekly rated club night without spreadsheets | Medium | Member roster, private lobby, results export |
| Schools / academies | Give students a safe place to play and improve | Medium–high | Teacher admin, age-appropriate moderation, progress reports |
| Coaches | Review a student's games and assign homework | High (per seat) | Shareable analysis, annotations, lesson plans |
| Tournament organisers | Host a one-off event with a clean experience | High (per event) | Brackets, scheduling, arbiter tools |
| Campus/corporate leagues | Build a recurring social competition | Medium | Team standings, seasons, branded pages |
| Individual players | Play rated games with friends and improve | Low (ads/freemium) | Fast matchmaking, fair ratings, good analysis |
8.3 Positioning
Recommended wedge: "The private chess club for communities that have outgrown spreadsheets and public lobbies."
Why this wedge works:
- It avoids a head-on network-effects fight with the consumer giants.
- It monetises a customer with a budget, not a user with a free account.
- It makes the existing features — private rooms, friends, ratings, history, analysis — directly valuable.
- It creates a repeatable sales motion: one club brings 20–200 players.
- It builds a data and workflow moat in a niche the giants underserve.
Positioning statement:
For chess clubs, schools, and coaches who want a owned, ad-free place to play and improve, GambitCore is a private competitive platform with real ratings, game archives, and coaching-grade analysis — without the noise of a public ladder.
8.4 Business models
These are hypotheses to validate, not promises.
| Model | Who pays | Pricing logic | Why it fits |
|---|---|---|---|
| Club subscription | Club admin | Per active member per month, or a flat tier by member count | Predictable recurring revenue; aligns with roster size |
| Coach seats | Individual coach / academy | Per coach per month | High willingness to pay if it saves review time |
| Tournament hosting | Organiser | Per event, with a size tier | One-off, high-intent purchase |
| White-label / branded instance | School, federation, company | Setup fee + annual licence | Highest contract value; requires admin and compliance work |
| Freemium consumer | Individual player | Free core; paid advanced analysis, history, themes | Useful for top-of-funnel, weak alone |
| Sponsorship | Brand / local business | Season sponsorship of a league | Works once there is a community and audience |
Example packaging (illustrative, validate with 10 customer interviews):
- Free: unlimited casual games, basic history, 3 analyses per month.
- Player Pro: $5–8/month — unlimited analysis, advanced stats, themes.
- Club: $29–99/month — private lobby, member roster, club leaderboard, 10–50 seats.
- Academy: $149–299/month — coach seats, student progress, assignments, branded pages.
- Tournament: $49–199 per event — bracket, live room, results export.
8.5 Unit economics
Chess is a favourable product economically:
- Low marginal compute per game. A game is a small stream of text events.
- Low storage per game. A PGN and metadata are kilobytes.
- Client-side analysis. The most expensive feature has near-zero server cost.
- High session length. A 10-minute game plus analysis is a meaningful engagement window.
- Natural virality. A private room link is an invitation; a challenge is a social act.
Main cost drivers:
- Hosting and managed database.
- Media storage and transformation.
- Support and moderation time.
- Payment processing.
- Trust and safety tooling as the community grows.
Gross margin is likely high at small scale (80%+), but the business is not infrastructure-bound — it is trust-bound. The first hires should be community/moderation and sales, not more servers.
8.6 Go-to-market
Phase 1 — Design partners (0–3 months)
- Recruit 5–10 clubs, schools, or coaches.
- Run their weekly games on GambitCore for free.
- Observe the real workflow: roster import, pairing, results, disputes.
- Ship only what removes friction from that workflow.
Phase 2 — Repeatable club motion (3–9 months)
- Package the club tier: private lobby, roster, leaderboard, results export.
- Create a "club night in a box" onboarding checklist.
- Publish case studies with real club metrics (games played, retention).
- Offer a tournament-night template to drive seasonal usage.
Phase 3 — Platform and partnerships (9–18 months)
- Coach marketplace or academy tier.
- White-label for federations and companies.
- Sponsored leagues.
- Public API and integrations.
Acquisition loops
- Invite loop: every private room is a shareable link; every challenge is a notification.
- Analysis loop: a shareable game report is a social object; add a public, unlisted report link.
- Club loop: one admin brings a cohort; the cohort brings friends.
- Content loop: opening guides and annotated games can rank for long-tail chess queries.
- Event loop: recurring club nights and tournaments create habit.
8.7 Activation and retention
Activation metric: percentage of new users who complete a game within 10 minutes of signup.
Friction removals that matter:
- No email verification before the first game (defer it).
- A default rating so the player can start immediately.
- A clear "play a friend" path for users who do not want a public queue.
- A visible room link and copy button.
- A result screen that offers rematch and analysis in one place.
Retention loops:
- Rating progression and a visible leaderboard.
- Match history with a reason to review.
- Analysis that turns a loss into a lesson.
- Friends and challenges that create a social obligation to return.
- Rematch that removes the friction of finding a new opponent.
- Seasonal leagues or club nights that create a calendar.
8.8 Cold-start liquidity
A two-sided matchmaking queue is empty until it is not. The product already has the right answer: private rooms and friend challenges first.
Recommended sequence:
- Bring your own opponent. Private room links and friend challenges create value with two users.
- Scheduled play windows. "Club night at 7pm" concentrates demand and makes the queue feel alive.
- Cohort onboarding. A club or class arrives together, so the queue has local liquidity.
- Bots later. A small set of clearly labelled bots can protect the first-time experience, but they must not pollute rated play.
- Public ladder last. Only open ranked matchmaking when the pool can support reasonable wait times.
8.9 KPIs
| Funnel stage | Metric | Why it matters |
|---|---|---|
| Acquisition | Visitor → signup rate | Landing page and CTA clarity |
| Activation | Signup → first completed game | The product's real onboarding |
| Engagement | Games per active user per week | Core habit |
| Match quality | Queue time p50/p95; abandonment before pairing | Liquidity and fairness |
| Retention | D1 / D7 / D30 | Product-market fit signal |
| Social | Friend requests per user; challenge acceptance rate | Strength of the social loop |
| Content | Analysis open rate; analyses per completed game | Depth of engagement |
| Quality | Disconnect rate; games finished by abandonment | Network and UX health |
| Monetisation | Free → paid conversion; revenue per club; churn | Business viability |
| Trust | Reports per 1,000 games; resolution time | Community health |
8.10 Risks and mitigations
| Risk | Why it matters | Mitigation |
|---|---|---|
| Cold start | An empty queue kills the first impression | Private rooms, scheduled events, cohort onboarding |
| Cheating / engine assistance | Undermines rated play | Post-game anomaly detection, device signals, server clock authority, transparent policy |
| Moderation and minors | Chat plus a young audience is a trust risk | Age gates, chat controls, report/block, human review |
| Consumer giant network effects | Hard to win a public ladder | Do not compete head-on; own private communities |
| Cloud cost creep | Analysis and media can grow | Client-side analysis, image transformation, caching, budgets |
| Security incident | A breach destroys trust | Hardening sprint, dependency scanning, least privilege, backups |
| Key-person risk | A solo build is fragile | Document architecture, automate deploy, add tests, consider a co-founder |
| Brand/IP | "GambitCore" and visual identity should be cleared | Trademark search before commercial launch |
| Payment fraud | Chargebacks and abuse | Use a mature processor, require verification for club tiers |
8.11 90-day plan
Days 1–30 — Make it trustworthy
- Server-side clock watchdog.
- Idempotent game completion and transactional rating update.
- Integration tests for the real routes and a socket E2E game.
- Security hardening: CSRF, CSP, upload limit, schema validation, friend-regex fix.
- Basic metrics and structured logs.
Days 31–60 — Make it sellable
- Club admin: roster, invite links, private lobby, results export.
- Per-format ratings or an honest rename of the metric.
- Shareable, unlisted analysis reports.
- Onboarding checklist and a demo script for design partners.
Days 61–90 — Make it repeatable
- Run 3 club nights and 1 tournament.
- Instrument activation and retention.
- Interview 10 admins/coaches; price-test the club tier.
- Decide: consumer freemium or B2B club platform. Do not pursue both at once.
What I built
GambitCore is a real-time multiplayer chess platform. A player can create an account, upload an avatar, add friends, challenge them, enter a ranked queue, play a timed game on an authoritative server, chat, resign, rematch, review the result, and then run a Stockfish-powered move-by-move analysis of the game.
The interesting part is not that it renders a chessboard. The interesting part is the closed loop:
identity → presence → matchmaking → room lifecycle → authoritative play
→ clock and result → rating update → persisted history → analysis → rematch
That loop forces real system-design decisions: who owns the truth, what happens when a socket dies, how a game ends exactly once, how a rating is updated without corrupting it, how to stop a client from making an illegal move, and how to keep the experience fast when the network is not.
The one-sentence pitch:
A production-shaped, real-time chess platform that treats the browser as a rendering terminal, the server as the referee, and MongoDB as the durable record — with Glicko-2 ratings and post-game engine analysis completing the competitive loop.
Three things a recruiter or hiring manager should remember:
- Hard correctness constraints. Chess is unforgiving: illegal moves, clock drift, and duplicate rating writes are visible immediately. This project is built around that constraint.
- A complete product loop, not a feature demo. Acquisition (landing page), activation (first game), retention (rating, history, friends, analysis, rematch), and social loops (challenges, shareable rooms).
- Production-shaped thinking. The code names its own limits: single-process state, hybrid clock authority, non-idempotent game completion, and a clear path to horizontal scale.
At a glance
| Question | Answer |
|---|---|
| Product | Real-time competitive chess for individuals, friends, clubs, and coaches |
| Core loop | Sign up → play or challenge → rated result → history → analysis → rematch |
| Hard constraint | Rules, turns, and results must be correct even when clients disagree |
| Stack | Node.js, Express 5, Socket.IO, MongoDB Atlas, Mongoose, EJS, vanilla JS, Glicko-2, Stockfish |
| Architecture | Single Node process; durable state in MongoDB; live state in memory; browser as renderer |
| Current stage | Production-shaped single-node build; hardening and scale-out documented |
| Business wedge | Private, branded chess for clubs, schools, and coaches — not a head-on consumer ladder |
2.1 The product in one paragraph
GambitCore is a place to play competitive chess with people you know or people the queue finds for you. It keeps your rating, remembers your games, lets you challenge friends, gives you a private room link, and shows you where you went wrong afterwards. It is designed for the full session — not just the move — and it works on desktop and mobile through a responsive board.
2.2 User journeys
| User | What they can do | Why it matters |
|---|---|---|
| Visitor | Browse the landing page, see the highest rating on the platform, sign up or log in through a modal | Low-friction acquisition; no separate auth page required |
| New player | Get a default rating of 1500 with high uncertainty (RD 350), play immediately, upload an avatar | Fast activation; the rating system is honest about not knowing a new player yet |
| Ranked player | Choose 3, 5, 10, or 30 minutes, enter the queue, get paired by time control, play a timed game | The core competitive loop |
| Private player | Create a server-authorized room, share the UUID link, play with a friend | Solves the cold-start problem for a new platform: bring your own opponent |
| Friend | Search users, send and accept requests, challenge a friend, and trigger a stored offline notification if they are away (the inbox UI is still a roadmap item) | Builds the social graph and a retention loop |
| Spectator | Join an existing room URL and receive board sync without a player role | Community and watchability; a foundation for tournaments |
| Analyst | Open a past game, step through every move, see engine evaluation, best move, eval bar, opening book, and move classifications | Turns a finished game into a reason to return |
| Returning player | See rating, games played, leaderboard, friends, requests, match history, and rematch | Retention and progression |
2.3 Page-by-page view of the product
| Page | What the user sees | What is happening underneath |
|---|---|---|
Landing (/) | Hero pitch, feature cards, tech stack, live highest rating, login/signup modal | The server verifies the session cookie, queries the top-rated user, and server-renders the page with EJS |
Dashboard (/dashboard) | Profile, avatar, rating, matches, global top 5, friends hub, play menu, match history | Auth middleware → user lookup → leaderboard query → SSR; the browser then fetches friends and history over JSON APIs |
Matchmaking (/matchmaking) | Time-control selector (3/5/10/30), ranked queue, private-room creator, join-by-code input | A Socket.IO connection enters the in-memory queue or creates a server-authorized private room |
Board (/play/:code) | Two player bars, clocks, board, captured pieces, move list, rewind controls, chat, resign/rematch, reconnect modal | Socket join, role assignment, authoritative move validation, snapshot broadcasts, clock updates, chat sanitisation |
Analysis (/analyze/:gameId) | Board, eval bar, engine score, best move, move list with classifications, keyboard navigation | PGN is loaded from MongoDB; the browser reconstructs FENs and runs Stockfish in a Web Worker |
2.4 Feature inventory
| Area | Implemented behaviour | User-facing value |
|---|---|---|
| Accounts | Register, login, logout, bcrypt password hashing, JWT in an HttpOnly cookie | Persistent identity without exposing credentials to JavaScript |
| Profile | Dashboard, rating, games played, avatar upload to Cloudinary with a 250×250 face crop, username change with a fresh JWT | Ownership and personalisation |
| Friends | Substring search by username, send request, accept request, list friends and pending requests | Social graph and repeat play |
| Challenges | Real-time challenge to an online friend; offline friend gets a notification stored on their user document (no inbox UI yet) | Direct invitation without a public lobby |
| Matchmaking | In-memory queue keyed by time control; first compatible pair is matched into a UUID room | Fast pairing for the common case |
| Private rooms | Server creates the room and reservation; client joins by UUID; 10-minute expiry | Play with a specific person, shareable link |
| Live game | Server-validated moves, legal-move hints, promotion modal, check and checkmate detection, captured-material tracker, move list, rewind, live clocks | A complete chess experience |
| Time controls | 3, 5, 10, and 30 minutes; server tracks remaining milliseconds per side | Multiple formats for different session lengths |
| Resilience | Reconnect within 60 seconds; snapshot sync; abandonment results in a loss | Network problems do not instantly destroy a game |
| Social in-game | Chat with server-side sanitisation and client-side textContent rendering; chat restored from sessionStorage on refresh | Presence and personality |
| Game lifecycle | Resign, rematch request/accept, result card, automatic room cleanup | Closure and a reason to play again |
| Persistence | Every finished game stored with players, winner, PGN, and timestamp | History, analysis, and future statistics |
| Ratings | Glicko-2 with rating, rating deviation (RD), and volatility; 100-point floor; games-played counter | Fairer, more statistically honest progression than naive Elo |
| Analysis | Browser-side Stockfish worker, MultiPV 3, adaptive movetime, opening book detection, centipawn-loss classification, eval bar, keyboard navigation | Coaching-grade review without server engine cost |
| Abuse control | Per-IP rate limit on auth, username character restrictions, regex-escaped search, UUID validation, XSS-safe chat | Basic but deliberate hardening |
2.5 What it deliberately is not
Scope discipline is part of the design:
- No engine assistance during live play (analysis is post-game only).
- No tournaments, puzzles, or lessons yet.
- No payments or subscriptions yet.
- No native mobile app (the board is responsive, not native).
- No moderation console or report workflow yet.
- No public API or third-party integrations yet.
These are roadmap items, not accidents. A portfolio project is stronger when it can explain what it chose not to build and why.
Architecture
The architecture is easier to understand as three planes of truth than as a list of technologies.
flowchart LR
subgraph Durable["Durable truth — MongoDB Atlas"]
U[Users, friends, notifications]
G[Finished games + PGN]
R[Ratings, RD, volatility]
end
subgraph Live["Live truth — Node process memory"]
Q[Matchmaking queue]
A[Active games + clocks]
P[Presence: username → sockets]
V[Reserved rooms + TTLs]
end
subgraph Client["Presentation truth — browser"]
B[Board, timers, move list, chat]
C[Disposable local view state]
end
Client <-->|"HTTP + Socket.IO"| Live
Live <-->|"Mongoose async I/O"| Durable
4.1 The principles that drive the code
1. The server is the referee; the client is a rendering terminal with intent.
The browser never decides whether a move is legal. It sends { from, to, promo }; the server calls chess.js, rejects wrong turns, and broadcasts the resulting FEN. The client's local board is a cache, not a source of truth.
2. Snapshots beat deltas for a turn-based game.
After every move, the server emits a game_sync snapshot: FEN, turn, timers, names, history, and the full FEN history. This is slightly more bandwidth than sending a single move, but it makes reconnection, spectating, and debugging dramatically simpler. For a game with a few dozen moves, the trade is obviously correct.
3. Durable state and ephemeral state have different owners. Users, finished games, and ratings live in MongoDB. Active games, queues, presence, and room reservations live in process memory. That split is why the platform is fast — and why a process restart loses live games. Naming that boundary is the first step to scaling it.
4. The room is the unit of consistency.
A UUID room is the boundary for authorization, broadcast, timers, chat, and cleanup. One game, one room, one set of players. That keeps concurrency reasoning local: most operations only need to reason about a single activeGames[code] object.
5. Time is a first-class state machine.
A game moves through reserved → waiting → active → finished → cleanup. Clocks, disconnect grace periods, challenge expiry, and room TTLs are all part of that machine. The code uses timers and timestamps rather than a single "is this still valid?" flag.
6. Identity is a capability, not a claim.
The socket username comes from the verified JWT, not from the client's find_match payload. Private rooms are unguessable UUIDs. The client cannot become another player by editing a request.
7. Analysis is a batch concern, not part of the live loop. The game server never blocks on Stockfish. Analysis happens after the game, in the browser, over a persisted PGN. That keeps the live path fast and the compute cost near zero.
4.2 The most useful interview sentence
"The hard part of a chess platform is not drawing the board. It is deciding who owns the truth when the network, the clock, and two clients disagree — and then making that ownership explicit in the code."
5.1 Component view
flowchart TB
Browser["Browser<br/>EJS-rendered pages + vanilla JS + GSAP"]
Proxy["Reverse proxy / TLS<br/>(deployment edge)"]
Express["Express 5<br/>REST + SSR + middleware"]
Socket["Socket.IO<br/>authenticated game engine"]
Memory["Process memory<br/>activeGames · queue · presence · reservations"]
Mongo[("MongoDB Atlas<br/>users · games · ratings")]
Cloud["Cloudinary<br/>avatars + transformations"]
Engine["Stockfish Web Worker<br/>client-side analysis"]
Browser -->|HTTPS| Proxy --> Express
Browser <-->|WebSocket| Proxy --> Socket
Express --> Memory
Socket --> Memory
Express --> Mongo
Socket --> Mongo
Express --> Cloud
Browser --> Engine
5.2 HTTP request lifecycle
- Request reaches Express.
cookie-parserextracts the JWT.requireAuthverifies it and attachesreq.user.- The controller reads or writes MongoDB through Mongoose.
- EJS renders a page, or JSON is returned.
- Static assets are served from
/public.
Representative routes:
| Method | Route | Purpose |
|---|---|---|
GET | / | Landing page, live highest rating, session-aware CTA |
POST | /api/auth/register | Create account, hash password, issue cookie |
POST | /api/auth/login | Verify credentials, issue cookie |
GET | /logout | Clear cookie |
GET | /dashboard | SSR dashboard with user stats and top players |
POST | /api/user/avatar | Authenticated multipart upload to Cloudinary |
POST | /api/user/update-name | Rename and reissue JWT |
GET | /api/users/search | Case-insensitive substring search, escaped regex, limit 8 |
POST | /api/friends/add | Send friend request |
POST | /api/friends/accept | Accept request, create mutual friendship |
GET | /api/friends | List friends and incoming requests |
GET | /matchmaking | Queue and private-room UI |
POST | /api/rooms/create | Server-authorized private room |
GET | /play/:partyCode | Board page |
GET | /api/games/history | Last 15 games for the user |
GET | /analyze/:gameId | Analysis page for a stored game |
5.3 Real-time lifecycle
sequenceDiagram
participant A as Player A
participant B as Player B
participant S as Socket.IO server
participant M as MongoDB
A->>S: connect (JWT cookie)
B->>S: connect (JWT cookie)
S-->>A: authenticated socket
S-->>B: authenticated socket
A->>S: find_match({ timeControl: 10 })
B->>S: find_match({ timeControl: 10 })
S->>S: pair queue entries, create UUID room + reservation
S-->>A: match_found(roomCode)
S-->>B: match_found(roomCode)
A->>S: join_party({ code, timeControl })
S-->>A: role_assigned("w")
B->>S: join_party({ code, timeControl })
S-->>B: role_assigned("b")
S->>S: isStarted = true, lastMoveTime = now
S-->>A: game_sync(snapshot)
S-->>B: game_sync(snapshot)
A->>S: attempt_move({ from, to, promo })
S->>S: validate turn + legality, deduct clock, push FEN
S-->>A: game_sync(snapshot)
S-->>B: game_sync(snapshot)
Note over S: game over → save Game, update Glicko-2, schedule cleanup
S->>M: Game.create(...)
S->>M: User updates (rating, RD, vol, gamesPlayed)
5.4 Room state machine
stateDiagram-v2
[*] --> Reserved: private room created or match paired
Reserved --> Waiting: first player joins
Waiting --> Active: second player joins
Active --> Finished: checkmate / timeout / resignation / abandonment / draw
Reserved --> Expired: TTL (60s challenge, 10min private room)
Finished --> Cleanup: 5-minute retention for review
Cleanup --> [*]
Each transition has an owner:
- Reserved: created by
createRoomor by the matchmaking pair. - Waiting: first
join_partycreates theactiveGamessession and assigns white. - Active: second player joins,
isStartedbecomes true, clock starts. - Finished: a single terminal event sets
gameOver, emitsgame_over, persists, and schedules cleanup. - Cleanup:
setTimeoutremoves the room from memory after five minutes.
5.5 Data model
erDiagram
USER {
ObjectId _id
string username
string email
string password_hash
string avatar
number rating
number rd
number vol
number gamesPlayed
ObjectId[] friends
ObjectId[] sentRequests
ObjectId[] receivedRequests
object[] notifications
date createdAt
date updatedAt
}
GAME {
ObjectId _id
string whitePlayer
string blackPlayer
string winner
string pgn
date playedAt
}
USER ||--o{ GAME : "plays as white/black by username"
Design notes:
friends,sentRequests, andreceivedRequestsare mutual arrays. Accepting a request removes it from both sides and adds each user to the other'sfriends.- Ratings are stored as
rating(integer),rd(integer after rounding), andvol(float). The schema setter roundsratingon write. Gamestores usernames rather thanObjectIdreferences. That makes history queries simple, but it means a username change does not rewrite history and there is no referential integrity. A production model should store both theObjectIdand a denormalised display name.Gamehas no time control, result reason, rating delta, or move count. Those are the first fields to add for analytics and a richer history UI.
5.6 Socket event contract
| Direction | Event | Payload | Server responsibility |
|---|---|---|---|
| Client → Server | user_online | none | Register socket in presence map |
| Client → Server | find_match | { timeControl } | Add to queue; pair compatible players |
| Client → Server | cancel_matchmaking | none | Remove socket from queue |
| Client → Server | challenge_friend | { targetUsername, timeControl } | Create reserved room; emit or store offline notification |
| Client → Server | join_party | { code, timeControl } | Authorize, assign colour, create or resume session |
| Client → Server | request_legal_moves | square | Return verbose legal moves for that square |
| Client → Server | attempt_move | { from, to, promo } | Validate turn and legality; update clock; broadcast |
| Client → Server | timeout | none | Validate turn; finish game on time |
| Client → Server | send_chat | string | Sanitise, truncate to 200 chars, broadcast |
| Client → Server | resign_game | none | Finish game in opponent's favour |
| Client → Server | request_rematch | none | Notify opponent |
| Client → Server | accept_rematch | none | Reset board, clocks, and history |
| Server → Client | role_assigned | 'w' | 'b' | 'spectator' | Tell the client how to render and what it may do |
| Server → Client | match_found | roomCode | Navigate both players to the board |
| Server → Client | incoming_challenge | { from, timeControl, roomCode } | Show challenge modal |
| Server → Client | challenge_sent | { roomCode } | Navigate challenger to the board |
| Server → Client | game_sync | FEN, turn, timers, names, history, FEN history | Reconcile the client snapshot |
| Server → Client | legal_moves_reply | array of moves | Highlight legal destinations |
| Server → Client | game_over | { reason, winnerName, winnerColor } | Show result card and trigger analysis flow |
| Server → Client | opponent_disconnected | grace seconds | Show reconnect countdown |
| Server → Client | rematch_requested / rematch_accepted | none | Drive rematch UI |
| Server → Client | receive_chat | { sender, text, color } | Append message safely |
The contract is intentionally small. Every event has a single responsibility, and every client action is expressed as intent, not as a state mutation.
5.7 Rating pipeline (Glicko-2)
- A terminal event (
checkmate,timeout,resignation,abandonment, ordraw) setssession.gameOver. updatePlayerRatingsAfterGame(white, black, winnerColor, pgn)is called.- The game is saved to MongoDB with both usernames, the result, the PGN, and a timestamp.
- Both users are loaded.
- White's score is derived:
1for a white win,0for a black win,0.5for a draw. calculateNewRatingsbuilds two Glicko-2 players from the storedrating,rd, andvoland updates them with one match result.- The new rating, RD, and volatility are written back;
gamesPlayedincrements. - Ratings are floored at 100.
Why Glicko-2 instead of Elo:
- A new player starts at 1500 with RD 350, which tells the system "we barely know this player."
- A win against a similarly uncertain opponent moves the rating more than a win against a well-established one.
- RD shrinks as games accumulate, making ratings more stable over time.
- Volatility captures how erratic a player's results are.
- Inactive players can be handled more gracefully because uncertainty grows again.
The honest caveat: the dashboard labels this "Blitz Elo," but the implementation is Glicko-2 and applies across all time controls. That is fine for a first version, but a production product should either rename the metric or maintain per-format ratings.
5.8 Analysis pipeline
The analysis page is a separate, post-game concern:
- The PGN is loaded from MongoDB into the analysis page.
chess.jsreconstructs every FEN from the move list.- A Stockfish worker is created from a Blob URL.
- The engine is configured with
MultiPV 3, so the UI can show the top three candidate moves. - For each position, the engine is asked to search with an adaptive movetime:
- opening (≤ 16 plies): 500 ms
- middlegame: 1200 ms
- endgame (> 60 plies): 1500 ms
- A safety timeout of
movetime + 300 msprevents the UI from hanging if the engine never returns a usable line. - The best move is compared with the move actually played.
- Centipawn loss is classified:
| Loss | Classification | Symbol |
|---|---|---|
| ≤ 0.10 | Best | ★ |
| ≤ 0.30 | Excellent | ! |
| ≤ 0.70 | Good | ✓ |
| ≤ 1.50 | Inaccuracy | ?! |
| ≤ 3.00 | Mistake | ? |
| > 3.00 or forced mate against | Blunder | ?? |
- A small opening book (Ruy Lopez, Sicilian, French, Caro-Kann, QGD, King's Indian) marks known theory so the user does not see "blunder" on a normal opening move.
- The eval bar normalises the engine's score to White's perspective for display.
Why this is a smart product decision: server-side Stockfish is expensive and bursty. Client-side analysis shifts compute to the user's device, keeps the live server responsive, and makes the feature viable at near-zero marginal cost. The trade-off is that analysis quality and speed depend on the device, and it cannot be used as a server-side anti-cheat signal.
5.9 Security model
| Threat | Control in the code | Assessment |
|---|---|---|
| Password theft | bcrypt with cost 10; passwords never returned | Good baseline; add a pepper and stronger cost as hardware improves |
| Session hijacking | JWT in HttpOnly cookie; Secure in production | Good; add explicit SameSite and consider short-lived access + refresh tokens |
| Socket impersonation | Username taken from the verified JWT, not the client payload | Strong design choice |
| Illegal moves | Server-side chess.js validation; turn check; promotion default | Strong for move legality |
| Cheating by client edits | Client only sends intent; server owns FEN and result | Strong for rules; clock enforcement is hybrid (see gaps) |
| XSS in chat | Server sanitises <>&"'/; client uses textContent | Good defence in depth |
| XSS in usernames | Registration blocks <>&"'/; EJS escapes by default | Good; keep the character policy centralised |
| Regex injection in search | searchUsers escapes regex metacharacters | Good |
| Regex injection in friend lookup | sendRequest builds new RegExp(\^${targetUsername}$`, 'i')` without escaping | Gap: escape or use an exact indexed lookup |
| Room guessing | UUID v4 room codes; server-side reservation | Good capability-URL model |
| Brute force | 20 requests per 15 minutes per IP on auth | Basic; in-memory store resets on restart and trusts X-Forwarded-For |
| Upload abuse | Format allow-list, Cloudinary transformation | Gap: no file-size limit |
| CSRF | Cookies are used; no explicit CSRF token | Modern browsers default to SameSite=Lax, which helps, but set it explicitly |
| Supply-chain risk | CDN scripts for GSAP, chessboard.js, chess.js, Stockfish | Gap: add CSP/SRI or self-host critical assets |
| Secret leakage | .env ignored; server refuses to start without critical secrets | Good operational hygiene |
Architecture graph
Nodes
- Browser
- Reverse proxy
- Express
- Socket.IO
- Process memory
- MongoDB Atlas
- Cloudinary
- Stockfish Web Worker
Edges
- Browser to Reverse proxy — HTTPS and WebSocket
- Reverse proxy to Express — HTTP
- Reverse proxy to Socket.IO — WebSocket
- Express to Process memory — reads and writes live state
- Socket.IO to Process memory — active games, queue, presence
- Express to MongoDB Atlas — Mongoose
- Socket.IO to MongoDB Atlas — persist games and ratings
- Express to Cloudinary — avatar uploads
- Browser to Stockfish Web Worker — client-side analysis
Key decisions and tradeoffs
7.1 Current posture: what is strong, what is fragile
| Dimension | Assessment |
|---|---|
| Correctness of move rules | Strong — single authoritative engine, server-side validation |
| Correctness of clocks | Partial — server-owned timers, but client-triggered timeout |
| Reconnection | Good — snapshot sync and 60-second grace |
| Durability | Partial — finished games persist; live games do not |
| Scalability | Single-process by design; needs shared state to scale out |
| Security | Good baseline; missing CSRF/CSP/upload limits/schema validation |
| Observability | Basic console logs; needs structured events and metrics |
| Test coverage | Contract-level; needs integration and socket E2E |
| Product loop | Strong — play, rate, persist, analyse, rematch |
| Operational simplicity | Strong — one process, one database, one media service |
7.2 What breaks at 10× and 100×
| Load | Symptom | Fix |
|---|---|---|
| 10× players | Queue wait times grow; a single process handles more sockets | Keep one process for correctness, add metrics, consider a second instance with sticky sessions |
| 10× concurrent games | In-memory activeGames grows; a restart loses more | Redis-backed session store; append-only move log |
| Multiple instances | Players land on different processes; rooms are invisible across them | Socket.IO Redis adapter, shared presence, room ownership or consistent hashing |
| Celebrity/tournament spike | One room broadcasts to many spectators | Fan-out via pub/sub, spectator read model, broadcast throttling |
| Analysis popularity | Client CPU is the bottleneck, not the server | Keep client-side; add a queue and “analysis ready” state for slow devices |
| Global audience | Latency to a single region | Multi-region edge, read replicas, region-aware matchmaking |
The key insight: chess is turn-based and low-write. A single Node process can serve a surprising number of games. The hard problems are fairness, correctness, and state ownership, not raw throughput. Do not distribute the game loop until the product actually needs it.
7.3 Scaling roadmap
Phase 0 — Harden the single node (before public launch)
- Add a server-side clock watchdog per active game.
- Make game completion idempotent with a
finalizedflag. - Wrap game save + rating updates in a transaction or an outbox.
- Add schema validation (Zod/Joi) at every HTTP and socket boundary.
- Add
helmet, explicitSameSite, CSRF protection for state-changing forms, and an upload size limit. - Add compound indexes for history (
whitePlayer + playedAt,blackPlayer + playedAt) and leaderboard (rating). - Add structured logs with
gameId,roomCode,username,event, andoutcome. - Self-host Stockfish and unify
chess.jsversions. - Fix the missing
/loginview or remove the route.
Phase 1 — Multi-instance (when one process is not enough)
- Socket.IO Redis adapter for cross-instance broadcast.
- Redis for active games, queue, presence, and reservations.
- Sticky sessions at the load balancer, or a Redis-backed session handshake.
- A distributed lock or room-owner lease so only one process mutates a game.
- Move the matchmaking queue to a sorted set with rating and wait-time scoring.
Phase 2 — Service boundaries (when the team grows)
- Gateway: auth, rate limiting, WebSocket termination.
- Matchmaking service: queue, pairing policy, fairness.
- Game service: room state machine, move validation, clock authority.
- Rating service: consumes
GameFinishedevents, writes ratings idempotently. - History/analysis service: PGN storage, analysis jobs, shareable reports.
- Notification service: offline challenges, email, push.
- Use a message bus with an outbox pattern; never dual-write to the database and the bus without one.
Phase 3 — Global and commercial
- Read replicas and region-aware routing.
- CDN for assets and analysis reports.
- WAF and bot protection.
- Per-tenant isolation for club/white-label deployments.
- Data retention and privacy controls for minors and regional compliance.
7.4 Consistency model by feature
| Feature | Consistency target | Mechanism |
|---|---|---|
| Move legality and board state | Strong, single-writer per room | One process owns the room; validate and broadcast in order |
| Clock | Strong | Server timestamps + watchdog |
| Game completion | Exactly once | Idempotency key / finalized flag + transaction/outbox |
| Ratings | Eventually consistent, but never lost | Event-driven rating update with retries |
| Presence | Eventually consistent | Redis TTL + heartbeat |
| Leaderboard | Eventually consistent | Periodic materialised view or cache |
| Matchmaking | Approximate | Queue with expanding rating window |
| Chat | Best effort | Broadcast; no durable history required |
| Analysis | Offline/batch | Client worker or job queue |
7.5 Failure modes and what should happen
| Failure | Current behaviour | Desired behaviour |
|---|---|---|
| Process restart | Live games are lost | Recover from Redis/move log; resume or void with clear messaging |
| MongoDB unavailable | Auth/history/rating writes fail | Circuit breaker, retry with backoff, graceful read-only mode |
| Cloudinary unavailable | Avatar upload fails | Fallback avatar, queued retry |
| Socket disconnect | 60-second grace, then abandonment | Same, plus server-side clock continues |
Duplicate game_over | Possible double persist/rating | Idempotent terminal transition |
| Clock reaches zero with no client event | Game can hang | Server watchdog finishes the game |
| Queue has one player | Waits indefinitely | Show wait time, suggest private room, allow cancel |
| Room code leaked | Anyone with the link can join a private room | Capability URL is acceptable; add optional password/invite list |
| Malicious move spam | Rejected but not throttled | Per-socket token bucket |
| Oversized avatar | Potentially accepted | Enforce file-size limit before upload |
7.6 Observability
The current code logs lifecycle events to the console. For production, instrument:
Metrics
- Queue time p50/p95/p99 by time control.
- Match completion rate and abandonment rate.
- Move latency p50/p95.
- Disconnect rate per 1,000 games.
- Games finished by reason (checkmate, timeout, resignation, abandonment, draw).
- Rating update failures and retries.
- Active games, connected sockets, queue depth.
- Analysis completion time and engine error rate.
Logs
- Structured JSON with
event,roomCode,gameId,username,colour,result,durationMs,traceId. - Never log tokens, passwords, or raw cookies.
Traces
- One trace per game from
match_foundtogame_over. - One trace per HTTP request for auth and uploads.
Alerts
- Queue depth above threshold for more than 60 seconds.
- Rating update failure rate above 0.1%.
- Socket disconnect spike.
- MongoDB latency above budget.
- Process memory growth without a corresponding drop in active games (leak signal).
7.7 Data model improvements
- Store
whiteUserIdandblackUserIdalongside the usernames. - Add
timeControl,resultReason,rated,ratingBefore,ratingAfter,moveCount, andstartedAttoGame. - Store a move list (
from,to,promotion,san,fenAfter,clockMs) for replay without PGN parsing. - Add a
GameEventorMovecollection if the product needs auditability or anti-cheat review. - Add compound indexes for the two history queries and the leaderboard.
- Consider per-format ratings (
bullet,blitz,rapid,classical) instead of one global rating. - Add
emailVerified,lastSeenAt,role, andstatustoUser. - Add a
Friendshipcollection if the mutual-array model becomes hard to query or audit.
7.8 Testing strategy
| Layer | What to test | Tooling |
|---|---|---|
| Unit | Glicko-2 math, sanitiser, PGN/FEN helpers, classification thresholds | Jest |
| HTTP contract | Auth, friends, upload, history, authorization | Supertest against the real exported app |
| Socket contract | Connect, queue, pair, join, move, illegal move, disconnect, rematch | socket.io-client integration test |
| Persistence | Game save + rating update atomicity, indexes | mongodb-memory-server |
| End-to-end | Two clients play a full game to checkmate | Playwright or a socket harness |
| Load | 1,000 concurrent sockets, queue churn | k6 or Artillery |
| Security | XSS payloads, regex injection, oversized upload, CSRF | Supertest + OWASP checklists |
The existing tests are a good start, but they should import the production routers. Otherwise they test a parallel implementation and can pass while the real route breaks.
7.9 Cost model (order of magnitude)
At early scale, the architecture is cheap because the expensive part — chess analysis — runs on the user's device.
| Cost | Driver | Early-stage posture |
|---|---|---|
| Compute | One small Node instance | Low; scales with sockets, not moves |
| MongoDB Atlas | User/game documents, indexes | Low; games are small text documents |
| Cloudinary | Avatar storage + transformations | Low; avatars are small and cached |
| Bandwidth | Board snapshots and assets | Moderate; snapshots are small JSON |
| Engine | Client CPU | Effectively zero marginal server cost |
| Support/moderation | Human time | The real cost as the community grows |
The business implication: gross margin is high at small scale, but trust and safety costs grow faster than infrastructure costs. Do not underprice a community product.
Hard parts
This is the section that matters most in an interview. Each problem is framed as constraint → decision → residual risk.
6.1 Making the server authoritative without making the UI feel slow
Problem. A naive client-side chess implementation is easy to cheat and hard to synchronise. A naive server-only implementation makes every click feel laggy.
Decision. The client sends only { from, to, promo }. The server validates the turn and legality, mutates the single Chess instance, and broadcasts a full game_sync snapshot. The client renders the snapshot and requests legal moves only for highlighting. Optimistic local moves are avoided on purpose.
Result. The rules cannot be bypassed by editing the client, and the UI still feels immediate because the round trip is short and the board re-renders from a known-good FEN.
Residual risk. The client can still spam attempt_move; the server rejects illegal or out-of-turn moves, but there is no per-socket move rate limit. Add a small token bucket per player.
6.2 Reconnection and state reconciliation
Problem. Mobile browsers suspend sockets. A player who refreshes or loses Wi-Fi should not lose a winning position.
Decision. The session stores the full FEN history, timers, names, and lastMoveTime. On reconnect, join_party re-associates the username with the existing colour, clears the disconnect timer, and emits a game_sync snapshot. The client also subtracts the elapsed server time for the side to move, so the clock does not jump backwards.
Result. A 60-second grace window with a visible countdown; if the player does not return, the opponent wins by abandonment.
Residual risk. The server does not persist active games, so a process restart loses them. The next step is a Redis-backed session store or an append-only move log.
6.3 Clock fairness and drift
Problem. Client clocks drift, tabs throttle timers, and a malicious client could simply refuse to report that its own time expired.
Decision. The server owns the remaining time. On each accepted move it subtracts Date.now() - lastMoveTime from the mover's clock and resets lastMoveTime. The client also runs a local countdown for display, but the server's timers are the source of truth. The timeout event is validated against the player's colour and the current turn.
Result. Normal play is fair and the clock stays consistent across reconnects.
Residual risk — and this is important to say out loud: the server does not run an independent clock watchdog. If the player whose turn it is never emits timeout, the game can hang. A production version needs a server-side scheduler per active game (or a periodic sweep) that declares a loss when a clock reaches zero, independent of either client. This is the single most valuable correctness upgrade.
6.4 Room authorization and join races
Problem. Two players can race to join the same room; a stranger can guess a room code; a private room can be created client-side without server authorization.
Decision. Private rooms are created by POST /api/rooms/create, which stores a reservation with the host and a 10-minute TTL. join_party checks the reservation and, for matchmaking rooms, verifies that the username is in allowedPlayers. The first joiner becomes White; the second becomes Black; later joiners become spectators. Room codes are UUID v4. The client validates the UUID format before navigating.
Result. The common race is handled by "first writer wins" inside the single-process event loop, and the room is no longer a client-invented concept.
Residual risk. Private rooms set isPrivate: true, and the authorization check is skipped for private rooms, so anyone with the UUID can join. That is a deliberate capability-URL model; it should be documented, and optionally paired with a password or an explicit invite list. join_party also still has a fallback that creates a session for an unreserved code, so a client can invent a room URL. Production should reject joins with no reservation unless a deliberate "casual room" mode exists.
6.5 Matchmaking queue integrity
Problem. A player can click "find match" twice, disconnect while queued, or be paired with the wrong time control.
Decision. The queue rejects duplicate usernames, removes the socket on cancel or disconnect, and pairs only entries with the same timeControl. The pair is removed before the room is emitted.
Result. Simple, predictable pairing for a single process.
Residual risk. Matchmaking ignores rating entirely. "Ranked" currently means "timed and rated," not "skill-matched." At low liquidity that is the right call — a fast mismatch is better than an empty queue — but the product should either rename the mode or add an expanding rating window as the player pool grows.
6.6 Memory lifecycle and orphan rooms
Problem. A real-time server that keeps games and reservations in memory will leak if nothing cleans them up.
Decision. Reserved rooms expire on a timer (60 seconds for challenges, 10 minutes for private rooms). Finished games are deleted from activeGames five minutes after the result, giving the client time to render the result and request a rematch. Matchmaking entries are removed on cancel and disconnect.
Result. The live state has an explicit lifetime, and the cleanup policy is visible in one place.
Residual risk. setTimeout handles are lost on process restart, and there is no periodic sweep for edge cases. A production version should use a TTL index or a Redis expiry, plus a janitor that scans for sessions with no activity.
6.7 Rating integrity
Problem. Ratings are the product's progression system. A double write, a partial write, or a wrong perspective corrupts trust.
Decision. Ratings are updated in one function after a terminal event. Glicko-2 receives the stored rating, rd, and vol; the result is written back with a 100-point floor and an incremented gamesPlayed. The game record is saved first, so there is an audit trail even if the rating update fails.
Result. A clean, understandable rating path with a mathematical foundation.
Residual risk. The game save and the two user updates are not in a transaction, and the terminal event is not explicitly idempotent. Two near-simultaneous terminal events could double-save or double-rate. Add a finalized flag on the session and a database transaction (or an outbox) around persistence.
6.8 XSS, injection, and abuse surface
Problem. A chat box, a username field, a search box, a file upload, and an auth endpoint are all classic attack surfaces.
Decision. Passwords are hashed; JWTs are HttpOnly; chat is sanitised server-side and rendered with textContent; usernames block HTML-significant characters; search escapes regex metacharacters; room codes are validated as UUIDs; auth endpoints are rate-limited; uploads allow only four image formats and are transformed by Cloudinary.
Result. A defence-in-depth posture that is appropriate for a first public version.
Residual risk. No CSRF token, no Helmet/CSP, no upload size limit, no schema validation library, and the friend lookup does not escape its regex. These are known, bounded gaps — the right next step is a small hardening sprint, not a rewrite. The analysis page also injects the stored PGN into a JavaScript template literal with <%- %>; it is server-generated today, but any future "import a PGN" feature must escape it or pass it as JSON.
6.9 Making client-side Stockfish reliable
Problem. Engine integration is notoriously flaky: workers fail to load, bestmove arrives without useful info lines, mate scores break arithmetic, and long searches freeze the UI.
Decision. The engine runs in a Web Worker created from a Blob URL, so it does not block the main thread. The code uses MultiPV 3, parses info lines for cp and mate, and treats bestmove as a termination signal rather than the source of the best move. A promise resolves only when multipv[1] is populated. A timeout sends stop to flush the engine. Mate scores are normalised to ±100 pawns for classification, and the display converts to White's perspective.
Result. A robust analysis loop with a loading overlay and adaptive depth.
Residual risk. The analysis page loads a different chess.js version (0.10.3) than the server (1.4.0), and all engine assets come from public CDNs. Self-host the engine and unify the rules library to remove version skew and supply-chain exposure.
6.10 The UX of a chessboard
Problem. A chessboard is deceptively hard to make feel right: orientation, coordinates, promotion, check highlighting, captured pieces, move history, and mobile sizing all interact.
Decision. The board is rendered from a 2D array derived from FEN. Click-to-select and legal-destination highlighting are driven by the server. The layout flips for Black by reordering flex children, not by rebuilding the DOM. Coordinates are generated dynamically. Promotion opens a modal with the four legal pieces. Captured material is derived by comparing the current board to the starting counts. The board sizes itself from the viewport with clamped square dimensions. Checkmate gets a projectile animation and a board shake for drama.
Result. A board that feels like a product rather than a grid of divs, and that works on mobile.
Residual risk. Drag-and-drop is not implemented; the interaction is click-based. Some animation code is imperative and could be extracted into a small view module. The projectile effect is pure delight — keep it, but gate it behind prefers-reduced-motion.
6.11 Deployment and network reality
Problem. MongoDB Atlas SRV lookups can fail on networks with restrictive or broken DNS, producing a confusing startup failure.
Decision. The server sets dns.setDefaultResultOrder('ipv4first') and pins public DNS servers before connecting. It also fails fast if JWT_SECRET or MONGODB_URI is missing, rather than booting into a broken state.
Result. A more predictable local and small-deployment experience.
Residual risk. Hardcoding DNS servers is an environment-specific workaround, not a general production strategy. Prefer the platform resolver, document the workaround, and remove it once the deployment network is known-good.
6.12 Testing a real-time system
Problem. Socket-driven flows are hard to test, and database-dependent tests are slow.
Decision. Jest and Supertest cover auth contracts, friend-request authorization, and the sanitiser. Mocks isolate the database. Tests verify status codes, response shapes, cookies, and the "no cookie means 401" boundary.
Result. Fast feedback on the parts that are easiest to regress.
Residual risk. The tests re-implement the routes inside the test file instead of importing the real router/controller, so they can drift from production. The next step is to export the Express app, use mongodb-memory-server, and add a Socket.IO client integration test that plays a short game end-to-end.
Story
9.1 The 30-second pitch
"I built GambitCore, a real-time multiplayer chess platform. The browser is a rendering terminal; the server is the referee. It handles authenticated sockets, matchmaking, private rooms, authoritative move validation, clocks, reconnection, chat, Glicko-2 ratings, persistent game history, and post-game Stockfish analysis. The hard part was not the board — it was making the network, the clock, and two clients agree on one version of the truth, and then designing the failure modes around that."
9.2 The 2-minute demo script
- Landing page (15s): show the product promise and the live highest rating pulled from MongoDB.
- Signup/login (10s): show the HttpOnly cookie session and the dashboard.
- Dashboard (20s): rating, games played, leaderboard, friends, avatar upload.
- Private room (20s): create a room, copy the link, join from a second browser/incognito window.
- Live game (30s): make moves, show legal-move hints, promotion, clock, captured pieces, chat, rewind.
- Finish (10s): resign or checkmate; show the result card and rating update.
- History and analysis (20s): open the game, step through moves, show eval bar, best move, classification.
- Close (5s): name the architecture and the next scaling step.
9.3 Resume bullets
Use these as templates. Replace placeholders with measured values; do not invent numbers.
- Built a real-time multiplayer chess platform with an authoritative Socket.IO game loop, server-side move validation, snapshot-based reconnection, and a 60-second disconnect grace period.
- Designed the full competitive loop: authenticated accounts, friend graph, ranked queue, private rooms, live clocks, chat, result handling, Glicko-2 ratings, persistent history, and Stockfish-powered post-game analysis.
- Implemented Glicko-2 rating updates with rating deviation and volatility, a rating floor, and a single persistence path for completed games.
- Hardened the public surface with bcrypt password hashing, HttpOnly JWT cookies, per-IP auth rate limiting, server-side chat sanitisation, XSS-safe DOM rendering, escaped search queries, and UUID room validation.
- Built a browser-side Stockfish analysis pipeline with MultiPV, adaptive search time, mate-score normalisation, opening-book detection, and centipawn-loss move classification.
- Audited the system for production readiness and produced a scaling plan covering idempotent game completion, server-side clock authority, Redis-backed room state, and Socket.IO multi-instance fan-out.
- Wrote Jest/Supertest tests for authentication, friend-request authorization, and sanitisation contracts; identified the gap between isolated route tests and real integration coverage.
9.4 STAR stories
Story 1 — "The client cannot be trusted"
- Situation: A chess client can be edited, replayed, or scripted.
- Task: Make the game fair without making it feel slow.
- Action: Moved all rule evaluation to the server; the client sends intent only. The server validates turn and legality, owns the FEN, deducts the clock, and broadcasts a full snapshot. Legal-move hints are requested from the server, not computed locally.
- Result: Illegal moves are rejected at the boundary; the UI still feels immediate because the snapshot is small and the round trip is short.
- Lesson: Authority is a design decision, not a library.
Story 2 — "The network will always fail"
- Situation: Mobile sockets suspend; refreshes happen mid-game.
- Task: Let a player return without losing the position.
- Action: Stored FEN history and timers in the session; re-associated the username on reconnect; sent a snapshot with elapsed time; added a 60-second grace period and an abandonment result.
- Result: A refresh or brief disconnect is recoverable; a permanent leave has a clear outcome.
- Lesson: Design the reconnect path before the happy path.
Story 3 — "The analysis engine was flaky"
- Situation: A browser engine can return partial
infolines, hang, or report mate scores that break arithmetic. - Task: Make analysis reliable enough to ship.
- Action: Used a Web Worker, MultiPV 3, a promise that resolves only when the top line exists, a
stopfallback, mate normalisation, adaptive movetime, and opening-book detection. - Result: A stable review experience with a loading state and useful classifications.
- Lesson: Treat external engines as unreliable dependencies and define a strict contract.
9.5 Interview questions you should be ready for
"Why Socket.IO instead of raw WebSockets?"
Rooms, reconnection, and a mature client/server contract. For a game where a room is the unit of consistency, Socket.IO's room abstraction removes a lot of plumbing. The cost is an extra protocol layer, which is acceptable at this scale. At high scale I would keep the abstraction but add the Redis adapter.
"Why MongoDB?"
The profile and game documents are naturally document-shaped, the schema is still evolving, and Atlas removes operational burden. The trade-off is that I have to design indexes and transactions deliberately. If the product became reporting-heavy, I would add a relational read model.
"How do you prevent cheating?"
Three layers: the server owns move legality and the result; the client only sends intent; and the socket identity comes from the JWT, not the payload. The honest gap is the clock — a malicious client can refuse to report its own timeout, so the next step is a server-side watchdog. Post-game engine assistance is a detection problem, not a prevention problem; I would add anomaly signals and a transparent policy.
"How would you scale this to 100,000 users?"
First, keep the game loop single-writer per room. Add Redis for active games, queue, presence, and reservations; add the Socket.IO Redis adapter; use sticky sessions or a room-owner lease. Then split matchmaking, game, rating, and analysis into services connected by an outbox/event bus. Ratings become an idempotent consumer of
GameFinished. Analysis stays client-side or moves to a job queue for shareable reports. The key is to distribute state, not to distribute the rules.
"What was the hardest bug?"
The analysis pipeline's mate-score handling. The engine reports mate from the side-to-move perspective, and the UI needs White's perspective. A naive arithmetic conversion produces
-M0and broken eval bars. I normalised mate to ±100 pawns for classification, handled mate 0 as a terminal state, and inverted the score based on the ply. The broader lesson was to define the contract for an external engine before building UI on top of it.
"What would you do differently?"
I would make game completion idempotent and transactional from day one, and I would add a server-side clock watchdog before writing the client clock. I would also test against the real routers instead of re-implementing them in the test file. Those three changes would remove the most dangerous correctness gaps.
"What are you most proud of?"
The complete loop. A user can arrive, find an opponent, play a fair game, get a rating, review the mistakes, and rematch — without leaving the product. That is a product, not a demo.
9.6 Claims to avoid (and what to say instead)
| Avoid saying | Why | Say instead |
|---|---|---|
| "100% cheat-proof" | The client still controls its own timeout reporting; engine assistance is a detection problem | "Server-authoritative for move legality and results; clock enforcement is being moved fully server-side" |
| "10ms latency" | Not measured in the repository | "Low-latency turn-based sync via Socket.IO; measure p50/p95 before quoting numbers" |
| "MongoDB matchmaking" | Matchmaking is in-memory; MongoDB stores durable data | "In-memory matchmaking with MongoDB-backed identity, history, and ratings" |
| "Elo rating" | The implementation is Glicko-2 | "Glicko-2 rating with RD and volatility" |
| "Zero latency" | Physically impossible | "Snapshot-based sync that keeps the client consistent after reconnects" |
| "Production-ready at scale" | Single-process state, no Redis, no watchdog | "Production-shaped single-node build with a documented path to horizontal scale" |
| "AI-powered analysis" | It is a deterministic chess engine, not an LLM | "Stockfish-powered analysis with centipawn-loss classification" |
| "Built for Evaluation II" | Academic context leaks into a product page | Replace with customer-facing trust, privacy, or feature copy |
| "Live Updates" on the leaderboard | The dashboard is server-rendered; the list is not pushed live | "Top rated players" or add a real-time leaderboard feed |
9.7 What to show in the repository
- This document.
- A short README with setup, screenshots, and the architecture diagram.
- A
docs/folder with the event contract and data model. - A demo GIF of the full loop: queue → game → result → analysis.
- A
SECURITY.mdwith the known gaps and the hardening plan. - A
ROADMAP.mdwith the 30/60/90-day plan.
9.8 The honest gap list (show this — it builds credibility)
- Server-side clock watchdog is missing.
- Game completion and rating updates are not idempotent/transactional.
- Live game state is process-local and lost on restart.
- Matchmaking does not consider rating.
- Tests do not exercise the real routers.
- No CSRF token, CSP, or upload size limit.
- Friend lookup builds an unescaped regex.
/loginroute references a missing view.- Two
chess.jsversions and CDN-hosted engine assets. - No structured observability or load test yet.
- No moderation/report tools.
- No per-format ratings.
- Offline challenge notifications are persisted but have no inbox or API to read them.
join_partyaccepts an unreserved room code, so a client can still invent a room URL.- No README,
.env.example, or CI pipeline yet.
A candidate who can name these, explain the impact, and sequence the fixes is more credible than one who claims there are none.
Stack
The stack is deliberately boring. For a real-time product at this stage, the differentiator is correctness and product loop, not infrastructure novelty.
| Layer | Choice | What it actually does here | Why this choice makes sense | Trade-off accepted |
|---|---|---|---|---|
| Runtime | Node.js | Single event loop for HTTP and WebSocket traffic | Chess is turn-based and I/O-bound, not CPU-bound; Node handles many idle connections cheaply | A CPU-heavy task would block the loop; engine work is pushed to the browser |
| HTTP framework | Express 5 | Routing, middleware, SSR, JSON APIs | Minimal ceremony, huge ecosystem, easy to reason about | No built-in schema validation or opinionated structure |
| Realtime | Socket.IO | Authenticated sockets, rooms, reconnection, event contracts | Rooms map naturally to games; reconnection and fallbacks are built in | Adds a protocol layer over raw WebSockets; needs a Redis adapter to scale across processes |
| Chess rules | chess.js (server) | Move legality, FEN, PGN, check/checkmate/stalemate/draw detection | A mature rules engine removes an entire class of bugs | Two versions exist in the project (server 1.4.0 vs analysis page 0.10.3); should be unified |
| Persistence | MongoDB Atlas + Mongoose | Users, friendships, notifications, games, ratings | Flexible document model for evolving profile and game metadata; Atlas removes ops burden | No relational constraints; transactions and indexes must be designed deliberately |
| Views | EJS | Server-rendered pages with per-user data | No build step, fast first paint, simple deployment, SEO-friendly landing page | Less component reuse than a framework; state is split between server render and browser |
| Browser logic | Vanilla JS | Board rendering, socket client, drag/click interaction, analysis orchestration | Full control over a custom board and animation; no framework overhead for a single-page game surface | More manual DOM work; needs discipline to avoid duplicated state |
| Animation | GSAP | Landing, dashboard, and queue transitions | Polished perceived performance with little code | External CDN dependency |
| Auth | jsonwebtoken + bcrypt + HttpOnly cookie | Stateless session, password hashing, socket handshake auth | No server-side session store; the same token authenticates HTTP and WebSocket | No refresh-token rotation; 24-hour expiry forces re-login |
| Uploads | Multer + Cloudinary | Avatar upload, format allow-list, face-aware 250×250 crop | Offloads storage, transformation, and CDN delivery | No explicit file-size limit in the Multer config; add one |
| Ratings | glicko2 | Rating, RD, volatility updates after each game | Glicko-2 models uncertainty; better for new and inactive players than Elo | More complex to explain; needs careful persistence and idempotency |
| Analysis | Stockfish 10.0.2 in a Web Worker + chessboard.js | MultiPV evaluation, best move, eval bar, move classification | Zero server engine cost; analysis scales with the user's device | Depends on CDN availability and client CPU; mobile devices are slower |
| Testing | Jest + Supertest | Auth, friends, and sanitisation contracts | Fast, isolated feedback | Tests re-implement routes instead of importing the real app; integration coverage is the next step |
| Dev loop | Nodemon | Auto-restart on change | Fast iteration | npm start also uses Nodemon; production should use node server.js |
Stack in one sentence:
A deliberately simple, single-process Node service that keeps the rules and the clock on the server, uses Socket.IO rooms as the concurrency boundary, and treats MongoDB as the durable record.