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.
Why this exists.
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.
The box it had to fit in.
How it runs.
Four deliberate choices.
Cloudflare Workers + D1 instead of Node/Express + Postgres.
ES Modules + Import Maps instead of a bundler.
js/lib/three/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.Singleton managers instead of a DI framework.
storageManager, statsTracker, achievementManager, cosmeticManager, leaderboardManager, accessibilityManagerStorageManager key.Polymorphic spawner instead of a mode-enum switch.
activeSpawner pattern: SpawnManager (endless) and WaveManager (survival) share one interfaceif (mode === 'wave') switches sprinkled through game.jsThings that were not obvious.
Anti-cheat without a server sim
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
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
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
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.What's running.
What I learned.
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.