[Tefuda Dev] One Engine, Three Runtimes: Shipping Rules That Can't Drift

English · 中文

English

Tefuda is a small card game I ship as a website, an iOS app and an Android app, with a server-verified leaderboard behind all three. What’s unusual isn’t that list — it’s how many places the same rules have to be true at the same instant: the browser has to play them, the phone has to play them, and the server has to re-derive your score without taking your word for it.

The obvious build writes those rules three times — TypeScript in the browser, Swift on the phone, something on the server — and then spends the rest of its life fixing one bug wearing three costumes: two copies disagree, and your score depends on who computed it.

This post is about the machinery that removed the copies. Not what the rulesare — that part is the game, and the least interesting half — but the fact that after all of it, the rules exist exactly once and the build refuses to let me forget it.

The Copy Problem

Start from what the leaderboard needs to be true. A submitted run is not a score. The client posts the moves it played, never the number it claims, and the server re-runs those moves from the same seed and takes the score off its own replay. A tampered log either fails to apply or scores exactly what it really scored. That’s the whole security model, and it costs nothing — no anti-cheat, no obfuscation, no signing.

The precondition is harder than it looks: the server’s replay and the player’s session must agree to the last digit. If the two copies of the rules differ by one character in one table, every score is a coin flip between two numbers, and the board silently fills with runs nobody actually played. So “share the code” isn’t a tidiness preference here. It’s the only version of this that works.

One Engine, Three Runtimes

The rules live in one TypeScript directory, and everything else consumes an artifact built from it:

  • The browser imports it directly. The site is a Next.js static export.
  • The Cloudflare Worker imports the same modules and replays every submission with them.
  • The iOS app loads a bundle of it into JavaScriptCore.
  • The Android app costs zero extra runtimes, because it’s a Trusted Web Activity — Chrome opened full-screen on the real origin. It holds no engine, no board, and no Kotlin worth speaking of. A deploy is the Android release.
flowchart TD
  ENGINE["lib/game<br/>the rules, defined once"]
  WIRE["lib/wire<br/>the API, described once"]

  ENGINE --> BROWSER["Browser<br/>static export"]
  ENGINE --> WORKER["Cloudflare Worker<br/>verifies by replay"]
  ENGINE --> JSC["JavaScriptCore<br/>in the iOS app"]

  WIRE -.-> BROWSER
  WIRE -.-> JSC

  ANDROID["Android TWA<br/>no engine, no Kotlin"] -->|opens the real origin| BROWSER

The native bridge is the part people expect to be ugly, and it is deliberately boring — one headless entry point whose only job is to re-export the engine as a JSON-in / JSON-out surface:

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, and Swift decodes it withCodable, so nothing is ambiguous about how a number or an enum bridges.

The rule at the top of that file is what keeps it honest: do not fork logic here — only re-export. The moment the native side computes something itself, it owns a rule, and a rule owned in two places will disagree with itself eventually. That goes further than it sounds: even a ranking table — the kind of thing you’d cheerfully retype into Swift as an enum in thirty seconds — is exposed through the engine and read once at launch, and so is the character cap on the name field. The Swift app knows how to draw the game. It doesn’t know how to play it.

The payoff: a run played natively verifies on the server with no Swift reimplementation to drift from.

The Wire Is Described Once, Too

Sharing the engine and then hand-writing two HTTP clients just moves the duplication one layer out. So the API is described exactly once as well — a single pure module owning every path, every query and body key, every response shape, and every error code.

Both clients are then only transport. The browser client is a fetch around it. The iOS app is a URLSession around it — and it gets the module inside the engine bundle, so it holds no endpoint of its own. It asks for a call as data, performs it, and hands the answer back to be decoded:

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

A native client that composes a URL is a second copy of that file, and it drifts the first time an endpoint moves. This way, changing an endpoint moves both clients — the app already on the App Store included.

Two constraints come with it, both learned by breaking them. The module must stay pure: no fetch, no storage, no DOM, no process.env, because it runs in a bare JavaScriptCore context where URL, btoa and TextEncoder are all undefined — which is why base64url is written out by hand in it. And it must stay outside the replay closure, for reasons that need the next section.

A Version Nobody Types

Shared code solves “the rules are the same.” It does not solve “the rules are the same right now,” which is the harder problem:

  • A browser tab keeps the JavaScript it loaded. A deploy does not reach it.
  • A native app adopts a downloaded engine only at its next cold start, and phones suspend apps for days rather than quitting them.

So every build derives an ENGINE_VERSION — a hash over exactly the modules a replay runs through, which is the closure esbuild resolves from the replay entry point. Two runtimes holding the same string are guaranteed to score a given move log identically. Two holding different strings are not. That’s the entire contract the leaderboard leans on.

It hashes the minified output, not the source bytes. Every version bump invalidates the runs in flight at that moment, so a reworded comment, a reformatted block or a renamed local must not cost somebody the game they’re in the middle of. Minification erases exactly those and keeps everything that can reach a score; a one-character change to a real table always moves the hash. The trade is that esbuild’s own version becomes an input — rare, and it fails in the safe direction: a spurious bump, never a missed one.

It’s derived, not declared, because a forgotten manual bump is precisely the failure the mechanism exists to catch. The script even guards against the hash becoming an input to itself: if the generated version file ever ends up inside the closure it’s hashing, the build throws.

This is also why the wire module has to stay out of that closure. If it were in, moving an endpoint would bump the version and strand every run in progress — paying a game-rules cost for a networking change.

The Archive: Compatibility Instead of Enforcement

The first version of the server enforced the version: post a run played under any other string and it came back stale_engine.

That was the right answer to the wrong question. A deploy doesn’t update the client someone is playing on — it strands them. The web player loses the run in progress. An iOS player who never force-quits could be locked out for days, unable to post anything at all. And the whole time, the rules their run was played under still existed and were perfectly well defined.

So the server keeps them. Every deployed engine is archived as a committed bundle named by its own version, the last six are kept, and a submitted run is replayed under the rules it declares rather than under today’s:

const rules = typeof engine === "string" ? ENGINES[engine] : undefined;
if (!rules) return json({ error: "stale_engine", engine: ENGINE_VERSION }, 409);
flowchart TD
  CLOSURE["the replay closure<br/>what a replay touches"]
  CLOSURE -->|esbuild, minified| VERSION["hash → ENGINE_VERSION"]
  VERSION --> ARCHIVE["engines/<br/>last 6 bundles, committed"]
  VERSION --> CLIENTS["shipped clients<br/>each stamped with its version"]

  CLIENTS -->|moves + the version they were played under| LOOKUP{"in the archive?"}
  ARCHIVE --> LOOKUP

  LOOKUP -->|no| STALE["409 stale_engine"]
  LOOKUP -->|yes| REPLAY["replay under THAT engine"]
  REPLAY --> ROW["the score it really scored"]

Two things this deliberately does not do. It does not re-score an old run by new rules — that would produce a number nobody played for. And it does not take the client’s word for anything: an archived engine replays a log exactly as the live one does, so a run posted under an old version is as verified as any other.stale_engine narrows to what it should always have meant — an engine so old the archive no longer carries it.

Archiving is the one step here that fails silently if you skip it. Nothing at runtime says so; players on the unarchived version simply stop being able to post. So it isn’t a habit, it’s a build step: predev writes the archive,prebuild runs it with --check and fails the build if it’s stale, and the pre-commit hook regenerates it.

There’s a rescue hatch — --recover <commit> rebuilds a missed bundle out ofgit archive <commit> rather than the working tree, since a version’s rules are whatever the source said at the commit that generated them. Two entries in my archive were recovered that way, hours before the history holding them got squashed. Which is also the warning: squash away the commit that generated a version and its rules are gone for good, along with any run played under them.

One last piece I like: the archive index is hand-editable for exactly one case: two version strings that mean the same rules, which happens when you change how the hash is computed rather than what it hashes. Pointing the old string at the existing bundle is a true statement about the world, and one no script can derive:

{
  "version": "d944246679f2",
  "file": "7766419a1169.js",
  "note": "The version live before the hash moved to esbuild's minified output. Same rules, same scores — only the way the string is computed changed."
}

Every run in flight on the old string kept posting.

Landing New Rules on a Client That’s Already Running

With the archive underneath, updating stops being urgent and becomes polite. The server exposes a pointer, not a payload:

{ version: ENGINE_VERSION, minUI: MIN_APP_UI, path: '/engine.js', accepts: ENGINE_HISTORY }

The app compares version against what it’s running and downloads only on mismatch, which on almost every launch means a hundred bytes and nothing else. That is the entire update mechanism — and it means a rules change reaches players who already have the app without waiting on App Review, because the rules were never Swift to begin with.

minUI is the half a download can’t answer. A bundle can teach the app new numbers; it cannot teach it to draw a control that doesn’t exist in the binary. An app below minUI therefore keeps the engine it has and goes on posting perfectly well until the archive ages that engine out — a nudge, not a cliff.

On the web, the update is a reload, and the interesting design question iswhen. Not a prompt, not a mid-run swap: a reload at the only moment nothing is at stake — a board that has been dealt and not yet played. That covers the case that actually happens (a tab left open overnight, a deploy landing while you sit on a results screen) and it never interrupts anybody, because at that moment there is nothing to interrupt. The native app makes the same trade: adopted at the next cold start, never mid-run.

One subtlety worth stealing: that reload also has to discard the saved board, not resume it — the deal on screen was made by the engine being left behind, and a new engine may deal the same seed differently. Resume it and the move log replays to a score the player never saw; nothing has been played, so the save costs nothing to drop. And the whole thing remembers it already tried once per version, because a client that can’t actually get the new build — an asset cache serving yesterday’s HTML, a proxy, a shell — would otherwise reload forever.

The Worker: Where the Server Actually Is

The server is one Cloudflare Worker with five endpoints, and the most important design decision in it is how little it runs.

The Worker is not the site. The game is a static export served by Cloudflare’s asset router; the Worker is configured to run first for exactly two path prefixes:

"assets": {
  "directory": "./out",
  "binding": "ASSETS",
  "run_worker_first": ["/api/*", "/w/*"],
}

Everything else — every page load, every asset, the board itself — never counts as an invocation. Playing the game costs zero Worker requests. Only submitting, reading a leaderboard and opening somebody’s shared link reach code I wrote.

flowchart TD
  REQ["a request"] --> SPLIT{"which path?"}

  SPLIT -->|everything else| ASSETS["asset router → ./out<br/>0 Worker invocations"]
  SPLIT -->|"/api/*"| WORKER["the Worker<br/>run_worker_first"]
  SPLIT -->|"/w/*"| WORKER

  WORKER --> DB[("D1<br/>one table, append-only")]
  WORKER --> CARD["resvg-wasm<br/>the shared run's card"]

The trust boundary is the replay, not the request. CORS is * on purpose: the endpoints carry no cookies and every score is re-verified by replay, so the requesting origin isn’t a trust boundary. (Using * rather than a reflected origin also keeps a Vary: Origin off the cached board, so one cached copy serves web and native alike.) The rate limit is deliberately light — player IDs are client-chosen, so it only caps how fast one client can flood the table. What holds the board up is that every row is a run the server replayed for itself.

Cache-Control is the infrastructure. No queue, no cache layer, no Redis. Four numbers instead: the board is cached 30s, a replay 300s, the engine pointer 300s, a shared run’s card an hour. Each one is an argument. The engine pointer is short because it’s the only thing standing between a rules change and a client that hasn’t noticed. The share card and its image use the same hour so the number on the picture and the number in the text can never drift apart.

Persistence is boring SQL. D1, four migrations, one table, plainprepare().bind(). Every accepted run is its own row — nothing is overwritten, so beating an old run never wipes the replay behind it. Deleting is pinned to(id, player), and the row ID is only ever handed back on the caller’s own rows, so even a leaked ID can’t remove somebody else’s run.

A shared run is carried, not stored. A share link holds the entire run — the seed and the move log — in its path. Nothing about it is on the server: the link works offline the moment it’s built, it never 404s, and there’s no row anyone can delete that takes it down. The score isn’t in it either; whoever opens it replays the log and finds out, which is the same standard the leaderboard holds a submission to.

That last one is the reason the Worker serves any HTML at all. The board reads the run out of a URL fragment, and a fragment is never sent — so a crawler could only ever be shown the bare site, and every shared run previewed as the same picture of the same empty table. So the Worker answers the path with a head naming that specific run, then hands a real browser straight back onto the fragment:

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

One invocation per opened link and per crawler that previews one — not one per player. And nothing in that head is trusted from the link: the name is clipped by the same rule the board stores one under, and the score is replayed for, not read off, the payload. A number in a URL is a claim, and a claim rendered into a preview card is a claim with a picture around it.

The picture itself is drawn in the Worker — SVG rasterized through resvg-wasm, with the two font weights imported as bytes because there’s no filesystem to read them off and no network it should be reaching over for them. Two things I got wrong first: initWasm may only be called once per isolate and throws on the second go, so the guard has to be the promise itself (two requests landing together on a cold isolate both await the same start), and the pixels have to be copied off the wasm memory before returning, because the response body is read after the function has already returned.

What Generalizes

Five things I’d do again on anything with a client and a server that must agree:

  1. If two runtimes must agree, generate the agreement — don’t document it. Every shared constant retyped in a second language is a scheduled outage.
  2. Version by a content hash of what can actually change the answer. Hash the minified closure, not the source, so formatting is free and semantics aren’t. Never hand-bump.
  3. Prefer compatibility to enforcement. Rejecting old clients doesn’t update them, it strands them. Keeping their old rules around costs six committed files.
  4. Make silent failures loud at build time. The archive step fails invisibly at runtime and obviously in prebuild --check. That asymmetry is the whole value of the check.
  5. Put the server only where the server is needed. Five endpoints, and the game itself never touches one of them.

The nice side effect is what the repos ended up looking like. The iOS app holds no rules. The Android app holds no code. The server holds no game logic the browser doesn’t hold too. Everything that could disagree with itself was deleted until one copy was left, and then the build was taught to shout when a second one starts to grow back.


中文

Tefuda 是我做的一款小卡牌遊戲,同時以網站、iOS App、Android App 三種形式上架,背後接著一個由伺服器驗證的排行榜。特別的不是這個組合,而是「同一套規則必須在同一瞬間成立」的地方有多少:瀏覽器要照它跑、手機要照它跑,而伺服器必須在不採信你說詞的前提下,自己把分數重新算出來。

最直覺的做法是把規則寫三次——瀏覽器裡的 TypeScript、手機上的 Swift、伺服器上再一份——然後往後的日子都在修同一個 bug 的三種變裝:兩份副本對不起來,而你的分數取決於是誰算的。

這篇講的是把副本消滅掉的那套機制。我不會談規則本身是什麼——那是遊戲的部分,也是比較不有趣的那一半。有趣的是:做完這一切之後,規則只存在一份,而且建置流程不准我忘記這件事。

副本問題

先從排行榜必須成立的那件事講起。一筆送出的紀錄不是分數。客戶端送的是它實際打過的每一步,永遠不是它宣稱的數字,伺服器再用同一個亂數種子把這串步驟重跑一次,分數以自己這次重播的結果為準。被動過手腳的紀錄,不是中途套用失敗,就是老老實實跑出它真正的分數。整個防作弊模型就這樣,而且成本是零——不需要反作弊、不需要混淆、不需要簽章。

它的前提比看起來難得多:伺服器的重播和玩家當下那一局,必須精準到最後一位數都一致。只要兩份規則差了某張表裡的一個字元,每一筆分數就成了兩個數字之間的擲硬幣,排行榜會安靜地被沒人真的打出來的紀錄填滿。所以在這裡,「共用程式碼」不是潔癖,而是唯一行得通的做法。

一套引擎,三個執行環境

規則只住在一個 TypeScript 目錄裡,其他所有東西吃的都是從它建出來的產物:

  • 瀏覽器直接 import。網站本身是 Next.js 的靜態輸出。
  • Cloudflare Worker import 同一批模組,每一筆送上來的紀錄都用它重播。
  • iOS App 把它打包成一份 bundle,載進 JavaScriptCore 執行。
  • Android App 不多花任何一個執行環境,因為它是 Trusted Web Activity——全螢幕開在正式站台上的 Chrome。它沒有引擎、沒有牌桌,也沒有值得一提的 Kotlin。這邊部署,就是 Android 出版本。
flowchart TD
  ENGINE["lib/game<br/>規則只定義一次"]
  WIRE["lib/wire<br/>API 只描述一次"]

  ENGINE --> BROWSER["瀏覽器<br/>靜態輸出"]
  ENGINE --> WORKER["Cloudflare Worker<br/>用重播驗證"]
  ENGINE --> JSC["JavaScriptCore<br/>iOS App 內"]

  WIRE -.-> BROWSER
  WIRE -.-> JSC

  ANDROID["Android TWA<br/>沒有引擎、沒有 Kotlin"] -->|開在正式站台上| BROWSER

大家預期最醜的通常是原生橋接那段,而它被我刻意做得很無聊——整個 headless 入口的唯一工作,就是把引擎重新輸出成一個 JSON 進、JSON 出的介面:

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)),
  // …
};

狀態以 JSON 字串跨過 JS↔Swift 的邊界,Swift 那側用 Codable 解碼,所以數字或 enum 怎麼橋接完全沒有模糊空間。

讓這件事保持誠實的規矩就寫在那個檔案最上面:不要在這裡分叉邏輯,只能重新輸出。 原生那側只要開始自己算某件事,它就擁有了一條規則,而被兩個地方各自擁有的規則,遲早會自己跟自己吵架。這條紀律比乍聽之下更嚴:連一張排序用的對照表——那種你會很開心地在三十秒內抄成一個 Swift enum 的東西——也是透過引擎拋出來、啟動時讀一次,玩家名稱的字數上限也一樣。Swift App 知道怎麼這場遊戲,但它不知道怎麼

換來的是:原生玩出來的一局,能在伺服器上通過驗證,中間沒有一份會走鐘的 Swift 實作。

通訊協定也只描述一次

共用了引擎卻手寫兩套 HTTP client,只是把重複往外推一層。所以 API 也只描述一次——一個純函式模組,把每一條路徑、每一個 query 和 body 的 key、每一種回應形狀、每一組錯誤碼全收在裡面。

兩邊的客戶端因此都只剩下傳輸。瀏覽器那份是包在外面的 fetch,iOS 那份是包在外面的 URLSession——而且它是從引擎 bundle 裡拿到這個模組的,所以它自己不持有任何一個 endpoint。它跟模組要一次呼叫該送什麼,自己送出去,再把回應丟回去解碼:

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

一個會自己組 URL 的原生客戶端,就是那個檔案的第二份副本,而它會在 endpoint 第一次搬家的當下走鐘。用現在這個做法,改一條 endpoint 會同時帶動兩邊——包含已經上架在 App Store 的那個版本。

伴隨而來的兩條限制都是撞過才學得會的:這個模組必須保持純粹:不能有 fetch、不能碰儲存、不能碰 DOM、不能讀 process.env,因為它跑在一個空的 JavaScriptCore 環境裡,那裡 URLbtoaTextEncoder 全都不存在——這也是為什麼 base64url 在裡面是手寫的。另外它必須待在重播閉包之外,理由留到下一節。

一個沒有人手動輸入的版本號

共用程式碼解決的是「規則是同一套」,沒有解決「規則此刻是同一套」,而後者才是難的那個:

  • 瀏覽器分頁會一直用它當初載到的 JavaScript。部署碰不到它。
  • 原生 App 只在下一次冷啟動時採用下載回來的引擎,而手機是把 App 掛起好幾天,不是關掉它。

所以每次建置都會推導出一個 ENGINE_VERSION——對「一次重播真正會經過的那些模組」取雜湊,也就是 esbuild 從重播進入點解析出來的那個閉包。兩個執行環境只要持有同一個字串,就保證會把同一串步驟算出同一個分數;持有不同字串就不保證。整個排行榜靠的就是這一條約定。

它雜湊的是壓縮後的輸出,不是原始碼位元組。 每一次版本變動都會讓當下進行中的局作廢,所以改個註解措辭、重排一個區塊、把區域變數改名,都不該害誰損失一局玩到一半的遊戲。壓縮剛好會把這些抹掉,而把所有能影響分數的東西留下;真正的表只要改一個字元,雜湊一定會動。代價是 esbuild 自己的版本也成了輸入之一——這很少見,而且它往安全的方向壞:只會多跳一次版本,不會漏跳。

它是推導出來的,不是宣告的,因為「忘記手動 bump」正是這套機制存在要抓的那個失誤。腳本甚至防了雜湊變成自己的輸入:如果那個產生出來的版本檔哪天跑進被雜湊的閉包裡,建置會直接丟錯。

這也是為什麼通訊協定模組必須待在那個閉包外面。如果它在裡面,搬一條 endpoint 就會讓版本跳動、讓所有進行中的局變成孤兒——為了一個網路層的改動,付一筆遊戲規則的帳。

封存:用相容取代強制

伺服器的第一版是強制版本相符的:用任何其他字串打出來的紀錄送上來,一律回 stale_engine

那是一個正確答案,只是問題問錯了。部署不會更新某個人正在玩的客戶端,它是把對方丟在原地。網頁玩家會失去進行中的那一局;一個從不強制關閉 App 的 iOS 玩家可能被鎖上好幾天,什麼都送不出去。而這整段期間,他們那一局所依循的規則明明還存在,而且定義得好好的。

所以伺服器把它們留著。每一個部署過的引擎都會被封存成一份以自己版本命名、進版控的 bundle,保留最近六個;送上來的紀錄,用它自己宣告的那套規則重播,而不是用今天的:

const rules = typeof engine === "string" ? ENGINES[engine] : undefined;
if (!rules) return json({ error: "stale_engine", engine: ENGINE_VERSION }, 409);
flowchart TD
  CLOSURE["重播閉包<br/>一次重播真正經過的模組"]
  CLOSURE -->|esbuild 壓縮後| VERSION["雜湊 → ENGINE_VERSION"]
  VERSION --> ARCHIVE["engines/<br/>最近六份 bundle,進版控"]
  VERSION --> CLIENTS["出貨的客戶端<br/>各自烙上自己的版本"]

  CLIENTS -->|步驟紀錄 + 它當初依循的版本| LOOKUP{"封存裡有嗎?"}
  ARCHIVE --> LOOKUP

  LOOKUP -->|沒有| STALE["409 stale_engine"]
  LOOKUP -->|有| REPLAY["用那一份引擎重播"]
  REPLAY --> ROW["它真正打出來的分數"]

有兩件事是它刻意不做的。它不會用新規則把舊紀錄重算一次——那會是一個沒有人為它玩過的數字。它也不會採信客戶端的任何說法:封存的引擎重播一串紀錄的方式,和線上那個一模一樣。stale_engine 於是收斂成它本來就該有的意思——舊到封存已經不再帶著它的引擎。

封存是這裡唯一一個「漏掉會安靜壞掉」的步驟。執行期沒有任何東西會提醒你,只是那個未封存版本上的玩家從此送不出成績。所以它不是一個習慣,而是一個建置步驟:predev 會寫入封存,prebuild 用 --check 跑一次、發現過期就讓建置失敗,pre-commit hook 則負責重新產生。

也有一條救生索——--recover <commit> 會改用 git archive <commit> 而不是工作目錄,把漏掉的 bundle 重建出來,因為一個版本的規則,就是產生它的那個 commit 當下原始碼所說的那樣。我的封存裡有兩筆就是這樣救回來的,而且是在那段歷史被 squash 掉的幾小時前。這同時也是警告:把產生某個版本的 commit squash 掉,那套規則就永遠消失了,連同任何一局照它玩過的紀錄。

還有一個我很喜歡的小地方——封存索引只在一種情況下可以手改:兩個版本字串其實代表同一套規則——當你改的是雜湊怎麼算,而不是它算什麼的時候。把舊字串指向既有的 bundle,是一句關於世界的真話,而且沒有腳本推導得出來:

{
  "version": "d944246679f2",
  "file": "7766419a1169.js",
  "note": "雜湊改成取 esbuild 壓縮輸出之前的線上版本。規則一樣、分數一樣,只有字串的算法變了。"
}

所有還停在舊字串上、進行到一半的局,都繼續送得出成績。

怎麼把新規則送進一個已經在跑的客戶端

有了封存墊在下面,更新就從緊急事件變成一件有禮貌的事。伺服器給出的是一個指標,不是內容:

{ version: ENGINE_VERSION, minUI: MIN_APP_UI, path: '/engine.js', accepts: ENGINE_HISTORY }

App 拿 version 跟自己正在跑的比對,不一樣才下載——在幾乎每一次啟動,這代表一百多個位元組,然後就沒了。整套更新機制就這樣。也就是說,一次規則調整能送到已經裝了 App 的玩家手上,不必等 App Review,因為那些規則從一開始就不是 Swift。

minUI 是下載回答不了的那一半。一份 bundle 可以教會 App 新的數字,但教不會它畫出一個二進位檔裡根本不存在的控制項。低於 minUI 的 App 因此會保留手上的引擎,而且照樣送得出成績,直到封存把那個引擎淘汰掉為止——這是一個提醒,不是一道斷崖。

在網頁這邊,更新就是重新載入,而有趣的設計問題是什麼時候。不是跳提示、也不是局中抽換:而是在唯一沒有任何東西可失去的那一刻重新載入——牌已經發好、但還沒動過的那個盤面。這剛好涵蓋了真正會發生的情況(分頁開著過了一夜、你停在結算畫面時剛好有一次部署),而且永遠不會打斷任何人,因為那一刻根本沒有東西可以打斷。原生 App 做的是同一個取捨:下次冷啟動時採用,絕不在局中。

一個值得抄走的細節:那次重新載入還必須把存檔丟掉,而不是接續它——螢幕上那副牌是被留在後面的那個引擎發的,而新引擎用同一個種子可能發得不一樣。接續下去,那串步驟會重播成一個玩家從沒看過的分數;反正還沒動過任何一步,丟掉存檔不花任何代價。而且整套機制會記得自己每個版本只試一次,因為一個其實拿不到新版本的客戶端——靜態快取還在餵昨天的 HTML、中間卡了個代理、或是某種殼——否則就會無限重新載入下去。

Worker:伺服器真正存在的地方

伺服器是一個 Cloudflare Worker、五個 endpoint,而它最重要的設計決策,是讓它盡量不要跑。

Worker 不是網站。 遊戲本體是靜態輸出,由 Cloudflare 的 asset router 直接送;Worker 只在兩個路徑前綴上優先執行:

"assets": {
  "directory": "./out",
  "binding": "ASSETS",
  "run_worker_first": ["/api/*", "/w/*"],
}

其他所有東西——每一次開頁、每一個靜態資源、牌桌本身——完全不算一次呼叫。玩這款遊戲要花的 Worker 請求數是零。只有送成績、讀排行榜,以及打開別人分享的連結,才會碰到我寫的程式碼。

flowchart TD
  REQ["一個請求"] --> SPLIT{"看路徑"}

  SPLIT -->|其他全部| ASSETS["asset router → ./out<br/>0 次 Worker 呼叫"]
  SPLIT -->|"/api/*"| WORKER["Worker<br/>run_worker_first"]
  SPLIT -->|"/w/*"| WORKER

  WORKER --> DB[("D1<br/>一張表,只增不改")]
  WORKER --> CARD["resvg-wasm<br/>分享出去那局的卡片"]

信任邊界在重播,不在請求。 CORS 開 * 是刻意的:這些 endpoint 不帶任何 cookie,每一筆分數都會被重播驗證,所以發出請求的來源根本不是信任邊界。(用 * 而不是回填來源,也讓被快取的排行榜身上不必掛 Vary: Origin,一份快取就能同時服務網頁和原生。)流量限制刻意做得很輕——玩家 ID 是客戶端自己挑的,所以它能做的只是壓住單一客戶端灌爆牌桌的速度。真正撐住排行榜的,是每一列都是伺服器自己重播過的一局。

Cache-Control 就是基礎建設。 沒有 queue、沒有快取層、沒有 Redis,只有四個數字:排行榜快取 30 秒、一筆重播 300 秒、引擎指標 300 秒、一則分享卡片一小時。每個數字都是一個論證。引擎指標很短,因為它是「規則變了」和「客戶端還沒發現」之間唯一的那道東西;分享卡片和它的圖用同一個小時,這樣圖上的數字和文字裡的數字就不可能對不起來。

持久化是很無聊的 SQL。 D1、四個 migration、一張表,樸素的 prepare().bind()。每一筆被接受的紀錄都是自己的一列——不覆寫任何東西,所以刷新紀錄不會抹掉舊的那一局,也不會抹掉它背後的重播。刪除被綁在 (id, player) 上,而列的 ID 只會回傳給它自己的擁有者,所以就算 ID 外洩,也刪不掉別人的紀錄。

分享出去的一局是被帶著走的,不是被存起來的。 分享連結把整局——種子和步驟紀錄——放在路徑裡。伺服器上沒有任何關於它的東西:連結在被建出來的那一刻就能離線使用,永遠不會 404,也沒有任何一列資料被誰刪掉會讓它消失。分數也不在裡面;打開的人自己重播那串紀錄才知道,這跟排行榜要求一筆送件的標準是同一套。

最後這點,正是這個 Worker 為什麼還要送 HTML 的原因。牌桌是從 URL 的 fragment 讀出那一局的,而 fragment 永遠不會被送出去——所以爬蟲能看到的永遠只有光禿禿的站台,每一則分享出去的紀錄都預覽成同一張空桌子的同一張圖。於是 Worker 用一個指名這一局的 head 回答那條路徑,然後把真正的瀏覽器直接送回 fragment 上:

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

每一條被打開的連結、每一隻預覽它的爬蟲各算一次呼叫——而不是每個玩家一次。而且那個 head 裡沒有任何東西是從連結裡採信的:名字用排行榜存名字的同一條規則裁切,分數是重播算出來的,不是從 payload 上讀來的。URL 裡的一個數字只是一句宣稱,而被畫進預覽卡片的宣稱,是一句附了圖的宣稱。

那張圖本身是在 Worker 裡畫的——SVG 經由 resvg-wasm 點陣化,兩種字重的字型以位元組的形式 import 進來,因為那裡沒有檔案系統可以讀,也沒有它應該為此連出去的網路。有兩個小地方我一開始做錯了:initWasm 每個 isolate 只能呼叫一次、第二次會丟錯,所以守衛必須是那個 promise 本身(兩個請求同時打到冷 isolate 時,兩邊等的是同一次啟動);還有像素必須在回傳前從 wasm 記憶體複製出來,因為 response body 是在函式回傳之後才被讀的。

五件我下次還會這樣做的事

  1. 兩邊必須一致,就讓建置去產生那份一致,不要寫在文件裡叫大家記得。 一個共用常數被第二種語言重打一次,就是一次排好時間的故障。
  2. 版本號用「會改變答案的東西」算出來。 雜湊壓縮後的閉包,不要雜湊原始碼——排版免費,語意才收費。手動 bump 遲早會忘。
  3. 能相容就不要強制。 擋掉舊的客戶端不會讓它變新,只會把它丟在那裡;留著它的舊規則,代價是六個檔案。
  4. 會安靜壞掉的事,想辦法讓它在建置時大聲壞掉。 封存漏掉,執行期什麼都不會講;prebuild --check 會直接讓建置失敗。
  5. 伺服器只放在真的需要伺服器的地方。 五個 endpoint,而遊戲本身一個都不會碰到。

最舒服的副作用,是這幾個 repo 最後長成的樣子。iOS App 裡沒有規則。Android App 裡沒有程式碼。伺服器上沒有任何一段瀏覽器不也持有的遊戲邏輯。所有可能跟自己吵架的東西都被刪到只剩一份,然後我再教會建置流程:第二份一開始長回來,就大聲喊。