Building one browser game is fun. Building the twentieth is where you find out whether you have a codebase or a pile of copies. We ship a library of free puzzle games online at Game2Over: 2048, Block Puzzle, Ball Sort, Flow Puzzle, Nuts and Bolts, Tube Slide, Hyper Snake and others. They're all Phaser HTML5 games that run in the browser with no download and no account needed to play. This post covers the one rule that kept that library maintainable.
The rule
If two games would write the same code, it goes in the framework. If it's what makes a game that game, it stays in the game's folder.
That sounds obvious. It's hard to apply consistently, because the first time you need a countdown bar, writing it inline is faster. By the third game you have three countdown bars with three slightly different pause bugs.
What ended up in the shared framework: audio synthesis, grid arithmetic, countdowns, progress bars, result panels, buttons, icons, cards, text styling, particles and localStorage helpers.
What stayed per-game: board rules, level generation, palettes, HUD layout and the named sound cues. Those are the game's identity.
A game is six small files, not one big one
library/<slug>/
theme.ts colors, geometry, type scale (no logic)
sfx.ts named audio cues, extends the shared AudioEngine
hud.ts chrome + result panels (no rules)
render.ts game-specific painting (optional)
level.ts puzzle generation / rules, pure (optional)
scene.ts gameplay only
The non-obvious file is theme.ts. It exists so scene.ts and hud.ts can agree on positions and colors without importing each other. Once the HUD stopped importing the scene, it stopped making rule decisions, and layout code stopped leaking into gameplay.
Keeping level.ts pure, with no Phaser imports, pays off for puzzle games especially. Level generation and solvability checks can be unit-tested without a canvas.
The tricky seam: who owns the clock?
The timer is the one place the split needs care. The HUD owns where the timer is drawn and what "urgent" looks like. The scene owns when it pauses and what expiry means. So the HUD exposes a view-only slice:
readonly timerView: Pick<GameTimerSpec, "bar" | "readout" | "urgent">;
…and the scene merges it with the behavior it owns:
this.timer = this.createTimer({
...this.hud.timerView,
isPaused: () => this.roundOver,
onExpire: () => this.lose(),
});
There's no shared mutable state and no event bus for something this local. The HUD can be restyled without touching game rules, and vice versa.
Starting a new game is a copy, not a blank file
New games start from a _template/ folder with the six-file split already wired. Renaming the scene class, the storage prefix and the game key is enough to get a playable shell. The rest of the work goes into the parts that make the game fun.
Why bother, for "just" puzzle games?
Because puzzle games for adults live or die on polish: input that feels instant, satisfying feedback, and a clear result screen. When those pieces are shared, fixing a bug in one result panel fixes it in all 21 games. Each improvement compounds instead of being redone 21 times.
You can play any of them free in the browser at game2over.com/games. Block Puzzle is a good one to poke at if you want to see the HUD/timer split in action.
Disclosure: I work on Game2Over, an online earning platform where these free browser games sit alongside partner games, offers and surveys. Signed-in players earn Z Coins for completed rounds. This post was drafted with AI assistance.
Top comments (0)