public/index.html — the browser game
public/index.html — the browser game
In one sentence: one file containing the whole client — layout, styling and the game code. It draws what the server tells it and sends which direction you are pressing. Nothing more.
Size: ~450 lines. Depends on: the Socket.IO client, served by our own server.
Why it is one file with no build step
The brief said the backend is the focus and the frontend can be minimal. A React app would add a build step, a bundler and a dependency tree for a page that draws circles on a canvas.
One file loads instantly, has nothing to install, and can be read top to bottom.
The important property is not size, though — it is that the client is deliberately powerless. It never computes a position, a score or a collision. If it did, the server would no longer be the authority and a modified browser could cheat.
The three parts
<style>— layout and colours.<body>— the canvas, join controls, leaderboard, activity feed.<script>— the game client.
The code, part by part
1. Connecting
const socket = io({ transports: ['websocket'] });
io() comes from /socket.io/socket.io.js, which our own server serves — no CDN
needed.
transports: ['websocket'] skips Socket.IO's default behaviour of starting with
HTTP long-polling and upgrading. We know WebSocket works, so going straight there
removes a slower first connection.
2. What the client remembers
let me = null; // { playerId, roomId, token, username }
let cfg = null; // world size and radii, from the server
let state = null; // the latest snapshot
let render = new Map(); // smoothed positions, for drawing only
let collectibles = new Map();
cfg comes from the server, not hardcoded. Change WORLD_WIDTH on the server
and the canvas resizes automatically — there is one source of truth.
render holds display positions, separate from the authoritative ones. More on
that in the drawing section.
collectibles is kept locally because the server only sends the full list
occasionally. This is the client half of the biggest performance optimisation in
the project.
3. Saved sessions — and a bug worth explaining
const SAVE_KEY = 'collectible-rush:sessions';
const MAX_SAVED = 6;
const SAVED_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
const sessionKey = (roomId, username) =>
String(roomId).toUpperCase() + '|' + String(username).toLowerCase();
This stores a list of sessions, keyed by room + username. It originally stored one session under one key, and that was a real bug.
What went wrong: localStorage is shared by every tab of the same browser.
Testing two players means two tabs. Player 1 joined and saved their token; player
2 joined in the second tab and overwrote it. Player 1's token was destroyed,
so when they came back they could not prove who they were and were locked out —
permanently, because they had scored, and a scored name is never released.
The fix was not more logic; it was the right data structure. One entry per player, not one per browser.
function loadAllSessions() {
try {
const parsed = JSON.parse(localStorage.getItem(SAVE_KEY) || '[]');
if (!Array.isArray(parsed)) return [];
const cutoff = Date.now() - SAVED_TTL_MS;
return parsed.filter((v) => v && v.username && v.token && v.roomId && v.savedAt > cutoff);
} catch (e) {
return [];
}
}
Every read is wrapped in try/catch and every field is checked. localStorage
can throw in private browsing mode, and the stored text could be corrupt. A
storage problem must never stop someone playing, so every failure path returns
an empty list.
Note also localStorage, not sessionStorage. sessionStorage is wiped when the
tab closes — which is exactly the moment a player most needs their token.
async function pruneFinishedSessions() {
const list = loadAllSessions();
if (list.length === 0) return;
const stillPlayable = await Promise.all(
list.map(async (v) => {
try {
const res = await fetch('/api/rooms/' + encodeURIComponent(v.roomId));
if (res.status === 404) return false; // room finished and cleaned up
if (!res.ok) return true;
const room = await res.json();
return room.status !== 'finished';
} catch (e) {
return true; // cannot reach the server - keep it
}
}),
);
writeAllSessions(list.filter((_, i) => stillPlayable[i]));
}
This fixed a second bug. The game_over handler clears a session — but only
for a player whose tab is still open when the game ends. Someone who left mid-game
never receives that event, so their entry lingered and the page offered a "rejoin"
into a lobby that had already finished.
The client cannot know, so it asks the server.
catch (e) { return true; } is deliberate: if the server is unreachable we
keep the entry. Destroying a token we cannot prove is dead would cost someone
their score.
4. Three ways to join
$('quickBtn').onclick = () => join({});
$('createBtn').onclick = () => join({ newRoom: true });
$('joinBtn').onclick = () => {
const code = $('roomId').value.trim();
if (!code) { $('err').textContent = 'Enter a room code, or use Quick play.'; return; }
join({ roomId: code });
};
Also a fix for a real bug. There used to be one button and an optional room code box. Leaving it blank meant "match me anywhere" to the server — but players read it as "create a new room for me". Two people pressing join ended up together when they expected separate lobbies.
The fix was to stop inferring. Three buttons, three explicit intents.
if (payload.token === undefined && !intent.newRoom) {
const prev = findSession(payload.roomId, username);
if (prev) {
payload.token = prev.token;
if (payload.roomId === undefined) payload.roomId = prev.roomId;
}
}
Attach this player's own token, looked up by room and name. And if we have a saved session but no room code, use the saved room — a token is only valid against the room that issued it, so sending one without the other is useless.
5. Receiving state
socket.on('state', applyState);
function applyState(snapshot) {
state = snapshot;
if (snapshot.collectibles) {
collectibles = new Map(snapshot.collectibles.map((c) => [c.id, c]));
}
$('hudStatus').textContent = snapshot.status;
$('hudRemaining').textContent = `${snapshot.collectiblesRemaining} / ${snapshot.collectiblesTotal}`;
$('board').innerHTML = snapshot.leaderboard.map(rowHtml).join('');
}
if (snapshot.collectibles) — the full list arrives only on joining and every 2
seconds. When it does, replace ours entirely, which makes any drift
self-healing. In between:
socket.on('claim', (e) => {
if (e.collectibleId) collectibles.delete(e.collectibleId);
log(`${e.username} scored (${e.remaining} left)`);
});
One deletion per claim. That pairing — occasional full refresh plus incremental removals — is what let the per-tick message shrink by about 20×.
6. Sending input
const keys = new Set();
const KEYMAP = {
ArrowUp: 'up', KeyW: 'up', ArrowDown: 'down', KeyS: 'down',
ArrowLeft: 'left', KeyA: 'left', ArrowRight: 'right', KeyD: 'right',
};
function isTyping(target) {
if (!target) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
}
addEventListener('keydown', (e) => {
if (isTyping(e.target)) return;
const k = KEYMAP[e.code];
if (!k) return;
e.preventDefault();
keys.add(k);
});
addEventListener('keyup', (e) => {
const k = KEYMAP[e.code];
if (k) keys.delete(k);
});
isTyping fixed a third bug. The movement handler is on the window and calls
preventDefault() on W, A, S, D and the arrow keys. That meant those characters
never reached the username box — you literally could not type "swad" as a name,
and arrow keys would not move the text cursor.
Note the asymmetry: keyup is not gated. If you hold a key on the canvas and
release it after clicking into a text box, the gate would swallow the release and
the key would stay stuck in the set — your player would slide forever. Releases
must always be processed.
e.code rather than e.key — code is the physical key position, so WASD works
on any keyboard layout.
setInterval(() => {
if (!me) return;
const dx = (keys.has('right') ? 1 : 0) - (keys.has('left') ? 1 : 0);
const dy = (keys.has('down') ? 1 : 0) - (keys.has('up') ? 1 : 0);
const changed = dx !== lastSent.dx || dy !== lastSent.dy;
if (!changed && dx === 0 && dy === 0) return;
lastSent = { dx, dy };
socket.emit('input', { dx, dy, seq: ++seq });
}, 50);
Sends 20 times a second, matching the server's tick rate. Sending faster would be wasted — the server only reads the latest value each tick.
(right ? 1 : 0) - (left ? 1 : 0) turns two keys into one number: right gives
1, left gives -1, both or neither gives 0.
if (!changed && dx === 0 && dy === 0) return; — standing still and unchanged
sends nothing at all. But a non-zero direction is resent every 50 ms even when
unchanged, because the server expires input after 600 ms as protection against a
lost "key released" message.
7. Drawing
function draw() {
requestAnimationFrame(draw);
if (!state || !cfg) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ffc857';
for (const c of collectibles.values()) {
ctx.beginPath();
ctx.arc(c.x, c.y, cfg.collectibleRadius, 0, Math.PI * 2);
ctx.fill();
}
requestAnimationFrame runs before each screen repaint — typically 60 times a
second, and it pauses entirely when the tab is hidden.
ctx.arc(x, y, radius, 0, Math.PI * 2) draws a circle. Math.PI * 2 radians is
360 degrees, so it is a full circle.
Note the loop is over the local collectibles map, not state.collectibles —
because most snapshots do not include that field.
for (const p of state.players) {
if (!p.connected) continue;
let r = render.get(p.id);
if (!r) { r = { x: p.x, y: p.y }; render.set(p.id, r); }
r.x += (p.x - r.x) * 0.35;
r.y += (p.y - r.y) * 0.35;
// …draw at r.x, r.y
}
The one place the client does its own arithmetic — and it is purely cosmetic.
The server sends positions 20 times a second, but the screen redraws 60 times a second. Drawing the raw values would look slightly steppy. So each frame the drawn position moves 35% of the way toward the real one.
This is smoothing, not prediction. The client never guesses where a player
will be; it only eases toward where the server says they are. The authoritative
value always wins, and render is never used for anything except drawing.
8. Game over
socket.on('game_over', (e) => {
if (me) { forgetSession(me.roomId, me.username); renderRejoin(); }
$('overTitle').textContent = e.reason === 'completed' ? 'All collected!' : 'Game abandoned';
$('overSub').textContent = `Finished in ${(e.durationMs / 1000).toFixed(1)}s`;
$('finalBoard').innerHTML = e.leaderboard.map(rowHtml).join('');
$('overlay').classList.add('show');
});
Clears only this game's session — other lobbies this browser can still rejoin are untouched.
The final leaderboard comes from the server in the game_over message, not from
the last snapshot. Every player receives the identical list, which an integration
test asserts.
9. Escaping usernames
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
Usernames come from other players and are inserted into HTML. Without escaping,
a name like <script>alert(1)</script> would run as code in every other
player's browser — a cross-site scripting attack.
The server's validation already rejects < and >, so this is a second line of
defence. Two independent checks, because one bug in either should not be enough.
What the client cannot do
| It never | Because |
|---|---|
| decides its own position | server integrates movement |
| decides who collected something | server checks distance |
| changes a score | server owns scores |
| moves faster by sending bigger numbers | server normalises direction to length 1 |
| see another lobby's data | Socket.IO rooms scope every message |
Everything it sends is a direction. Everything it shows came from the server.
If an interviewer asks
"Why no React?"
"It draws circles on a canvas from a server-owned state object. React's value is managing component state, and there isn't any here — the server owns all of it. A build step and a dependency tree would be cost without benefit."
"Is the interpolation client-side prediction?"
"No. Prediction guesses where you will be and reconciles later. This only eases toward where the server says you are, because 20 Hz updates on a 60 Hz screen look steppy. The authoritative position always wins."
"Tell me about a frontend bug you fixed."
"Reconnect looked broken — a player who left couldn't get back in. The server was fine; I verified it issued distinct tokens and accepted the right one. The bug was that I'd stored the session under a single
localStoragekey, but localStorage is shared across tabs, so a second player joining overwrote the first player's token. One entry per player fixed it."