5. The realtime layer — src/realtime/

The bridge between sockets and game rules. No rules live here — every handler validates a payload, calls one function in src/game/, and translates the plain result into socket messages.

FileResponsibility
index.jsCreates the Socket.IO server and wires each connection
handlers.jsPer-socket event handlers
broadcaster.jsThe single 20 Hz loop and all game-over handling

index.jsattachRealtime({ httpServer, lobbyManager })

Attaches Socket.IO to the same http.Server Express is using, so there is one process, one port, and no CORS configuration for the bundled frontend.

The server options, and why each is set

new Server(httpServer, {
  perMessageDeflate: false,
  maxHttpBufferSize: 16 * 1024,
  pingInterval: 25_000,
  pingTimeout:  20_000,
})
OptionReason
perMessageDeflate: falseMessages are a few hundred bytes. Compressing them costs more CPU than the bandwidth it saves, and CPU is the scarce resource at 1,000 sockets.
maxHttpBufferSize: 16 KBNothing a client legitimately sends is large. Capping well below the 1 MB default means a malicious client cannot make the server allocate for it.
pingInterval / pingTimeoutStated explicitly because they matter: a dead connection is noticed within ~45 s, which is what triggers the disconnect grace period.

Per connection

io.on('connection', (socket) => {
  socket.data.gameId = null;
  socket.data.playerId = null;
  registerSocketHandlers({ io, socket, lobbyManager });
});

socket.data is Socket.IO's per-connection scratch space. Holding the player's identity there means a handler never has to search every lobby to work out who is talking — it is an O(1) getGame(lobbyManager, socket.data.gameId) instead.

Then broadcaster.start().


handlers.js

createRateLimiter()

A per-socket cost ceiling so one misbehaving client cannot monopolise the event loop for everyone else in its lobby.

if (!bucket || now - bucket.start >= 1000) bucket = { start: now, count: 0 };
bucket.count += 1;
return bucket.count <= maxPerSecond;

A fixed one-second window rather than a token bucket: less precise at the boundary, but it is five lines and the goal is a ceiling, not fairness. Limits: 60 input/sec, 25 collect/sec.

guard(socket, eventName, handler)

Wraps every handler so a thrown error becomes a message to that one client.

try { handler(payload, ack); }
catch (err) { ack?.(failure); socket.emit('error:event', {...}); }

Without this, an uncaught throw inside a Socket.IO handler propagates to the event loop and can take the process down — ending all 100 lobbies because one client sent something strange.

It also normalises the ack: Socket.IO passes the acknowledgement callback as the last argument, so guard pops it if the last argument is a function, giving every handler a consistent (payload, ack) signature.

resolveGame(lobbyManager, { gameId, code, username })

Decides which lobby a join is aiming at: explicit gameId, then code, then matchmaking via quickPlay. The username is passed through so matchmaking can skip lobbies where that name is already in use.


Event: join

The longest handler, in order:

  1. socket.data.gameId already set? → ALREADY_IN_GAME.
  2. Reconnect path — if playerToken and gameId are present, try reconnectPlayer(). On success: rebind socket.data, rejoin the room, emit joined (with reconnected: true), tell the room via player:joined, resend the leaderboard. If it fails, fall through to a normal join rather than erroring — a returning player whose grace expired should never be left staring at an error screen.
  3. validateUsername().
  4. resolveGame()LOBBY_NOT_FOUND if a target was named but does not exist.
  5. isJoinable()LOBBY_FULL or GAME_NOT_ACTIVE.
  6. addPlayer() → may still return USERNAME_TAKEN.
  7. socket.join(game.id)the entire lobby-isolation mechanism, two lines.
  8. emit('joined') to this socket only — it carries the secret playerToken.
  9. socket.to(room).emit('player:joined')socket.to excludes the sender, so the new arrival doesn't get told about themselves.
  10. io.to(room).emit('leaderboard')io.to includes everyone.

The socket.to vs io.to distinction appears throughout: socket.to(room) is "everyone else", io.to(room) is "everyone".

Matchmaking deliberately lives here rather than in an HTTP endpoint. If POST /quick-play picked a lobby and the socket joined a moment later, the lobby could fill in between. Doing find-and-seat in one synchronous function closes that gap — the same single-threaded argument as tryCollect.

Event: input

The highest-frequency event, so it does the least possible work: rate-limit, validate, setInput, return. It never broadcasts — the tick does.

Invalid payloads are dropped silently. At 20 messages/second per player, a round-trip error message for every bad one would be worse than the problem.

Event: collect

Rate-limit → validateIdtryCollect.

  • Failure → ack the requester only (they need the reason; nobody else cares).
  • Successio.to(room).emit('collected') and emit('leaderboard'), then ack.

Confirmed immediately rather than on the next tick. Movement can tolerate 50 ms; the feedback on grabbing something has to feel instant.

The game ending is not handled here — the tick loop detects it, so there is exactly one code path that ends a game.

Event: leave

An intentional exit, unlike a dropped connection. Removes the player outright (freeing slot and username), tells the room player:left with reason LEFT, and clears socket.data.

Note it clears socket.data even if the game no longer exists — which is what lets a client leave cleanly after a game has already been torn down.

Event: disconnect

The player is NOT deleted. markDisconnected() keeps their score, slot, position and username for the grace period; the tick loop expires them if they never return. A two-second wifi blip should not wipe a leading player's score.

The room is told player:left with reason DISCONNECTED (so clients grey the player out rather than removing them) plus a fresh leaderboard.


broadcaster.jscreateBroadcaster({ io, lobbyManager })

The tick loop

One setInterval for the whole process, not one per lobby.

timer = setInterval(tick, TICK_INTERVAL_MS);   // 50 ms
timer.unref?.();                               // don't hold the process open

100 lobbies would otherwise mean 100 competing timers. A single loop is easier to reason about and gives one number worth measuring — how long a tick takes across every lobby — which is exactly what /health reports.

Each tick, for every ACTIVE game:

  1. tickGame(game, dt, now) — move everyone, expire dead players.
  2. Emit player:left (reason TIMEOUT) for each expired player.
  3. isFinished()endGame(FINISHED).
  4. players.size === 0
    • playerSeq > 0endGame(ABANDONED) (everybody left)
    • else if older than emptyLobbyTtlMsremoveGame() silently (an HTTP-created lobby nobody ever joined; no game:over because there is nobody to tell, and no database row because there is nothing to record)
  5. If anyone expired, resend the leaderboard.
  6. countConnected(game) > 0 → emit state.

Step 6 skips empty rooms. While every player is away inside their grace window there is nobody to send to, so the work is skipped entirely.

dt handling:

const dt = Math.min((now - lastTickAt) / 1000, MAX_DT);   // MAX_DT = 0.25

Real elapsed time, clamped. A paused process (debugger, GC pause, laptop sleeping) would otherwise produce a dt of several seconds and fling every player across the map on resume.

endGame(game, status)

Five steps, and the order is deliberate:

if (!finishGame(game, status)) return;   // idempotent: never end a game twice

io.to(game.id).emit('game:over', {...}); // 1. tell players FROM MEMORY

for (const socketId of [...room]) {      // 2. release every socket
  socket.data.gameId = null;
  socket.data.playerId = null;
  socket.leave(game.id);
}

removeGame(lobbyManager, game.id);       // 3. drop the lobby

persistGame(game).catch(...);            // 4. database, NOT awaited
  • game:over first, from memory, 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 released after the emit — they must still be in the room to receive it. The room Set is copied to an array first because socket.leave() mutates the very Set being iterated.
  • This release step fixed a real bug. Without it 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. Three integration tests cover it.
  • Persistence is not awaited, so the game loop never blocks on the database. persistGame swallows and logs its own failures.

metrics()

Feeds /health. Tick durations go into a 600-slot ring buffer (30 seconds of history at 20 Hz), sorted on read for p50/p95/max.

{ tickRate, ticks, gamesFinished, tickMs: { p50, p95, max } }

A ring buffer rather than an unbounded array: the metric must not itself become a memory leak in a long-running process.

tickOnce and endGame are exported

Both are returned from the factory so a test can drive the loop by hand instead of waiting on wall-clock time.

Built with LogoFlowershow