8. The frontend — public/

Plain HTML, CSS and ES modules. No framework, no bundler, no build step. Express serves the directory statically, so it is the same origin as the API and the websocket — which is why there is no CORS configuration anywhere.

FileResponsibility
index.htmlThree screens plus a game-over overlay
style.cssDark theme, responsive
js/net.jsAll socket wiring and session storage
js/input.jsKeyboard → direction vector
js/render.jsCanvas drawing and interpolation
js/main.jsGlue: DOM in, socket out

The client contains no game rules. It asks; the server decides.


index.html

Three sections, toggled with the hidden attribute:

  1. #screen-join — username, Quick Play, join-by-code, create private lobby, and the all-time leaderboard
  2. #screen-game<canvas> plus a side panel (lobby code, orbs remaining, live leaderboard, connection status)
  3. #overlay — final leaderboard and "Play again"

Script loading order matters:

<script src="/socket.io/socket.io.js"></script>
<script type="module" src="/js/main.js"></script>

The Socket.IO client is served automatically by the Socket.IO server at that path — it is not a file in the repo. It is a classic script, so it executes immediately and defines the global io; type="module" scripts are deferred by default, so main.js always runs after io exists.


js/net.jscreateNet(handlers)

All socket wiring lives here. The rest of the frontend never touches io() directly — the same separation as keeping game rules out of socket handlers on the server, pointing the other way.

const socket = io({
  transports: ['websocket'],
  reconnectionDelay: 400,
  reconnectionDelayMax: 3000,
});

transports: ['websocket'] skips the default HTTP long-polling handshake and upgrade, saving a round trip.

Session storage and reconnect

const RECONNECT_KEY = 'collector.session';   // { gameId, playerToken }

Saved on joined, cleared on game:over. sessionStorage, not localStorage — it is per-tab, so two tabs are genuinely two players. Every read and write is wrapped in try/catch because private browsing can refuse storage entirely; if it does, reconnect stops working but the game does not.

The automatic rejoin:

socket.on('connect', () => {
  if (session?.playerToken) {
    socket.emit('join', { ...session }, (result) => {
      if (!result?.ok) { saveSession(null); handlers.onSessionLost?.(result); }
    });
  }
});

Socket.IO restores the transport on its own; this restores the player on top of it. It also fires on a page refresh, which is what makes refreshing mid-game keep your score.

The returned API

join(), sendInput(), collect(), leave(), hasSession().

join and collect wrap Socket.IO's acknowledgement callback in a Promise, so main.js can await them. sendInput deliberately has no ack — it is the highest-frequency message and a round trip per keystroke would be waste.


js/input.jscreateInput({ onDirection, sendRateHz })

Keyboard → a direction vector, sent at a fixed rate.

const KEYS = { ArrowUp: [0,-1], KeyW: [0,-1], /* ... */ };

Uses event.code (physical key) rather than event.key, so WASD works on non-QWERTY layouts.

Three details that each fix a real problem:

1. A fixed 20/sec timer, and only on change.

if (next.dx !== lastSent.dx || next.dy !== lastSent.dy) onDirection(...);

Holding a key fires keydown repeatedly at the OS repeat rate, which would flood the socket with identical messages.

2. Typing is not hijacked.

if (event.target.tagName === 'INPUT') return;

Without this you could not type w or s into the username field.

3. blur clears held keys.

Alt-tabbing with a key held would otherwise leave the player sliding forever, because the keyup lands in another window.

Opposite keys cancel via Math.sign(dx); the server normalises magnitude anyway.


js/render.jscreateRenderer(canvas)

Canvas drawing at 60 fps via requestAnimationFrame, plus the one piece of client-side cleverness in the project.

Interpolation

The server is authoritative at 20 Hz. Drawing those positions raw looks visibly steppy, so each frame every player eases toward its latest server position:

pos.x += (player.x - pos.x) * 0.25;
pos.y += (player.y - pos.y) * 0.25;

This is presentation only. The smoothed position is never sent back and never used to decide anything — the server's copy stays the only truth. Smoothing hides latency; it does not invent state.

The view object

Mirrors the server's world: world, rules, players, collectibles, meId, and pop (collection burst animations). A separate drawn map holds the eased positions, kept apart from the authoritative ones.

rules arrives from the server in getFullState — the client is told the radii and pickup distance rather than hard-coding them, so it draws exactly what the server checks against.

Drawing

Background grid, orbs (with glow), expiring burst rings, then players. Your own player gets a white outline and a faint circle showing your pickup range, which makes "get close to collect" visible rather than something you infer.

Disconnected players draw at 35% opacity with (away) after their name.

XSS

ctx.fillText(player.username, ...)

fillText draws a string as pixels — a username can never become markup. The DOM side uses textContent for the same reason.

The returned API

start, stop, reset(state, meId), applySnapshot, addPlayer, removePlayer, setConnected, removeCollectible, collectiblesNearMe, knownPlayer.

applySnapshot only updates players it already knows — a player it has never seen arrives via player:joined, which carries the username the snapshot omits.


js/main.js

Glue. Wires DOM events to net, and net callbacks to renderer and the DOM.

Auto-collect — "collect by moving close to it"

function tryCollectNearby() {
  for (const id of renderer.collectiblesNearMe()) {
    if (now - attempted.get(id) < RETRY_MS) continue;   // 400 ms per-orb cooldown
    attempted.set(id, now);
    net.collect(id).then((result) => {
      if (!result?.ok && result?.reason === 'ALREADY_COLLECTED')
        renderer.removeCollectible(id);
    });
  }
}

Called on every state event. The client only ever asks. The server re-checks the distance against its own copy of your position and decides — so a modified client requesting every orb at once would simply be told TOO_FAR each time.

The cooldown stops a request firing every frame while one is already in flight. When a request loses, the orb is dropped locally so no ghost is left on screen.

Disconnect is not departure

if (reason === 'DISCONNECTED') renderer.setConnected(playerId, false);
else renderer.removePlayer(playerId);

A DISCONNECTED player is greyed out — they keep their slot and score during the grace window and may come back. LEFT and TIMEOUT remove them.

Rendering the leaderboard — XSS

const name = document.createElement('span');
name.textContent = row.connected ? row.username : `${row.username} (away)`;

Built with createElement + textContent, never innerHTML. A username is untrusted input from another player; interpolating it into an HTML string would be a stored-XSS hole. list.replaceChildren() clears the previous rows.

Error messages

Reason codes are mapped to human text, with a fallback:

{ LOBBY_FULL: 'That lobby is full — try Quick Play.',
  LOBBY_NOT_FOUND: 'No lobby with that code.',
  GAME_NOT_ACTIVE: 'That game has already finished.',
  USERNAME_TAKEN: `Someone in that lobby is already called "${username}". Pick another name.`,
  ALREADY_IN_GAME: 'You are already in a game — leave it first.',
}[result?.reason] || result?.message || 'Could not join. Please try again.'

ALREADY_IN_GAME should now be unreachable — the server releases sockets when a game ends — but it is kept as a readable fallback rather than showing a raw code.

Screen transitions

showGame(), showJoin(), showGameOver(result). showJoin() also stops the renderer and input listeners and refreshes the all-time leaderboard, so nothing keeps running behind the join screen.

Built with LogoFlowershow