[Tefuda] One Engine, Three Runtimes: Web, iOS, and Android Share One Ruleset

中文版:一份引擎,三個執行環境

Tefuda is a small card game I built. It ships as a website, an iOS app, and an Android app — all three backed by the same server-verified leaderboard.

The combination isn’t the hard part. The hard part is that the same ruleset has to hold in four places at once: each of the three clients runs it, and the server recomputes the score itself, trusting nothing the client says.

The obvious approach is to write the rules three times — TypeScript for the browser, Swift for the phone, and another copy for the server. The cost is that every bug gets fixed three times, and the moment two implementations disagree, the score depends on which one did the computing.

So there’s exactly one copy of the rules.

This post starts with a player’s path through the game, answering three questions in order: who computes what you do on screen? When does anything hit the API? Where does the data live? The sections after that all deal with different faces of the same headache — the rules change version, and the three clients don’t find out at the same time.


1 | Three Apps, but Only Two Ways of Drawing the Table

The differences between the three clients are smaller than they look:

ClientWho draws the tableWho runs the rulesHow the rules update
WebNext.js static output, running in the browserBrowserA refresh is the latest version
iOS appNative SwiftUI, not a single line of HTMLJavaScriptCore (iOS’s built-in JS engine)Downloads in the background, swapped in on the next cold start
Android appIt doesn’t draw anything — it’s Chrome, full-screenBrowserThe moment the site deploys, Android has effectively shipped a release

The Android row is deliberate: it’s a Trusted Web Activity — Chrome pointed at https://playtefuda.com with the address bar stripped off, backed by the app and the site cross-verifying they’re the same product. There isn’t a single .kt file anywhere in the repo.

So of the three apps, only two places actually run the rules: the browser, and JavaScriptCore inside the iOS app. The server runs them once more on its own, which makes three — that’s what “three runtimes” in the title means.

flowchart TD
  SRC(["lib/game + lib/wire<br/>rules and API, each written once"])
  SRC --> BUNDLE[/"engine.js<br/>the single built file, ~12 KB"/]

  BUNDLE --> WEB{{"Browser<br/>web players run it here"}}
  BUNDLE --> JSC{{"JavaScriptCore<br/>the iOS app runs it here"}}
  BUNDLE --> WK{{"Cloudflare Worker<br/>the server reruns it here"}}

  WEB -.-> TWA["Android app<br/>a full-screen browser, runs no engine of its own"]

Three terms get used strictly for the rest of this post:

  • Engine = the built rules artifact. One file, no imports, no config, no network calls.
  • Runtime = a host that can run that file: the browser, JavaScriptCore, Cloudflare Workers.
  • Run = one game, recorded as “a seed plus a sequence of moves” — never a score.

2 | How Many API Calls Does One Run Take? Zero

From the deal to the final score, an entire run needs no server at all. The shuffle is determined by the seed; what a move does, what a hand is worth, when an upgrade fires — all of it is pure functions, computed on your own device. The game itself is a static file served directly by Cloudflare’s asset router, so a web player never touches the Worker even once.

The server only shows up for four things: submitting a score, viewing the leaderboard, watching someone else’s replay, and — iOS only — asking “is there a new engine build.” The entire API is these five calls:

CallWhenSendsReturns
GET /api/leaderboardOpening the leaderboardwhich board (normal / zen / daily challenge), my player id, pageone page of rows
GET /api/replayClicking a row to watch itwhich board, which rankseed, move log, engine version
POST /api/scoreHitting submit at the end of a runseed or date, move log, name, player id, engine version — no scorethe score and rank the server computed
DELETE /api/scoreDeleting your own recordrow id + player id
GET /api/engineEvery launch, once the table is drawnnothingwhich engine is live and where to fetch it

All three clients hit the first four; the last one is iOS-only. Two other things go over the network — /engine.js and /engines/<version>.js — but they aren’t API calls, they’re static files served by the same asset router, and they never wake up a line of code I wrote.

flowchart TD
  REQ["A request comes in"] --> SPLIT{"Check the path"}

  SPLIT -->|"/api/*"| WORKER{{"Worker<br/>the five API calls"}}
  SPLIT -->|"/w/*"| WORKER
  SPLIT -->|"everything else<br/>pages, images, engine.js, engines/*.js"| ASSETS("asset router → static files<br/>0 Worker calls")

  WORKER --> DB[("D1<br/>one table")]

Playing the game costs zero Worker requests. Beyond those four things, the only other thing that touches my code is opening a link someone shared (/w/*, covered in section 10).


3 | Where the Data Actually Lives

Three places, and what’s stored barely overlaps between them.

(1) On the player’s device

The web stores in localStorage, iOS stores in UserDefaultsthe same set of things, right down to identical key names, because those keys come from lib/wire.ts and both sides read from that same code:

What’s storedWeb (localStorage)iOS (UserDefaults)
Player identity (a random id string)tefuda_player_idsame name
Display nametefuda_player_namesame name
Language / theme / mutetefuda_lang and two otherssame names
Which daily-challenge days played, score eachtefuda_daily_v1same name
Table mid-runtefuda_save_v6none
Score that failed to send, waiting to retrytefuda_pending_v1none
Next engine to swap in on next launchnonea .js file under Application Support + three keys

The last three rows are platform differences, not inconsistent design: a browser tab can be refreshed at any moment, so the web has to persist the table and any score that failed to send; a phone app that gets swiped away just lives in memory and is right where you left it. Conversely, only iOS needs to persist a new engine to disk — it’s the only one whose rules aren’t refetched on every open (section 9).

There’s no account here. No password, no cookie, no session. The player id is a random string the client generates itself; it’s not authentication, just a marker meaning “these rows came from the same device.” The leaderboard’s trustworthiness doesn’t depend on it at all — it depends on the replay in section 4.

(2) The server’s database

D1 (Cloudflare’s SQLite), six migrations, one table. Every accepted record is one row:

ColumnExampleWhat it’s for
movesp0p1c0dd…391 moves, two characters each
seed2880217059which deck was dealt
date2026-07-25which day’s daily challenge (or free)
score608074what it added up to
engine7766419a1169which ruleset it was played under

The score is stored, but it isn’t the source of truth — just a cached result of one computation. The real source of truth is (seed, moves, engine), because those reproduce the score exactly. It’s also why replay is possible at all: a viewer isn’t fetching a recording, they’re rerunning that exact run.

This table is append-only. Beating your own record doesn’t overwrite the old row, so an old replay someone else shared doesn’t break just because you got better.

(3) A directory in version control

Every file under engines/ is one entire ruleset that was once live, about 11 KB each, all checked into git. Why it has to exist is the subject of section 8.

In other words, everything the server knows about a given player is a few rows in that table; everything it knows about the game itself is that table plus that directory.


4 | How a Run Becomes a Row on the Leaderboard

The first three sections were a static map; this one is the path through it:

flowchart TD
  PLAY["Player finishes a run<br/>in the browser or the iOS app"]
  PLAY -->|"POST /api/score<br/>seed + moves + engine version, no score"| WORKER{{"Worker"}}
  WORKER --> LOOKUP{"Does engines/ have this version?"}
  LOOKUP -->|"no"| STALE["409 stale_engine"]
  LOOKUP -->|"yes"| REPLAY["Replay with that engine<br/>same seed, same 391 moves"]
  REPLAY --> ROW[("Written to D1<br/>seed · moves · engine · score")]
  ROW --> WATCH["Weeks later someone opens it to watch<br/>fetches /engines/&lt;version&gt;.js and reruns it"]

Two things in here are the entire design.

The client sends the actual moves it played, not the score it claims. The server replays that move sequence with the same seed and trusts only its own result. A tampered record either fails to apply or produces its real score. That’s the entire anti-cheat model, and it costs nothing: no obfuscation, no signing, no heuristics.

The precondition is stricter than it looks. The server’s replay and the player’s actual run must agree to every last digit. The instant two copies of the rules differ by one character in a scoring table, every score becomes a coin flip between two numbers, and the leaderboard quietly fills up with records nobody actually earned. So sharing code here isn’t fastidiousness — it’s the only approach that works at all.


5 | lib/game: Rules and Nothing Else

A TypeScript directory, about twenty-five hundred lines, that knows how a deck gets shuffled from a seed, what a move does to the state, how a hand compares to another, what a run is worth. It’s all plain functions operating on plain data: placeCard(state, index) returns a new state. No React, no DOM, no fetch, no storage, no clock, and no randomness that doesn’t come from the seed.

That last item matters most: everything here has to be a pure function of (seed, moves), because those are the only two things the server will ever have. The moment one scoring rule asks what time it is, that run can never be verified again. As for what the game looks like — animation, sound, layout, copy — none of it lives here, and none of it can ever change a score.

The discipline that takes the most sustained effort is exactly where the boundary of “rules” gets drawn. The Swift app has no table of which hand beats which — it asks the engine at launch; the player-name character limit isn’t in Swift either, the app uses the same clipping rule the leaderboard uses to store names. This kind of constant is exactly the thing that’s thirty seconds away from being copy-pasted into Swift, and exactly the thing most likely to drift.

The Swift app knows how to draw this game. It doesn’t know how to play it.

The native bridge is deliberately boring: a headless entry point whose only job is to re-export the engine as a JSON-in, JSON-out interface.

const TefudaEngine = {
  version: ENGINE_VERSION,
  newDaily: (date: string): string => JSON.stringify(createDailyState(date)),
  apply: (state: string, move: string): string =>
    JSON.stringify(apply(parse(state), move)),
  canDiscard: (state: string): boolean => canDiscard(parse(state)),
  // …
};

State crosses the JS↔Swift boundary as a JSON string, decoded on the Swift side with Codable. The top of the file states the rule outright: don’t fork logic here, only re-export it. The moment the native side computes something on its own, it owns a rule — and the same rule held independently on both sides will eventually disagree.


6 | lib/wire: The API Gets Described Exactly Once, Too

The five-call API table in section 2 isn’t documentation I wrote up — it’s the contents of one file.

Sharing the engine but hand-writing two HTTP clients would just push the duplication out one layer. The iOS app would hold its own copy of paths and parameter names, and it would go stale the first time an endpoint moved — silently, on someone else’s device, with no way to fix it short of a resubmission to review.

So the API also gets described exactly once: a pure-function module that holds every path, every query and body key, every response shape, every error code. Both clients are left with nothing but transport. The web side is a thin fetch wrapper; the iOS side is a thin URLSession wrapper — and it gets this module from the engine bundle, holding no endpoints of its own:

wire: {
  request: (op: string, ask: string): string => { /* → { method, path, body } */ },
  decode:  (op: string, status: number, body: string, ask: string): string => { /* … */ },
}

The one thing the Swift side decides for itself is that a request that never went out is called offline. Even which key player identity is stored under, how many characters a name can be, what a share link looks like — all of it is an answer this module supplies on request.

That’s how it crosses runtimes: it isn’t a library the app links against, it’s data the engine hands over. Changing one endpoint moves both sides at once — including the version already live on the App Store.

Both constraints were learned the hard way. It has to be pure: no fetch, no touching storage, the DOM, or process.env, because it runs inside a bare JavaScriptCore environment where URL, btoa, and TextEncoder simply don’t exist — base64url is hand-rolled as a result. It has to stay outside the replay closure, for the reason in the next section.


7 | ENGINE_VERSION: A Version Number Nobody Types by Hand

Up to this point, “there’s exactly one copy of the rules” already holds. But that only solves “the rules are the same set” — not “the rules are the same set right now,” and the second one is the hard part. A browser tab keeps running whatever JavaScript it originally loaded; a deployment doesn’t touch it. The native app only adopts a downloaded engine on its next cold start, and a phone usually suspends the app for days rather than closing it.

So every build derives an ENGINE_VERSION: a hash of “the modules a replay actually passes through” — the closure esbuild resolves out from the replay entry point. Two runtimes holding the same string are guaranteed to compute the same score for the same move sequence; different strings guarantee nothing. The leaderboard relies on exactly this contract, and it’s the content of the engine column from section 3.

What gets hashed is the minified output, not the source. Every version bump invalidates any run currently in progress, so editing a comment, reordering a block, renaming a local variable shouldn’t cost anyone a run they’re halfway through. Minification erases exactly those things while preserving everything that can affect a score. The cost is that esbuild’s own version becomes an input too — rare, and it fails in the safe direction: it can only trigger an extra bump, never miss one it should have made.

It’s derived, not declared, because “forgot to bump it by hand” is exactly the mistake this mechanism exists to catch. The script also guards against the hash becoming an input to itself: if the generated version file ends up inside the closure being hashed, the build fails outright.

This is also why lib/wire has to stay outside the closure. If it were inside, moving one endpoint would bump the version and invalidate every run in progress — paying a game-rules bill for a networking-layer change.


8 | engines/: Old Rules Don’t Get Thrown Away

The server’s first version enforced an exact version match: any record played under a different string got stale_engine, no exceptions.

That was the right answer to the wrong question. A deployment doesn’t update a client someone is mid-run on — it just leaves them behind. A web player would lose the run in progress; an iOS player, who’s never forced to close the app, could be locked out for days, unable to submit anything. And the whole time, the ruleset their run was following still exists, still perfectly well-defined.

So the server keeps them. Every deployed engine gets archived as a bundle named after its own version and checked into version control; a submitted record gets replayed under the ruleset it declares, not today’s:

const rules = typeof engine === "string" ? ENGINES[engine] : undefined;
if (!rules) return json({ error: "stale_engine", engine: ENGINE_VERSION }, 409);

Two things it deliberately doesn’t do. It doesn’t recompute an old record under the new rules — that would be a number nobody ever actually played to get. And it doesn’t trust anything the client claims — an archived engine replays a record exactly the way the live one does, so a run submitted under an old version is just as verified as one under the new one. stale_engine collapses down to what it should mean: old enough that the archive no longer carries that engine at all.

Where an old engine actually lives

The word “archived” carries too much implied meaning. An archived engine is a file — not a container image, not a blob in a database, not a service still running somewhere:

engines/7766419a1169.js       11 KB, checked into version control

Self-contained, minified JavaScript, bundling in only the replay entry point, exporting two or three functions, with no imports, no config, no network calls:

export { rr as replayDaily, tr as replayFree };

git log has it, an editor can open it, and it’ll still run in five years, because it doesn’t depend on anything. And the same bytes have three readers, so the build copies it out to wherever each one can reach it:

flowchart TD
  SRC[/"engines/&lt;version&gt;.js<br/>checked into version control, ~11 KB"/]
  SRC -->|"imported by registry.ts, built into the Worker"| W{{"Server<br/>scores submitted records"}}
  SRC -->|"copied to public/engines/"| B{{"Browser<br/>loaded via import()"}}
  SRC -->|"wrapped as .app.js"| A{{"iOS app<br/>evaluated by JavaScriptCore"}}

The third one needs that wrapper because JavaScriptCore has no module loader — an export in a script is a syntax error. So the build produces one more form, rewritten onto a global instead. Its input is the archive file’s own bytes, not a recompile of the source: rebuilding from source could produce something subtly different from the copy the server scores against, and then the number on screen and the number on the board would stop agreeing.

Thirteen rows that couldn’t be watched

The part that breaks silently is the last step. The engine column is, in effect, a foreign key pointing at a file in a directory, and nothing protects it: no REFERENCES, no migration, no type. Delete the file, and the column still says 7766419a1169, and the row still shows 608074, because that number was already computed and stored at submit time. Only the replay breaks.

I know exactly how this breaks because I did it. The archive kept the six most recent versions, and thirteen rows pointed at a seventh. Nothing errored, no test failed, not one line of log fired — the board looked completely fine. The only symptom was those thirteen replays freezing halfway through with the counter still showing the full length: today’s engine deals a different deck and rejects a move that no longer fits. It looks exactly like a broken feature.

The lesson is that “keep the most recent N” was the wrong shape to begin with. The engine serves two different needs, with two different lifetimes:

  • Submitting a score — a client still running that version. This window ends: a tab refreshes, a phone’s next cold start updates it.
  • Replay — every row that names it. This window never ends, because a row sits on the all-time leaderboard until someone beats it.

Letting release cadence decide archive depth answers the first need and ignores the second — and the second is the one that was actually binding all along. What should be kept isn’t “six versions” or “three months,” it’s every version named by any row still alive. So this is now a query, not a guess:

version        rows    verdict
4c8e230326bc      3    this build — never drop
7766419a1169     16    needed: 16 rows replay through it
f4cacd061554     13    needed: 13 rows replay through it
e99a0a3e2210      0    no row needs it — droppable

Two facts that live in different places — a file directory and a SQLite column — get put side by side. Nothing else in the system sees both at once, which is exactly why this went wrong in the first place.

A missed archive says nothing at runtime, so archiving isn’t a habit, it’s a build step: predev writes the archive, prebuild --check verifies it and fails the build if it’s stale, and a pre-commit hook regenerates it. There’s one more safety line: --recover <commit> rebuilds a missing bundle using git archive <commit> instead of the working tree, because a version’s ruleset is exactly the source at that commit. That cuts both ways as a warning: squash away the commit that produced a version, and that ruleset is gone permanently — along with any run ever played under it.

I recovered those thirteen rows, but only through luck: the commit that produced that bundle had already been squashed away, and the file survived as an unreachable blob in git’s object store that hadn’t been garbage-collected yet. git cat-file -p f2cc99af67b6 handed back 11 KB that reproduced all thirteen stored scores exactly. That wasn’t a recovery procedure. That was a near miss.


9 | OTA: Getting New Rules Into an App That’s Already Running

The previous section was about records played under old rules. This one runs the other direction: how a client gets the new ones. Web and Android just refresh; only iOS has this problem, because it’s the only client carrying its rules with it.

Why bundle an engine into the app at all

The question I get asked most: since the engine is just JavaScript, why doesn’t the app simply fetch the latest one on every launch, play an update animation, and store it? Because that puts a network dependency on the launch path, and the game itself needs no network at all.

  1. The first launch has to be playable. Fresh install, on a plane, in the subway. If the engine only ever comes from the network, the app is an empty table the moment there’s no network — and App Review will open it on genuinely bad connections.
  2. Launch shouldn’t wait on the network. Blocking the table on every single launch for a 12 KB file that changes about once a month isn’t a good trade. The order runs the other way: the table draws first and becomes playable, and only then does a .task ask the server in the background. The player never sees it happen.
  3. Even once it’s fetched, it can’t swap in immediately. Changing engines changes how the deck gets dealt; swapping mid-run would mean the table in front of the player and the score a later replay computes stop agreeing. So a new engine is always held back until the next cold start — and if it has to survive across launches, there has to be a local copy of “the engine to boot with” in the first place.
  4. It’s a fallback. If the stored bundle fails to start up, the app falls back to the one built into the binary. Without that built-in copy, this fallback would collapse into “the app won’t open.”

So the copy built into the app is the floor and the one on the server is the ceiling. The floor is just as playable and just as leaderboard-eligible, because the server replays under whatever version that run declares (section 8).

This is the same shape as CodePush or Expo Updates: a baseline in the binary, a background fetch, applied on the next launch. The only difference is that most of them default to blocking the splash screen on the download; Tefuda doesn’t — a 12 KB rules diff isn’t worth making anyone stare at a loading screen one second longer. And to scope it honestly: this isn’t a code-push framework, it’s one hash comparison plus one file download.

The flow, including every branch where it decides not to update

flowchart TD
  DEV["I change lib/game"] --> BUILD["Build: derive ENGINE_VERSION,<br/>bundle engine.js, archive a copy"]
  BUILD --> DEPLOY["Deploy, engine.js becomes a static asset"]
  DEPLOY --> MANIFEST("GET /api/engine<br/>version · build · minUI · path")

  APP["App launches with its current engine<br/>table already drawn, already playable"] --> MANIFEST
  MANIFEST --> SAME{"Same build hash as what I have?"}
  SAME -->|"same"| STOP["Do nothing<br/>almost every launch stops here"]
  SAME -->|"different"| UI{"Does it need a newer table than I can draw?"}
  UI -->|"yes"| HOLD["Keep the current engine<br/>still playable, still leaderboard-eligible"]
  UI -->|"no"| DL["Download engine.js"]
  DL --> PROBE{"Evaluate it in a throwaway context —<br/>does it start? What version does it claim?"}
  PROBE -->|"fails, or version mismatch"| DROP["Discard it, this attempt never happened"]
  PROBE -->|"starts and matches"| STAGE["Write to Application Support,<br/>mark for adoption on next launch"]
  STAGE --> COLD["Swapped in on next cold start"]

What the server hands back is a pointer, not the content — almost every launch, it’s just a hundred-odd bytes:

{
  version: ENGINE_VERSION,    // which ruleset
  build: sha256(servedBytes), // which bytes
  minUI: MIN_APP_UI,          // oldest UI version that can draw the table
  path: '/engine.js',         // a path, not an absolute URL
  accepts: ENGINE_HISTORY,    // every version the archive can still replay
}

Why version alone isn’t enough. ENGINE_VERSION only hashes the replay closure, but the bundle holds more than that closure: lib/wire, the tutorial, and the bridge interface are all in there. So changing the wire protocol produces different bytes under the same version string; an app that only checks version would stay stuck on the old bundle forever, with zero symptoms, because everything it used to be able to do it still can. This isn’t hypothetical: that’s exactly how the new wire.enginePath shipped — before build was added, an already-installed app never picked it up at all. The version says which ruleset; the hash says which bytes.

What OTA can update: anything in the bundle that’s JavaScript — the scoring table, deck composition, what a move does, daily-challenge variants, the wire protocol, the tutorial script. What it can’t update: Swift. A bundle can teach the app new numbers, but it can’t teach it to draw a control that doesn’t exist in the binary. That’s what minUI is for. Concretely: zen mode has no clock and no discarding, so a table built before zen existed would print “−1 placements remaining” next to a goal that can no longer time out, with a button beside it that does nothing when tapped. Nothing crashes — the screen is just describing a different game. An app below minUI keeps its current engine and keeps submitting scores just fine; this is a caution, not a cliff.

Security, honestly stated. This bundle isn’t signed. Transport is HTTPS to the same origin the app already talks to, and the server returns a path, not an absolute URL, so nothing on the server can point the device at a different host. Integrity is checked by trying it: a downloaded bundle is evaluated in a throwaway context and asked what version it is, and only gets stored if it starts up and matches the manifest. None of this has to carry much weight, because the server never trusted the client’s engine to begin with — tampering with the bundle on your own phone changes only what shows up on your own screen. What signing would protect here is a player not fooling themselves.

What happens when it breaks. Every failure path resolves to the same outcome: the app keeps running whatever it’s already running:

  • Offline, 500, 404 → this update attempt is silently skipped, retried next launch.
  • Bundle fails to start → discarded before it’s ever stored.
  • A stored bundle turns out not to start at launch → the app falls back to the engine built into the binary and discards the stored one. That fallback is the difference between “this update didn’t take” and “the app won’t open.”
  • The app itself ships a newer engine than what’s stored → the stored one gets discarded. This is the rollback case, and the one I got wrong twice. There’s no inherent ordering between two version hashes, so the app compares “the bytes of the engine currently built into the binary” against “which one the stored copy was staged on top of.” It used to compare build numbers instead — a local rebuild never touches that number, so a stored bundle would outrank every rebuild, and the JavaScript running on the phone ended up months older than the app surrounding it.

Web’s OTA is just a reload, and the design question is entirely when. No prompt, no mid-run swap: it reloads at the one moment there’s genuinely nothing to lose — a table that’s been dealt but never touched. That covers exactly the cases that actually happen: a tab left open overnight, a deploy landing while you’re sitting on the results screen. The native app makes the same trade-off, just moving that moment to the next cold start.

One detail worth stealing: that reload also has to discard the saved state rather than resume it. The deck on screen was dealt by the engine being left behind, and the new engine can deal differently from the same seed — resuming would replay that move sequence into a score the player never actually saw. Since not a single move has been made yet, discarding costs nothing. It also remembers to only try each version once, otherwise a client that genuinely can never get the new version (a static cache still serving yesterday’s HTML, a proxy stuck in the middle) would reload forever.


10 | The Worker: Where the Server Actually Sits

The server is one Cloudflare Worker, five API paths, and its most important design decision is making sure it runs as rarely as possible.

The trust boundary sits at the replay, not at the request. CORS is opened to * deliberately: these endpoints carry no cookies, every score gets replay-verified, and the origin a request comes from was never the trust boundary to begin with. (Using * instead of echoing back the origin also means the cached leaderboard doesn’t need Vary: Origin.) Rate limiting is deliberately light — the player id is something the client picks for itself, so the most it can do is throttle how fast a single client can flood the table. What actually holds up the leaderboard is that every row gets replayed by the server itself.

Cache-Control is the infrastructure. No queue, no cache layer, no Redis — just four numbers: 30 seconds for the leaderboard, 300 seconds for a replay, 300 seconds for the engine manifest, one hour for a share card. The manifest is short because it’s the only buffer between “the rules changed” and “the client hasn’t found out yet”; a share card and its image share the same one-hour window, so the number on the image and the number in the text can never disagree.

Persistence is boring SQL. D1, six migrations, one table, plain prepare().bind(). Deletion is scoped to (id, player), and a row’s id is only ever returned to its own owner — even a leaked id can’t delete someone else’s record.

A shared run travels with the link, it isn’t stored. A share link puts the entire run — seed and move log — right in the path, and the server holds no data about it: the link works offline the instant it’s created, can never 404, and no row being deleted can invalidate it. The score isn’t in there either — whoever opens it replays the log themselves.

This is also why the Worker serves HTML at all (the /w/* in the routing diagram). The table reads that run out of the URL’s fragment, and a fragment never gets sent to the server — a crawler sees nothing but the bare page, and every share would preview as the same empty table. So the Worker answers that path with a head naming the specific run, then sends a real browser on to the fragment:

<script>
  location.replace(target);
</script>

That costs one call per link opened or crawled, not one per player. Nothing in that head is trusted straight off the link: the name is clipped by the same rule the leaderboard uses, and the score is computed by replay, never read off the payload. A number sitting in a URL is just a claim; a claim rendered into a preview card is a claim with a picture attached.

The image itself gets drawn inside the Worker: an SVG rasterized through resvg-wasm, fonts imported in as raw bytes, because there’s no filesystem to read from there. Two things I got wrong at first: initWasm can only be called once per isolate and throws on the second call, so the guard has to be the promise itself; and the pixels have to be copied out of wasm memory before the function returns, because the response body only gets read after the function has already returned.


11 | What This Shape Makes Cheap

None of the following is done today:

  • A fourth runtime is nearly free. Anything that can run 12 KB of zero-dependency JavaScript can play or verify a run — a Deno script, a Discord bot, a native Android shell for whenever the TWA stops being enough. What has to be ported is the bridge file, not the rules.
  • Archive auditing belongs in CI. The query that cross-checks engines/ against the engine column already exists — it’s just not wired to anything that fails a build yet. The thirteen-row incident happened exactly in the gap between “a check exists” and “the check runs itself.”
  • Signing the bundle becomes important on the day the engine starts deciding something the server doesn’t independently recompute. Today it doesn’t, and that’s the only reason a run-it-and-see check is enough.
  • Keeping one more archived version costs 12 KB. The current depth is decided by how many versions the board still names, not by what the Worker can fit.

12 | Five Things I’d Do the Same Way Again

  1. When two sides must agree, make the build produce that agreement — don’t write it in a doc and hope someone remembers. A shared constant retyped into a second language is a failure with a release date already on it.
  2. Derive the version number from whatever can change the answer. Hash the minified closure, not the source: formatting is free, semantics is what gets billed. And never bump it by hand.
  3. Prefer compatibility to enforcement. Blocking an old client doesn’t make it new, it just leaves it behind; keeping its old rules around costs a directory of 12 KB files.
  4. Whatever fails silently at runtime, make it fail loudly at build time. A missed archive says nothing at runtime; prebuild --check fails the build outright.
  5. Put the server only where a server is genuinely needed. Five routes, and the game itself never touches one of them.

Appendix | Types for the Five API Calls

The table in section 2 was the version for people; this is the same thing for the compiler, with shapes taken from lib/wire:

type LeaderboardRequest = {
  board: "all" | "zen" | `daily:${string}`; // normal / zen / a given day's daily challenge
  playerId: string;
  page: number;
};
type LeaderboardResponse = {
  rows: { rank: number; name: string; score: number; playerId: string }[];
  page: number;
  hasMore: boolean;
};

type ReplayRequest = { board: string; rank: number };
type ReplayResponse = { seed: string; moves: string; engine: string };

type ScoreRequest = {
  seed?: string; // normal / zen
  date?: string; // daily challenge: date replaces seed
  moves: string;
  name: string;
  playerId: string;
  engine: string;
  // no score — the server replays (seed, moves, engine) to compute it
};
type ScoreResponse = { score: number; rank: number };

type DeleteScoreRequest = { id: string; playerId: string };
// response: 204, no body

type EngineManifest = {
  version: string; // which ruleset
  build: string; // which bytes
  minUI: number; // oldest UI version that can draw the table
  path: string; // a path, not an absolute URL
  accepts: string[]; // every version the archive can still replay
};

ScoreRequest has no score field, and that’s not an omission — it’s what all of section 4 was about. DeleteScoreRequest scopes itself with (id, player) together, the deletion rule from section 10. And version and build existing as separate fields is the distinction from section 9: one names the ruleset, the other names the bytes.