Three clients
73thousand lines,
written three times
Engineering case study · Everywhen
How 930 places, one rulebook and a daily schedule reach iPhone, Android and the web, and stay the same game on all three.



Three clients means three places for one game to quietly become three games.
Three clients
73thousand lines,
written three times
One rulebook
1,141cases every client
must agree with
14 files, generated from the shipping Swift
The schedule
930places, each scheduled
exactly once
Shipping
5days from the first
commit to shipping v1.0
The shape of it
Everywhen is a monorepo with three separate clients: SwiftUI on iPhone and iPad, Kotlin and Compose on Android, React on the web. What they share is not interface code but data and rules. Content lives in one place and is compiled out to each app; new days arrive from a CDN.
places.json is a build artifact. A typo is fixed once, in content/, and reaches all three clients.Every app ships with its content, so a first launch works on a plane. Remote content only adds: it can’t take away what shipped.
Stories and dates travel over the air, so a correction reaches every phone without waiting on App Review.
One rulebook
Porting a scoring curve by reading Swift and retyping it in Kotlin and TypeScript works until someone tunes one of them. So the Swift is the rulebook: a generator calls the shipping Swift code and dumps 1,141 golden cases, and the Android and web tests assert against those files directly. Drag the pin, then retune Swift alone.
✓ fixtures match 549 scoring cases, regenerated from Swift and diffed on every gate run
Native apps can’t download code, so over-the-air changes are data. Nine scoring and hint keys can be tuned from a rules.json on the CDN, each inside hard bounds. Round count, weights and the 1,000 max are not on the list: stored results, streaks and shares are built on them.
whereBullseyeKmfull marks inside thiswhereScaleKmhow fast credit fallswhereShapethe curve’s shoulderwhenTolFractiontolerance, share of the agewhenMinTolYearsnever tighter than thiswhenScaleTolsfalloff, in toleranceswhenShapethe when curve’s shoulderregionChipRoundsrounds with a region chiperaChipRoundsrounds with an era chipThe schedule is the product
A place never repeats in the daily, and an unscheduled place is invisible in the app. At five a day that is about 150 places a month, forever, so the schedule is a ledger with rules, and a validator that fails the build.
Jul 2026
Aug 2026
Sep 2026
Oct 2026
Nov 2026
Dec 2026
Jan 2027
Every day takes exactly one place from each fame rating, because the rounds are ordered easy to hard by fame.
A day of five European places is a giveaway. The validator counts regions.
If a single year would score full marks on all five, the day is rejected.
Manifest last
New months reach phones as packs on Cloudflare R2, listed by one manifest. The upload order is fixed so an interrupted publish can’t hurt anyone. Try the other order.
Finished cleanly: every phone sees the new month on its next refresh.
“A manifest naming an object nobody uploaded is an outage on every phone at once; objects nobody’s manifest names are harmless litter.”
From publish.py. The phone mirrors it: a downloaded manifest is written to disk last, after its packs, and a failed fetch leaves the last good catalog exactly where it was.
Offline first
You never need an account to play. Every screen reads the store on the device; signing in adds a background sync to Supabase that reconciles when it can. The interesting part is the small rules that keep that honest.
syncedAt == nil // dirtyEvery new row is dirty by default, so there is no call site to forget.
.upsert(rows, onConflict: "user_id,day_key",
ignoreDuplicates: true)A second phone that played the same day offline can’t overwrite it.
let wireDateEpsilon: TimeInterval = 0.002The server trims dates to milliseconds. Comparing exactly would re-dirty every row on every pull, forever.
StreakTracker.currentStreak(days:now:)A restored phone once said “0-day streak” over hundreds of played days. Now one derivation, pinned at sync_tests.swift:715.
Same pixels, three renderers
Where the interface has to match, I share the numbers rather than the code. The review sheet moves with the same constants on all three platforms, ported from my motion prototypes.
static let decelerationRate = 0.998
static let rubberBandFactor = 0.55
static let overhang = 400.0
static let settle = Spring(stiffness: 200, damping: 30, mass: 1)
static let settleReduced = Spring(stiffness: 260, damping: 40, mass: 1)
SwiftUI’s ImageRenderer, Compose at a fixed export density, and a 2D canvas on the web all draw the same 360 × 450 frame at 3×, in fixed light colours.
static let size = CGSize(width: 360, height: 450)
let renderer = ImageRenderer(content:
PassportShareCard(stats: stats, milestone: milestone))
renderer.scale = 3 // 360×450 pt → 1080×1350 px
Each place’s icon is a few SVG path strings, drawn natively everywhere. Browsers and Android forgive things iOS’s path parser doesn’t, so a glyph can look right in two places and break in the third.
M4 18a8 8 0 0116 0
Browser, Androidcompact arc flags read as 0, 1, then 16
iPhone“0116” is one number; every later argument shifts
Q, T and exponents, which iPhone skips.每個地方
All 930 places read in Traditional and Simplified Chinese: titles, clues, stories, answers and choices. Only the Traditional is written by hand. The Simplified is generated from it, so the two can’t disagree.
English · source
The beach where the pavement became a picture
繁體 · written, then proofread
人行道變成一幅畫的海灘
简体 · derived, never typed
人行道变成一幅画的海滩
from: 8dd826412e0f
A day can publish in English before its Chinese is ready; the validator only warns.
iOS has no Chinese serif and bundling one was ruled out, so dates keep New York numerals beside PingFang characters.
Tests without XCTest
There is no XCTest target. Game logic lives in Foundation-only Swift files, so each rule compiles into a small command-line harness that runs without a simulator.
ship.py runs before anything uploads. run_gate.sh is the same list, minus the last step.A test file that no runner collects fails the gate instead of silently never running.
A hint may not name its place or run past 140 characters. A test also guards the ratchet’s own patterns, so deleting one can’t “fix” every leak.
Every sheet must render over the app’s root, or an alert raised while it’s open tears it down and shows nothing. A hook reruns that check after any edit to a modal.
One command to ship
./scripts/ship.py runs the whole iOS release: preflight, the gate, archive, upload, and the App Store record. Android goes up through the Gradle Play Publisher, and the web deploy runs tsc -b first, so there is no way to ship a tree that doesn’t typecheck.
Export once renumbered a build without saying so. Now preflight asks App Store Connect for the highest build first, export may not renumber, and the build step checks the number inside the .ipa.
What I take from it


Everywhen
Four new places are waiting today. Guess where, guess when, and see how close you get.