工程實作 15 min read ★ Featured

用 Claude Code 從零做一款遊戲:不是會 prompt,是會這三件工程紀律

我用 Claude Code 從零做了一款 Splendor(璀璨寶石)網頁版 —— 規則引擎、React UI、單元測試、E2E、連 100 張卡牌插圖都生好,全程沒手寫幾行 code。但能跑得順的關鍵不是 prompt 寫得好,而是三件工程紀律:規則引擎跟 UI 徹底分離、用「不變量測試 + fuzzing」逼出 AI 看不到的暗 bug、E2E 走完整 round-trip 而不是繞過引擎。這篇拆解這套協作方法,附這個專案的真實程式碼。

用 Claude Code 從零做一款遊戲:不是會 prompt,是會這三件工程紀律
本文目錄 · 11
TL;DR:我用 Claude Code 從零做了一款 Splendor(璀璨寶石)網頁版——規則引擎、React UI、單元測試、E2E、連 100 張卡牌插圖都生好,全程沒手寫幾行 code。但能跑得順的關鍵不是「prompt 寫得好」,而是三件工程紀律:規則引擎跟 UI 徹底分離、用「不變量測試 + fuzzing」逼出 AI 看不到的暗 bug、E2E 走完整 round-trip 而不是繞過引擎。這篇拆解這套協作方法,附這個專案的真實程式碼。
用 Claude Code 做出來的 Splendor 網頁版

📌 目錄

  • 不是「會 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 隨機對局守恆)
E2E2 個(完整一局 + 非法動作)
執行期依賴只有 react / react-dom,動畫純 CSS + Web Animations API
卡牌插圖100 張,生圖成本 $0
測試行數比實作還多,不是因為龜毛,是因為這是讓我敢相信「AI 寫的引擎是對的」的唯一理由

🧭 我的 Claude Code 協作心法

把上面收斂成幾條可以複用的原則:

  • 先定架構邊界,再開工。 規則 / UI 分離這條線在第一行邏輯前就劃好,後面所有任務都標明主場與禁區,AI 不會越界亂改。

  • 不要求 AI 完美,要求它沒辦法錯而不被抓到。 純函式 + 不變量 + fuzzing,讓機器幫你驗那些你讀不完的組合。

  • 驗收看 round-trip,不看 handshake。 引擎測試綠燈不算數,要 E2E 在瀏覽器裡真的玩到分出勝負。

  • 同一套紀律貫穿到底。 連生圖腳本都驗 mime-type。工程習慣是跨領域的,不是只在「寫 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 驗收,適用任何有明確規則與狀態轉移的系統——訂單、計費、權限、工作流引擎都一樣。遊戲只是規則密集、好示範。

    🔗 延伸資源

    author
    陳彥彤

    AI 工程師 · AI 顧問。Java 後端 8 年、AI 工程師 2 年。AI 內訓 · AI 導入顧問 · 前後端與雲端培訓。

    support

    覺得文章有用可以到 GitHub 給個 star,或是透過信箱聊聊 AI 內訓、AI 導入顧問或前後端 / 雲端培訓。

    related

    相關文章

    [AI 工具] · 19min
    open-slide:用 Claude Code 寫簡報,4 千星 AI agent 簡報框架
    open-slide 是台灣開發者 Yiwei Ho(@1weiho)做的開源簡報框架,定位是「給 AI coding agent 用的」:你在對話框講想要什麼,Claude Code 幫你把每一頁寫成 React 投影片,框架負責 canvas、縮放、導航、hot reload、演講模式這些雜事。上線沒多久 GitHub star 就從 4 千衝破 4.6k,repo 卡片已顯示 5k。這篇拆解它跟 Slidev / reveal.js 的根本差異(每頁是任意 React component、跑在固定 1920×1080 canvas)、npx 一行安裝、三個內建 agent skill(/create-slide、/slide-authoring、/apply-comments)怎麼配合,以及 inspector 點一下留 comment 讓 agent 改稿的工作流,附可直接抄的指令。
    [AI 工具] · 14min
    我把「截圖寫操作手冊」做成 Claude Code skill:screendoc 拆解(4 視角審查 + 一鍵重跑)
    每次系統改版都要重截一遍操作手冊、還老是截圖跟說明對不上?我把這件苦差事做成 Claude Code skill:/screendoc。一句指令偵測前端框架、跑 Playwright 截圖、用 UI/UX + 系統分析 + 新手 + 開發者 4 個視角逐張比對「截圖 vs 說明 vs 原始碼」、不一致就局部重截迭代到全過,最後產一份對外可發表的離線 HTML 手冊。本文拆它在幹嘛、Phase 0–5 跑了什麼、為什麼要 4 視角不是過度設計,以及怎麼透過 yc-plugin 一鍵裝起來用。附我在一個無關的 React + Vite 專案上 round-trip 跑通的實測。
    [Claude Code] · 23min
    Ralph Loop 實戰:6 個 README 沒講的 Claude Code Stop Hook 踩坑(含 TDD 收斂 prompt 模板)
    Ralph Loop 不是新模型也不是黑科技,本質就是 Claude Code Stop hook 攔住 exit + 同 prompt 重餵。本文拆解機制、實戰場景、4 個踩坑、2 個進階組合,以及一份可複製貼上的 TDD 收斂 prompt 模板。配合 ralph-loop-skill 那篇一起讀。