CASE · 032024 — ONGOINGSOLOEDGE-STATIC · 60FPS
◦ SHATTERGRID · shattergrid.com

A Twin-Stick-Shooter. In the browser. Zero build.

Geometry-Wars arcade with 20K particles, a deterministic 60Hz loop, and edge leaderboards. Pure ES Modules — every fork runs with python -m http.server.

§ 01Problem · motivation

Why this exists.

Browser arcade shooters in the Geometry Wars mold are either old Flash relics, native titles behind paywalls, or WebGL demos with no meta-progression.

Retro-arcade fans and casual players want a mechanically deep twin-stick shooter right in the browser — no install, no account, no load time — with leaderboards, daily challenges, and cosmetics. The existing options fall short: either persistence is missing (anonymous itch.io builds) or performance is (Canvas2D clones stutter at tens of thousands of particles).

Shattergrid solves both: a Three.js WebGL renderer with pooled particle systems, a deterministic fixed-timestep loop, and a lean Cloudflare backend for scores, daily seeds, and cosmetics — all free on the free tier.

§ 02Constraints · operating box

The box it had to fit in.

Every architecture decision had to be checked against these constraints.
C/01 · OPS
Solo dev, side project — everything has to be maintainable without an ops team.
C/02 · BUILD
Zero-build pipeline: pure ES Modules + Import Maps, no npm/Webpack/Vite.
C/03 · BUDGET
Hosting budget ~€0/month — CF Workers free tier + D1, Alpine container on a personal VM.
C/04 · PERF
60 FPS on 5-year-old desktop hardware — 20k-particle pool, spatial-hash collisions.
C/05 · OFFLINE
PWA-installable, service worker pre-caches all 50+ assets — playable offline.
C/06 · ANTI-CHEAT
Score submissions without login: SHA-256 hash + rate-limit + outlier flagging. No forced auth.
C/07 · A11Y
Colorblind modes (Prot / Deut / Trit), reduced motion, one-handed autoaim — hard requirement.
C/08 · TRAFFIC
Daily peak at the daily reset (UTC 00:00), long-tail in between — free tier covers it.
§ 03Architecture · game-loop topology

How it runs.

No backend state in the game itself — the simulation runs deterministically in the browser. Cloudflare only handles scores, seeds, and cosmetics.
engine.live·frame 14,023·tickrate 60hz
fps ·budget ·particles
INPUT · 01
Gamepad / KB+M
polled @ 60Hz
events/s0
PWA · 02
Service Worker
50+ assets · pre-cached · offline
cachev1.4.2
SPAWNER · 03
activeSpawner
SpawnManager · WaveManager
modeendless / wave
A11Y · 04
accessibilityManager
prot · deut · trit · reduced
protdeuttrit
SIM · 05 · FIXED
60 Hz · accumulator
mulberry32 · deterministic
SPATIAL · 06
Spatial hash
O(n) collisions · grid bucket
RENDER · 07
Three.js · RAF
decoupled · interpolated
target144 hz cap
GPU · 08
WebGL · bloom · CA
20k particle pool
pool13.6k
AUDIO · 09
StemPlayer · 4 stems
drums · bass · melody · atmos
DBMA
STORE · 10
Singleton managers
storage · stats · achievements
backinglocalStorage
EDGE · 11
/api/score · CF Worker
SHA-256 gate · rate-limit
submits/min0
D1 · 12
SQLite · leaderboard
scores · daily seeds · cosmetics
tierfree
EVENT LOG · /api/score · sim tick · audio gate · D1 writes
21:14:08simtick · accumulator drained · 2.4ms · seed mulberry32
21:14:07edgePOST /api/score · sha256 ok · rate ok · D1 insert · 38ms
21:14:06spatialhash · 482 entities · 1,204 pair-checks · 0.6ms
21:14:05audiostem · melody gate · intensity 0.78 · gain 0→1 · 200ms
21:14:04a11yprotanopia · enemy palette remapped · once per spawn
21:14:03renderRAF · interp α=.42 · draw 6.1ms · bloom on
21:14:01spawnerWaveManager · wave 7 · 24 enemies · pattern crescent
21:13:59storestatsTracker · combo×8 · maxMultiplier 5.2 · localStorage
21:13:57pwasw · cache hit · /js/lib/three/three.module.js · 0ms
21:14:08simtick · accumulator drained · 2.4ms · seed mulberry32
21:14:07edgePOST /api/score · sha256 ok · rate ok · D1 insert · 38ms
21:14:06spatialhash · 482 entities · 1,204 pair-checks · 0.6ms
21:14:05audiostem · melody gate · intensity 0.78 · gain 0→1 · 200ms
21:14:04a11yprotanopia · enemy palette remapped · once per spawn
21:14:03renderRAF · interp α=.42 · draw 6.1ms · bloom on
21:14:01spawnerWaveManager · wave 7 · 24 enemies · pattern crescent
21:13:59storestatsTracker · combo×8 · maxMultiplier 5.2 · localStorage
21:13:57pwasw · cache hit · /js/lib/three/three.module.js · 0ms
§ 04Decisions · trade-offs

Four deliberate choices.

Per decision: what was chosen, instead of what, why.
D/01

Cloudflare Workers + D1 instead of Node/Express + Postgres.

chosen
CF Workers (free tier) + D1 for scores, daily seeds, rate limits
instead of
Express API on a VM + PostgreSQL
reason
The free tier fully covers realistic traffic, zero cold starts globally, no ops overhead. D1 is more than enough for a handful of tables holding scores and rate limits — no migrations pipeline, no connection pool, no PITR setup.
D/02

ES Modules + Import Maps instead of a bundler.

chosen
Pure ESM, Three.js self-hosted under js/lib/three/
instead of
Vite / Webpack + TypeScript
reason
The project has no dependencies apart from Three.js. Zero build-step keeps the deploy pipeline down to a single docker compose up on Nginx-Alpine, and diff reviews show real source files, not transpiled output. Every fork runs locally with python -m http.server.
D/03

Singleton managers instead of a DI framework.

chosen
Exported instances: storageManager, statsTracker, achievementManager, cosmeticManager, leaderboardManager, accessibilityManager
instead of
DI container or IoC framework
reason
For a single-player game with ~12 global subsystems, a DI container is overkill. Module scope is enough, and each manager cleanly encapsulates its own StorageManager key.
D/04

Polymorphic spawner instead of a mode-enum switch.

chosen
activeSpawner pattern: SpawnManager (endless) and WaveManager (survival) share one interface
instead of
if (mode === 'wave') switches sprinkled through game.js
reason
New modes (boss rush, custom challenges) need no changes to the main loop — just a new spawner that implements the same interface. The loop calls blindly every frame.
§ 05Highlights · interesting bits

Things that were not obvious.

Edge cases and details that only became clear during the build.

Anti-cheat without a server sim

H/01
The client sends score + SHA-256 hash over (score, kills, survivalTime, maxCombo, maxMultiplier, timestamp, secret). The worker verifies the hash, checks a per-IP rate limit (30 s), and flags statistical outliers (score > 5× the current p99 within the same game-mode bucket).

No user account needed, no replay stream — enough for a hobby game to keep 95% of fire-and-forget cheaters out without slowing down the UX. Anyone more determined can still extract the secret from the JS — but then their score is simply flagged and drops off the leaderboard.

Dynamic soundtrack from 4 stems

H/02
StemPlayer loads drums / bass / melody / atmosphere in parallel, starts them together on the audio clock, and fades each stem in via a GainNode as soon as an intensity threshold (computed from enemyCount + combo + multiplier) is reached.

Edge case: AudioContext has to be resume()d after a user gesture (browser policy). Falls back to a single bgmusic.mp3 if stems are missing or decodeAudioDatafails — the game stays audible even when the dynamic layer is down.

Colorblind at enemy init

H/03
Color remapping happens once at EnemyBaseinstantiation via a lookup map (protanopia / deuteranopia / tritanopia) — not per frame in a fragment shader.

Saves GPU budget for bloom / chromatic aberration, and particles inherit the new colors automatically because they follow the parent color. Reduced-motion additionally disables shake + bloom + aberration and drops particle density to 25%.

Fixed timestep with accumulator

H/04
60 Hz sim tickrate decoupled from the requestAnimationFrame render. On 144 Hz monitors the game renders at 144 FPS but simulates deterministically at 60 FPS.

Consequence: daily-challenge seeds (mulberry32PRNG with a date seed) produce exactly the same spawn pattern on any hardware — speedruns and daily rankings are comparable whether you're on a 60 or 240 Hz monitor.
§ 06Stack · in production

What's running.

Working toolchain in production — nothing theoretical.
Three.jsES ModulesImport MapsCloudflare WorkersD1 (SQLite)Nginx (Alpine)DockerService Worker · PWAWebAudio · AudioContextSHA-256 (Web Crypto)Spatial Hashmulberry32 PRNGParticle Pool (20k)
§ 07Reflection · takeaways

What I learned.

The project is running. These are the things I'm carrying into the next ones.

Zero-build is a delivery feature.

No node_modules, no transpilation — which also means: no supply-chain worries, no npm-audit noise, no lock-file merge conflicts. A static repo that ships via docker compose up on top of an Nginx-Alpine image. On the next hobby codebase I'll stick with ESM + Import Maps, as long as there are no hard node_modules dependencies.

Determinism before scale.

Fixed timestep + PRNG seeds are cheap to build and solve three problems at once: daily challenges, speedrun fairness, replay capability for later. It was one of the cheapest architectural moves on the project and it's now the first item on my checklist for every future game project.

◦ NEXT CASE · 04 / 11
Stack Surge
← all projects