All projects

GambitCore

Real-time competitive chess, built like a product, not a board demo.

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

SegmentJob to be doneWillingness to payWhat they need first
Chess clubsRun a weekly rated club night without spreadsheetsMediumMember roster, private lobby, results export
Schools / academiesGive students a safe place to play and improveMedium–highTeacher admin, age-appropriate moderation, progress reports
CoachesReview a student's games and assign homeworkHigh (per seat)Shareable analysis, annotations, lesson plans
Tournament organisersHost a one-off event with a clean experienceHigh (per event)Brackets, scheduling, arbiter tools
Campus/corporate leaguesBuild a recurring social competitionMediumTeam standings, seasons, branded pages
Individual playersPlay rated games with friends and improveLow (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.

ModelWho paysPricing logicWhy it fits
Club subscriptionClub adminPer active member per month, or a flat tier by member countPredictable recurring revenue; aligns with roster size
Coach seatsIndividual coach / academyPer coach per monthHigh willingness to pay if it saves review time
Tournament hostingOrganiserPer event, with a size tierOne-off, high-intent purchase
White-label / branded instanceSchool, federation, companySetup fee + annual licenceHighest contract value; requires admin and compliance work
Freemium consumerIndividual playerFree core; paid advanced analysis, history, themesUseful for top-of-funnel, weak alone
SponsorshipBrand / local businessSeason sponsorship of a leagueWorks 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:

  1. Bring your own opponent. Private room links and friend challenges create value with two users.
  2. Scheduled play windows. "Club night at 7pm" concentrates demand and makes the queue feel alive.
  3. Cohort onboarding. A club or class arrives together, so the queue has local liquidity.
  4. Bots later. A small set of clearly labelled bots can protect the first-time experience, but they must not pollute rated play.
  5. Public ladder last. Only open ranked matchmaking when the pool can support reasonable wait times.

8.9 KPIs

Funnel stageMetricWhy it matters
AcquisitionVisitor → signup rateLanding page and CTA clarity
ActivationSignup → first completed gameThe product's real onboarding
EngagementGames per active user per weekCore habit
Match qualityQueue time p50/p95; abandonment before pairingLiquidity and fairness
RetentionD1 / D7 / D30Product-market fit signal
SocialFriend requests per user; challenge acceptance rateStrength of the social loop
ContentAnalysis open rate; analyses per completed gameDepth of engagement
QualityDisconnect rate; games finished by abandonmentNetwork and UX health
MonetisationFree → paid conversion; revenue per club; churnBusiness viability
TrustReports per 1,000 games; resolution timeCommunity health

8.10 Risks and mitigations

RiskWhy it mattersMitigation
Cold startAn empty queue kills the first impressionPrivate rooms, scheduled events, cohort onboarding
Cheating / engine assistanceUndermines rated playPost-game anomaly detection, device signals, server clock authority, transparent policy
Moderation and minorsChat plus a young audience is a trust riskAge gates, chat controls, report/block, human review
Consumer giant network effectsHard to win a public ladderDo not compete head-on; own private communities
Cloud cost creepAnalysis and media can growClient-side analysis, image transformation, caching, budgets
Security incidentA breach destroys trustHardening sprint, dependency scanning, least privilege, backups
Key-person riskA solo build is fragileDocument architecture, automate deploy, add tests, consider a co-founder
Brand/IP"GambitCore" and visual identity should be clearedTrademark search before commercial launch
Payment fraudChargebacks and abuseUse 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:

  1. Hard correctness constraints. Chess is unforgiving: illegal moves, clock drift, and duplicate rating writes are visible immediately. This project is built around that constraint.
  2. 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).
  3. 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

QuestionAnswer
ProductReal-time competitive chess for individuals, friends, clubs, and coaches
Core loopSign up → play or challenge → rated result → history → analysis → rematch
Hard constraintRules, turns, and results must be correct even when clients disagree
StackNode.js, Express 5, Socket.IO, MongoDB Atlas, Mongoose, EJS, vanilla JS, Glicko-2, Stockfish
ArchitectureSingle Node process; durable state in MongoDB; live state in memory; browser as renderer
Current stageProduction-shaped single-node build; hardening and scale-out documented
Business wedgePrivate, 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

UserWhat they can doWhy it matters
VisitorBrowse the landing page, see the highest rating on the platform, sign up or log in through a modalLow-friction acquisition; no separate auth page required
New playerGet a default rating of 1500 with high uncertainty (RD 350), play immediately, upload an avatarFast activation; the rating system is honest about not knowing a new player yet
Ranked playerChoose 3, 5, 10, or 30 minutes, enter the queue, get paired by time control, play a timed gameThe core competitive loop
Private playerCreate a server-authorized room, share the UUID link, play with a friendSolves the cold-start problem for a new platform: bring your own opponent
FriendSearch 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
SpectatorJoin an existing room URL and receive board sync without a player roleCommunity and watchability; a foundation for tournaments
AnalystOpen a past game, step through every move, see engine evaluation, best move, eval bar, opening book, and move classificationsTurns a finished game into a reason to return
Returning playerSee rating, games played, leaderboard, friends, requests, match history, and rematchRetention and progression

2.3 Page-by-page view of the product

PageWhat the user seesWhat is happening underneath
Landing (/)Hero pitch, feature cards, tech stack, live highest rating, login/signup modalThe 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 historyAuth 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 inputA 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 modalSocket 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 navigationPGN is loaded from MongoDB; the browser reconstructs FENs and runs Stockfish in a Web Worker

2.4 Feature inventory

AreaImplemented behaviourUser-facing value
AccountsRegister, login, logout, bcrypt password hashing, JWT in an HttpOnly cookiePersistent identity without exposing credentials to JavaScript
ProfileDashboard, rating, games played, avatar upload to Cloudinary with a 250×250 face crop, username change with a fresh JWTOwnership and personalisation
FriendsSubstring search by username, send request, accept request, list friends and pending requestsSocial graph and repeat play
ChallengesReal-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
MatchmakingIn-memory queue keyed by time control; first compatible pair is matched into a UUID roomFast pairing for the common case
Private roomsServer creates the room and reservation; client joins by UUID; 10-minute expiryPlay with a specific person, shareable link
Live gameServer-validated moves, legal-move hints, promotion modal, check and checkmate detection, captured-material tracker, move list, rewind, live clocksA complete chess experience
Time controls3, 5, 10, and 30 minutes; server tracks remaining milliseconds per sideMultiple formats for different session lengths
ResilienceReconnect within 60 seconds; snapshot sync; abandonment results in a lossNetwork problems do not instantly destroy a game
Social in-gameChat with server-side sanitisation and client-side textContent rendering; chat restored from sessionStorage on refreshPresence and personality
Game lifecycleResign, rematch request/accept, result card, automatic room cleanupClosure and a reason to play again
PersistenceEvery finished game stored with players, winner, PGN, and timestampHistory, analysis, and future statistics
RatingsGlicko-2 with rating, rating deviation (RD), and volatility; 100-point floor; games-played counterFairer, more statistically honest progression than naive Elo
AnalysisBrowser-side Stockfish worker, MultiPV 3, adaptive movetime, opening book detection, centipawn-loss classification, eval bar, keyboard navigationCoaching-grade review without server engine cost
Abuse controlPer-IP rate limit on auth, username character restrictions, regex-escaped search, UUID validation, XSS-safe chatBasic 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

  1. Request reaches Express.
  2. cookie-parser extracts the JWT.
  3. requireAuth verifies it and attaches req.user.
  4. The controller reads or writes MongoDB through Mongoose.
  5. EJS renders a page, or JSON is returned.
  6. Static assets are served from /public.

Representative routes:

MethodRoutePurpose
GET/Landing page, live highest rating, session-aware CTA
POST/api/auth/registerCreate account, hash password, issue cookie
POST/api/auth/loginVerify credentials, issue cookie
GET/logoutClear cookie
GET/dashboardSSR dashboard with user stats and top players
POST/api/user/avatarAuthenticated multipart upload to Cloudinary
POST/api/user/update-nameRename and reissue JWT
GET/api/users/searchCase-insensitive substring search, escaped regex, limit 8
POST/api/friends/addSend friend request
POST/api/friends/acceptAccept request, create mutual friendship
GET/api/friendsList friends and incoming requests
GET/matchmakingQueue and private-room UI
POST/api/rooms/createServer-authorized private room
GET/play/:partyCodeBoard page
GET/api/games/historyLast 15 games for the user
GET/analyze/:gameIdAnalysis 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 createRoom or by the matchmaking pair.
  • Waiting: first join_party creates the activeGames session and assigns white.
  • Active: second player joins, isStarted becomes true, clock starts.
  • Finished: a single terminal event sets gameOver, emits game_over, persists, and schedules cleanup.
  • Cleanup: setTimeout removes 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, and receivedRequests are mutual arrays. Accepting a request removes it from both sides and adds each user to the other's friends.
  • Ratings are stored as rating (integer), rd (integer after rounding), and vol (float). The schema setter rounds rating on write.
  • Game stores usernames rather than ObjectId references. 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 the ObjectId and a denormalised display name.
  • Game has 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

DirectionEventPayloadServer responsibility
Client → Serveruser_onlinenoneRegister socket in presence map
Client → Serverfind_match{ timeControl }Add to queue; pair compatible players
Client → Servercancel_matchmakingnoneRemove socket from queue
Client → Serverchallenge_friend{ targetUsername, timeControl }Create reserved room; emit or store offline notification
Client → Serverjoin_party{ code, timeControl }Authorize, assign colour, create or resume session
Client → Serverrequest_legal_movessquareReturn verbose legal moves for that square
Client → Serverattempt_move{ from, to, promo }Validate turn and legality; update clock; broadcast
Client → ServertimeoutnoneValidate turn; finish game on time
Client → Serversend_chatstringSanitise, truncate to 200 chars, broadcast
Client → Serverresign_gamenoneFinish game in opponent's favour
Client → Serverrequest_rematchnoneNotify opponent
Client → Serveraccept_rematchnoneReset board, clocks, and history
Server → Clientrole_assigned'w' | 'b' | 'spectator'Tell the client how to render and what it may do
Server → Clientmatch_foundroomCodeNavigate both players to the board
Server → Clientincoming_challenge{ from, timeControl, roomCode }Show challenge modal
Server → Clientchallenge_sent{ roomCode }Navigate challenger to the board
Server → Clientgame_syncFEN, turn, timers, names, history, FEN historyReconcile the client snapshot
Server → Clientlegal_moves_replyarray of movesHighlight legal destinations
Server → Clientgame_over{ reason, winnerName, winnerColor }Show result card and trigger analysis flow
Server → Clientopponent_disconnectedgrace secondsShow reconnect countdown
Server → Clientrematch_requested / rematch_acceptednoneDrive rematch UI
Server → Clientreceive_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)

  1. A terminal event (checkmate, timeout, resignation, abandonment, or draw) sets session.gameOver.
  2. updatePlayerRatingsAfterGame(white, black, winnerColor, pgn) is called.
  3. The game is saved to MongoDB with both usernames, the result, the PGN, and a timestamp.
  4. Both users are loaded.
  5. White's score is derived: 1 for a white win, 0 for a black win, 0.5 for a draw.
  6. calculateNewRatings builds two Glicko-2 players from the stored rating, rd, and vol and updates them with one match result.
  7. The new rating, RD, and volatility are written back; gamesPlayed increments.
  8. 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:

  1. The PGN is loaded from MongoDB into the analysis page.
  2. chess.js reconstructs every FEN from the move list.
  3. A Stockfish worker is created from a Blob URL.
  4. The engine is configured with MultiPV 3, so the UI can show the top three candidate moves.
  5. 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
  6. A safety timeout of movetime + 300 ms prevents the UI from hanging if the engine never returns a usable line.
  7. The best move is compared with the move actually played.
  8. Centipawn loss is classified:
LossClassificationSymbol
≤ 0.10Best★
≤ 0.30Excellent!
≤ 0.70Good✓
≤ 1.50Inaccuracy?!
≤ 3.00Mistake?
> 3.00 or forced mate againstBlunder??
  1. 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.
  2. 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

ThreatControl in the codeAssessment
Password theftbcrypt with cost 10; passwords never returnedGood baseline; add a pepper and stronger cost as hardware improves
Session hijackingJWT in HttpOnly cookie; Secure in productionGood; add explicit SameSite and consider short-lived access + refresh tokens
Socket impersonationUsername taken from the verified JWT, not the client payloadStrong design choice
Illegal movesServer-side chess.js validation; turn check; promotion defaultStrong for move legality
Cheating by client editsClient only sends intent; server owns FEN and resultStrong for rules; clock enforcement is hybrid (see gaps)
XSS in chatServer sanitises <>&"'/; client uses textContentGood defence in depth
XSS in usernamesRegistration blocks <>&"'/; EJS escapes by defaultGood; keep the character policy centralised
Regex injection in searchsearchUsers escapes regex metacharactersGood
Regex injection in friend lookupsendRequest builds new RegExp(\^${targetUsername}$`, 'i')` without escapingGap: escape or use an exact indexed lookup
Room guessingUUID v4 room codes; server-side reservationGood capability-URL model
Brute force20 requests per 15 minutes per IP on authBasic; in-memory store resets on restart and trusts X-Forwarded-For
Upload abuseFormat allow-list, Cloudinary transformationGap: no file-size limit
CSRFCookies are used; no explicit CSRF tokenModern browsers default to SameSite=Lax, which helps, but set it explicitly
Supply-chain riskCDN scripts for GSAP, chessboard.js, chess.js, StockfishGap: add CSP/SRI or self-host critical assets
Secret leakage.env ignored; server refuses to start without critical secretsGood 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

DimensionAssessment
Correctness of move rulesStrong — single authoritative engine, server-side validation
Correctness of clocksPartial — server-owned timers, but client-triggered timeout
ReconnectionGood — snapshot sync and 60-second grace
DurabilityPartial — finished games persist; live games do not
ScalabilitySingle-process by design; needs shared state to scale out
SecurityGood baseline; missing CSRF/CSP/upload limits/schema validation
ObservabilityBasic console logs; needs structured events and metrics
Test coverageContract-level; needs integration and socket E2E
Product loopStrong — play, rate, persist, analyse, rematch
Operational simplicityStrong — one process, one database, one media service

7.2 What breaks at 10× and 100×

LoadSymptomFix
10× playersQueue wait times grow; a single process handles more socketsKeep one process for correctness, add metrics, consider a second instance with sticky sessions
10× concurrent gamesIn-memory activeGames grows; a restart loses moreRedis-backed session store; append-only move log
Multiple instancesPlayers land on different processes; rooms are invisible across themSocket.IO Redis adapter, shared presence, room ownership or consistent hashing
Celebrity/tournament spikeOne room broadcasts to many spectatorsFan-out via pub/sub, spectator read model, broadcast throttling
Analysis popularityClient CPU is the bottleneck, not the serverKeep client-side; add a queue and “analysis ready” state for slow devices
Global audienceLatency to a single regionMulti-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 finalized flag.
  • Wrap game save + rating updates in a transaction or an outbox.
  • Add schema validation (Zod/Joi) at every HTTP and socket boundary.
  • Add helmet, explicit SameSite, 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, and outcome.
  • Self-host Stockfish and unify chess.js versions.
  • Fix the missing /login view 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 GameFinished events, 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

FeatureConsistency targetMechanism
Move legality and board stateStrong, single-writer per roomOne process owns the room; validate and broadcast in order
ClockStrongServer timestamps + watchdog
Game completionExactly onceIdempotency key / finalized flag + transaction/outbox
RatingsEventually consistent, but never lostEvent-driven rating update with retries
PresenceEventually consistentRedis TTL + heartbeat
LeaderboardEventually consistentPeriodic materialised view or cache
MatchmakingApproximateQueue with expanding rating window
ChatBest effortBroadcast; no durable history required
AnalysisOffline/batchClient worker or job queue

7.5 Failure modes and what should happen

FailureCurrent behaviourDesired behaviour
Process restartLive games are lostRecover from Redis/move log; resume or void with clear messaging
MongoDB unavailableAuth/history/rating writes failCircuit breaker, retry with backoff, graceful read-only mode
Cloudinary unavailableAvatar upload failsFallback avatar, queued retry
Socket disconnect60-second grace, then abandonmentSame, plus server-side clock continues
Duplicate game_overPossible double persist/ratingIdempotent terminal transition
Clock reaches zero with no client eventGame can hangServer watchdog finishes the game
Queue has one playerWaits indefinitelyShow wait time, suggest private room, allow cancel
Room code leakedAnyone with the link can join a private roomCapability URL is acceptable; add optional password/invite list
Malicious move spamRejected but not throttledPer-socket token bucket
Oversized avatarPotentially acceptedEnforce 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_found to game_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

  1. Store whiteUserId and blackUserId alongside the usernames.
  2. Add timeControl, resultReason, rated, ratingBefore, ratingAfter, moveCount, and startedAt to Game.
  3. Store a move list (from, to, promotion, san, fenAfter, clockMs) for replay without PGN parsing.
  4. Add a GameEvent or Move collection if the product needs auditability or anti-cheat review.
  5. Add compound indexes for the two history queries and the leaderboard.
  6. Consider per-format ratings (bullet, blitz, rapid, classical) instead of one global rating.
  7. Add emailVerified, lastSeenAt, role, and status to User.
  8. Add a Friendship collection if the mutual-array model becomes hard to query or audit.

7.8 Testing strategy

LayerWhat to testTooling
UnitGlicko-2 math, sanitiser, PGN/FEN helpers, classification thresholdsJest
HTTP contractAuth, friends, upload, history, authorizationSupertest against the real exported app
Socket contractConnect, queue, pair, join, move, illegal move, disconnect, rematchsocket.io-client integration test
PersistenceGame save + rating update atomicity, indexesmongodb-memory-server
End-to-endTwo clients play a full game to checkmatePlaywright or a socket harness
Load1,000 concurrent sockets, queue churnk6 or Artillery
SecurityXSS payloads, regex injection, oversized upload, CSRFSupertest + 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.

CostDriverEarly-stage posture
ComputeOne small Node instanceLow; scales with sockets, not moves
MongoDB AtlasUser/game documents, indexesLow; games are small text documents
CloudinaryAvatar storage + transformationsLow; avatars are small and cached
BandwidthBoard snapshots and assetsModerate; snapshots are small JSON
EngineClient CPUEffectively zero marginal server cost
Support/moderationHuman timeThe 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

  1. Landing page (15s): show the product promise and the live highest rating pulled from MongoDB.
  2. Signup/login (10s): show the HttpOnly cookie session and the dashboard.
  3. Dashboard (20s): rating, games played, leaderboard, friends, avatar upload.
  4. Private room (20s): create a room, copy the link, join from a second browser/incognito window.
  5. Live game (30s): make moves, show legal-move hints, promotion, clock, captured pieces, chat, rewind.
  6. Finish (10s): resign or checkmate; show the result card and rating update.
  7. History and analysis (20s): open the game, step through moves, show eval bar, best move, classification.
  8. 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 info lines, 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 stop fallback, 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 -M0 and 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 sayingWhySay 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 pageReplace with customer-facing trust, privacy, or feature copy
"Live Updates" on the leaderboardThe 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.md with the known gaps and the hardening plan.
  • A ROADMAP.md with the 30/60/90-day plan.

9.8 The honest gap list (show this — it builds credibility)

  1. Server-side clock watchdog is missing.
  2. Game completion and rating updates are not idempotent/transactional.
  3. Live game state is process-local and lost on restart.
  4. Matchmaking does not consider rating.
  5. Tests do not exercise the real routers.
  6. No CSRF token, CSP, or upload size limit.
  7. Friend lookup builds an unescaped regex.
  8. /login route references a missing view.
  9. Two chess.js versions and CDN-hosted engine assets.
  10. No structured observability or load test yet.
  11. No moderation/report tools.
  12. No per-format ratings.
  13. Offline challenge notifications are persisted but have no inbox or API to read them.
  14. join_party accepts an unreserved room code, so a client can still invent a room URL.
  15. 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.

LayerChoiceWhat it actually does hereWhy this choice makes senseTrade-off accepted
RuntimeNode.jsSingle event loop for HTTP and WebSocket trafficChess is turn-based and I/O-bound, not CPU-bound; Node handles many idle connections cheaplyA CPU-heavy task would block the loop; engine work is pushed to the browser
HTTP frameworkExpress 5Routing, middleware, SSR, JSON APIsMinimal ceremony, huge ecosystem, easy to reason aboutNo built-in schema validation or opinionated structure
RealtimeSocket.IOAuthenticated sockets, rooms, reconnection, event contractsRooms map naturally to games; reconnection and fallbacks are built inAdds a protocol layer over raw WebSockets; needs a Redis adapter to scale across processes
Chess ruleschess.js (server)Move legality, FEN, PGN, check/checkmate/stalemate/draw detectionA mature rules engine removes an entire class of bugsTwo versions exist in the project (server 1.4.0 vs analysis page 0.10.3); should be unified
PersistenceMongoDB Atlas + MongooseUsers, friendships, notifications, games, ratingsFlexible document model for evolving profile and game metadata; Atlas removes ops burdenNo relational constraints; transactions and indexes must be designed deliberately
ViewsEJSServer-rendered pages with per-user dataNo build step, fast first paint, simple deployment, SEO-friendly landing pageLess component reuse than a framework; state is split between server render and browser
Browser logicVanilla JSBoard rendering, socket client, drag/click interaction, analysis orchestrationFull control over a custom board and animation; no framework overhead for a single-page game surfaceMore manual DOM work; needs discipline to avoid duplicated state
AnimationGSAPLanding, dashboard, and queue transitionsPolished perceived performance with little codeExternal CDN dependency
Authjsonwebtoken + bcrypt + HttpOnly cookieStateless session, password hashing, socket handshake authNo server-side session store; the same token authenticates HTTP and WebSocketNo refresh-token rotation; 24-hour expiry forces re-login
UploadsMulter + CloudinaryAvatar upload, format allow-list, face-aware 250×250 cropOffloads storage, transformation, and CDN deliveryNo explicit file-size limit in the Multer config; add one
Ratingsglicko2Rating, RD, volatility updates after each gameGlicko-2 models uncertainty; better for new and inactive players than EloMore complex to explain; needs careful persistence and idempotency
AnalysisStockfish 10.0.2 in a Web Worker + chessboard.jsMultiPV evaluation, best move, eval bar, move classificationZero server engine cost; analysis scales with the user's deviceDepends on CDN availability and client CPU; mobile devices are slower
TestingJest + SupertestAuth, friends, and sanitisation contractsFast, isolated feedbackTests re-implement routes instead of importing the real app; integration coverage is the next step
Dev loopNodemonAuto-restart on changeFast iterationnpm 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.