Biblical Battle Plans (opens in a new tab) is a Bible reading tracker built like a role-playing game: reading plans, streaks, XP, guilds, and a journal. It started as a web app. It now has a native iOS app in TestFlight, and that app only exists because of a rebuild I did for a different reason.
Why version one could not have an app
Version one hit its limits around a hundred users. There was no test environment, so every change was tested on the real thing. Worse, the rules of the game lived in database queries. How a streak is counted, when a shield saves it, how many chapters a reading is worth: all of it was spread across queries only the web app knew how to call.
You cannot put a second client on top of that. A native app would need its own copy of every rule, and two copies of a rule drift. I had already seen it happen inside the web app alone, when a client-side copy of the chapter math drifted from the server's and inflated the streak card by four chapters.
What version two changed
Version two is a Turborepo. The web app is Next.js, the database is Neon through Drizzle, and the rules live in one services package with tests next to them. The API routes are thin: check who you are, validate the input, call a service, return the result.
I made that move for the web app's sake, but it is exactly what a native app needs. The SwiftUI app calls the same routes the web app does. One place computes a streak, and both apps ask it.
Auth is the one place the clients differ. The web uses NextAuth sessions. The iOS app gets a short-lived access token and a refresh token, kept in the Keychain. Every route checks for a bearer token first, then a session, and an invalid bearer token fails the request instead of falling back to a cookie.
Types that cannot drift
The contracts live in TypeScript: response shapes in a shared types package, request bodies as Zod schemas. The Swift is generated from them. One script reads the TypeScript types with the compiler API and writes Swift Codable structs. Another walks the Zod schemas at runtime and writes the matching request structs. The level table, the XP for each level and which tier of hero it earns, is generated the same way.
Generation does not help if someone forgets to run it, so CI runs it and fails if the output changed:
pnpm run sync:ios-types
git diff --quiet -- apps/ios/BiblicalBattlePlans/Sources/Core/Models/Generated
Change a response type for the web without regenerating the Swift, and the pull request goes red. The Xcode project gets the same treatment. It is generated from a project.yml with XcodeGen, and CI regenerates it with a pinned, checksum-verified XcodeGen, fails on any difference, then builds and tests on a simulator. macOS runners bill at ten times the Linux rate on a private repo, so that job only runs when something under apps/ios changes.
What was hard
Drift the gate could not see
When I started porting the core reader, the iOS endpoint list had drifted badly. Eight endpoints pointed at routes that did not exist, and the dashboard fetched a stats route that had never been built, so it failed on every load. The iOS client also sends snake_case bodies that some web routes did not accept.
Two passes fixed it. The first reconciled the endpoints and taught the routes to accept both casings. The second narrowed loose response types into exact ones, which fixed two bugs in the web app along the way. A second client asks questions the first one never did.
Fixtures from the real server
Generated types prove the Swift matches the TypeScript. They do not prove the server returns what the TypeScript says. So a script signs in to a local dev server, creates the state it needs (a guild, chat, a started plan, a journal entry, today's reading), and saves the real JSON responses as fixtures. A Swift test decodes each one into its generated type.
The script refuses to run unless the server is using the local database, and a second run writes identical files. Fixtures that churn on every run teach you to ignore them.
Porting the pixel art
The web app's look is retro: a pixel font, panels with hard shadows, and an animated sprite for each tier of hero. I ported every color, radius, and shadow from the web's stylesheet into a SwiftUI theme by hand, with pixel type for titles and stats and the system font for anything you actually read.
The sprites read the same sheet table as the web, draw nearest-neighbor so the pixels stay crisp, and pause under Reduce Motion. Porting them turned up a web bug: thirteen rows of the shared table listed more frames than the image draws, so those loops flashed blank frames on both platforms. The Squire's damage animation, the one you see when a streak breaks, was blank half the time. I fixed both tables together and added a test that the last listed frame of every row has art.
One more lesson: XcodeGen silently ignores a resources key at the target level, so for a while the font and asset catalog were simply not in the app. A test now fails if they stop shipping.
Push notifications, twice
The push pipeline had never worked in any build. On the phone, the permission prompt was never requested and the entitlement Apple requires was missing. On the server, sends went through fetch, which speaks HTTP/1.1, and Apple's push service only speaks HTTP/2. No notification ever reached Apple, and any failure deleted every iOS device token.
The server now sends over Node's http2 module and only deletes a token when Apple says it is dead. On the phone, I stopped asking cold. Onboarding ends with a reminders step that explains what you will get before iOS shows its prompt, and installs that had already allowed notifications get a one-time backfill so they keep their reminders.
Signing and TestFlight
The release script archives, exports, checks the build for the production push entitlement and the www universal link domain, and only then uploads. Apple does not follow the redirect from the bare domain when it checks that link, so www is the one that matters.
The first real upload still failed. My App Store Connect API key had the App Manager role, which cannot use cloud-managed distribution signing. The script now signs with an Account Holder or Admin account signed in to Xcode, and that is how the first internal build went up.
Where it stands
The app is in TestFlight with internal testers, iPhone only. It is not in the App Store. It has three tabs, Home, Quests, and Hero, with reading check-offs on the dashboard, side quests, the church calendar, the animated tier sprites, and reminder settings. Guilds, chat, the journal feed, and achievements are not on iOS yet. Sign in with Apple, moderation for guild chat, and final icon art are being built now, ahead of external testing and a store submission.
What I would tell myself at version one
Put the rules in one place before you need a second client. I rebuilt Biblical Battle Plans so I could test it and stop breaking production, and the iOS app is the second payoff of that decision. Most iOS features have been SwiftUI and very little new logic, because the logic was already tested and behind an API.
The rest is discipline you can automate: generate the types, fail the build when they drift, decode real responses in tests, check the build before you upload it. I build with Claude Code, and those gates matter more when code gets written fast. They let me move quickly on two platforms without keeping two versions of the truth.
The project's case sheet has the rest.
Relentlessly curious. Unreasonably willing to build.