-
-
The reel at Willow Lake, 0:57 left on the timer ring. Hold to pull, release to give line; 0.0s of 6s held so far.
-
The Water: Willow Lake (up to Rare) and Reed Shallows are open; Quarry Pool costs 1,200 coins; Deep Sea needs the Angler's Pass.
-
Same reel with 0:03 left: the timer ring has turned orange and the hold counter still reads 0.0s of the 6s needed.
Lunker is a fishing game where the notification is the game. The fish bite while the app is closed: the push is your rod twitching, and you have 60 seconds to tap in and win the reel before the catch escapes. Built on OneSignal and RevenueCat.
- Code: https://github.com/edycutjong/lunker
- Google Play: https://play.google.com/store/apps/details?id=dev.edycu.lunker
- Landing page: https://lunker.edycu.dev/
- Live ledger: https://lunker.edycu.dev/verify/
- Demo video: https://youtu.be/RSfPnZU19P8
The Google Play listing is in review and may not load yet. The demo video was filmed on an Android 15 emulator; the reel is started from "Cast now", and that fish escaped.
Inspiration
Every game I have installed sends me the same notification: "Come back for your daily reward!" I mute it inside a week, and so does everyone else, because it carries no stakes. The thing it announces will still be there in six hours.
Real fishing is the opposite. The whole skill is being present at the moment the line moves. You cannot schedule a bite; you can only be there or not.
So I asked what would happen if the notification were not a reminder about the game but the game itself. The notification IS the game mechanic.
What it does
You install Lunker, pick a lake, and close the app.
Later, on a train or while making dinner, your phone buzzes: "Your rod is twitching at Willow Lake — 60s before it escapes." Tap it and you land directly in the reel, not on a home screen, with the clock already running. A needle moves across a tension bar; hold it inside the moving green zone for six seconds, with a haptic tick on every zone crossing, and the fish is yours. Miss, and it throws the hook and swims off.
Landed fish fill an album by rarity and pay out COIN. COIN unlocks Quarry Pool (1,200 COIN). The Angler's Pass subscription (7-day free trial) opens the Deep Sea, the only lake whose table holds Legendaries. Each lake has its own fish table and its own bite hours, so which notifications you get tomorrow depends on what you did today.
Delete OneSignal and there is no bite, so there is no game — not a degraded game, no game.
How we built it
The design question was: where does a bite come from, and who is allowed to decide what you caught?
Client: React Native 0.79 on Expo 53, TypeScript, Android only. Server: one Cloudflare Worker (7 routes plus a cron handler) over a Cloudflare D1 database with 5 tables. The lake table, fish table and reel-tension model live in one shared/ module imported by both the app and the Worker, so the two cannot disagree about what a Moonlight Koi weighs.
The bite is sent by a cron handler, and that was the key decision. Every fifteen minutes the Worker asks which players are due: at most one bite an hour, never before 08:00 or after 23:00 local time, capped per lake per day, weighted by whether the lake fishes by day or by night. For each due player it writes the roll seed to D1 first, then sends the push through the OneSignal REST API with a 60-second TTL and a deep link into the minigame. The ordering is the point: the seed has to exist before the notification leaves, because it is what makes the catch verifiable afterwards, and a OneSignal Journey cannot write a row to my database before it sends.
On the device, OneSignal owns identity and reach: OneSignal.initialize and OneSignal.login with the same user id RevenueCat uses, Notifications.addEventListener('click') for the deep link, User.addTags for current_lake, streak_days, unlocked_count and rare_count (the streak is computed server-side from your local calendar days), and an in-app permission prime triggered with InAppMessages.addTrigger after your first landed catch. The native prompt fires only from the prime's own accept button. The Worker also fires two OneSignal custom events, rare_landed and lake_unlocked.
The economy is server-settled. When you land a fish, the app posts to /catch-resolved and does not say what it caught. The Worker rolls the fish against the committed weight table using the seed it stored before the push, grants COIN through RevenueCat's Virtual Currency REST API (v2), and returns the result (no catch has settled on a device yet). A client-supplied fish, rarity or coins is ignored, and a test asserts it. The HUD balance is a read of RevenueCat's ledger via Purchases.invalidateVirtualCurrenciesCache() then Purchases.getVirtualCurrencies(), never a local counter. Spending is an atomic server-side debit; an insufficient balance returns a 422 the app shows as "1,200 COIN — you have 840".
Purchases run through Purchases.getOfferings() and Purchases.purchasePackage(): three consumable coin packs and the Angler's Pass subscription. The Deep Sea is gated by RevenueCatUI.presentPaywallIfNeeded against the anglers_pass entitlement, and the Worker re-checks the entitlement, so the paywall is not enforced only in the app. Purchases.restorePurchases() covers reinstalls. The paywall itself is designed in the RevenueCat dashboard; there is no paywall UI in the codebase. Purchase events come back to the Worker as a webhook, HMAC-verified over the raw body with event_id as the primary key, so retries are idempotent. That table is what /verify renders (0 purchases to date).
/verify is where I would point a judge first. It is a read-only, unauthenticated page that computes the answer-rate statistic from the live ledger with the same computeBench function the CLI runs, so the page and the command cannot disagree. As of 2026-09-30 it shows 145 bites sent to 7 testers and none answered within 60 seconds yet — the denominator is honest.
Built solo, with Claude Code doing most of the implementation. The judgement calls were mine: what a bite costs a player in attention, and who gets to decide what you caught.
Challenges we ran into
The 60-second window was not 60 seconds. The minigame clock advanced by the clamped frame delta, which is right for the physics and wrong for the countdown. On a phone dropping frames, the fish escaped after 80 real seconds at 15 fps and 120 at 10 fps, and a player could background mid-bite for ten minutes and come back to a full window. The existing "frame-rate independent" test could never have caught it, because 30 and 120 fps both sit under the clamp. There are now two clocks — one for physics, one for real time — and tests at 60/30/15/10/5 fps plus a ten-minute background.
The app typechecked green for weeks and could never have been built. The client imports shared game logic from the repo root. tsc resolved those imports fine; Metro, the React Native bundler, roots itself at app/ and refused. The first assembleRelease died in bundling. CI had been certifying "the types are consistent", not "the app builds". The fix was four lines of Metro config; CI now runs the bundler and greps the fish table out of the output.
A billing failure silently deleted the game. Boot configured RevenueCat, awaited its login, and only then initialised OneSignal, with no error handling. Any RevenueCat rejection meant that player never received another bite. Push was downstream of billing, which is backwards for a game whose premise is push. OneSignal now initialises first and every RevenueCat call is guarded on its own.
The economy I called unforgeable was forgeable. An adversarial audit wrote working exploits: the replay guard trusted a client-supplied key, and eight parallel claims produced eight RevenueCat grants. Both money routes now reserve the row before calling RevenueCat, so the reservation is the lock.
Accomplishments that we're proud of
- The client cannot forge a catch or a coin. Fish are rolled server-side from a seed written before the push; the HUD only reads RevenueCat's balance. A replayed or parallel catch calls RevenueCat exactly once, and tests assert both.
- The answer-rate number is reproducible by a stranger.
node scripts/lunker-verify.mjs bench --source remote --url https://lunker.edycu.workers.devruns the samecomputeBenchas/verify. The window is measured from send, and unanswered bites stay in the denominator, which makes the number worse on purpose. - 274 tests across 13 files, run on Node 22 and 24, including route contracts against real SQLite running the real migration, and the bench arithmetic against a fixture whose answer was computed by hand before the code existed.
- The repo argues with itself and loses.
README.mdcarries a dated "What we got wrong" section: a method that does not exist, a streak nothing incremented, a permission prime that did not prime.
What we learned
A green check mark is worth exactly what it checks. Typechecking is not building, and a "frame-rate independent" test that never drops a frame proves nothing. I also learned that in a push-first product the notification stack has to survive everything else failing — billing included — and that the server, not the phone, has to own anything worth money.
What's next for Lunker
Honestly listed, because the repo lists it too:
- Lifecycle Journeys: the tide reminder (only if no bite was answered that day), the milestone catch on
rare_landed, and a lapsed-angler win-back. The events and tags they branch on already ship; the Journeys themselves are dashboard work I have not finished. - The RevenueCat → OneSignal integration, so win-backs can branch on a lapsed Angler's Pass.
- More lakes and seasonal fish tables, and a bite cadence that adapts per player rather than per lake.
Thanks for reading. If you only have a minute, watch the demo video and open /verify.
— Edy
Built With
- cloudflare-d1
- cloudflare-workers
- codeql
- eslint
- expo-haptics
- expo.io
- github-actions
- gitleaks
- onesignal
- prettier
- react-native
- react-native-svg
- react-navigation
- revenuecat
- sqlite
- typescript
- vitest
Log in or sign up for Devpost to join the conversation.