-
-
Rice plate + one side, small budget: 2 of 5 pillars lit. Lauk suggests a free lime wedge or raw veg + chili, cheapest first.
-
Add raw veg + chili and the ring goes from 2 to 4 of 5 lit. Eat until satisfied, not until empty.
-
After the meal, one tap: still hungry, fine or satisfied. The week strip has no streaks and no red days.
-
Tap the meal already in front of you, then how much more you would spend: Free, Small, Medium or Treat.
-
Words Lauk never uses: calorie, diet, weight, guilt and more. A test fails if one ever appears. No camera, no account, no cloud.
-
Twelve plates in four decks. Counter is free (one check a day); Instant, Delivery and Cafeteria unlock through RevenueCat.
-
Decks, Unlimited with 7 days free, Restore purchases, and the pillars Lauk has learned you value, all on the device.
-
The whole flow on one card: tap the plate and a ceiling, see the hollow pillars, pick the cheapest add, watch two pillars fill.
Lauk is a ten-second plate check for people who buy every meal. Tap the plate already in front of you and what you would still spend; Lauk returns three cheap adds, cheapest first, that make that plate more satisfying. It never counts anything.
Google Play: https://play.google.com/store/apps/details?id=dev.edycu.lauk (in Google Play review) · Site: https://lauk.edycu.dev/ · Code (MIT): https://github.com/edycutjong/lauk · Video: https://youtu.be/Zt6rDbKOMFg
Built solo for Shipaton 2026 with Expo, React Native and RevenueCat. Entered in the Influencer Award — Nutrition & Healthy Eating, the RevenueCat Peace Prize and the HAMM Award.
Inspiration
A familiar budget lunch: rice and one fried side from the stall downstairs, eaten in four minutes, followed by a packet of instant noodles at four in the afternoon. The plate was not wrong. It was hollow in three of the five ways a meal satisfies, and a couple of thousand rupiah of raw vegetables and chili would have fixed two of them.
People who buy every meal — boarding-house students, junior office staff, delivery riders — are not short of food information. They are short of one decision they can act on at the counter, with the money they actually have. Most food apps answer a counting question or a cooking question. We wanted one that answers "I already bought this — what is the one cheap thing that would make it satisfying?"
Lauk is Indonesian for the side that goes with the rice. That is the whole idea: not a new meal, one better side.
What it does
- Tap the plate. Twelve universal plates across four decks — Counter (rice plate + one side, fried rice, noodle soup), Instant (instant noodles, bread + spread, a ready meal), Delivery (fried chicken + rice, burger + fries, pizza slices) and Cafeteria (a tray, sandwich + chips, a salad or grain bowl).
- Tap a ceiling. Free, Small, Medium or Treat — shown in your local currency.
- See the ring. Lauk maps the plate onto five satisfaction pillars — filling, fresh, rich, bright, crunch — and draws the hollow ones empty.
- Get up to three cards, cheapest first, from a hand-written table of 40 adds you can actually get at that kind of counter. Tap one: the ring fills, the phone gives one haptic, and a single line fades in — Eat until satisfied, not until empty.
- After the meal, tap one of three faces — still hungry, fine, satisfied. That is the only log in the app. There are no streaks, no missed days and no red. The faces nudge five pillar weightings (±0.15 per tap, clamped to 0.5–1.5, visible and resettable in Settings), so tomorrow's cards lean toward what actually satisfied you.
The reference query, exactly as the app ranks it (npm run check -- rice_side 2):
Rice plate + one side (nasi + 1 lauk) · Medium ≤ Rp 5k
plate filling ● fresh ○ rich ◐ bright ○ crunch ○ 2 of 5 lit
1. Lime wedge (jeruk nipis) — Free → lifts bright
after filling ● fresh ○ rich ◐ bright ◐ crunch ○ 3 of 5 lit
2. Raw veg + chili (lalapan + sambal) — Rp 2.000 → lifts fresh + bright
after filling ● fresh ◐ rich ◐ bright ◐ crunch ○ 4 of 5 lit
3. Fried egg (telur dadar / ceplok) — Rp 5.000 → lifts rich
after filling ● fresh ○ rich ● bright ○ crunch ○ 2 of 5 lit
Eat until satisfied, not until empty.
The plate you already have goes from 2 of 5 to 4 of 5 for Rp 2,000, and the user never sees a number about their body.
What Lauk never does is a screen in Settings, not a slogan. A 22-word denylist — the counting, dieting and shaming vocabulary — is scanned across every plate, every add, every UI string and the paywall copy on every test run; one hit fails the build. The brief asks for meals that are more satisfying "without calorie counting, macro tracking, or restrictive meal plans". In Lauk that is not a setting. It is the only mode there is, and CI enforces it.
No food database, no camera, no AI, no backend, no account, no cloud. The plate log (the last 60 checks) stays on the phone.
How we built it
- Expo 53 / React Native 0.79 / TypeScript, Android first, packaged for Google Play (signed release AAB).
- A pure core with zero React Native imports (
app/src/core/): the pillar model, the ranker, the face-driven weightings, the currency tier labels and the copy-lint. The app, a no-credential CLI (npm run check) and the benchmark import the same modules, so what the terminal prints is exactly what the phone renders. - Hand-authored content: 12 plates and 40 adds, seeded by
scripts/seed.tsand pinned with a content hash that the tests and the benchmark both check against the JSON on disk. - RevenueCat is the entire backend — twelve SDK calls in one file,
app/src/rc/purchases.ts(every call is listed in that file). Entitlement math is a pure function overCustomerInfo.entitlements.active(app/src/rc/access.ts) tested over all 16 entitlement combinations. - UI:
react-native-svgfor the pillar ring, React NativeAnimatedfor the cue (Reanimated was removed — one fewer native module),expo-hapticsfor the single haptic,expo-localizationfor the currency,@react-native-async-storage/async-storagefor preferences, weightings and the log. - Tests as the spec: 76 vitest tests in under a second, including a 150,000-query exhaustive verification of the ranker (every plate × ceiling × weighting: every card affordable, useful, distinct and cheapest-first — 0 violations), 1,000-run determinism, six regression tests each named after the defect it pins, and entitlement boundary tests (expired, purchase-record-only, sibling deck, look-alike id → all locked). 100% line coverage on the pure core, enforced.
- Benchmark:
npm run bench— 28,800 queries, p50 2.7 µs, p95 6.0 µs (measured 2026-09-26) against a 1 ms budget; fails on content-hash drift. - Harness: a 6-stage GitHub Actions pipeline (quality → security → Metro bundle with assertions that the RevenueCat calls are in the Hermes bundle → Android native build → bench → gate), CodeQL, gitleaks over full history, TruffleHog, Dependabot.
- AI coding tools were used throughout; every claim here is checkable from a fresh clone with
npm install --legacy-peer-deps && npm test.
Challenges we ran into
- Making "three useful cards" true for every input, not just the demo. Writing the content invariants exposed that the Instant deck had no Treat-tier adds at all, although the spec assumed at least two per tier. Three existing adds (a grilled chicken piece, a big fruit box, a tempe stir-fry serving) are now also available at Instant, and the invariants hold across all 150,000 queries.
- A stale entitlement read. The daily-cap check was reading
CustomerInfocaptured in a closure, so a purchase that had just landed could still hit the cap. The gate now decides on the refreshedCustomerInfo(commit232222c), and the regression is pinned by name. - Copy that lives outside the repo. Paywall text is authored in the RevenueCat dashboard, where the copy-lint cannot see it. It is written first in
docs/paywall-copy.md, linted in the same test, then pasted verbatim. - Universal plates from a local habit. The idea comes from Indonesian rice-and-side eating; the twelve plates had to read as ordinary to anyone, with the local names kept as a subtitle.
- Judges and store reviewers cannot always use a free trial. Unlimited carries a 7-day trial, and a promo code for full access is provided to judges in the submission form.
Accomplishments that we're proud of
- The core loop is one tap from a plate that was already bought to a better one: 2 of 5 → 4 of 5 for Rp 2,000.
- The brief's exclusions are a failing test, not a promise.
- 150,000 queries, 0 violations; 76 tests; no mock of the sponsor SDK anywhere and no demo or offline flag in the app.
- Take RevenueCat out and the app collapses to one deck and one check a day — the SDK is the engine, not a buy button.
What we learned
- RevenueCat Targeting plus a Placement replaces an if-statement: the app states one fact about the user (
eating_context) and the dashboard decides which offering they see.syncAttributesAndOfferingsIfNeededexists for exactly this — the very next placement read reflects the rule. - An exhaustive invariant test on a small content table finds real content bugs, not just code bugs.
What's next for Lauk
- Authored reference prices for more currencies (only IDR has exact prices today; elsewhere the ceilings are tier labels such as "≤ \$2").
- More plates, sourced from what people actually report eating — every new plate must pass the same invariants.
- A RevenueCat Experiment on the deck-versus-Unlimited offer once there are enough users for a readout to mean something.
Honest limits
- Every screen above has run on an Android 15 emulator (full functional run and a 1,000-event monkey test, 0 crashes). Purchases need the Play listing, which is in review; no real-money purchase has been made yet. The paywall and purchase in the demo video use a RevenueCat Test Store build of the same code (no money charged).
- Exact prices exist only for IDR; other currencies show tier ceilings.
- Paywall copy is dashboard-authored (linted in the repo before pasting).
=================== LUNKER — paste into https://devpost.com/software/lunker ===================
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
=================== NANTI — paste into https://devpost.com/software/nanti-mpghoc ===================
Nanti (Indonesian for "later") makes a correctly formatted Indonesian formal letter and hands you the PDF first. Only then does one rewarded ad go on a visible tab, capped at one, designed to be settled through RevenueCat Ads with AdMob server-side verification, or cleared for good with Pro.
- Google Play: https://play.google.com/store/apps/details?id=dev.edycu.nanti
- Landing page + privacy policy: https://nanti.edycu.dev/
- Source code (MIT): https://github.com/edycutjong/nanti
- Judge page: https://nanti.edycu.dev/judge.html
- Demo video (under 2 min): https://youtu.be/i9_0VmnGx8A
The Google Play listing is in review and may not load yet. The demo video was filmed on an Android 15 emulator, where no rewarded ad could load, so it shows the no-ad path; the Pro purchase in it uses a RevenueCat Test Store build (no money charged).
Entered for the Catvertising Award, the HAMM Award and the RevenueCat Peace Prize.
Inspiration
In Indonesia a lot of ordinary life runs on the surat: a one-page formal letter with a very particular shape. Sender block, city and date, Perihal, Kepada Yth., the body, Hormat saya. You need one to miss a day of work, to keep your child home from school, to let someone collect a document for you, to resign, to apply for a job. You usually need it on your phone, right now, and you usually send it over chat.
The letter apps that exist put a 30-second ad between you and the PDF. The ad is the toll you pay before you get what you came for, at the exact moment you are in a hurry. I wanted the opposite order. You get the value first, and the ad comes afterwards. It is small, visible, capped, and you settle it when it suits you.
What it does
- Pick one of eight letters: leave from work, leave from school, power of attorney (surat kuasa), statement (surat pernyataan), job application, resignation, annual-leave request, general request.
- Fill five fields. City and date are pre-filled in the Indonesian format. A live paper preview redraws the A4 letter on every keystroke.
- Tap Buat PDF. The PDF is printed on the device and the share sheet opens with a properly named file. No ad has appeared.
- Back on Home, an amber pill slides in: "Tab: 1 ad owed · pay later."
- When you start your next letter while the tab is open, a settle sheet offers three exits:
- Watch one rewarded ad. When the ad completes, AdMob's server-side callback is designed to reach RevenueCat, which grants one unit of a virtual currency called
ADS; the app reads the new balance, and the pill turns green and disappears. Not yet observed on a physical device (see G1 below). - Nanti Pro. A RevenueCat Paywall opens. Pro clears the tab for good.
- No ad available? You continue anyway. The letter is delivered and the tab stays at 1, never 2.
- Watch one rewarded ad. When the ad completes, AdMob's server-side callback is designed to reach RevenueCat, which grants one unit of a virtual currency called
The rule the app is held to: you never pay before a letter; every letter is delivered before its own ad; the one ad you owe is settled before the next free letter. The alternatives are never (Pro) or waived (no fill).
The letters are always in Indonesian because that is what the recipient expects. The app itself runs in Indonesian or English (auto-detected, with an override in Settings).
How we built it
Expo 54 / React Native 0.81, TypeScript, no backend of our own. RevenueCat is the only server the product depends on.
The tab is not counted by the app. It is a derived value:
debt = clamp(letters_exported − ADS_balance, 0, 1)
letters_exported is a local SQLite count. ADS is a RevenueCat virtual currency that the app only reads (getVirtualCurrencies()). Settling an ad follows RevenueCat's React Native reward-verification flow:
-
Purchases.generateRewardVerificationToken(impressionId)binds the impression to this customer before the ad is requested. - AdMob
RewardedAd.createForAdRequestis called withserverSideVerificationOptionscarrying the token, so AdMob's SSV callback goes to RevenueCat. - On
EARNED_REWARD,Purchases.pollRewardVerification(clientTransactionId, …)waits for RevenueCat to confirm the server-side grant. - The balance is re-read with
getVirtualCurrencies(). Only this read clears the tab. If verification fails, nothing happens and the tab stays.
The whole ad lifecycle is reported to RevenueCat Ads through Purchases.adTracker (trackAdLoaded, trackAdDisplayed, trackAdOpened, trackAdRevenue with impression-level revenue, trackAdFailedToLoad), keyed by the same impressionId the reward token was minted for. This is built to put every settled ad next to every Pro purchase in the same RevenueCat customer.
Pro is the RevenueCat entitlement pro. It is sold through RevenueCatUI.presentPaywall with the offering from getCurrentOfferingForPlacement('tab_locked'). getCustomerInfo() + addCustomerInfoUpdateListener gate the tab, and restorePurchases() sits in Settings. After every export the app calls setAttributes({ letters_made, writer_tier }) + syncAttributesAndOfferingsIfNeeded(), so a RevenueCat Targeting rule can show heavy writers (3+ letters) a lifetime-first offering.
Letters are eight pure TypeScript functions that return HTML (packages/surat). They are golden-tested byte-for-byte and rendered to PDF with expo-print. The same package ships as a CLI (npx tsx bin/surat.ts demo 1) so anyone can inspect a letter without a phone.
Proof, not promises, all reproducible from a fresh clone without credentials:
- 81 tests pass, including 8/8 golden templates.
npm run ablationgreps the source for any client-side settle increment and must print 0.- The settle state machine is walked over 299,592 event paths.
settledis reached on 504 of them, every one throughverified. - The ledger is checked exhaustively over 70,602 transitions: no client event can lower the debt.
- 100% line coverage is enforced on the pure modules (templates, ledger, settle machine, i18n).
- CI runs in 6 stages: quality → security (CodeQL, gitleaks over full history, TruffleHog) → Metro bundle → Android native build → bench + offline verification → gate.
On-device verification (G1): not yet run on a physical phone. On the emulator no rewarded ad could load, so the app took the no-fill path: the letter is delivered and the tab stays at one (shown in the demo video).
Challenges we ran into
- RevenueCat Ads on React Native is beta and has no AdMob adapter, so the whole settle path is the manual one. We generate the impression id ourselves, attach the RevenueCat token to AdMob's SSV options, poll for the verified result and refresh the balance. The code follows RevenueCat's published React Native sample and is checked against the SDK's typings.
- The native build took four attempts. Expo 53 failed because
react-native-google-mobile-ads16.5 needs RN 0.80+ codegen. On Expo 54,play-services-ads25.x ships Kotlin-2.3 metadata against a 2.1 toolchain. Pinning an older ads SDK broke the library's own source. The fix is one compiler flag, packaged as a unit-tested Expo config plugin so it can't regress. - The round-trip can't be exercised early. AdMob's SSV can't be enabled on Google's sample ad units, so no end-to-end run is possible until a real AdMob unit exists. The app never fakes it: on the sample unit every settle correctly ends in verification failed and the tab stays.
- Restraint. The no-ad-available path had to let the letter through without turning into a loophole. The cap of one solves it: at worst you owe one ad, never two.
- We logged four developer-experience findings from the typings and docs (for example, a token docstring that disagrees with the guide's sample, and mediator names typed as plain
string). They are indocs/DX-REPORT.mdfor the RevenueCat team.
Accomplishments that we're proud of
- An ad users don't hate, in the judges' own words. It comes after the value, it is visibly capped at one, and it pays for a letter you already sent. It never gates the one you are writing.
- A tab the client cannot game. No code path marks an ad as paid locally, and the tests prove it (ablation 0; 299,592 paths; 70,602 transitions).
- Built so ad revenue and subscription revenue land in one RevenueCat customer, keyed by the same impression id.
- Eight letters, golden-tested byte-for-byte. Zero backend of our own.
Pricing and results
Nanti Pro is $2.99 a month with a 7-day free trial, or $9.99 lifetime; letters stay free, with one rewarded ad owed per letter at most. Pre-launch: the app is in Google Play review, so there is no revenue yet.
Who it helps
The people who need a surat fastest — a parent keeping a sick child home, a worker who has to miss a shift, someone sending a relative to collect a document — are often on a cheap phone and short of time and data. Nanti gives them a correct letter first, free and with no account, and never makes them sit through an ad to get it.
What we learned
The order of value and payment is a product decision, not an ad-network setting. Moving the ad from before the PDF to after it changed everything downstream: the cap, the tab, the need for a server-verified ledger, and the reason Pro exists.
We also learned to treat a reward as something the server grants and the client only reads. That one rule made the whole design testable.
What's next
- More letter types by request, and a saved sender profile.
- Running the rewarded-ad round-trip on physical phones and publishing the measured settle latency (p50/p95).
- iOS. The code is plain Expo, but this entry is Google Play only.
Built With
- android
- async-storage
- expo-haptics
- expo-localization
- expo.io
- github-actions
- google-play
- react-native
- react-native-purchases
- react-native-purchases-ui
- react-native-svg
- revenuecat
- typescript
- vitest
Log in or sign up for Devpost to join the conversation.