CASE · 042025 — ONGOINGSOLOOFFLINE-FIRST · PWA
◦ STACK SURGE · stacksurge.app

A stacker. Offline-first. No email.

Browser-Arcade mit Cloud-Sync, Events und globalem Leaderboard — Vanilla JS, LocalStorage als Source-of-Truth, Supabase Free-Tier als additive Ebene. Nickname + PIN statt DSGVO-Schleppe.

§ 01Problem · motivation

Why this exists.

Stack-Tower-Arcades leben im App Store — voller Werbung, kein Cross-Device-Progress, kein ernstzunehmendes Leaderboard.

Die Ketchapp-Schule des Stack-Towers hat das Genre definiert und dann in Werbe-Interstitials vergraben. Wer heute "Stack" spielen will, bekommt proprietäre App-Store-Titel mit intrusiver Monetarisierung, keinem Gerätewechsel-Support und Leaderboards, die kaum mehr als Dekoration sind.

Stack Surge bringt die Mechanik zurück in den Browser — instant spielbar via URL, als PWA installierbar, ohne Email und ohne App-Store-Zwang. Fortschritt synct optional, Achievements, Daily Challenges und Quest Chains laufen lokal, Leaderboard global. Das Design-Ziel: zwei Tap entfernt von der ersten Runde, der Rest passiert im Hintergrund.

§ 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. Zero DevOps. Alles läuft auf Supabase Free-Tier + statischem Hosting.
C/02 · BUDGET
Hard Cap: < 5 €/Monat inkl. Domain — realistisch unter Free-Tier-Limits.
C/03 · LATENCY
60 FPS im Canvas auf Low-End-Mobiles. Tap-Timing mit 8 px Perfect-Tolerance.
C/04 · OFFLINE
PWA + Service Worker. LocalStorage als Source-of-Truth — Cloud nur additiv, nie blockierend.
C/05 · ANTI-CHEAT
Öffentliches Leaderboard ohne autoritativen Server-Game-State — signierte Submissions + serverseitige Plausibilitätschecks.
C/06 · AUTH
Keine Email. Nickname + 4–6-stelliger PIN. Rate-Limit 5/min, 30 s Lockout.
C/07 · TRAFFIC
Spiky durch Share-Karten — Leaderboard-Spitzen ohne Autoscale überstehen.
C/08 · BUNDLE
Vanilla JS + Canvas. Vite + Terser. Time-to-Interactive < 1 s auf 3G.
§ 03Architecture · sync model

How it runs.

Zwei parallele Tracks — Client LocalStorage und Cloud Supabase — reconciled durch den SyncService. Records via Math.max, Collections als Union, Offline spielt blind weiter.
sync.live·online yes·drift
plays/24h 0·p50 sync
CLIENT · 01
Tap input · 60Hz
requestAnimationFrame
taps/min0
CLIENT · 02
Game loop · Δt
BlockPlacement · Perfect 8px
fps60
RENDER · 03
Canvas · GradientCache
ParticleSystem · object pool
TRUTH · 04
LocalStorage
storage · key-map · primary
writes/s0
EVENTS · 05
SeasonalEventManager
themes-as-data · ESM modules
standardpremiumseasonal
SIGN · 06
HMAC · 32-bit hash
(s,b,p,d,t) + signature salt
saltVITE env
RECONCILE · 07
SyncService · max + union
0 conflicts · sv+
strategylocal-first
LEADERBOARD · 08
Global top 50
on-demand fetch
top10
CLOUD · 09
Supabase · Postgres
free tier · eu-central · RLS
rls checks0
RPC · 10
submit_score()
sig + plausibility gates
SIGRATIORATE
AUTH · 11
Nick + PIN
5/min · 30s lockout · SHA-256
sessions0
PWA · 12
Service Worker
assets · API · fonts
cache hit88%
EVENT LOG · /rpc/submit_score · sync · service-worker
21:14:08gameblock.placed · perfect · streak 14 · score +220
21:14:07syncreconcile · max(bestScore) · union(achievements ×3)
21:14:05rpcsubmit_score · sig ok · ratio ok · rate ok · 312ms
21:14:03authlogin · nick:surger99 · pin ok · session 218
21:14:01localstorage.write · bestScore=48210 · coins=1240
21:13:58eventstheme.activate · seasonal · halloween · ThemeReactions
21:13:55swcache.hit · /api/leaderboard · 88% · 12ms
21:13:52rpcsubmit_score · sig fail · rejected · rate-limit -1
21:13:48rendergradient.cache · 14 keys · 60fps · particles 142
21:14:08gameblock.placed · perfect · streak 14 · score +220
21:14:07syncreconcile · max(bestScore) · union(achievements ×3)
21:14:05rpcsubmit_score · sig ok · ratio ok · rate ok · 312ms
21:14:03authlogin · nick:surger99 · pin ok · session 218
21:14:01localstorage.write · bestScore=48210 · coins=1240
21:13:58eventstheme.activate · seasonal · halloween · ThemeReactions
21:13:55swcache.hit · /api/leaderboard · 88% · 12ms
21:13:52rpcsubmit_score · sig fail · rejected · rate-limit -1
21:13:48rendergradient.cache · 14 keys · 60fps · particles 142
§ 04Decisions · trade-offs

Four deliberate choices.

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

Vanilla JS + Canvas statt React / Three.js / Phaser.

chosen
Direkter 2D-Context, eigener GameLoop mit Delta-Time, modulare Systems/Entities
instead of
React für UI + Phaser/Pixi für Rendering
reason
Ein 2D-Stacker braucht kein VDOM und keinen Scene-Graph. Frame-Hot-Path bleibt dünn, Code-Splitting hält den Initial-Chunk klein, der Rest lädt lazy. Jede Framework-Schicht wäre pure Steuer — und ich kontrolliere jede Allocation pro Frame selbst.
D/02

Supabase + RLS statt eigenes Node-Backend.

chosen
Postgres mit RLS-Policies, RPC-Functions in supabase/security.sql
instead of
Eigener Express/Socket-Server im Container
reason
Alle Schreibpfade laufen über RPCs mit RLS — Leaderboard, Auth, Sync. Keine Container, keine Deploy-Pipeline, kein Ops. Für die erwartete Nutzerbasis reicht Free-Tier satt, und wenn es wächst, skaliert Supabase linear mit dem Preis statt exponentiell mit meiner Zeit.
D/03

LocalStorage als Source-of-Truth, Cloud nur additiv.

chosen
storage als primärer State, SyncService reconciled später gegen Supabase — Records via Math.max, Collections als Union
instead of
Cloud als primärer State, alle Writes blockierend übers Netz
reason
Offline spielbar, kein Blocking-Launch, Netzwerkfehler kosten Sync-Latenz — keine Spielerfahrung. Der Sync-Pfad ist der einzige Ort, an dem Netzwerk wehtun darf, und genau dort ist er eingesperrt. Fire-and-Forget Writes im Client sind ehrlich, Cloud ist Bonus.
D/04

Nickname + PIN statt Email-Auth.

chosen
Eindeutiger Nickname + 4–6-stelliger numerischer PIN · Rate-Limit 5/min, 30 s Lockout
instead of
Email + Passwort + Magic-Link-Flow
reason
Email-Auth ist für ein Casual-Game zu viel Reibung und zieht DSGVO-Pflichten nach. Nick+PIN braucht keine Verifikations-Infrastruktur, und die Reibung ist eine Zeile. Trade-off: kein Recovery-Kanal — bewusst akzeptiert und dokumentiert. Wer den PIN vergisst, startet neu. Casual-Game, kein Bank-Account.
§ 05Highlights · interesting bits

Things that were not obvious.

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

GradientCache gegen GC-Stottern

H/01
Naiv erzeugt BackgroundSystem pro Frame dutzende createLinearGradient-Aufrufe — auf alten Androids mördert das den GC, FPS kollabieren.

Lösung: ein GradientCache mit Key aus Farben + Dimensionen. Invalidierung nur bei Theme-Wechsel. Zusammen mit einem ParticleSystem-Object-Pool (Swap-and-Pop statt Array.splice: O(1) statt O(n)) bleibt der Fever-Mode auf Low-End-Devices bei konstanten 60 FPS.

Anti-Cheat ohne autoritativen Server

H/02
Score-Submissions werden clientseitig signiert — ein 32-Bit Hash über (s, b, p, d, t) + VITE_SIGNATURE_SALT aus der Env. Die Supabase-RPC submit_score speichert die Signatur und führt eigene Plausibilitätschecks: Score-Cap pro Block, min. Zeit pro Block, perfects ≤ blocks, streak ≤ perfects, Rate-Limit 5 Submissions / 5 min pro Nickname.

Keine perfekte Lösung — der Salt liegt im Bundle, Reverse-Engineering ist möglich. Aber: genug, um den Fire-and-Forget-Cheat vom öffentlichen Board fernzuhalten, ohne Game-State auf einen Server zu zwingen. Pragmatisch kalibriert gegen die Angreifer, die tatsächlich auftauchen.

Seasonal-Events als Daten, nicht Code

H/03
Themes liegen als reine Daten in src/data/themes/{standard,premium,seasonal}/ — pro Theme ein ESM-Modul mit Color-Phases, Effect-Flags und -Counts. Der SeasonalEventManager detektiert das Datum, aktiviert Themes + ThemeReactions automatisch — fliegende Fledermäuse zu Halloween, Schneeflocken zu Weihnachten.

Neuer Event = neue Theme-Datei + ein Eintrag in SeasonalEvents.js mit Datumsfenster. Kein Code-Change am Renderer, keine Deploy-Koordination. Die Grenze zwischen Content-Change und Code-Change bewusst gezogen und bewahrt: was Autoren und was Entwickler anfassen, ist seit Tag 1 getrennt.

Reconcile mit max() und Union

H/04
SyncService zieht beim Login den Cloud-State, mergt gegen LocalStorage und schreibt das Ergebnis zurück. Strategie pro Feld: bestScore und Statistik- Counter via Math.max, inventory und achievements als Union, equipped und settings beim ersten Sync aus der Cloud, danach local-first. coins beim Restore-Sync via Math.max, im aktiven Spiel local — sonst würde Cloud bereits ausgegebene Coins zurückspielen.

Konsequenz: wer auf drei Geräten spielt, verliert keinen Fortschritt — Hochwasser-Marken bleiben konsistent, Käufe und freigeschaltete Inhalte addieren sich, die zuletzt aktive Session bestimmt den Loadout-State. Schmal genug, dass die Konflikt-Cases im Code direkt sichtbar bleiben — keine versteckte Last-Write-Wins-Magie, die später jemand versehentlich "optimiert".
§ 06Stack · in production

What's running.

Working toolchain in production — nichts Theoretisches.
Vanilla JSHTML5 CanvasVite · TerserSupabasePostgres · RLSRPC FunctionsService Worker · PWALocalStorageWeb Crypto · SHA-256 (PIN-Hash)VitestObject PoolGradientCacheSeasonal data themes
§ 07Reflection · takeaways

What I learned.

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

Offline-first ist Feature, nicht Fallback.

LocalStorage als Source-of-Truth hat den ganzen UX-Flow entkoppelt — kein Loading-Spinner, keine "Verbindung wird hergestellt"-Blocker, kein Geräusch, wenn das Netz wackelt. Der Sync-Pfad ist eng, getestet und hat klare Fehler-Grenzen. Das nehme ich in jede App mit, die irgendwo zwischen "rein lokal" und "rein vernetzt" lebt.

Backend-as-a-Service bis es weh tut.

Free-Tier-Supabase hat mich komplett von Ops befreit — keine VMs, kein nginx, kein pg_dump-Cronjob. RLS ersetzt einen API-Server, den ich sonst selbst hätte bauen müssen. Die Grenze ist klar: sobald ich custom Realtime-Game-State brauche, geht das nicht mehr. Aber bis dahin: jeder Solo-Dev sollte das als Default evaluieren, bevor er wieder einen Node-Server containerisiert.

◦ NEXT CASE · 05 / 11
DefOrbit
← alle Projekte