From Buggy Prototype to Polished Arcade: How I Upgraded Three Browser Games
Over the past few days I've been deep in the trenches refactoring and rebuilding three browser games built on a lightweight 2D/3D canvas engine. What started as "fix a few bugs" turned into a full-blown feature overhaul across a platformer, a racer, and an FPS.
Here's what actually changed, why, and what I learned.
The Starting Point
Three games, all built on the same homegrown engine:
- Neon Caverns: Seasons — a platformer with 20 levels and a coin economy
- Death Boost: Seasons — a pseudo-3D arcade racer
- Riftfire (formerly Raycast FPS) — a raycast shooter
The initial request was simple: "there are bugs." But as I dug in, the real issues were architectural — not just a few typos.
Neon Caverns: Seasons
The Bugs
Two silent killers were ruining the game:
1. Tile mutations across restarts. The engine's tilemap constructor mutated a shared array every time a level loaded. After one restart, every tile lookup broke. Players saw invisible walls.
2. The pause button drifted off screen. It was added as a world-space entity, so it scrolled away with the camera instead of staying pinned to the HUD.
The Overhaul
- 4 seasons × 12 levels = 48 unique stages, each procedurally generated but deterministic per run.
- Ramping difficulty curve — enemy HP, speed, accuracy, and map density all scale with a single normalized value from 0 to 1.
- Full procedural audio — every sound effect and music track is synthesized live via the Web Audio API. No external files, no downloads.
- Persistent coins and upgrades — health, speed, jump, magnet range, shield tiers, all saved to localStorage.
- Star ratings per level (1–3 stars based on health, time, and coin collection).
- Season unlock chaining — clear all 12 levels of a season to open the next one.
Death Boost: Seasons
The Bugs
This game had a list:
- Invisible hazards were killing players
- Cars were colliding from behind the player
- Traffic recycled to a moving target, causing pile-ups
- Menu clicks silently failed when audio init threw
- Sprites rendered ~1000× too small to be visible
The Overhaul
- 4× wider road — from 2000 to 3400 units wide, 5 lanes instead of 3.
- 60% fewer obstacles, 50% less traffic, longer spacing between hazards.
- 12 tracks total with lap counts from 1 to 3.
- Bug fixes: signed z-window for hazard collisions, cooldowns on repeated hits, sprite scale corrected to match world geometry, audio failures no longer swallow input.
- Always-visible pause button — now pinned to screen coordinates instead of world space.
- Per-level difficulty display — cars show their speed, health, and boost stats as animated bars.
Riftfire
The Overhaul
What was originally a 5-map raycast FPS became:
- 12 seasons × 30 levels = 360 stages, all procedurally generated with seeded randomness.
- Difficulty ramp — enemy HP, damage, accuracy, speed, and fire rate all scale from easy (level 1) to brutal (level 360).
- Procedural audio — every gunshot has its own synthesized waveform. Rifle, pistol, shotgun, sniper, minigun — all distinct, all generated at runtime.
- Adaptive music — four different musical scales that switch based on the current level's difficulty.
- Weapons shop, ranks, and coin economy — buy new weapons, earn XP, climb 10 ranks.
- Mobile touch support — analog joystick for movement, drag-to-aim for firing.
The Common Threads
Across all three games, the same principles showed up again and again:
1. Separate World Space from Screen Space
The pause button bug and the sprite scale bug both came from mixing world coordinates with screen coordinates. Anything that should stay fixed on the HUD must be drawn after the camera transform is popped.
2. Never Trust Global Mutable State
The tilemap bug was caused by an engine-level array.unshift() that silently mutated data shared between level loads. Whenever possible, rebuild state fresh at scene boundaries.
3. Procedural Content Is Cheap and Endless
All three games now generate their content at runtime. This means:
- No asset loading delays
- No missing-file errors
- No copyright issues
- No download size
Web Audio can synthesize a gunshot, a jump sound, or a full musical theme in a few lines of code.
4. Input Handling Must Be Defensive
The click-swallowing bug in Death Boost happened because initAudio() threw an exception and aborted the event listener before pendingClick was ever set. Always set state before calling anything that might throw.
5. Difficulty Should Be a Single Scalar
Rather than tweaking 20 numbers per level, each game derives all difficulty factors from one normalized value:
difficulty = (globalLevel / totalLevels) ^ 0.85
That one number then scales enemy HP, count, speed, accuracy, and map density. It's clean, tunable, and easy to reason about.
What I'd Do Differently
If I started over:
- Version control from day one. This was all done in single monolithic files.
- Write tests for input flow. The click bug was caught by a human, not a test.
- Separate rendering from state. Several bugs came from render functions that also mutated game state.
- Build a small dev console — a way to jump to any level, tweak difficulty, and inspect state.
Final Numbers
| Game | Levels | Audio | Notable Feature |
|---|---|---|---|
| Neon Caverns: Seasons | 48 | 100% procedural | Season unlock chains |
| Death Boost: Seasons | 12 tracks | 100% procedural | Multi-lap enduros |
| Riftfire | 360 | 100% procedural | Adaptive music scales |
All three ship as single HTML files, no external assets, and run in any modern browser.
Closing Thoughts
The best thing about browser games is how fast the iteration loop can be. Change a number, refresh the page, feel the difference. The worst thing is how quickly small architectural mistakes compound into invisible bugs.
The three games above went from "playable but broken" to "actually fun" by fixing a handful of fundamental issues and then building outward. Every new feature is now cheap to add because the foundation is solid.
If you're building a browser game and something feels off, it's almost never "just one bug." It's usually a small design decision that ignored a boundary between two systems. Find the boundary. Fix the boundary. The rest follows.
Top comments (0)