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

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

Geometry-Wars-Arcade mit 20K Partikeln, deterministischem 60Hz-Loop und Edge-Leaderboards. Pure ES Modules — jeder Fork läuft mit python -m http.server.

§ 01Problem · motivation

Why this exists.

Browser-Arcade-Shooter im Stil von Geometry Wars sind entweder alte Flash-Relikte, native Titel hinter Paywalls oder WebGL-Demos ohne Meta-Progression.

Retro-Arcade-Fans und Gelegenheitsspieler wollen direkt im Browser — ohne Install, ohne Account, ohne Ladezeit — einen mechanisch tiefen Twin-Stick-Shooter mit Leaderboards, Daily Challenges und Cosmetics. Existierende Lösungen reichen nicht: Entweder fehlt die Persistenz (anonyme itch.io-Builds) oder die Performance (Canvas2D-Klone ruckeln bei zehntausenden Partikeln).

Shattergrid löst beides: Three.js-WebGL-Renderer mit pooled Particle-Systems, deterministischem Fixed-Timestep-Loop und einem schlanken Cloudflare-Backend für Scores, Daily-Seeds und Cosmetics — alles kostenlos im Free Tier.

§ 02Constraints · operating box

The box it had to fit in.

Jede Architektur-Entscheidung musste gegen diese Constraints geprüft werden.
C/01 · OPS
Solo-Dev, nebenberuflich — alles muss ohne Ops-Team wartbar sein.
C/02 · BUILD
Zero-Build-Pipeline: Pure ES Modules + Import Maps, kein npm/Webpack/Vite.
C/03 · BUDGET
Hosting-Budget ~0 €/Monat — CF Workers Free Tier + D1, Alpine-Container auf eigener VM.
C/04 · PERF
60 FPS auf 5-Jahre-alter Desktop-Hardware — 20k-Partikel-Pool, Spatial-Hash-Kollisionen.
C/05 · OFFLINE
PWA-installierbar, Service Worker pre-cached alle 50+ Assets — spielbar ohne Netz.
C/06 · ANTI-CHEAT
Score-Submissions ohne Login: SHA-256-Hash + Rate-Limit + Outlier-Flagging. Kein Zwangs-Auth.
C/07 · A11Y
Colorblind-Modi (Prot / Deut / Trit), Reduced Motion, One-Handed-Autoaim — harte Anforderung.
C/08 · TRAFFIC
Täglicher Peak beim Daily-Reset (UTC 00:00), Long-Tail dazwischen — Free Tier deckt das ab.
§ 03Architecture · game-loop topology

How it runs.

Kein Backend-State im Spiel — Simulation läuft deterministisch im Browser. Cloudflare kümmert sich nur um Scores, Seeds und 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.

Pro Entscheidung: was gewählt, statt was, warum.
D/01

Cloudflare Workers + D1 statt Node/Express + Postgres.

chosen
CF Workers (Free Tier) + D1 für Scores, Daily-Seeds, Rate-Limits
instead of
Express-API auf VM + PostgreSQL
reason
Free Tier deckt realistisches Traffic komplett ab, Zero-Cold-Start global, kein Ops-Overhead. D1 reicht für ein paar Tabellen mit Scores und Rate-Limits locker — keine Migrations-Pipeline, kein Connection-Pool, kein PITR-Setup.
D/02

ES Modules + Import Maps statt Bundler.

chosen
Pure ESM, Three.js self-hosted unter js/lib/three/
instead of
Vite / Webpack + TypeScript
reason
Das Projekt hat keine dependencies außer Three.js. Zero Build-Step hält die Deploy-Pipeline auf einen docker compose up in Nginx-Alpine, und Diff-Reviews zeigen echte Quelldateien, keine transpilierten Outputs. Jeder Fork läuft lokal mit python -m http.server.
D/03

Singleton-Manager statt DI-Framework.

chosen
Exportierte Instanzen: storageManager, statsTracker, achievementManager, cosmeticManager, leaderboardManager, accessibilityManager
instead of
DI-Container oder IoC-Framework
reason
Für ein Single-Player-Game mit ~12 globalen Subsystemen ist ein DI-Container Overkill. Module-Scope reicht, und jeder Manager kapselt seinen eigenen StorageManager-Key sauber.
D/04

Polymorpher Spawner statt Mode-Enum-Switch.

chosen
activeSpawner-Pattern: SpawnManager (Endless) und WaveManager (Survival) teilen ein Interface
instead of
if (mode === 'wave')-Switches durchs game.js
reason
Neue Modi (Boss-Rush, Custom Challenges) erfordern keine Änderungen am Main-Loop — nur ein neuer Spawner, der dasselbe Interface bedient. Der Loop ruft blind pro Frame.
§ 05Highlights · interesting bits

Things that were not obvious.

Edge-Cases und Details, die erst beim Bauen klar wurden.

Anti-Cheat ohne Server-Sim

H/01
Der Client schickt Score + SHA-256-Hash über (score, kills, survivalTime, maxCombo, maxMultiplier, timestamp, secret). Der Worker verifiziert den Hash, checkt pro-IP-Rate-Limit (30 s) und flaggt statistische Outlier (Score > 5× aktuellem p99 derselben Spielmodus-Klasse).

Kein User-Account nötig, kein Replay-Stream — reicht für ein Hobby-Game, um 95 % der Fire-and-Forget-Cheater draußen zu halten, ohne die UX zu bremsen. Wer mehr will, kann immer noch den Secret aus dem JS extrahieren — aber dann ist der Score eben markiert und fliegt aus dem Leaderboard.

Dynamischer Soundtrack aus 4 Stems

H/02
StemPlayer lädt drums / bass / melody / atmosphere parallel, startet sie gleichzeitig am Audio-Clock, und blendet jeden Stem per GainNode ein, sobald eine intensity-Schwelle (berechnet aus enemyCount + combo + multiplier) erreicht wird.

Edge-Case: AudioContext muss nach User-Gesture resume()d werden (Browser-Policy). Fallback auf single bgmusic.mp3, wenn Stems fehlen oder decodeAudioData scheitert — das Spiel bleibt hörbar, auch wenn die dynamische Schicht down ist.

Colorblind beim Enemy-Init

H/03
Farb-Remapping passiert einmal bei EnemyBase-Instanzierung über eine Lookup-Map (Protanopia / Deuteranopia / Tritanopia) — nicht pro Frame in einem Fragment-Shader.

Spart GPU-Budget für Bloom / Chromatic-Aberration, und Partikel erben die neuen Farben automatisch, weil sie dem Parent-Color folgen. Reduced-Motion deaktiviert zusätzlich Shake + Bloom + Aberration und senkt Partikel-Dichte auf 25 %.

Fixed-Timestep mit Accumulator

H/04
60 Hz Sim-Tickrate entkoppelt vom requestAnimationFrame-Render. Auf 144-Hz-Monitoren rendert das Spiel 144 FPS, simuliert aber deterministisch 60 FPS.

Konsequenz: Daily-Challenge-Seeds (mulberry32-PRNG mit Datums-Seed) produzieren auf jeder Hardware exakt dieselben Spawn-Pattern — Speedruns und Daily-Rankings sind vergleichbar, egal ob 60- oder 240-Hz-Monitor.
§ 06Stack · in production

What's running.

Working toolchain in production — nichts Theoretisches.
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.

Projekt läuft. Diese Dinge nehme ich in die nächsten mit.

Zero-Build ist Delivery-Feature.

Keine node_modules, keine Transpilation — das heißt auch: keine Supply-Chain-Sorgen, kein npm-Audit-Lärm, keine Lock-File-Merge-Konflikte. Ein statisches Repo, das per docker compose up in ein Nginx-Alpine-Image rollt. Bei der nächsten Hobby-Codebase bleibe ich bei ESM + Import Maps, solange es keine harten node_modules-Abhängigkeiten gibt.

Determinismus vor Skalierung.

Fixed-Timestep + PRNG-Seeds sind billig zu bauen und lösen auf einmal drei Probleme: Daily-Challenges, Speedrun-Fairness, Replay-Fähigkeit für später. Das war einer der günstigsten Architektur-Moves im Projekt und ist jetzt der erste Punkt auf meiner Checkliste für jedes zukünftige Game-Projekt.