What changed, and why
A play-tester paid with a test card and got nothing — no runs, no feedback, no way forward. Two problems hid behind that one symptom, and three UX gaps made it worse. This change fixes the crediting bug and closes the gaps.
The bug: the Stripe flow worked end to end except the credit. The Supabase function that adds runs to a balance had an ambiguous SQL reference, so every paid webhook threw and no runs were ever added. The gaps: the balance was invisible (nothing showed how many runs you had), the packs were an easy-to-miss inline strip, and the return from Stripe gave no confirmation.
Blast radius is the monetization layer plus a route move: the game leaves / so a new landing page can live there. Gameplay logic is untouched. Read the credit fix first — it's the one that was losing money — then the four UX pieces.
The bug: a paid webhook that never credited
credit_runs adds a pack's runs to a device's balance, once per Stripe session. It returns a table whose columns include runs_remaining — and PL/pgSQL turns those OUT columns into in-scope variables. So the bare returning runs_remaining in the upsert was ambiguous: the table column, or the OUT variable? Postgres refused it with column reference "runs_remaining" is ambiguous, the function threw, and the webhook never credited.
The fix is one qualifier: balances.runs_remaining. In an INSERT … ON CONFLICT, the target table is referenceable by name in RETURNING, so this names the column unambiguously — the same shape the working spend_run uses.
The whole function; line 41 is the one-word fix.
supabase/migrations/0004_credits.sql · 44 lines
⋯ 11 lines hidden (lines 1–11)
There's no Postgres in CI, so a unit test can't catch a live SQL ambiguity. The fix was applied to the live database and verified by crediting the tester's stuck session through this exact function (it returned credited: true).
Runs made visible: an endpoint, a gauge, an account
You couldn't see your balance anywhere. Now a tiny endpoint reads it (granting the free trial on a brand-new device, so a first-timer sees their free run, not zero), and a persistent header gauge shows it.
Device-keyed; ensureDevice grants the free run on first sight.
server/api/balance.get.ts · 16 lines
The ◈ N runs gauge, and the account popover it opens.
components/RunsBalance.vue · 90 lines
⋯ 57 lines hidden (lines 34–90)
The gauge shows a dash until the balance loads, so it never flashes a misleading zero. The store reads the balance on mount and again whenever a run is committed, so the number stays live without a manual reload.
loadBalance + the modal/purchase actions; loadPacks caches only a non-empty catalog.
stores/game.ts · 1197 lines
⋯ 1061 lines hidden (lines 1–1061)
⋯ 79 lines hidden (lines 1119–1197)
Selling, made prominent (but not annoying)
The packs were a thin inline strip under the mission list — easy to miss. They move into a focused, dismissible modal that opens two ways: on demand from the header/account, and automatically the moment a commit is refused for being out of runs. It marks the cheapest per-run tier "best value".
The packs as ledger rows + the best-value mark; the Tab focus trap and buy/redirect.
components/RunPacksModal.vue · 153 lines
⋯ 27 lines hidden (lines 1–27)
⋯ 39 lines hidden (lines 61–99)
⋯ 11 lines hidden (lines 143–153)
The mission screen's paywall is now a short notice plus a button that reopens the modal (it auto-opens once on the refused commit; this is the way back if you dismissed it).
The paywall opens the modal instead of listing packs inline.
components/MissionSelect.vue · 132 lines
⋯ 49 lines hidden (lines 1–49)
⋯ 75 lines hidden (lines 58–132)
chooseObjective: capture the post-charge balance; open the modal on a 402.
stores/game.ts · 1197 lines
⋯ 1009 lines hidden (lines 1–1009)
⋯ 169 lines hidden (lines 1029–1197)
The return from Stripe
Checkout now sends the player back to the board with a query flag. The page reads it once, shows a confirmation banner, refreshes the balance, then strips the query so a later refresh can't re-fire the banner.
Redirects target /play (the game's new home).
server/api/checkout.post.ts · 56 lines
⋯ 43 lines hidden (lines 1–43)
Read purchase=success|cancel once, then strip the query.
pages/play.vue · 498 lines
⋯ 381 lines hidden (lines 1–381)
⋯ 94 lines hidden (lines 405–498)
The post-checkout banner (success / cancel), self-dismissing.
pages/play.vue · 498 lines
⋯ 46 lines hidden (lines 1–46)
⋯ 436 lines hidden (lines 63–498)
A landing page, and the route move
The game now lives at /play; / is a new static landing that orients a newcomer — the premise, how a turn works, and the hook that the first run is free — with a Begin CTA into the game. It's pure presentation: no store, no AI or cost calls.
The hero + Begin CTA, and the three how-it-works beats.
pages/index.vue · 104 lines
⋯ 11 lines hidden (lines 1–11)
⋯ 38 lines hidden (lines 34–71)
⋯ 16 lines hidden (lines 89–104)
Tests and verification
New unit tests cover the balance endpoint's free-grant contract, the store's loadBalance / openBuyModal / notePurchaseReturn (including that an empty packs read isn't cached), and the two new components — the gauge/popover and the modal (buy flow, best-value mark, and the Tab focus trap). The MissionSelect paywall test now asserts the modal opens. Full suite green (534) and nuxt typecheck clean.
Beyond green tests: the dev server was booted and every surface eyeballed — landing, gauge, account popover, modal on desktop and mobile, and the success banner. An independent adversarial review of the diff returned no critical findings; its three flagged issues (the banner-vs-webhook race, the empty-packs cache, and the modal focus trap) are the fixes folded in above.