Case study · iOS app

SPT·50

The license-plate game is a road-trip standby: spot all fifty states before you get home. Nobody ever finishes. SPT·50 keeps the game running after the trip ends — asynchronous multiplayer played over weeks or months, in several games at once, with people who were never in your car. It is on the App Store.

  •   on the App Store
  • SwiftUI · Firebase · iOS 18+
  • shipped: 2026-08

View on the App Store

A collaborative game board in SPT-50: a Summer Road Trip game at 26 of 50, a Spotted a Plate button, a US map with the claimed states filled in green against unclaimed cream, and a feed of the family's latest spots — Idaho by Dad two hours ago, Minnesota by Mom, Wisconsin by Maya.
A collaborative board: one map for the whole group, filled from the same spot documents a competitive game would render as separate boards. The map is SVG paths compiled at build time, not a projection computed on the phone.

One dataset, two games

A game runs in one of two modes. Competitive gives every player their own board of fifty and turns it into a race. Collaborative gives the whole group one shared board to fill together. They feel like different games, and the obvious implementation is two of everything: two schemas, two write paths, two sets of rules, two sets of bugs.

The mode never reaches the data. Every spot is a document in one subcollection under its game, written the same way whoever claimed it and whatever mode the game is in. Collaborative boards dedupe those spots by state; competitive boards group them by player. Mode is a rendering decision made at the last possible moment, which means one write path, one set of security rules to get right, and no migration if a group ever wants to switch. Scores are derived from the spots that are already loaded — there are no counter documents to drift out of sync with the thing they count.

The map is data, not a dependency

The board is a real map of the United States: tap Nevada, claim Nevada. Getting an accurate one into a mobile app usually means dragging a projection library along for the ride, so the app spends startup cycles reprojecting geography that has not moved since the last release.

Instead a build-time script takes the us-atlas TopoJSON — already projected with the Albers USA composite, so Alaska and Hawaii sit in their familiar insets — and flattens the fifty states into SVG path strings with a computed viewBox. The output is a 124 KB JSON file, committed and reproducible. The app parses it into SwiftUI paths and aspect-fits it to whatever space it has. No projection code ships, and the map component knows nothing about Firebase: it takes a dictionary of state-to-status and hands back taps, which is what makes it testable without a backend.

A hit test against Rhode Island is unkind on a phone, so spotting by name is a first-class path rather than a fallback — it is also the current accessibility answer for anyone not tapping precise shapes.

Notifications that don't punish you for playing

A shared board plus a naïve write trigger equals a notification per spot. One passenger working through a rest-stop parking lot would generate a dozen pushes in as many minutes, and everyone else in the game would turn notifications off — permanently, and on the first day.

So the backend debounces. A Cloud Function on spot creation fans the new spot out into per-recipient digest documents; a scheduled function sweeps every minute and sends a single coalesced push per digest once that game has been quiet for two minutes — but never holds one longer than five, so an active game still feels live. One flurry of spotting becomes one notification, and a tapped notification deep-links to the board that produced it rather than dropping you on the home screen to find it yourself.

The rejection: a nonce race in Sign in with Apple

App Review rejected build 1 under guideline 2.1 with a single line: an error message during Sign in with Apple. The screenshotless kind of report that is very easy to file under "reviewer had no iCloud account on that device" and resubmit unchanged.

The backend logs argued otherwise. Firebase had recorded no failed sign-in attempt at all for that window — consistent with the identity provider rejecting the credential before it ever became an attempt, which is not what a device-side problem looks like.

The bug was a race. Sign in with Apple wants a nonce: the app sends Apple the SHA-256 hash, gets an identity token back with that hash inside it, and passes the raw value to Firebase, which checks that the two agree. The auth service kept one raw nonce in a property, and every new request overwrote it. Tap the button twice while the system sheet is slow on a bad connection and two requests are in flight: the first one completes carrying the first request's hash, while the property now holds the second request's raw value. The pair does not match, Firebase refuses it, and the user gets exactly the unhelpful error message the reviewer described. Reproduced on a physical device by doing precisely that.

The fix replaces the single slot with a registry: each prepared request registers its raw nonce under its own hash, and when an authorization completes the app reads the nonce claim out of the returned identity token and looks up the raw value that actually belongs to it. Any interleaving now resolves correctly, on both the sign-in and the delete-account re-authentication path. The token payload is decoded, not trusted — Firebase still verifies the signature. The regression tests interleave two and three concurrent requests, because a fix for a race that is only tested serially is not tested.

Resubmitted the same day with a note explaining the root cause. The lesson worth keeping: a vague reviewer report plus telemetry that shows nothing is not evidence that nothing happened.

Decisions worth naming

Firestore lives behind a protocol
No view and no view-model imports Firebase. The repository is an interface; the Firestore implementation is one conformance and the in-memory fake used by the tests is another. That is what lets 116 unit tests cover the game logic without a network, a simulator account, or a live database — and it is the reason the nonce race could be pinned down in tests instead of by resubmitting to Apple and waiting.
Security rules are code, and code gets tested
Membership gating, the locked-down invite and digest collections, and the game-name length cap all live in firestore.rules, exercised by 21 tests against the Firestore emulator on every pull request and deployed automatically when they pass. Client-side validation is a courtesy to the user; the rules are the actual boundary.
Invite codes are built to be read aloud
Six uppercase characters with the ambiguous glyphs removed — no 0 against O, no 1 against I or L — because the codes get shouted across a car. Universal links on spt-50.app handle the case where somebody can paste, and a small lookup collection lets a non-member resolve a code to a game without being able to read anything else about it.
Honor system, deliberately
No photo, no verification, no proof. This is a game between people who know each other, and a proof requirement would cost more in friction than cheating costs in fun. The fields for photos and verification exist in the model and stay empty in v1 — the schema is ready for a decision that has not been made yet.
Leaving is as first-class as arriving
Account deletion is in the app, not in an email to support: it re-authenticates with Apple, revokes the Apple token, then a callable function purges roster entries, spots, orphaned games and invites, pending digests, and the profile before deleting the auth user. Apple requires it. It is also the correct behaviour, and the thing that ought to be hardest to get right is the thing that most deserves a test.
The pipeline is part of the product
Pull requests run linting, the unit suite, the rules emulator tests, and the Cloud Functions tests behind one required check. Signed builds and TestFlight delivery run on a self-hosted Mac mini with a persistent signing keychain, after hosted runners spent a month re-importing certificates on every job. Releases are tagged; the deployed thing is the thing CI tested.

Stack

Swift · SwiftUI · async/await · Firebase Auth (Sign in with Apple) · Cloud Firestore · Cloud Functions gen 2 (TypeScript) · FCM · XCTest · GitHub Actions · self-hosted macOS runner

iPhone-only and portrait-only for v1, with Dynamic Type support throughout and a design system — road-trip Americana, cream paper and highway-sign green — implemented once as tokens and shared components rather than restyled per screen. Deferred on purpose: photo verification, per-state slogans, leaderboards, and a provinces ruleset.