8. The frontend — public/
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.
| File | Responsibility |
|---|---|
index.html | Three screens plus a game-over overlay |
style.css | Dark theme, responsive |
js/net.js | All socket wiring and session storage |
js/input.js | Keyboard → direction vector |
js/render.js | Canvas drawing and interpolation |
js/main.js | Glue: DOM in, socket out |
The client contains no game rules. It asks; the server decides.
index.html
Three sections, toggled with the hidden attribute:
#screen-join— username, Quick Play, join-by-code, create private lobby, and the all-time leaderboard#screen-game—<canvas>plus a side panel (lobby code, orbs remaining, live leaderboard, connection status)#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.js — createNet(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.js — createInput({ 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.js — createRenderer(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.