Skip to content
← All work

Engineering case study · Everywhen

One game.Three clients.Nothing drifts.

How 930 places, one rulebook and a daily schedule reach iPhone, Android and the web, and stay the same game on all three.

  • SwiftUI
  • Kotlin · Compose
  • React · TypeScript
  • Supabase
  • Cloudflare R2
Everywhen on the web: today’s daily card beside a full-screen map.
Web · React
The iPhone reveal for Rani ki Vav: the guess, the answer and both scores.
iOS · SwiftUI
Android: guessing when for a valley, Industrial range, the year slider at 1850.
Android · Compose
Scroll

Three clients means three places for one game to quietly become three games.

Three clients

73thousand lines,
written three times

  • iOSSwift28,388
  • AndroidKotlin25,912
  • WebTypeScript18,465

One rulebook

1,141cases every client
must agree with

14 files, generated from the shipping Swift

The schedule

930places, each scheduled
exactly once

days
186
repeats
0
runway
65 d

Shipping

5days from the first
commit to shipping v1.0

iOS versions
14
Android build
15
languages
3

The shape of it

One source, three native apps.

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.

content/the only place a fact is writtenThe Past130Turning Points163Science & Invention88Arts & Culture118Belief & Myth104Earth & Sky125The Living World74Mind & Society128+ schedule · collections · icons+ 繁體 and 简体 overlaysbuild_content.pyvalidates, then fans outplaces · schedule · collections“adding a platform means adding a path here”publish.py → R2a manifest + month packsnew days and fixed facts,no app releaseiPhone & iPadResources/places.jsonAndroidassets/content/places.jsonWebpublic/content/places.jsonbundledfetched 30 days ahead, on wifi
Every places.json is a build artifact. A typo is fixed once, in content/, and reaches all three clients.

The bundle is a floor

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.

A wrong fact is a data fix

Stories and dates travel over the air, so a correction reaches every phone without waiting on App Review.

One rulebook

Generate the agreement. Don’t re-read it.

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.

01k2k3k4k5k6k2000
  • Swift121computes, and generates the fixtures
  • Kotlin121asserts against the fixtures
  • TypeScript121asserts against the fixtures

✓ fixtures match 549 scoring cases, regenerated from Swift and diffed on every gate run

Tuning without a release

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.

rules.jsondefault, inside its bounds
  • whereBullseyeKm25full marks inside this
  • whereScaleKm2,600how fast credit falls
  • whereShape0.7the curve’s shoulder
  • whenTolFraction0.05tolerance, share of the age
  • whenMinTolYears40never tighter than this
  • whenScaleTols10falloff, in tolerances
  • whenShape0.7the when curve’s shoulder
  • regionChipRounds5rounds with a region chip
  • eraChipRounds3rounds with an era chip
Wrong type or out of bounds rejects the whole file.A 404 means back to the compiled defaults.Scores already recorded never change.

The schedule is the product

930 places. Each one, once.

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

Each day is five stripes, one per place, in its realm’s colour. The solid squares of the summer are the old themed days, often one realm; from September 23 every day is five unrelated places.today scheduled ahead Sep 22, the old edge

One of each fame

Every day takes exactly one place from each fame rating, because the rounds are ordered easy to hard by fame.

At least four regions

A day of five European places is a giveaway. The validator counts regions.

No one guess sweeps

If a single year would score full marks on all five, the day is rejected.

Manifest last

Failure should be litter, not an outage.

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.

  1. 1Objectsupload every new packrclone copy, never sync, never delete
  2. 2Verifydownload each one backfrom its public URL, sha256 and size
  3. 3Applythe new manifest is a superseta missing month is a month deleted from every phone
  4. 4Manifestlast, and alonenow phones can see the new packs

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

The screen never waits for the network.

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.

Screenreads local, always
On the deviceSwiftData · Room · IndexedDB
Supabase6 tables, row-level security

A flag, not an outbox

syncedAt == nil   // dirty

Every new row is dirty by default, so there is no call site to forget.

The first result stands

.upsert(rows, onConflict: "user_id,day_key",
        ignoreDuplicates: true)

A second phone that played the same day offline can’t overwrite it.

Two milliseconds

let wireDateEpsilon: TimeInterval = 0.002

The server trims dates to milliseconds. Comparing exactly would re-dirty every row on every pull, forever.

One streak

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

Shared numbers, native hands.

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)
EVERYWHEN#122😬46😬54😬47🌟80November 8616 of 1,000🔥 40-day streakWhere & When · a daily geography & history game

One share card, three renderers

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

930 icons, read the way iPhone reads them

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

  1. M
  2. 4
  3. 18
  4. a
  5. 8
  6. 8
  7. 0
  8. 0
  9. 1
  10. 16
  11. 0

iPhone“0116” is one number; every later argument shifts

  1. M
  2. 4
  3. 18
  4. a
  5. 8
  6. 8
  7. 0
  8. 0116
  9. 0
So the build reads every icon path with iOS’s own tokenizer before it ships, and also refuses Q, T and exponents, which iPhone skips.

每個地方

One language, derived from another.

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

Each Simplified entry carries a hash of the Traditional it came from. Edit the Traditional and forget to convert, and the gate fails.

English never waits

A day can publish in English before its Chinese is ready; the validator only warns.

Serif numbers, sans Chinese

iOS has no Chinese serif and bundling one was ruled out, so dates keep New York numerals beside PingFang characters.

Tests without XCTest

A gate that can’t pass by accident.

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.

  • 31swiftc harnessesno XCTest target, no simulator
  • 350pytest testscontent, schedule, publishing
  • 527Android @TestJVM only, no emulator
  • 136web test filesvitest, typecheck, oxlint
  1. ✓pytest
  2. ✓Chinese overlays
  3. ✓__main__ files
  4. ✓Swift harnesses
  5. ✓fixture drift
  6. ✓ruff
  7. ✓content + schedule
What ship.py runs before anything uploads. run_gate.sh is the same list, minus the last step.

No orphans

A test file that no runner collects fails the gate instead of silently never running.

Ratchets at zero

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.

A hook, not a reminder

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

From a clean tree to waiting for review.

./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.

  1. preflightclean tree, working key, next build number asked of App Store Connect
  2. testthe whole gate
  3. buildarchive, export, check the number inside the .ipa
  4. uploadto App Store Connect
  5. releaseversion, copy, attach, submit
  6. → WAITING_FOR_REVIEWthe script’s last state
JulAugSepOctNov1.01.31.5.11.61.71.81.9.2
archive said11
.ipa said12

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

Make the wrong thing loud.

  1. Generate agreement.Three clients stay one game when the rules are dumped from one implementation and asserted everywhere else.
  2. Order writes so failure is harmless.The same rule on the server and the phone: the pointer goes last.
  3. Green isn’t the flow.The first Android daily passed every test and still felt wrong in five ways. Now every port gets played end to end before it ships.
The Android scorecard: the day’s four places with their scores.The iPhone scorecard: 616 of 1,000 and a 40-day streak.
The scorecard on Android and on iPhone. Same rules, each in its own platform’s hand.