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.
Why this exists.
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.
The box it had to fit in.
How it runs.
Four deliberate choices.
Vanilla JS + Canvas instead of React / Three.js / Phaser.
GameLoop with delta-time, modular systems/entitiesSupabase + RLS instead of a custom Node backend.
supabase/security.sqlLocalStorage as the source of truth, cloud additive only.
storage as the primary state, SyncService reconciles later against Supabase — records merge via Math.max, collections via unionNickname + PIN instead of email auth.
Things that were not obvious.
GradientCache against GC stutter
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
(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
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
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.
What's running.
What I learned.
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.