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

A stacker. Offline-first. No email.

Browser arcade with cloud sync, events, and a global leaderboard — vanilla JS, LocalStorage as source of truth, Supabase free tier as an additive layer. Nickname + PIN instead of GDPR baggage.

§ 01Problem · motivation

Why this exists.

Stack-tower arcades live in the App Store — packed with ads, no cross-device progress, no leaderboard worth taking seriously.

The Ketchapp school of stack-towers defined the genre and then buried it under interstitial ads. Anyone who wants to play "Stack" today ends up with proprietary App Store titles shipping intrusive monetization, no device-switch support, and leaderboards that are little more than decoration.

Stack Surge brings the mechanic back to the browser — instantly playable via URL, installable as a PWA, with no email and no App Store lock-in. Progress syncs optionally, achievements, daily challenges and quest chains run locally, the leaderboard is global. Design goal: two taps away from the first round, the rest happens in the background.

§ 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. Zero DevOps. Everything runs on the Supabase free tier + static hosting.
C/02 · BUDGET
Hard cap: < €5/month including the domain — realistic under free-tier limits.
C/03 · LATENCY
60 FPS in the canvas on low-end mobiles. Tap timing with an 8 px perfect tolerance.
C/04 · OFFLINE
PWA + service worker. LocalStorage as source of truth — cloud is additive only, never blocking.
C/05 · ANTI-CHEAT
Public leaderboard without an authoritative server-side game state — signed submissions + server-side plausibility checks.
C/06 · AUTH
No email. Nickname + 4–6-digit PIN. Rate limit 5/min, 30 s lockout.
C/07 · TRAFFIC
Spiky thanks to share cards — survive leaderboard peaks without autoscale.
C/08 · BUNDLE
Vanilla JS + canvas. Vite + Terser. Time-to-interactive < 1 s on 3G.
§ 03Architecture · sync model

How it runs.

Two parallel tracks — client LocalStorage and cloud Supabase — reconciled by the SyncService. Records merge via Math.max, collections via union, offline keeps playing blind.
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.

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

Vanilla JS + Canvas instead of React / Three.js / Phaser.

chosen
Direct 2D context, a hand-rolled GameLoop with delta-time, modular systems/entities
instead of
React for UI + Phaser/Pixi for rendering
reason
A 2D stacker needs no VDOM and no scene graph. The frame hot path stays thin, code splitting keeps the initial chunk small while the rest loads lazily. Any framework layer would be pure tax — and I control every allocation per frame myself.
D/02

Supabase + RLS instead of a custom Node backend.

chosen
Postgres with RLS policies, RPC functions in supabase/security.sql
instead of
A self-hosted Express/Socket server in a container
reason
All write paths go through RPCs with RLS — leaderboard, auth, sync. No containers, no deploy pipeline, no ops. For the expected user base the free tier is plenty, and when it grows, Supabase scales linearly with price instead of exponentially with my time.
D/03

LocalStorage as the source of truth, cloud additive only.

chosen
storage as the primary state, SyncService reconciles later against Supabase — records merge via Math.max, collections via union
instead of
Cloud as primary state, all writes blocking over the network
reason
Playable offline, no blocking launch, network errors cost sync latency — not game experience. The sync path is the only place where the network is allowed to hurt, and that is exactly where it is sandboxed. Fire-and-forget writes on the client are honest, the cloud is a bonus.
D/04

Nickname + PIN instead of email auth.

chosen
Unique nickname + 4–6-digit numeric PIN · rate limit 5/min, 30 s lockout
instead of
Email + password + magic-link flow
reason
Email auth is too much friction for a casual game and drags GDPR obligations along with it. Nick+PIN needs no verification infrastructure, and the friction is a single line. Trade-off: no recovery channel — consciously accepted and documented. Forget the PIN, start fresh. Casual game, not a bank account.
§ 05Highlights · interesting bits

Things that were not obvious.

Edge cases and details that only became clear while building.

GradientCache against GC stutter

H/01
Naively, BackgroundSystem fires dozens of createLinearGradient calls per frame — on older Androids that murders the GC and FPS collapses.

Fix: a GradientCache keyed by colors + dimensions. Invalidated only on theme switch. Together with a ParticleSystem object pool (swap-and-pop instead of Array.splice: O(1) instead of O(n)), fever mode stays at a constant 60 FPS on low-end devices.

Anti-cheat without an authoritative server

H/02
Score submissions are signed on the client — a 32-bit hash over (s, b, p, d, t) + VITE_SIGNATURE_SALT from env. The Supabase RPC submit_score stores the signature and runs its own plausibility checks: a score cap per block, a min time per block, perfects ≤ blocks, streak ≤ perfects, and a 5-submissions / 5-minute rate limit per nickname.

Not a perfect solution — the salt lives in the bundle, reverse engineering is possible. But: enough to keep fire-and-forget cheats off the public board without forcing game state onto a server. Pragmatically calibrated against the attackers who actually show up.

Seasonal events as data, not code

H/03
Themes live as pure data in src/data/themes/{standard,premium,seasonal}/ — one ESM module per theme with color phases, effect flags and counts. The SeasonalEventManager detects the date and activates themes + ThemeReactions automatically — flying bats at Halloween, snowflakes at Christmas.

New event = new theme file + a single entry in SeasonalEvents.js with a date window. No code change in the renderer, no deploy coordination. The boundary between content change and code change was drawn deliberately and preserved: what authors touch versus what developers touch has been separate since day one.

Reconcile with max() and union

H/04
SyncService pulls the cloud state on login, merges it against LocalStorage, and writes the result back. Per-field strategy: bestScore and statistics counters via Math.max, inventory and achievements as a union, equipped and settings from the cloud on first sync, local-first after that. coins use Math.max on the restore sync but stay local during an active session — otherwise the cloud would replay coins the player has already spent.

Consequence: playing on three devices loses no progress — high-water marks stay consistent, purchases and unlocks accumulate, and the most recent active session decides the loadout state. Tight enough to keep the conflict cases visible in the code — no hidden last-write-wins magic for someone to accidentally "optimize" later.
§ 06Stack · in production

What's running.

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

What I learned.

Project is live. These are the things I'm taking into the next ones.

Offline-first is a feature, not a fallback.

LocalStorage as source of truth decoupled the entire UX flow — no loading spinner, no "connecting…" blocker, no noise when the network wobbles. The sync path is tight, tested, and has clear failure boundaries. I'm taking this into every app that lives somewhere between "purely local" and "purely networked."

Backend-as-a-service until it hurts.

Free-tier Supabase freed me from ops entirely — no VMs, no nginx, no pg_dump cronjob. RLS replaces an API server I would otherwise have had to build myself. The boundary is clear: as soon as I need custom realtime game state, it stops working. But until then: every solo dev should evaluate this as the default before containerizing yet another Node server.

◦ NEXT CASE · 05 / 11
DefOrbit
← all projects