Shipaton 2026 · Keep Them Coming Back · Best Game

The lake texts you when a fish is biting.

You mute every game within a week — none of their pushes were ever worth it. Lunker sends one kind: a bite. Tap within 60 seconds, hold the needle in the zone for six seconds, and the fish is yours. Miss it and it escapes.

View on GitHub Pitch deck Live ledger Get it on Google Play
ttl: 60the push expires on its own· 274 tests
274
tests, on Node 22 and 24
npm test · across 13 files · real SQLite
60s
then OneSignal drops the push
ttl: 60 · worker/src/lib/onesignal.ts
6s
of steady hold lands the fish
requiredHold: 6 · shared/tension.js
1,200
COIN for Quarry Pool, debited server-side
cost: 1200 · shared/content.js
—
of bite pushes answered within 60s
Loading from /verify…
—
median open latency
Measured from send, not from open
Reading the live ledger… These two are read live from /verify on every visit, so there is nothing here to type in by hand. OneSignal's first real bite goes out after the 2026-09-26 fix.
Lunker — the reel at Willow Lake with 3 seconds left, the ring turned amber Lunker — the reel-tension minigame at Willow Lake, 37 seconds left, hold 0.0 of 6 seconds 0:37 · reeling0:03 · about to escape
The bite · what leaves the Worker

Tap it, and you are already reeling.

The deep link opens the minigame, not a home screen. The roll seed is written to D1 before this request leaves, so the phone never gets to name its own fish.

OneSignalClient.sendBite()POST /notifications
"name": "bite:willow",
"include_aliases": { "external_id": ["…"] },
"contents": { "en": "Your rod is twitching at Willow Lake\n60s before it escapes." },
"app_url": "lunker://bite/willow?nid=…",
"collapse_id": "bite:…",
"ttl": 60
Screens: the release APK on an emulator, 2026-09-26. Payload: worker/src/lib/onesignal.ts. OneSignal has sent 0 bites so far.
The bank · how it works

A game that only speaks when it matters.

One mechanic, one loop. Every rule below is enforced by the server, and every chip names the exact call.

Bite times, as the dispatcher enforces them

Every 15 minutes the cron asks who is due. A lake only bites in its own hours, never while you are asleep, and never twice within the hour.

Willow Lakefree · 08–19 · 3–5 a day · up to rare
Reed Shallowsfree · 08–23 · 2–4 a day · up to uncommon
Quarry Pool1,200 COIN · 08–23 · 2–3 a day · up to rare
Deep SeaAngler’s Pass · 18–23 · 1–2 a day · up to legendary

may biteasleep — no lake bitesnever two bites within 1 hourrendered from isWithinCadence() · worker/src/lib/bite.ts

The push is the timer

"60s before it escapes" fits Android's ~65-char budget, and OneSignal drops the push at 60 s. Two bites can never stack.

ttl: 60collapse_id

Your phone never names its fish

/catch-resolved has no fish field. The server rolls from a seed it wrote before the push left.

HMAC-SHA256(notification_id)

Your phone never moves its balance

Every grant and spend is a server-side RevenueCat Virtual Currency call; if RevenueCat does not confirm, the catch comes back settled: false. The HUD re-reads after invalidateVirtualCurrenciesCache().

RevenueCat VC REST v2getVirtualCurrencies()

Two ways into the next lake

Quarry Pool for 1,200 COIN, debited atomically. Deep Sea with the Angler’s Pass entitlement. Each unlock fires a OneSignal event a Journey can enter on.

anglers_passlake_unlocked
Lunker — The Water: Willow Lake, Reed Shallows, Quarry Pool for 1,200, Deep Sea with Angler's Pass
The logbook · honest by design

What we won’t tell you yet.

“When this section was written (2026-09-26) the ledger held no rows, and a figure here would be a guess.”

— README · Engineering Rigor

“Which is why OneSignal had sent zero notifications. CI was green because the suite stubbed fetch with arrow functions.”

— README · What we got wrong

“Unanswered bites stay in the denominator … ignoring a bite counts against us.”

— README · Engineering Rigor
Questions from the bank

What a judge would ask.

Has a real bite been sent yet?

No — OneSignal has sent 0 notifications. Until 2026-09-26 the Worker called fetch with the wrong this, and the Workers runtime threw Illegal invocation on every request. It is fixed, and tests/fetch-binding.test.js fails on the old code.

Why is the answer-rate number empty?

Because the live ledger has no rows yet. The number is read from /verify on every visit and is never typed in; an empty ledger renders as “no data yet”, not as a guess.

Is COIN real?

The server path is built and tested: grants, spends, the atomic debit and the settled: false refusal. The COIN currency has not yet been created in RevenueCat, so no grant has settled. When it is, the balance you see is RevenueCat’s word, not the phone’s.

Can a modified APK claim a Legendary?

No. The client never sends a fish. The server rolls against the lake’s committed table with a seed derived from HMAC-SHA256(notification_id, ROLL_SERVER_SECRET), written before the push leaves.

What happens if I ignore the push?

The fish escapes and your streak survives. The bite still counts: its telemetry row is written at send time, so an unanswered bite stays in the denominator of the number we lead with.

Can I play it today?

Not from a store yet — the Google Play listing is not live, and there is no demo video yet. The buttons say so instead of 404-ing. The code, the tests and the ledger are public now.