[Tefuda] 一份引擎,三個執行環境:網頁、iOS、Android 共用同一套規則
Tefuda 是我做的一款小卡牌遊戲,同時以網站、iOS App、Android App 三種形式上架,背後接同一個由伺服器驗證的排行榜。
麻煩的不是這個組合,而是同一套規則必須同時在四個地方成立:瀏覽器要照它跑、iOS App 要照它跑、Android 要照它跑,而伺服器要在不採信任何用戶端說法的前提下,自己把分數重算一次。
最直覺的做法是把規則寫三次:瀏覽器的 TypeScript、手機的 Swift、伺服器再一份。代價是之後每個 bug 都要修三次,而且兩份實作只要對不起來,分數就取決於是誰算的。
所以規則只有一份。
這篇不從架構圖開始,而是從一個玩家的動線開始,按順序回答三個問題:你在螢幕上做的事是誰算的?什麼時候會打到 API?留下來的資料躺在哪裡? 這三題答完,整套系統就講完了一半;後面幾節處理的都是同一個麻煩的不同面向——規則會改版,而三個用戶端不會同時知道。
1|三個 App,其實只有兩種畫法
先把「三個用戶端」拆開,因為它們的差別比看起來小很多:
| 用戶端 | 誰畫牌桌 | 誰跑規則 | 規則怎麼更新 |
|---|---|---|---|
| 網頁 | Next.js 靜態輸出,跑在瀏覽器裡 | 瀏覽器 | 重新整理就是最新的 |
| iOS App | 原生 SwiftUI,一行 HTML 都沒有 | JavaScriptCore(iOS 內建的 JS 引擎) | 背景下載,下次冷啟動換上 |
| Android App | 它不畫,它就是全螢幕的 Chrome | 瀏覽器 | 網站部署完,Android 就等於出了一版 |
Android 那一列不是偷懶,是刻意的:它是一個 Trusted Web Activity,也就是「Chrome 開在 https://playtefuda.com 上、網址列拿掉」,因為 App 和網站互相證明過彼此是同一個產品。整個 repo 裡沒有任何一個 .kt 檔案。
所以三個 App,實際上只有兩個地方在跑規則:瀏覽器,和 iOS App 裡的 JavaScriptCore。伺服器自己也要跑一次,加起來三個——標題的「三個執行環境」就是這個意思。
flowchart TD
SRC(["lib/game + lib/wire<br/>規則和 API,各只寫一次"])
SRC --> BUNDLE[/"engine.js<br/>建置出來的單一檔案,約 12 KB"/]
BUNDLE --> WEB{{"瀏覽器<br/>網頁玩家在這裡跑它"}}
BUNDLE --> JSC{{"JavaScriptCore<br/>iOS App 在這裡跑它"}}
BUNDLE --> WK{{"Cloudflare Worker<br/>伺服器在這裡把它重跑一次"}}
WEB -.-> TWA["Android App<br/>就是全螢幕的瀏覽器,自己不跑引擎"]有三個詞這篇會用得很嚴格,後面每一節都靠這個區分:
- 引擎(engine)= 建置出來的規則產物。一個檔案,沒有 import、沒有設定、沒有網路呼叫。
- 執行環境(runtime)= 能跑這個檔案的宿主:瀏覽器、JavaScriptCore、Cloudflare Workers。
- 一局(run)= 一場遊戲,紀錄成「一個 seed + 一串步驟」,從來不是一個分數。
2|玩一局會打幾條 API?零條
最反直覺的一句話先講完:從發牌到結算,整局遊戲不需要伺服器。
洗牌是 seed 決定的;一步棋做了什麼、一手牌值多少分、什麼時候升級,全都是純函式,在你自己的裝置上算完。網頁玩家連 Worker 都碰不到一次——遊戲本體是靜態檔案,由 Cloudflare 的 asset router 直接送。
伺服器只在四件事情上出現:送成績、看排行榜、看別人的重播,以及(只有 iOS)問一句「引擎有沒有新版」。
整套 API 就這五條,沒有第六條:
| 呼叫 | 什麼時候 | 送什麼 | 回什麼 |
|---|---|---|---|
GET /api/leaderboard | 打開排行榜 | 哪個模式(一般模式/禪模式/每日挑戰)、我的玩家 id、分頁 | 一頁的列 |
GET /api/replay | 點開某一列想看 | 哪個板、第幾名 | seed、步驟紀錄、引擎版本 |
POST /api/score | 一局結束按送出 | seed 或日期、步驟紀錄、名字、玩家 id、引擎版本——沒有分數 | 伺服器自己算出來的分數和排名 |
DELETE /api/score | 刪掉自己的紀錄 | 列的 id + 玩家 id | — |
GET /api/engine | 每次啟動,牌桌畫好後 | 什麼都不送 | 現在線上是哪個引擎、去哪抓 |
前四條三個用戶端都會打;只有最後一條 GET /api/engine 是只有 iOS 會打。
另外有兩種東西也會走網路,但它們不是 API,是靜態檔案:/engine.js(iOS 抓新引擎)和 /engines/<版本>.js(要重播一局舊規則打的遊戲時,抓那套舊規則)。它們一樣由 asset router 送,不會叫醒任何一行我寫的程式碼。
伺服器端只有兩個路徑前綴會真的執行我的程式:
flowchart TD
REQ["一個請求進來"] --> SPLIT{"看路徑"}
SPLIT -->|"/api/*"| WORKER{{"Worker<br/>五條 API"}}
SPLIT -->|"/w/*"| WORKER
SPLIT -->|"其他全部<br/>頁面、圖片、engine.js、engines/*.js"| ASSETS("asset router → 靜態檔案<br/>0 次 Worker 呼叫")
WORKER --> DB[("D1<br/>一張表")]這張圖真正想說的是最下面那條線:玩這款遊戲要花的 Worker 請求數是零。 只有送成績、讀排行榜、看重播,和打開別人分享的連結(/w/*,留到後面分享卡片那節細講是什麼),才會碰到我寫的程式碼。
3|資料存在哪
三個地方,而且三邊存的東西幾乎不重疊。
(1) 玩家的裝置上
網頁存在 localStorage、iOS 存在 UserDefaults——存的是同一組東西,連 key 的名字都一樣,因為玩家身分那兩個 key 是 lib/wire.ts 給的,兩邊都從同一份程式碼讀:
| 存什麼 | 網頁(localStorage) | iOS(UserDefaults) |
|---|---|---|
| 玩家身分(一串隨機 id) | tefuda_player_id | 同名 |
| 顯示名稱 | tefuda_player_name | 同名 |
| 語言/主題/靜音 | tefuda_lang 等三個 | 同名 |
| 每日挑戰打過哪幾天、各拿幾分 | tefuda_daily_v1 | 同名 |
| 進行到一半的牌桌 | tefuda_save_v6 | 沒有 |
| 送不出去、等重試的成績 | tefuda_pending_v1 | 沒有 |
| 下次啟動要換上的新引擎 | 沒有 | Application Support 底下一個 .js 檔 + 三個 key |
最後三列的差異全都是平台差異,不是設計不一致:分頁隨時會被重新整理,所以網頁得把牌桌和送不出去的成績寫下來;手機 App 被切走時整個活在記憶體裡,回來就還在原地。反過來,只有 iOS 需要把「下次啟動要換上的引擎」存到磁碟——因為只有它的規則不是每次開啟都重抓(第 9 節)。
這裡沒有帳號。 沒有密碼、沒有 cookie、沒有 session。玩家 id 是用戶端自己隨機產生的一串字,它不是身分驗證,只是一個「這幾列是同一支裝置送的」的標記。這件事之所以無所謂,是因為排行榜的可信度完全不靠它——靠的是第 4 節的重播。
(2) 伺服器的資料庫
D1(Cloudflare 的 SQLite),六個 migration,一張表,每一筆被接受的紀錄就是一列:
| 欄位 | 例子 | 用來做什麼 |
|---|---|---|
moves | p0p1c0dd… | 391 步,每步兩個字元 |
seed | 2880217059 | 當初發的是哪一副牌 |
date | 2026-07-25 | 哪一天的每日挑戰(或 free) |
score | 608074 | 打出來是多少 |
engine | 7766419a1169 | 那是在哪一套規則下打的 |
分數雖然存著,但它不是事實來源,只是一次計算的快取。真正的事實是 (seed, moves, engine) 這三欄,因為它們可以把分數精準重算出來。這就是這裡「伺服器驗證」的意思,也是重播之所以可能的原因:觀看者不是在抓一段錄影,而是把那一局重跑一次。
這張表只增不改。刷新自己的紀錄不會覆蓋舊的那一列,所以別人分享出去的舊重播不會因為你變強而失效。
(3) 版控裡的一個目錄
engines/ 底下每個檔案是一整套「曾經上線過的規則」,各約 11 KB,全部納入 git。它為什麼必須存在,是第 8 節整節的內容。
換句話說,伺服器上關於一位玩家的全部,就是那張表裡的幾列;而伺服器上關於這款遊戲的全部,就是那張表加上那個目錄。
4|一局怎麼變成排行榜上的一列
前三節是靜態的地圖,這節是動線。跟著一局遊戲走一次,所有零件會照順序出場:
flowchart TD
PLAY["玩家打完一局<br/>在瀏覽器或 iOS App 上"]
PLAY -->|"POST /api/score<br/>seed + 步驟 + 引擎版本,沒有分數"| WORKER{{"Worker"}}
WORKER --> LOOKUP{"engines/ 裡有這個版本嗎?"}
LOOKUP -->|"沒有"| STALE["409 stale_engine"]
LOOKUP -->|"有"| REPLAY["用那一份引擎重播<br/>同一個 seed、同樣 391 步"]
REPLAY --> ROW[("寫進 D1<br/>seed · moves · engine · score")]
ROW --> WATCH["幾週後有人點開想看<br/>抓 /engines/<版本>.js 再跑一次"]裡面有兩件事就是整個設計。
用戶端送的是它實際打過的每一步,不是它宣稱的分數。 伺服器用同一個 seed 把這串步驟重跑一次,以自己重播的結果為準。被動過手腳的紀錄,不是套用失敗,就是跑出它真正的分數。整個防作弊模型就這樣,成本是零:不需要混淆、不需要簽章、不需要任何啟發式判斷。
前提比看起來嚴格。 伺服器的重播和玩家當下那一局,必須每一位數都一致。兩份規則只要有一張分數表差一個字元,每筆分數就是兩個數字之間的擲硬幣,排行榜會安靜地被沒人真的打出來的紀錄填滿。所以共用程式碼在這裡不是潔癖,是唯一可行的做法。
5|lib/game:只有規則,別的都不放
它是什麼。 一個 TypeScript 目錄,兩千五百行左右,知道一副牌怎麼從 seed 洗出來、一步棋對狀態做了什麼、一手牌怎麼比大小、一局值多少分。裡面全是對單純資料做事的單純函式:placeCard(state, index) 回傳一個新的狀態。沒有 React、沒有 DOM、沒有 fetch、沒有儲存、沒有時鐘,也沒有任何不是從 seed 來的隨機。
最後一項最重要。 這裡的每件事都必須是 (seed, 步驟) 的純函式,因為稍後伺服器手上就只有這兩樣東西。只要有一條計分規則去問了現在幾點,那一局就再也驗證不了。
它刻意不管的事:遊戲長什麼樣子。動畫、音效、排版、文案、顯示個人最佳紀錄的那個畫面,通通不在這裡,也通通不可能改變分數。
真正花最多力氣維持的紀律,是「規則」的邊界拉到多遠。Swift App 裡沒有一張「哪種牌型贏哪種」的對照表,它啟動時去問引擎;玩家名稱的字數上限也不在 Swift 裡,排行榜存名字用哪條規則裁切,App 就用同一條。這種常數正好是那種三十秒就能抄成 Swift 的東西,也正好是最會偏掉的東西。
Swift App 知道怎麼「畫」這場遊戲,不知道怎麼「玩」。
原生橋接通常是大家預期最醜的一段,這裡刻意做得無聊:一個 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 怎麼對應沒有模糊空間。檔案最上面寫著這條規矩:不要在這裡分叉邏輯,只能重新輸出。原生那側一旦自己算某件事,就等於擁有了一條規則,而同一條規則被兩邊各自持有,遲早會不一致。
6|lib/wire:連 API 也只描述一次
第 2 節那張五條 API 的表格,不是我整理出來的文件,而是一個檔案的內容。
共用了引擎卻手寫兩套 HTTP client,只是把重複往外推一層。iOS App 會自己持有一份路徑和參數名,而 endpoint 第一次搬家它就過期了——安靜地、在別人的裝置上,而且除了送審沒有別的辦法修。
所以 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 => { /* … */ },
}
Swift 這一側唯一自己決定的事,是「一個根本沒送出去的請求叫做 offline」。連玩家身分存在哪個 key、名字最多幾個字、分享連結長什麼樣,都是跟這個模組要的答案。
這就是它跨執行環境的方式:它不是一個 App 去連結的函式庫,而是引擎交出來的資料。改一條 endpoint 會同時帶動兩邊,包含已經上架在 App Store 的那個版本。
兩條限制都是撞過才知道的。
它必須純粹:不能用 fetch、不能碰儲存、DOM 或 process.env,因為它跑在一個空的 JavaScriptCore 環境裡,URL、btoa、TextEncoder 全都不存在。base64url 因此是手寫的。
它必須待在重播閉包之外,理由在下一節。
7|ENGINE_VERSION:沒有人手打的版本號
到這裡為止,「規則只有一份」已經成立了。但它只解決了「規則是同一套」,沒解決「規則此刻是同一套」,而後者才難:
- 瀏覽器分頁會一直用它當初載到的 JavaScript,部署碰不到它。
- 原生 App 只在下次冷啟動時採用下載回來的引擎,而手機通常是把 App 掛起好幾天,不是關掉。
所以每次建置都推導出一個 ENGINE_VERSION:對「一次重播真正會經過的模組」取雜湊,也就是 esbuild 從重播進入點解析出來的閉包。兩個執行環境持有同一個字串,就保證同一串步驟算出同一個分數;字串不同就不保證。排行榜靠的就是這條約定,而它也是第 3 節那張表裡 engine 欄位的內容。
雜湊的是壓縮後的輸出,不是原始碼。 每次版本變動都會讓當下進行中的局作廢,所以改個註解、重排一個區塊、把區域變數改名,都不該害誰損失一局玩到一半的遊戲。壓縮剛好抹掉這些,而保留所有能影響分數的東西;真正的分數表只要改一個字元,雜湊一定會動。代價是 esbuild 自己的版本也成了輸入——這很少見,而且壞的方向是安全的:只會多跳一次版本,不會漏跳。
它是推導出來的,不是宣告的,因為「忘記手動 bump」正是這套機制要抓的失誤。腳本還防了雜湊變成自己的輸入:產生出來的版本檔如果跑進被雜湊的閉包裡,建置直接丟錯。
這也是 lib/wire 必須待在閉包外面的原因。如果在裡面,搬一條 endpoint 就會讓版本跳動、讓所有進行中的局作廢——為了一個網路層的改動,付一筆遊戲規則的帳。
8|engines/:舊規則不能丟
伺服器第一版是強制版本相符的:用其他字串打出來的紀錄一律回 stale_engine。
答案正確,但問題問錯了。部署不會更新某個人正在玩的用戶端,只會把對方留在原地。網頁玩家會失去進行中的那一局;從不強制關閉 App 的 iOS 玩家可能被鎖上好幾天,什麼都送不出去。而這段期間,他們那一局依循的規則明明還存在,也定義得好好的。
所以伺服器把它們留著。每個部署過的引擎都封存成一份以自己版本命名、納入版控的 bundle;送上來的紀錄,用它自己宣告的那套規則重播,而不是今天的:
const rules = typeof engine === "string" ? ENGINES[engine] : undefined;
if (!rules) return json({ error: "stale_engine", engine: ENGINE_VERSION }, 409);
有兩件事它刻意不做。不用新規則把舊紀錄重算一次——那會是一個沒人為它玩過的數字。也不採信用戶端的任何說法——封存的引擎重播一串紀錄的方式,和線上那份完全一樣,所以用舊版本送上來的一局,和新版本一樣是被驗證過的。stale_engine 於是收斂成它本來該有的意思:舊到封存已經不再帶著它的引擎。
舊引擎實際上住在哪
「封存」兩個字承擔了太多意思,這裡講具體的——我自己隔幾個月回來看,唯一拼不回來的就是舊規則實體上放在哪。
一份封存的引擎就是一個檔案。 不是容器映像檔,不是資料庫裡的 blob,也不是還跑在某處的服務:
engines/7766419a1169.js 11 KB,納入版控
自給自足的壓縮 JavaScript,只把重播的進入點打包進去,匯出兩三個函式,沒有 import、沒有設定、沒有網路呼叫:
export { rr as replayDaily, tr as replayFree };
git log 裡有它,編輯器打得開,五年後照樣跑得動,因為它不依賴任何東西。
同一份位元組,三個讀者,所以建置把它複製到各自搆得著的位置:
flowchart TD
SRC[/"engines/<版本>.js<br/>納入版控,約 11 KB"/]
SRC -->|"由 registry.ts 匯入,編進 Worker"| W{{"伺服器<br/>替送上來的紀錄計分"}}
SRC -->|"複製到 public/engines/"| B{{"瀏覽器<br/>用 import() 載入"}}
SRC -->|"包裝成 .app.js"| A{{"iOS App<br/>JavaScriptCore 求值"}}第三個需要那層包裝,是因為 JavaScriptCore 沒有模組載入器——script 裡出現 export 是語法錯誤,不是引擎。所以建置多產一種形式,改成掛到全域變數上。它的輸入是封存檔自己的位元組,不是重新編譯一次原始碼:從原始碼重建,可能產出跟伺服器計分用的那份有微妙差異的東西,接著畫面上的數字和榜上的數字就對不起來了。
十三列看不了的紀錄
會安靜壞掉的是最後一步。 第 3 節那張表的 engine 欄位,實質上是一個指向某個目錄裡某個檔案的外鍵,卻沒有任何東西在保護它:沒有 REFERENCES、沒有 migration、沒有型別。把檔案刪掉,欄位裡照樣寫著 7766419a1169,那一列照樣顯示 608074,因為那個數字在送出的當下就算好存下來了。只有重播會壞,而且壞的方式是走幾步就停住,因為今天的引擎發的是另一副牌,於是拒絕了一個對不上的步驟。
我知道它是這樣壞的,因為我幹過。封存留最新六個版本,卻有十三列還指著第七個。沒有東西報錯、沒有測試失敗、沒有任何一行 log,板子看起來完好。只是那十三局沒辦法被看,而唯一的症狀是播到一半凍住、計數器卻顯示著完整長度,跟功能壞掉長得一模一樣。
教訓是「保留最近 N 個」這個形狀本身就選錯了。 引擎服務的是兩件不同的事,期限也不同:
- 送成績——還在跑那個版本的用戶端。這段期間會結束:分頁重新載入、手機下次冷啟動就更新。
- 重播——每一列指名它的紀錄。這段期間永遠不會結束,因為一列會待在全時排行榜上,直到有人超越它。
用發版節奏決定封存深度,等於回答了第一件、忽略了第二件,而真正有約束力的一直是第二件。該保留的不是「六個版本」也不是「三個月」,而是任何一列還活著的紀錄所指名的每一個版本。所以這件事現在是一句查詢,不是一個猜測:
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
兩個住在不同地方的事實——一個檔案目錄,和一個 SQLite 欄位——被並排放在一起。系統裡沒有別的東西同時看得到兩邊,這也是它一開始會出錯的原因。
封存是這裡唯一一個「漏掉會安靜壞掉」的步驟,所以它不是一個習慣,是一個建置步驟:predev 寫入封存,prebuild 用 --check 檢查、過期就讓建置失敗,pre-commit hook 負責重新產生。另外有一條救生索:--recover <commit> 改用 git archive <commit> 而不是工作目錄,把漏掉的 bundle 重建出來,因為一個版本的規則,就是產生它那個 commit 當下的原始碼。反過來也是警告:把產生某個版本的 commit squash 掉,那套規則就永久消失,連同任何一局照它玩過的紀錄。
那十三列我救回來了,但靠的是運氣:建出那份 bundle 的 commit 已經被 squash 掉,檔案以一個還沒被垃圾回收的 unreachable blob 形式留在 git 的物件庫裡。git cat-file -p f2cc99af67b6 吐回 11 KB,精準重現了全部十三筆已存的分數。那不是一套復原程序,那是差點出事。
9|OTA:把新規則送進已經在跑的 App
前一節講的都是「用舊規則打的紀錄怎麼辦」。這一節是反方向:用戶端怎麼拿到新的。
網頁和 Android 沒有這個問題——重新整理就是最新版。有問題的只有 iOS,因為它是唯一一個把規則帶在身上的用戶端。
為什麼 App 裡還要編一份引擎進去
這是我自己最常被問、也最值得先講清楚的一題:既然引擎是 JavaScript、可以從網路抓,那 App 為什麼不乾脆每次啟動去雲端抓最新的,跑個更新動畫存到手機裡就好?
因為那樣做會在啟動路徑上放一個網路依賴,而遊戲本身根本不需要網路。具體有四個理由:
- 第一次打開就要能玩。 全新安裝、在飛機上、在地鐵裡。引擎如果只從網路來,沒網路時 App 就是一張空桌子。App Review 也會在很爛的網路下打開它。
- 啟動不該等網路。 那個「更新動畫」正是要避開的東西:它為了一個平均一個月才變一次的 12 KB 檔案,在每一次啟動時擋住牌桌。現在的順序是相反的——牌桌先畫好、可以玩了,
.task才在背景去問伺服器。整趟更新沒有任何一個畫面在等它,玩家也永遠看不到它。 - 就算抓下來了,也不能立刻換。 換引擎會改變發牌方式,局中抽換等於玩家眼前的牌桌和之後重播出來的分數對不上。所以新引擎一律留到下次冷啟動才採用——既然要跨啟動保存,本地就一定得有一份「開機用的引擎」。
- 它是退路。 存起來的 bundle 如果起不來,App 退回內建那一份。沒有內建那一份,這條退路就變成「App 開不起來」。
所以編進 App 的那份是地板——全新安裝、離線、出事時都是它——伺服器上的那份是天花板。地板一樣能玩、能上榜,因為伺服器是用那一局宣告的版本重播的(第 8 節)。
而且這其實就是常見做法:CodePush、Expo Updates 這類 OTA 方案,形狀都是「binary 裡有一份 baseline + 背景抓更新 + 下次啟動套用」。差別只在很多方案預設會在 splash 期間等下載完成,Tefuda 不等——12 KB 的規則差異不值得任何人多看一秒的載入畫面。
還要先把規模講清楚:這不是什麼 code push 框架。它就是一次雜湊比對加一次檔案下載,而它之所以能這麼小,靠的是前面每一節。
流程,包含它決定「不更新」的每個岔路
flowchart TD
DEV["我改了 lib/game"] --> BUILD["建置:推導 ENGINE_VERSION、<br/>打包 engine.js、封存一份"]
BUILD --> DEPLOY["部署,engine.js 成為靜態資源"]
DEPLOY --> MANIFEST("GET /api/engine<br/>version · build · minUI · path")
APP["App 用手上的引擎啟動<br/>牌桌已經畫好,可以玩了"] --> MANIFEST
MANIFEST --> SAME{"build 雜湊和我手上的一樣?"}
SAME -->|"一樣"| STOP["什麼都不做<br/>幾乎每次啟動都停在這"]
SAME -->|"不一樣"| UI{"它需要比我畫得出來更新的牌桌嗎?"}
UI -->|"需要"| HOLD["留著現有引擎<br/>照樣能玩、能上榜"]
UI -->|"不需要"| DL["下載 engine.js"]
DL --> PROBE{"丟進臨時 context 跑起來——<br/>起得來嗎?它說自己是哪個版本?"}
PROBE -->|"起不來或對不上"| DROP["丟掉,這次當沒發生"]
PROBE -->|"起得來且對得上"| STAGE["寫進 Application Support,<br/>標記為下次啟動採用"]
STAGE --> COLD["下次冷啟動時換上"]伺服器給的是一個指標,不是內容——幾乎每次啟動都只是一百多個位元組:
{
version: ENGINE_VERSION, // 哪一套規則
build: sha256(servedBytes), // 哪一份位元組
minUI: MIN_APP_UI, // 最舊哪一版牌桌畫得出來
path: '/engine.js', // 一條路徑,不是絕對網址
accepts: ENGINE_HISTORY, // 封存還重播得了的每一個版本
}
為什麼需要 build,光有 version 為什麼不夠。 ENGINE_VERSION 只雜湊重播閉包,這是刻意的——正是它讓「搬一條 endpoint」不會害誰損失進行中的一局。但 bundle 裡裝的比那個閉包多:lib/wire 在裡面,新手教學和橋接介面也在裡面。所以改一次通訊協定,會產生不同的位元組、卻是同一個版本字串;只看 version 的 App 就會永遠停在舊 bundle 上,而且沒有任何症狀,因為它本來會的事情都還能做。這不是假設:新的 wire.enginePath 就是這樣上線的,在 build 加進來之前,一台裝好的 App 都沒收到。版本說的是哪套規則,雜湊說的是哪份位元組,兩個問題不一樣,兩個都要。
OTA 更新得了什麼:那份 bundle 裡是 JavaScript 的東西都行——分數表、牌組組成、一步棋做什麼、每日挑戰的變化、通訊協定、新手教學腳本。
更新不了什麼:Swift。一份 bundle 可以教會 App 新的數字,但教不會它畫出一個二進位檔裡根本不存在的控制項。minUI 就是講這件事的。具體一點:禪模式沒有時鐘、也不能棄牌,所以一張在 zen 出現之前做好的牌桌,會在一個再也不會超時的目標旁邊印出「還剩 −1 次擺放」,旁邊還擺一個按了完全沒反應的按鈕。什麼都不會當掉,畫面只是在描述另一款遊戲,而這正是這個數字要擋掉的事。低於 minUI 的 App 因此留著手上的引擎,照樣送得出成績——這是一個提醒,不是一道斷崖。
安全性,老實講。 這份 bundle 沒有簽章,而我認為這是想清楚後的選擇,不是偷懶。傳輸是 HTTPS,連的是 App 本來就在連的同一個 origin——而且伺服器回的是一條路徑,不是絕對網址,所以伺服器上的東西沒辦法把裝置指到另一台主機去。完整性用「跑跑看」來檢查:下載回來的 bundle 會先在一個用完即丟的 context 裡求值,然後問它自己是哪個版本,起得來、而且跟 manifest 說的一致,才會被存起來等下次啟動。截斷的下載會被丟掉。
而這些之所以都不必扛太重的責任,是因為排行榜的模型:伺服器本來就不信任用戶端的引擎。 有人去改自己手機上的那份 bundle,改到的只有自己螢幕上顯示的東西——分數是伺服器拿那一局宣告的封存引擎重算的。被改過的引擎送不出假分數,最多只能產生一串套用失敗的紀錄。簽章在這裡要保護的,是玩家不要騙自己。
壞掉的時候會怎樣。 每一條失敗路徑,結果都是「App 繼續跑它現在跑的東西」:
- 離線、500、404 → 這次更新安靜跳過,下次啟動再試。
- Bundle 起不來 → 在存檔之前就被丟掉。
- 存起來的 bundle 在啟動時居然起不來 → App 退回編進二進位檔的那份引擎,並把存起來的丟掉。這個退路就是「這次更新沒用」和「App 開不起來」之間的差別。
- App 自己送了一份比存起來更新的引擎 → 把存起來的丟掉。這是 rollback 的情況,也是我做錯兩次的地方。兩個版本雜湊之間沒有先後可言,所以 App 比的是「目前二進位檔裡那份引擎的位元組」和「當初是蓋在哪份上面存的」。以前比的是 build number——本機重建永遠不會動到它,於是一份存起來的 bundle 蓋過了每一次重建,最後手機上跑的 JavaScript 比它外面那個 App 還舊了好幾個月。
網頁那邊的 OTA 就是重新載入,設計問題在什麼時候。不跳提示,也不在局中抽換:在唯一沒有東西可失去的那一刻重新載入——牌已經發好、但還沒動過的盤面。這剛好涵蓋了真正會發生的情況(分頁開著過了一夜、你停在結算畫面時剛好有一次部署),而且不會打斷任何人。原生 App 是同一個取捨,只是把那一刻挪到下次冷啟動。
一個值得抄走的細節:那次重新載入還必須把存檔丟掉,而不是接續它。螢幕上那副牌是被留在後面的引擎發的,新引擎用同一個 seed 可能發得不一樣——接續下去,那串步驟會重播成一個玩家從沒看過的分數。反正還沒動過任何一步,丟掉不花代價。另外它會記得每個版本只試一次,否則一個其實拿不到新版本的用戶端——靜態快取還在餵昨天的 HTML、中間卡了個代理、或某種殼——會無限重新載入下去。
10|Worker:伺服器真正的位置
伺服器是一個 Cloudflare Worker、五條 API 路徑,而它最重要的設計決策,是讓它盡量不要跑(第 2 節那張分流圖)。
信任邊界在重播,不在請求。 CORS 開 * 是刻意的:這些 endpoint 不帶 cookie,每筆分數都會被重播驗證,發出請求的來源本來就不是信任邊界。(用 * 而不是回填來源,也讓被快取的排行榜不必掛 Vary: Origin,一份快取就能同時服務網頁和原生。)流量限制刻意做得很輕——玩家 id 是用戶端自己挑的,所以它能做的只是壓住單一用戶端灌爆牌桌的速度。撐住排行榜的,是每一列都經過伺服器自己重播。
Cache-Control 就是基礎建設。 沒有 queue、沒有快取層、沒有 Redis,只有四個數字:排行榜 30 秒、一筆重播 300 秒、引擎 manifest 300 秒、分享卡片一小時。每個數字都是一個理由。manifest 很短,因為它是「規則變了」和「用戶端還沒發現」之間唯一的緩衝;分享卡片和它的圖用同一個小時,圖上的數字和文字裡的數字才不可能對不起來。
持久化是很無聊的 SQL。 D1、六個 migration、一張表,樸素的 prepare().bind()。刪除綁在 (id, player) 上,而列的 id 只會回傳給它自己的擁有者,就算 id 外洩也刪不掉別人的紀錄。
分享出去的一局是帶著走的,不是存起來的。 分享連結把整局——seed 和步驟紀錄——放在路徑裡。伺服器上沒有任何關於它的資料:連結在被建出來的那一刻就能離線使用,永遠不會 404,也沒有任何一列資料被誰刪掉會讓它失效。分數也不在裡面,打開的人自己重播那串紀錄才知道,標準和排行榜要求一筆送件的一樣。
這點也正是這個 Worker 還要送 HTML 的原因(就是分流圖裡的 /w/*)。牌桌是從 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 是在函式回傳之後才被讀的。
11|這套形狀讓什麼變便宜了
以下有些我會做,有些只是這個架構剛好讓它變簡單。今天全部都還沒做:
第四個執行環境幾乎免費。 任何能跑 12 KB、零依賴 JavaScript 的東西,都能玩或驗證一局——一支 Deno 腳本、一個 Discord bot、哪天 TWA 不夠用時的原生 Android 殼。要移植的是那個橋接檔,不是規則。
封存稽核應該進 CI,而不是一個我要記得跑的指令。 那句把 engines/ 和 engine 欄位對起來的查詢已經寫好了,只是還沒接到任何會讓建置失敗的地方。十三列那次事故,就發生在「有一個檢查」和「檢查會自己跑」之間的空隙裡。
替 bundle 簽章在哪一天會變重要:當引擎開始決定某件伺服器不會自己重算的事。今天它不會,這也是「跑跑看」這種檢查就夠用的唯一原因。
多留一個封存版本的成本是 12 KB。 現在的深度是被「板子還指名幾個版本」決定的,不是被 Worker 塞不塞得下決定的。
12|五件我下次還會這樣做的事
- 兩邊必須一致,就讓建置產生那份一致,不要寫在文件裡叫人記得。 一個共用常數被第二種語言重打一次,就是一次排好時間的故障。
- 版本號用「會改變答案的東西」算出來。 雜湊壓縮後的閉包,不要雜湊原始碼:排版免費,語意才收費。也不要手動 bump。
- 能相容就不要強制。 擋掉舊的用戶端不會讓它變新,只會把它留在原地;留著它的舊規則,代價是一個裝著 12 KB 檔案的目錄。
- 會安靜壞掉的事,讓它在建置時大聲壞掉。 封存漏掉,執行期什麼都不會講;
prebuild --check會直接讓建置失敗。 - 伺服器只放在真的需要伺服器的地方。 五條路徑,而遊戲本身一條都不會碰到。
附錄|五條 API 的型別
第 2 節那張表講的是給人看的版本;這裡是同一件事給編譯器看的版本——形狀取自 lib/wire(第 6 節),不是逐字貼原始碼,但每一個欄位都對得上表格裡的那一格。
type LeaderboardRequest = {
board: "all" | "zen" | `daily:${string}`; // 一般模式/禪模式/某天的每日挑戰
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; // 一般模式/禪模式
date?: string; // 每日挑戰:用日期代替 seed
moves: string;
name: string;
playerId: string;
engine: string;
// 沒有 score —— 伺服器用 (seed, moves, engine) 自己重播算出來
};
type ScoreResponse = { score: number; rank: number };
type DeleteScoreRequest = { id: string; playerId: string };
// 回應:204,沒有 body
type EngineManifest = {
version: string; // 哪一套規則
build: string; // 哪一份位元組
minUI: number; // 最舊哪一版牌桌畫得出來
path: string; // 一條路徑,不是絕對網址
accepts: string[]; // 封存還重播得了的每一個版本
};
ScoreRequest 沒有 score 欄位,不是省略——那正是第 4 節整節在講的事:用戶端送的是它打過的步驟,分數是伺服器自己重播出來的。DeleteScoreRequest 靠 (id, player) 兩個欄位一起限定範圍,這也是第 10 節提到的那條刪除規則。EngineManifest 五個欄位裡 version 和 build 分開存在,是第 9 節花一整段解釋的那個區別:一個說規則,一個說位元組。