5. The realtime layer — src/realtime/
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.
| File | Responsibility |
|---|---|
index.js | Creates the Socket.IO server and wires each connection |
handlers.js | Per-socket event handlers |
broadcaster.js | The single 20 Hz loop and all game-over handling |
index.js — attachRealtime({ 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,
})
| Option | Reason |
|---|---|
perMessageDeflate: false | Messages 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 KB | Nothing 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 / pingTimeout | Stated 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:
socket.data.gameIdalready set? →ALREADY_IN_GAME.- Reconnect path — if
playerTokenandgameIdare present, tryreconnectPlayer(). On success: rebindsocket.data, rejoin the room, emitjoined(withreconnected: true), tell the room viaplayer: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. validateUsername().resolveGame()→LOBBY_NOT_FOUNDif a target was named but does not exist.isJoinable()→LOBBY_FULLorGAME_NOT_ACTIVE.addPlayer()→ may still returnUSERNAME_TAKEN.socket.join(game.id)— the entire lobby-isolation mechanism, two lines.emit('joined')to this socket only — it carries the secretplayerToken.socket.to(room).emit('player:joined')—socket.toexcludes the sender, so the new arrival doesn't get told about themselves.io.to(room).emit('leaderboard')—io.toincludes everyone.
The
socket.tovsio.todistinction 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 → validateId → tryCollect.
- Failure → ack the requester only (they need the reason; nobody else cares).
- Success →
io.to(room).emit('collected')andemit('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.js — createBroadcaster({ 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:
tickGame(game, dt, now)— move everyone, expire dead players.- Emit
player:left(reasonTIMEOUT) for each expired player. isFinished()→endGame(FINISHED).players.size === 0→playerSeq > 0→endGame(ABANDONED)(everybody left)- else if older than
emptyLobbyTtlMs→removeGame()silently (an HTTP-created lobby nobody ever joined; nogame:overbecause there is nobody to tell, and no database row because there is nothing to record)
- If anyone expired, resend the leaderboard.
countConnected(game) > 0→ emitstate.
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:overfirst, 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
Setis copied to an array first becausesocket.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
joinhit theALREADY_IN_GAMEguard 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.
persistGameswallows 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.