The constraint that shaped everything
Campus Dude exists for a specific moment: a student has ten minutes between classes, on campus wifi, on a phone or a shared library desktop. They want to play something. They do not want to make an account, install anything, or wait.
That is the whole brief, and it turns out to be a demanding one. Ten minutes means the game has to be playable in under three seconds from tapping the link. Shared library desktop means no installs and no assumptions about the machine. Campus wifi means the payload has to be small. And "does not want to make an account" rules out most of the conventional scaffolding around a games site.
Every technical decision below falls out of those four sentences.
Why no framework
My day job is Next.js. My instinct was to reach for it here too. I did not, and the reason is the three-second budget.
A framework-based game page has to ship the framework runtime, hydrate, and then start the game. For a content site that cost is well worth paying — you get routing, data fetching, and a component model. For a Canvas game, almost none of that is useful. The game is a single <canvas> element and a loop. Everything the framework provides sits between the user and the thing they came for.
So each game is a plain HTML document, a stylesheet, and a JavaScript file. No bundler, no transpiler, no build step. The browser parses the HTML, runs the script, and the game starts. Deployment is copying files.
The unglamorous benefit is that this has not broken once. There is no dependency tree to audit, no build that can fail, no framework major version to migrate across. A game I wrote in week one still runs identically today because nothing underneath it moved.
Shared styling comes from CSS custom properties in a single file each game imports:
:root {
--bg: #f4f1ec;
--ink: #16150f;
--accent: #ff4d2e;
--radius: 12px;
--font-display: 'Space Grotesk', system-ui, sans-serif;
}
That gives twenty-two games one visual identity without a component library. When I changed the accent colour, I changed one line.
One game loop, twenty-two games
The games differ enormously — Carrom Board is physics on a board, Neon Rush is an endless runner, Campus 2048 is a grid puzzle. What they share is the loop, and I wrote it once:
function createLoop(update, render) {
let last = 0;
let rafId = null;
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05); // clamp tab-switch spikes
last = now;
update(dt);
render();
rafId = requestAnimationFrame(frame);
}
return {
start() { last = performance.now(); rafId = requestAnimationFrame(frame); },
stop() { cancelAnimationFrame(rafId); },
};
}
Two details in there earned their place the hard way. Passing delta time to update rather than assuming a fixed step means the game runs at the same speed on a 60Hz laptop and a 120Hz phone. And clamping dt to 50ms stops the physics exploding when someone switches tabs for a minute and requestAnimationFrame resumes with an enormous gap — without the clamp, a carrom striker teleports through the board.
Pairing stop() with the Page Visibility API also stops a backgrounded tab burning battery, which matters when the audience is on phones all day.
Scores without accounts
No login was a product decision, not a technical one, but it forced a technical answer: where do scores live?
They live in localStorage, keyed per game. A player gets a generated handle on first visit — Player_2185 and similar — which persists in the same store. It is not an identity, it is a label on a high score.
The honest trade-off: clear your browser data and your scores are gone, and your scores do not follow you to another device. For a study-break games site that is an acceptable loss, and it buys something valuable — there is no account system, no password reset, no personal data to protect, and no signup screen between the link and the game.
Anything that reads storage is wrapped, because storage throws rather than returning null in private-browsing contexts and when a browser blocks site data:
function readScore(gameId) {
try {
return Number(localStorage.getItem('cd:score:' + gameId)) || 0;
} catch {
return 0; // private mode, blocked storage — just play without a high score
}
}
What this approach costs
I am not going to pretend this is free. Three things are genuinely worse without a framework.
There is real duplication. Every game page repeats its own header markup and script tags. With twenty-two games, changing the site header means changing twenty-two files. I use a small script for that, which is a build step in everything but name.
State-heavy UI is tedious. The Canvas rendering is fine — you are drawing every frame anyway. But the surrounding UI, menus and settings and score panels, is manual DOM work that a component model would have made shorter.
No type safety. On a codebase this size I feel it. Refactoring a shared helper means grepping and hoping rather than letting the compiler find the callers.
Would I choose it again? For this site, yes. The constraint was time-to-playable on a bad connection, and nothing beats a document that starts working the moment it arrives. If Campus Dude grows features that need real state — accounts, multiplayer, a persistent profile — the calculation changes, and I will happily pay the framework cost then.
Pick the constraint that actually matters first. The stack falls out of it.

