Graze instead of dodge: the core loop rewards proximity, so the player's instinct is inverted and the skill ceiling rises. Deterministic simulation core: combat math is extracted into a pure module pinned by golden vectors, so balance changes are verifiable. No bundler: the deployed artifact is the source, which removes a whole class of build-drift bugs. Offline-first: a service worker precaches the whole shell so the game runs with no network at all. Honest scope: leaderboards and a global mirror are built but not promoted until the backend is live.
01 The challenge
Bullet hells are usually about not getting hit. I wanted a game where the safest play is the worst play.
EMBERFALL is a graze-to-score shooter. Enemy fire that passes close to your ship without connecting registers a graze, and grazes feed a heat meter that multiplies your score. Flying into the bullet curtain is the correct strategy, which means the player's real skill is judging distance under pressure rather than memorising a safe lane.
The second constraint was shipping. A browser game that needs a build step, a package tree, and a deploy pipeline can still be zero-dependency, but it introduces a failure surface that has nothing to do with the game. The requirement was that the shipped file be the source, with no compilation between writing code and playing it.
02 The graze mechanic
Two meters, one clock, and a decay curve that punishes camping.
- Graze radius — near-miss detection uses a radius around the ship, widened for the tighter-hitbox ship class
- Heat — each graze adds heat; heat multiplies the score awarded, so consecutive close passes compound
- Combo window — the combo multiplier stays alive only while you keep grazing
- Independent decay — heat falls on its own clock rather than inside the combo window, so a single late graze still pays out for about three seconds
- Ship variants — different playable ships trade hitbox size, dash count, bomb count, and graze multiplier against each other
Heat decaying inside the combo window meant the multiplier froze whenever the window closed, and every late-run graze overpaid. Moving decay onto its own timer fixed a whole class of scoring edge cases, and the behaviour is pinned by a unit test rather than left to a playtester's memory.
03 Eight worlds, five bosses
Difficulty ramps on a fixed wave schedule, with bosses arriving on a director cycle rather than a hand-placed trigger.
| Element | Design |
|---|---|
| Worlds | Eight, entered on a fixed wave progression (4, 9, 14, 20, 26, 33, 40, 48) so the difficulty curve is the same for every run |
| Bosses | Five named encounters, each with its own art, hitbox, health pool, and score value |
| Phases | Bosses gain phases as they lose health; later phases add attack patterns rather than only raising damage |
| Patterns | Rotating spiral curtains, sweeping cross-beams, and node-based turrets on multi-part bosses |
| Director | A wave director owns spawn timing and composition, so encounters are composed rather than hand-scripted |
Boss order and stat relationships are asserted directly in the test suite, including a check that the final boss outstats its sibling. Balance is a contract, not a hope.
04 Deterministic simulation core
The part of the game most likely to break is the part that decides whether you lived. So it was extracted and made pure.
- Pure simulation module — combat math lives in a dependency-free package with no DOM and no canvas access
- Seeded RNG — deterministic random streams, so a bug reproduces exactly instead of appearing once in fifty runs
- Golden vectors — T-DET anchor tests pin expected outputs, so a balance change shows up as a diff rather than a hunch
- Headless testability — because the core is pure, it runs in a plain test runner with no browser and no graphics stack
Input / Render
Presentation
Pure combat math
Seeded RNG
Golden vectors
Headless tests
EMBERFALL: presentation → pure simulation → verified core
05 The CI gate chain
Forty-six steps, and none of them exist to prove the build works. They exist to stop the build from lying.
The workflow runs as a series of independent gates, each of which can fail the run on its own. A game that only ships when every gate is green is a game whose balance, economy, and copy are all verifiable at merge time.
- Syntax gate — every shipped script is parse-checked before anything else runs
- Dead-code and load-order gate — catches unused modules and scripts loaded in the wrong order
- Simulation unit tests — the pure core against its golden vectors
- Economy model gate — the scoring economy is checked as a model, not by feel
- Copy guard — the player-facing text is checked against the product's actual rules, so copy cannot drift from behaviour
- SQL binding audit — catches the integer-versus-text trap class in database bindings
- Parity gate — the client display and the server's authorisation must agree on the daily economy
- API smoke and drill steps — the live command deck is started and exercised through a full challenge loop
- Browser E2E — sixteen Playwright specs covering gameplay, onboarding, accounts, accessibility surfaces, and link safety
One big test job tells you something broke. A chain of narrow gates tells you what broke, in minutes instead of an hour, and each gate names the class of mistake it exists to catch. The copy guard is the one I would keep if I had to delete everything else.
06 Zero build step, offline PWA, live boards
No bundler and no package tree. The game needs no network to play; the leaderboard is the one optional online feature.
- Plain modules — the game itself is vanilla JavaScript and Canvas 2D; the only external library is a vendored supabase-js, loaded only when a player opens the global board or is already signed in
- Service worker — the application shell is precached, so the game is fully playable offline after first load
- Procedural audio — sound is synthesised with WebAudio at runtime, which keeps the payload small and the game self-contained
- Portal build — a separate script produces a portal-safe copy with online features off and hard size and content checks, so a portal package can never leak a network call
Global and daily leaderboards are live, with player accounts on Supabase. Players sign in with an email magic link and pick a callsign. Scores are written only through two database functions (submit_score and set_callsign), with row-level security on, score bounds checks and per-player rate limits; the public boards are read-only views. An earlier plan for a self-hosted game server was dropped before it was ever deployed. Scores are bounds-checked, not replayed on a server, so the boards are not claimed to be cheat-proof. Sign in with X is in progress and not live yet.
07 What I learned
Four lessons I carried into every project after this one.
- Extract the risky core early — the moment combat math stopped touching the canvas, it became testable, and testing it found bugs that playtesting never would have
- Make the mechanic the identity — a single inverted rule (graze to score) is easier to explain, easier to balance, and more memorable than a pile of features
- Separate gates beat one big job — narrow checks that each own a class of failure turn a slow red build into a fast, specific error
- Constraints pay off — "no build step" was meant to save time. It also removed an entire category of deployment bug and made the artifact inspectable by hand