2. End-to-end flows

Each flow traces one user action through every layer. Function names match the source exactly.


Joining a lobby

Trigger: the player types a name and clicks Quick Play.

[browser] main.js  attemptJoin()
    └─▶ net.js  join({username})          emit 'join' with an ack callback
[server] handlers.js  socket.on('join')
    1. guard()                  wrap so a throw becomes a message, not a crash
    2. socket.data.gameId set?  → reject ALREADY_IN_GAME
    3. playerToken present?     → try the reconnect path (see below)
    4. validateUsername()       → reject USERNAME_TOO_SHORT / _INVALID / _TOO_LONG
    5. resolveGame()            → by gameId, by code, or quickPlay() matchmaking
    6. isJoinable()             → reject LOBBY_FULL / GAME_NOT_ACTIVE
    7. addPlayer()              → reject USERNAME_TAKEN, else create the player
    8. socket.data.gameId/playerId = ...    remember who this connection is
    9. socket.join(game.id)                 ◀── the entire isolation mechanism
   10. emit 'joined'         → this socket only (carries the secret playerToken)
   11. emit 'player:joined'  → everyone else in the room
   12. emit 'leaderboard'    → the whole room
[browser] net.js  'joined' → saveSession({gameId, playerToken}) to sessionStorage
          main.js onJoined() → renderer.reset(state) → showGame()

Why matchmaking happens over the socket, not HTTP. If POST /quick-play picked a lobby and the socket joined it a moment later, the lobby could fill in between. Doing find-and-seat inside one synchronous handler closes that gap — the same single-threaded argument that makes tryCollect safe.

Quick Play's three rules (quickPlay() in src/game/lobbyManager.js):

  1. Skip a lobby where the name is taken — matchmaking should find you somewhere to play, not refuse you over a lobby you never chose.
  2. Skip a lobby whose players have all disconnected. Those are ghosts inside their grace window; seating you there gives you a "live game" of grey blobs.
  3. Otherwise prefer the lobby with the most connected players, so people gather into real games instead of scattering across half-empty ones.

Joining by code skips rules 2 and 3: you picked that lobby deliberately, so joining a friend who is briefly disconnected still works.


Moving

[browser] input.js  keydown/keyup → held keys → {dx, dy} direction
          (a fixed 20/sec timer, and only when the direction CHANGED)
    └─▶ net.js  sendInput(dx, dy)         emit 'input' (no ack — fire and forget)
[server] handlers.js  socket.on('input')
    1. rate limit (60/sec)  → silently dropped past the ceiling
    2. validateDirection()  → silently ignored unless two finite numbers
    3. setInput(game, playerId, dx, dy)
          └─ normalises to length ≤ 1, stores on the player. MOVES NOBODY.
          ▼  (up to 50 ms later)
[server] broadcaster.js  tick()
    tickGame(game, dt, now)
      └─ per connected player: pos += direction × speed × dt, clamped to walls
    io.to(game.id).emit('state', getSnapshot(game))
[browser] main.js onState() → renderer.applySnapshot()
          render.js draws at 60 fps, easing 25% toward the server position/frame

Clients send a direction, never a position. The server owns every coordinate. This is what makes the distance check on collection meaningful — if client positions were trusted, the anti-cheat check would be validating the cheater's own numbers.

Input rate is decoupled from broadcast rate. setInput deliberately does not move the player or send anything; movement happens only in the tick. That is the whole performance story (see 01-architecture.md).

dt is real elapsed time, not a hard-coded 1/20, so a stalled loop moves players a consistent distance rather than teleporting or freezing them. It is clamped to 0.25 s so a paused process (debugger, laptop sleeping) does not fling everyone across the map on resume.


Collecting a collectible — the contested path

This is the part the assessment is really testing.

[browser] main.js onState() → tryCollectNearby()
          renderer.collectiblesNearMe()  → ids within the pickup radius
          (a 400 ms per-orb cooldown stops re-asking every frame)
    └─▶ net.js  collect(id)                emit 'collect' WITH an ack
[server] handlers.js  socket.on('collect')
    1. rate limit (25/sec)
    2. validateId()
    3. tryCollect(game, playerId, collectibleId)   ◀── synchronous, atomic
    4a. ok    → emit 'collected' + 'leaderboard' to the ROOM, ack the winner
    4b. fail  → ack only the requester, with the reason
[browser] onCollected() → remove the orb, update the counter
          a loser told ALREADY_COLLECTED drops the orb locally too,
          so no ghost orb is left on screen

Inside tryCollect (src/game/game.js):

if (item.collectedBy !== null)  return { ok: false, reason: 'ALREADY_COLLECTED', ... };  // CHECK
if (distance > reach)           return { ok: false, reason: 'TOO_FAR' };

item.collectedBy = playerId;                                                             // CLAIM
player.score += 1;
game.remaining -= 1;

Why no lock is needed. Node's single thread runs this function to completion before any other socket event gets a turn. The second player always finds collectedBy already set.

The property doing the work: no await between the CHECK and the CLAIM. Add one and the function yields mid-way, the other handler runs, both see an unclaimed orb, and both score. Persistence therefore happens at game end, never inside this function.

The equivalent in other settings:

Where state livesCorrect mechanism
one Node process (here)synchronous check-and-claim; no lock
PostgresUPDATE ... SET collected_by = $1 WHERE id = $2 AND collected_by IS NULL, then check the affected row count
multiple serversan atomic primitive in shared state, e.g. Redis SET NX

The game does not end here. isFinished() is checked by the tick loop, so there is exactly one code path that ends a game.


Ending a game

[server] broadcaster.js  tick()
    isFinished(game)?   (remaining === 0)
      └─▶ endGame(game, 'FINISHED')
            1. finishGame()       → returns false if already ended (idempotent)
            2. emit 'game:over' to the room  ◀── FROM MEMORY, before any DB call
            3. release every socket: clear socket.data + socket.leave(room)
            4. removeGame() from the lobbyManager
            5. persistGame(game)  → NOT awaited; failures logged, not thrown
[browser] net.js clears the stored session → main.js showGameOver() overlay

Order matters and is deliberate:

  • Players see the final leaderboard before Postgres is touched. If the database is down the match still completes correctly; only the history row is lost, and that is logged.
  • Sockets are released after the game:over emit — they must still be in the room to receive it. Skipping this step was a real bug: the socket kept the dead game's id, so the next join hit the ALREADY_IN_GAME guard and "Play again" failed on a connection that was in fact free.
  • persistGame is not awaited, so the tick loop never blocks on the database.

A lobby also ends as ABANDONED when everyone has left (players.size === 0 and playerSeq > 0). The playerSeq check distinguishes "everybody left" from "nobody has arrived yet" — without it, a lobby created over HTTP would be destroyed on the very next tick, before anyone could use the join code.


Disconnecting and reconnecting

A dropped connection is not a departure.

socket closes
    └─▶ handlers.js  socket.on('disconnect')
          markDisconnected(game, playerId)
            connected = false, disconnectedAt = now, dx = dy = 0 (stop sliding)
          emit 'player:left' {reason:'DISCONNECTED'} + 'leaderboard' to the room
          ├── player returns within 30 s ─────────────────────────────────┐
          │      browser reconnects; net.js auto-emits 'join' with the     │
          │      stored playerToken                                        │
          │      reconnectPlayer() → same player id, same score, same spot │
          │                                                                │
          └── grace expires ──────────────────────────────────────────────┘
                 broadcaster tick → tickGame() returns the id in `expired`
                 removePlayer() frees the slot AND the username
                 emit 'player:left' {reason:'TIMEOUT'}

The grace period is a timestamp checked in the tick, not a setTimeout. That keeps the logic pure, avoids thousands of timers at scale, and lets a test verify a 30-second expiry instantly by passing a future clock value:

tickGame(game, 0.05, Date.now() + config.lobby.disconnectGraceMs);

The username stays claimed during the grace window. Releasing it early would let someone take the name and produce two identical rows on the leaderboard the moment the original player reconnects.

Socket.IO reconnects the transport on its own. The playerToken restores the player on top of it — a different thing, and the reason a mid-game refresh keeps your score.


Reading the all-time leaderboard

The only flow that is plain request/response, with no sockets involved.

[browser] main.js loadAllTime()   fetch('/api/leaderboard?limit=10')
[server] app.js → leaderboard.routes.js → leaderboard.controller.js
          getLeaderboardHandler()
            └─▶ leaderboardService.getAllTimeLeaderboard({limit})
                  clamp limit to 1..100
                  prisma.playerStat.findMany({ orderBy: totalScore desc, take })
[browser] rendered with createElement + textContent (never innerHTML)

One indexed ORDER BY ... LIMIT n on a pre-aggregated table, so the cost does not grow with match history. See 07-persistence.md.

Built with LogoFlowershow