TL;DR:我用 Claude Code 從零做了一款 Splendor(璀璨寶石)網頁版——規則引擎、React UI、單元測試、E2E、連 100 張卡牌插圖都生好,全程沒手寫幾行 code。但能跑得順的關鍵不是「prompt 寫得好」,而是三件工程紀律:規則引擎跟 UI 徹底分離、用「不變量測試 + fuzzing」逼出 AI 看不到的暗 bug、E2E 走完整 round-trip 而不是繞過引擎。這篇拆解這套協作方法,附這個專案的真實程式碼。
📌 目錄
- 不是「會 prompt」就能做出遊戲
- 第一個決策:規則引擎跟 UI 徹底分離
- 純函式引擎:每個 action 回傳新狀態
- 怎麼確保 AI 寫的引擎沒暗 bug:不變量 + fuzzing
- E2E 要走 round-trip,不是繞過引擎
- 把生圖也納進來:100 張卡圖零成本
- 整個專案的數字
- 我的 Claude Code 協作心法
- 常見問題 FAQ
- 延伸資源
🎮 不是「會 prompt」就能做出遊戲
「用 AI 寫一款遊戲」這句話現在很容易被講成「丟個 prompt 就生一個」。實際做過就知道,難的從來不是讓 AI 吐出能跑的 code——它很會。難的是讓那堆 code 正確、可維護、改一個地方不會在別處爆掉,而且你有辦法證明它對。
我這次的題目是 Splendor(璀璨寶石):2-4 人、90 張發展卡 + 10 張貴族卡、5 種寶石 + 黃金、15 分獲勝。規則不算複雜,但「拿寶石上限 10 個」「買卡折抵」「貴族自動拜訪」「保留卡 + 抽金幣」這些交互規則,任何一條寫錯都會讓遊戲在某個邊角當掉。
我把 Claude Code 當資深同事用,不是當許願池。下面這幾個決策,決定了這專案有沒有辦法收尾。
🧱 第一個決策:規則引擎跟 UI 徹底分離
最重要的一個架構決定,在寫第一行邏輯前就定好:遊戲規則放 src/game/,完全不碰 React;UI 放 src/components/,完全不寫規則。
src/game/ ← 純 TypeScript,零 React,只有規則
engine.ts 409 行:所有 action 的合法性與狀態轉移
cards.ts 90 張發展卡 + 10 張貴族卡的資料
types.ts GameState / GameAction / Player 等型別
bot.ts 貪婪 AI(也給測試與 E2E 當驅動器)
src/components/ ← React,只負責畫面與互動
GameScreen.tsx 組裝畫面、把點擊轉成 action 丟給引擎
CardView.tsx / GemSupply.tsx / PlayerPanel.tsx ...
為什麼這條線這麼關鍵?因為它讓「測試遊戲規則」這件事完全不需要渲染任何畫面。引擎是一堆 (state, action) => newState 的純函式,測試直接餵狀態、檢查結果,毫秒級跑完幾百個 case。如果規則跟按鈕的 onClick 綁在一起,你就只能靠點 UI 來測,慢又脆。
對 AI 協作來說還有一個隱性好處:邊界清楚,Claude 改 UI 時不會手滑去動規則,改規則時也不會牽動畫面。 我給它的任務描述永遠寫明「主場是哪個檔、禁區是哪個檔」,它就不會把兩件事混在一起改成一團。
⚙️ 純函式引擎:每個 action 回傳新狀態
引擎的開頭就把契約寫死:
// Splendor 規則引擎 — 純函式、immutable。每個 action 回傳新 GameState 或錯誤。
每個動作都走同一個入口 applyAction,回傳一個帶 ok 旗標的結果——成功給新 state,失敗給錯誤訊息,從不就地改舊 state:
export function applyAction(state: GameState, action: GameAction): ActionResult {
switch (action.type) {
case 'TAKE_THREE': // 拿三種不同色
case 'TAKE_TWO': // 拿兩個同色
case 'RESERVE': // 保留一張卡 + 抽一枚金幣
case 'BUY': // 買卡(含折抵與找零)
// 非法動作 → return { ok: false, error }
}
}
immutable 的好處在多人遊戲特別明顯:你永遠能拿「動作前的 state」跟「動作後的 state」做對比,不用擔心某個函式偷改了共用物件。這也是下一節「不變量測試」能成立的基礎——狀態不會被悄悄改掉,我才驗得準。
🔬 怎麼確保 AI 寫的引擎沒暗 bug:不變量 + fuzzing
這是整篇最重要的一段。AI 寫的 code 最大的風險不是「跑不起來」,是「跑起來、看起來對、但在某個你沒測到的組合下默默算錯」。 對策不是讀完每一行(讀不完),是用機器幫你驗它怎麼樣都不會違反遊戲的物理定律。
Splendor 有一條鐵則:寶石守恆——銀行裡的寶石 + 所有玩家手上的寶石,永遠等於開局的總量,而且每種顏色都不能是負數。我把這條寫成測試裡的不變量:
/** 寶石守恆:bank + 所有玩家手上 = 初始總量,且每色都 ≥0 */
function assertGemConservation(state: GameState, playerCount: number): void {
for (const g of ALL_GEMS) {
expect(state.bank[g] ?? 0).toBeGreaterThanOrEqual(0);
for (const p of state.players) {
expect(p.gems[g] ?? 0).toBeGreaterThanOrEqual(0);
}
}
const totalInPlay =
totalGemsOf(state.bank) + state.players.reduce((s, p) => s + totalGemsOf(p.gems), 0);
expect(totalInPlay).toBe(INITIAL_BANK_TOTAL[playerCount]);
}
光有不變量還不夠,得有大量隨機局面去撞它。所以我加了一個 fuzzing 測試:用固定 seed 的偽隨機數(LCG)讓貪婪 bot 自動對打幾百局,每走一步就跑一次 assertGemConservation:
// ── 確定性 rng(LCG)讓每個測試可重現 ──
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1103515245) + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
}
seed 固定 = 測試可重現,哪天 fuzzing 抓到 bug,我能用同一個 seed 100% 重跑那一局。這比「人工想 case」強太多:人想不到的組合,隨機對打會幫你撞出來,而不變量會在它違規的那一步立刻 fail。
這套(純函式 + 不變量 + 固定 seed fuzzing)就是我驗證 AI 寫的引擎的核心。我不需要相信 Claude 每一行都對,我只需要讓它沒辦法違反守恆而不被抓到。
為什麼這招專治「人工想不到的錯誤」
這套針對的就是一類最難抓的 bug:單看那段 code 完全合理、一般點測也過,只在某個特定狀態組合下才算錯。 Splendor 裡最容易出這種洞的是「買卡時用金幣(萬用幣)折抵、且需要混合支付」的找零邏輯——支付與找零牽涉多種寶石的加減,人工很難把所有組合想全。固定 seed 的 fuzzing 不靠想,它讓 bot 把這些組合一局局打出來;只要哪一步讓銀行 + 玩家手上的寶石總量對不上初始值,守恆斷言就在那一步 fail,而我能用同一個 seed 100% 重跑定位。靠人工想 case,這種「特定支付組合才觸發」的洞幾乎不可能補全。
🌐 E2E 要走 round-trip,不是繞過引擎
引擎測完是對的,還要確認「瀏覽器裡點下去」真的有接到引擎,而不是 UI 自己另算一套。我在 GameScreen 掛了一個測試橋接,把真實的 dispatch / getState 暴露給 Playwright:
// E2E 測試橋接:把當前 state + dispatch 掛到 window,讓 Playwright 能驅動真實 UI 狀態
// (走 useGame.dispatch → applyAction → setState 的完整 round-trip,而非繞過引擎)。
const ENABLED = import.meta.env.DEV || import.meta.env.VITE_E2E === '1';
export function installTestHook(api: SplendorTestApi): void {
if (!ENABLED) return;
window.__SPLENDOR_TEST__ = api;
}
關鍵在那句註解:走完整 round-trip,而非繞過引擎。 E2E 用貪婪 bot 透過這個橋接驅動真實的 dispatch,一路 dispatch → applyAction → setState → 重新渲染,最後在畫面上斷言「勝利畫面出現」「分數 ≥ 15」:
expect(result.isOver, '遊戲應該分出勝負').toBe(true);
expect(result.winnerPoints, '勝者應達到 15 分').toBeGreaterThanOrEqual(15);
await expect(page.getByTestId('win-overlay')).toBeVisible();
還有一個反向測試:開局就想買最貴的 T3 卡(資源不夠),斷言它失敗、且回合不前進、畫面顯示錯誤。這驗的是「非法操作在真實 UI 裡也被引擎擋下來」,不是只在單元測試裡擋。
這跟我一貫的原則一致:「修好了」的證據是 round-trip——從輸入端進、輸出端出、人看得到結果,而不是看到一個 connected 字串就喊好。 E2E 看到瀏覽器真的分出勝負,才算這遊戲能玩。
🎨 把生圖也納進來:100 張卡圖零成本
遊戲能跑之後,還缺 90 張發展卡 + 10 張貴族卡的插圖。這部分我另外寫了兩篇:用 Hugging Face FLUX.1-schnell 當主力批次生圖、額度用完 fallback 到 免註冊的 Pollinations 補量,再用 sharp 後製依寶石色染色,100 張一次跑完、整副風格統一、總花費 $0。
值得提的是:生圖腳本也套同一套「round-trip 驗證」紀律——寫檔後一定用 file --mime-type 驗它真的是圖,擋掉「存在但打不開」的壞檔。同一套工程習慣,從遊戲引擎一路貫到生圖管線。
📊 整個專案的數字
| 項目 | 數字 |
|---|---|
| 規則引擎 | engine.ts 409 行純函式 |
| 引擎測試 | engine.test.ts 412 行(比實作還長) |
| 單元測試 | 27 個(含 fuzzing 隨機對局守恆) |
| E2E | 2 個(完整一局 + 非法動作) |
| 執行期依賴 | 只有 react / react-dom,動畫純 CSS + Web Animations API |
| 卡牌插圖 | 100 張,生圖成本 $0 |
🧭 我的 Claude Code 協作心法
把上面收斂成幾條可以複用的原則:
Claude Code 很強,但它強的是執行;判斷「什麼叫做對、怎麼證明對」還是工程師的事。把這部分握緊,它就是個能扛活的資深同事。
❓ 常見問題 FAQ
Q1:用 Claude Code 做遊戲,是不是丟個 prompt 就好?
不是。它能很快生出能跑的 code,但「正確、可維護、可驗證」要靠架構決策(規則/UI 分離)和測試策略(不變量 + fuzzing + round-trip E2E),這些是工程師要把關的。
Q2:為什麼要把規則引擎跟 UI 分離?
因為這樣測試規則完全不用渲染畫面,純函式餵狀態、檢查結果,毫秒級跑完幾百個 case。也讓 AI 改 UI 時不會手滑動到規則,邊界清楚。
Q3:fuzzing 測試是什麼?為什麼對 AI 寫的 code 特別重要?
用固定 seed 的隨機數讓 bot 自動對打幾百局,每步檢查遊戲不變量(如寶石守恆)。AI 寫的 code 最大風險是「看起來對、某個沒測到的組合下默默算錯」,隨機對打能撞出人工想不到的 case。
Q4:E2E 為什麼強調「走 round-trip 不繞過引擎」?
因為要驗的是「瀏覽器點下去真的接到引擎」,而不是 UI 自己另算一套。透過掛在 window 的測試橋接驅動真實 dispatch,一路渲染到勝利畫面,才證明整條鏈是通的。
Q5:這套方法只適用遊戲嗎?
不。純函式核心 + 不變量測試 + round-trip 驗收,適用任何有明確規則與狀態轉移的系統——訂單、計費、權限、工作流引擎都一樣。遊戲只是規則密集、好示範。
🔗 延伸資源
- HF FLUX.1-schnell 免費生 100 張卡牌插圖 — 這專案的生圖管線(主力)
- Pollinations 免註冊生圖 API — 生圖管線(免費補量)
- Vitest 官方文件 — 本專案的單元 / fuzzing 測試框架
- Playwright 官方文件 — E2E 測試框架
- Splendor 規則(BoardGameGeek) — 遊戲規則來源