← Build log

Build log

The schema had already done it

Hedge is a daily calibration trivia game I build solo; the architecture lives in the case study. This is the story of adding multi-device iCloud sync to an app with no backend, and finding most of the design already waiting in the schema.

The question came up in a hotel room, a quick weekend trip to Nashville. Hedge had just shipped iPad support, which meant for the first time a player might own two copies of the game, and I asked what felt like the obvious next question: we're multi-device now, so do we keep the database in the cloud? My first instinct was to sync the database. That instinct was wrong in a useful way.

Sync facts, not a database

Hedge's whole pitch is that there is no backend. No accounts, no servers, nothing to sign into, a privacy label that honestly says "Data Not Collected." I was not going to give that up for sync. The reframe that made everything work: don't sync a database, sync facts. An answer in Hedge is immutable by design. Once you bet 80% on FALSE, that bet is permanent, no replays, because replaying would destroy the calibration signal the whole app exists to measure. Immutable facts are the easiest thing in distributed systems to reconcile. You never merge them. You just union them. So the design became an append-only log of facts (answers, pack unlocks, completed runs) pushed into the user's own private iCloud database and set-unioned on every device. Apple hosts it under the player's Apple ID. I never see a byte of it.

The constraint that had already designed it

Then came the part that still makes me smile. On day one of this project, months before sync was a thought, the schema got a UNIQUE constraint on answers.question_id with a comment calling it the single most important integrity rule in the app. It existed to stop replay-for-better-score. When I went to design the cloud conflict story, it turned out the constraint had already designed it. Make the cloud record's name the question id, save with fail-if-exists, and the first device to reach the server owns the answer. A conflict hands the losing device the server's record, and the loser adopts it. The same invariant, lifted into the sky. The merge code is allowed to replace a dual-play loser; the player still can't replay anything. Immutability binds the player, not the reconciler.

Derive, don't store

The second gift was that Hedge derives almost everything on read. Streaks, chip balances, the RESUME state on the home screen, the calibration chart: none of it is stored, all of it is recomputed from the answers table every time. Which means merged rows create a correct UI with zero new code. The first time I watched it happen, a fresh iPad simulator pulled three answers mid-launch and the home orb quietly said RESUME, 3 of 5, for a round that device had never seen. Nothing in the user interface knew sync existed (or had to). The facts arrived and the UI told the truth about them.

Zero counters

Chips were the one place I expected a real distributed-systems headache, two devices spending from one balance, and the answer turned out to be: sync zero counters. Earned chips derive from the merged answers. Spent chips are the sum over the merged unlock ledger, because the set of unlocked packs IS the spend history. Two offline devices could theoretically spend their only chip on two different packs, and when they come back online the merged ledger tells the story of a negative balance. The policy I wanted was generous: you don't owe me a chip, you get both packs and have zero chips until you earn another one.

The write-up caught a bug

Here's my favorite part: writing this entry is what caught the bug. I typed that generous claim, then asked whether it was actually true. It wasn't. The balance math had a floor at zero, but a floor only hides a debt, it doesn't forgive it. The arithmetic still remembered the minus one, so the next chip you earned would have silently vanished paying off a deficit you could never see, right while the store celebrated a new chip that never appeared. The code was changed before this entry could be published: the reconcile now writes the debt down the moment it's discovered, so your spend can never exceed what you ever had. The double-spender nets a free pack in their own single-player game, and anyone grinding that hack across two airplane-moded devices is someone thoroughly enjoying Hedge. Why would I want to piss them off? They might be the ones spending real money someday if Hedge ever becomes popular enough to need monetization. The write-up audited the implementation, and the implementation lost. I see this every day at my day job. When you go through the steps to explain your work, you look at things with fresh eyes and it often brings valuable insights.

The free hard part

And the punchline. Gut Check replays shuffle their questions every run, so handing a half-finished replay between devices looked like the hardest item on the list: I'd have to sync the order itself. The actual difficulty turned out to be zero. Months ago, so that resuming a replay on the same device wouldn't need any stored state, the shuffle was made deterministic, seeded from the pack id and attempt number, which means my second run and your second run (everyone's second run) through a pack all have the same order. Each new attempt gets a different order, but because the shuffle comes from an algorithm, it never needs to be saved. The property that made local resume cheap made distributed resume free. I verified it live: started attempt two on the phone, picked up the iPad, and it opened on the same question three in the same order.

The finale

The finale happened when I finally stepped away from the simulators. My real iPhone with its extensive streak and play history, my real iPad with a fresh install and no history at all, and my real Apple ID. The phone pushed its entire history to the cloud on first launch, 410 answers and every pack run, 460 records in one shot. The iPad adopted all of it, skipped the tutorial (a device joining an account with three months of history is a homecoming, not a first run), matched the chip balance to the penny, and drew my full calibration chart. Then the test that sold me: I answered three of today's five on the phone, put it down, picked up the iPad, and the button said RESUME. I finished the round there, picked the phone back up, and it said SCORE. The full round played across two devices but the celebration was more than just the digital confetti you see after a perfect round. It was me knowing that adding in multiple device support actually meant something now. For an app that asks everyone the same five questions each day, having it on your tablet AND your phone didn't truly make sense until the two devices were able to synchronize your daily play.

The best part of this project is what it didn't require. No backend, no accounts, no rewrite. The constraints I chose in April, immutable answers, derive-don't-store, deterministic shuffles, turned out to be the sync design, waiting. The architecture disappeared into the product.