Inspiration
I installed the biggest gamified lifting app on the store and it handed me a character sheet in ninety seconds. Strength 12. Endurance 12. Agility 14. I hadn't lifted anything yet. The numbers came out of a questionnaire. I trained with it for two weeks and they never moved, because there was nothing underneath them that could move.
That was the entire brief for ZENKAI, and it fits in one rule:
No number in the game layer may exist unless it was computed from a set you actually logged.
No login-streak XP, no invented quests, no rank from a quiz. If your stats go up, it's because your lifts went up.
It reads like a design preference. It's an architectural constraint, and it decided nearly everything that followed, including the parts of the app that have nothing to do with the game layer. If the character sheet can only be built from logged work, then the app has to be good enough at programming and at in-gym logging that there is real work to build it from. The RPG layer is the reward. The training engine is the product.
What it does
- Builds a real periodized program. A 49-slide onboarding collects your goal, level, equipment, available days, injuries and starting strength. A deterministic TypeScript engine (117 exercises) outputs a full 8-to-12-week block: split, exercises, sets, reps, starting loads, rest, progressive overload, deload weeks, all of it before you walk into the gym.
- Programs supersets and circuits, not just straight sets. You declare a training
style at onboarding (
straight/supersets/circuits) and the generator pairs or triples non-interfering movements to fit real work into the time you actually have. A superset and a circuit are the same primitive at two sizes. Heavy primary lifts are never grouped automatically: those are the lifts whose load progression feeds Strength, and they need full rest to be worth counting. - Guides every session. One exercise per screen, a set logged in two taps, big targets, no keyboard. Automatic rest timer with haptics and a background notification. Grouped exercises are driven round by round. Fully offline.
- Turns work into a character sheet. Five stats (Strength, Endurance, Volume, Recovery, Discipline) recomputed from your logged sets. LEVEL only goes up; RANK goes up and down. 19 titles, 41 achievements, all earned. Every formula is visible in-app, with your own numbers substituted into it.
- Proves progress. Estimated 1RM and a strength curve per exercise, personal records, a volume heatmap by muscle group, a bodyweight chart, a Sunday recap, full exportable history.
- Regenerates at the end of a block from the loads you actually logged. A recalculation, not a reset.
How I built it
Expo SDK 54 / React Native 0.81 / TypeScript strict. The interesting part isn't the stack, it's how far that one rule propagated into it.
Derived, never stored. Because no number may be invented, nothing in the game layer
can be a field you write to. Stats, LEVEL, RANK, titles and achievements are recomputed
from set_logs on every read; a stored snapshot exists only so the app can show you the
delta after a session. There is no addXP() anywhere in the codebase. There is nowhere to
add it to.
Recomputation forces locality. Rebuilding a character sheet from your whole logging history on every read is only free if that history is on the device. So SQLite + Drizzle is the source of truth, not a cache, and Supabase is a best-effort mirror. That turned out to match the real environment exactly: gym basements have no signal, and the app never asks for any.
The engine is pure TypeScript. src/engine/ has zero React imports and zero network
calls: onboarding answers in, a full block out. It runs in a console, which is how it gets
tested: a reference suite pushes 12 profiles through the generator on every change
(beginner at home with no equipment, advanced 6 days a week, a declared lower-back injury,
an 8-week horizon, a 6-month horizon, a supersets profile, a circuits profile, and a
degraded case with no strength data at all), and engine:check fails on domain violations
such as weekly volume above the ceiling for a training level.
How I used RevenueCat
- The SDK is the single source of truth for entitlement.
react-native-purchasesis isolated in one boundary file (src/lib/purchases.ts); no screen imports the SDK, and no screen ever reads a price from the code when RevenueCat is configured. - Configured once at startup with the device id as
app_user_id. ZENKAI has no accounts and no login screen. RevenueCat identifies the anonymous device, and Supabase Auth is only offered after a purchase. - A hard paywall gates the whole app. The access guard reads the
proentitlement fromCustomerInfo; without it the app is locked to a preview. - Two offerings, three products. The main offering exposes
zenkai_weeklyas the price anchor andzenkai_yearlyas the real offer. On the RevenueCat path, no price is read from the code: amount, currency and period come from the offering at runtime, and the only figures in the source sit in a mock that exists so the screens still render without an API key. That is why this paragraph can't quote you a figure either. A second offering (zenkai_yearly_offer) backs a downsell shown after a refusal, with a countdown that is persisted, so killing the app doesn't reset it. A fake deadline is the same lie as a fake stat. - Restore purchases is wired on both the paywall and the profile screen.
Challenges I ran into
Making supersets count. Adding grouped exercises reopened a question I thought was
settled: does one round of a superset count as a hard set? If it doesn't, the engine
stacks more sets on top of work you already did and the plan silently overshoots. If a
45-minute circuit does, weekly volume blows past the ceiling that engine:check treats as
a blocking error. The answer was to split the two: a round of a strength superset counts
as one hard set per member (an antagonist pairing is real strength work, each movement
goes to the same RIR and neither limits the other), while a time-capped circuit counts for
zero, like a finisher. The tell that it was the right rule is what it cost: sets on a
grouped exercise is simply the number of rounds, which is what sets had always meant.
Volume accounting, the session progress bar and the history log needed no changes at all.
Keeping the game layer honest under refactoring. "Every number is earned" is easy to write and easy to erode: one cached rank field added for performance and the whole thesis is gone. Keeping recomputation as the only path meant accepting that a snapshot is never authoritative, only a photograph used to display a delta.
Zero network on the entire conversion path. Onboarding, plan generation and the paywall all run before any account exists and before any request is made. That's a hard constraint on the generator: it has to produce a full 8-to-12-week block on-device, fast enough that the reveal reads as computation rather than as a loading screen.
Accomplishments I'm proud of
- A rank that can go down. RANK falls when you stop training. It is the single mechanic most likely to cost me a subscriber, and it is the reason the other numbers mean anything at all. Keeping it in was the hardest product call of the build.
- The app shows its own math. A transparency screen prints every formula behind every stat, with your own numbers substituted into it. A gamified app is supposed to hide that; ZENKAI's entire claim collapses if it does.
- The full loop ships in one release. Onboarding, plan generation, the in-gym logger, the character sheet, the end-of-block regeneration and the paywall are all live in the same build, and I built it solo. Nothing in the demo is a mock.
- Nothing in the funnel is fake. No countdown that resets when you reopen the app, no projection presented as a promise. The rule that governs the stats governs the sales pitch too.
What I learned
- One rule stated early is worth more than ten features. "Every number is earned" was a sentence written before any code. It then answered questions I hadn't asked yet: how to store stats (don't), where the data should live (on the device), whether to fake a countdown on the downsell (no). Decisions that would have been arguments became lookups.
- The cheapest implementation is evidence that the domain rule is correct. The
superset counting rule required no change to volume accounting or to the session UI,
because it finally said out loud what
setshad always meant. When the rule you believe in turns out to be expensive to implement, it's usually the wrong rule. - Determinism is a testing strategy, not a purity exercise. A pure engine with fixed inputs makes a full generated block a reviewable diff. Shipping grouped exercises was safe because the ten pre-existing reference profiles generated byte-for-byte identical output afterwards.
- Draw the paid boundary before you need it. Isolating RevenueCat in one file from the start let entitlement behave like what it actually is, an external truth that changes on its own through a restore or an expiry, without a single screen having to know.
Built With
- drizzle-orm
- expo-router
- expo.io
- react-native
- react-native-purchases
- reanimated
- revenuecat
- sqlite
- supabase
- typescript
