模組四|蜜蜂、碰撞與勝負(Day 16–20)
Day 10 結尾我留了一句話:模擬層我敢說穩,畫面層不敢,而「不同幀率會不會玩出不同勝負」是 Day 19 的題目。今天就是那一天。
先把事實攤開。這個專案的隨機來源是一個 62 行、零 import 的純函式檔(src/utils/seededRandom.js);src/ 底下 Math.random 零命中【實測:grep -rn 'Math\.random' src/,無輸出】;tests/unit/determinism.test.js 用 173 行在 Node 裡組出一整場沒有畫面的模擬,跑 600 個固定步,把整個蜂群的狀態拿去逐項比對。這三件事都成立。
不成立的是標題那件事:我沒有在兩台不同刷新率的實機上,跑同一條線比對過勝負。
結論先講:決定性不是「別用 Math.random」這一條規則,是一條從亂數源、容器迭代順序到時間軸的鏈;而且它保證的範圍比直覺小——只到「同一個 JavaScript 引擎之內」為止,再往外靠的不是位元一致,是關卡留裕度。
src/utils/seededRandom.js 全檔 62 行,沒有任何 import,做兩件事:把任意字串壓成一個 32 位元整數,再從那個整數長出一條可重播的數列。
function hashSeed(seed) {
if (typeof seed === 'number' && Number.isFinite(seed)) {
return Math.floor(seed) >>> 0
}
const text = String(seed ?? '')
let hash = 2166136261
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return hash >>> 0
}
export function createRandom(seed) {
let state = hashSeed(seed)
const next = () => {
state = (state + 0x6d2b79f5) >>> 0
let value = Math.imul(state ^ (state >>> 15), 1 | state)
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value
return ((value ^ (value >>> 14)) >>> 0) / UINT32_RANGE
}
// range / pick / angle / fork 都掛在同一條 state 上
}
2166136261 與 16777619 是 FNV-1a 的偏移基底與質數,0x6d2b79f5 是 mulberry32 的 Weyl 常數(:10、:14、:31)。沒有一個字是密碼學等級的,也不需要——條件只有兩個:同一個 seed 給同一串值、不同 seed 給不同串值。
真正值錢的設計在 :51 的 fork(salt),理由寫在 :20-26 的 JSDoc:每條 stream 各自持有自己的 state,從一條抽值不會挪動另一條。每隻蜜蜂拿到的是 this.random.fork('bee-' + index),所以第三隻蜜蜂這一步多抽了一個亂數(例如它剛好卡住、要挑左右方向,那是 Day 18 的事),不會讓第四隻的漫遊角度整個偏掉。
共用一條 stream,會讓「誰先抽」變成遊戲規則的一部分。 這種相依不會報錯,只會在你改了某個不相干的分支之後讓重播對不上。

專案裡有一條規則是機器在守的。eslint.config.js:56 起的 no-restricted-syntax,第二條選擇器長這樣:
{
// Catches both `Math.random()` and aliasing such as `const r = Math.random`.
selector:
"MemberExpression[object.name='Math'][property.name='random']",
message:
'Gameplay randomness must come from createRandom() in src/utils/seededRandom.js so simulations stay reproducible.'
}
它擋的是 AST 節點不是字串,所以 const r = Math.random 這種先取別名再呼叫的寫法一樣被擋(註解寫在 :65)。lint 在 CI 的 verify job 裡,這條規則有真的執行者——Day 29 會把它放進覆蓋率表一起算。
但擋掉 Math.random 只解決了一半,另一半藏在容器裡。BeeSystem.js:191-194 有一段註解與一個刻意寫成索引的迴圈:
Fixed index order: never Set/Map iteration, so replays stay identical.
用 for...of 走一個 Set,在同一個引擎裡順序也是穩定的——但只要有人在中間刪除再插入,順序就跟著改,而這種改動在 code review 裡看起來像無害的重構。專案把它寫成測試:determinism.test.js:147-172 跑 400 步之後抓 beeSystem.bees.map(bee => bee.index),斷言它等於自己排序後的樣子,並斷言那個容器是 Array。
第三個來源是牆上時鐘。BeeSystem.js:42-48 的 JSDoc 最後一句是 There is deliberately no Date.now() or performance.now().;倒數系統更狠,tests/unit/CountdownSystem.test.js:105-107 把 CountdownSystem.js 的原始碼讀進來,用正規表示式斷言它沒有牆上時鐘。用測試掃自己的原始碼,是這個 repo 裡最直白的一種「把約定變成閘門」。
| 隱性亂數源 | 這個專案的處置 | 有沒有執行者 |
|---|---|---|
Math.random() 與其別名 |
eslint.config.js:56-71 的 AST 選擇器 |
✅ lint 在 CI verify job |
Set/Map 迭代順序 |
固定索引迴圈(BeeSystem.js:191-194) |
✅ determinism.test.js:147 |
Date.now()/performance.now() |
只吃 deltaMs,不讀時鐘 |
✅ CountdownSystem.test.js:105 掃原始碼 |
這一段的機制 Day 10 已經拆過,這裡只講它對「決定性」的貢獻。
GameLoop.js:79 先把這一幀夾在 MAX_FRAME_MS = 100 以內;:84-91 的 while 迴圈每次固定扣掉一個 fixedStepMs,最多跑 MAX_CATCH_UP_STEPS = 3 次;:88 傳給場景的是 this.fixedStepMs 這個常數,不是這一幀實際過了多久。
最後那一句是關鍵。物理與蜜蜂看到的時間永遠是 1000/60 毫秒,一個 30fps 的裝置只是把兩個步長塞進同一幀跑完,不是把步長拉長成兩倍。tests/unit/GameLoop.test.js:23-38 把它釘成斷言:餵一個 250ms 的長幀,updateFixed 恰好被呼叫 3 次、累加器停在 10。
還有一個小到會被跳過、形狀卻很典型的東西:src/config/game.js:11-16 的 ACCUMULATION_EPSILON_MS = 1e-6,註解說明累加 FIXED_STEP_MS 會落在整毫秒目標前面幾飛秒,不補這個容差,deadline 會被推遲一整個步長。浮點誤差在固定步長系統裡不會表現成「數字不準」,會表現成「差一步」——而差一步足以改變勝負。
tests/unit/seededRandom.test.js 有 7 個測試在守純函式層(同 seed 同序列 :9、stream 互不干擾 :24-32、fork 可重現 :62-68)。但那一層過了,不代表遊戲可重播。
determinism.test.js:26-71 的 simulate() 做的是另一件事:在 Node 裡真的組出 PhysicsManager、關卡剛體、一條防線、CollisionSystem 與 BeeSystem,手動跑 600 個固定步,回傳蜂群快照、生成總數與狗的座標。
it('replays bit-for-bit with the same seed and the same line', () => {
const first = simulate({ seed: level01.seed })
const second = simulate({ seed: level01.seed })
expect(first.bees.length).toBeGreaterThan(0)
expect(second).toEqual(first)
})
:78 那行 toBeGreaterThan(0) 是這個檔案最值得抄走的一行。沒有它,一個「蜜蜂根本沒生出來」的 bug 會讓 expect(second).toEqual(first) 變成兩個空陣列相等——測試會綠,而且綠得毫無破綻。可重現性測試天生有這個弱點:什麼都沒發生,也是一種完美的重現。
六個測試分工如下:
| 測試(行號) | 守什麼 |
|---|---|
:74 同 seed 逐位重播 |
主張本身 |
:82 換 seed 就不同 |
防止「決定性」退化成「常數」 |
:89 120/300/900 步都一樣 |
不是只有 600 步剛好對上 |
:97 鄰近 stream 先被抽乾 |
stream 隔離真的成立 |
:108 body 數 < 100 |
與 Day 27 的效能預算掛鉤 |
:147 不依賴 Set/Map 迭代順序 |
隱性亂數源 |

這是全篇唯一一段有實測、而且結論是「我原本想的太滿」的證據。
openspec/changes/archive/2026-08-06-add-bee-and-countdown/design.md 的 D10 記著:原本的決策 D3 主張「給定 seed 與防線就能位元級重現」,單元測試在 Node 裡證實了;但 E2E 在 mobile-iphone(WebKit)profile 上出現了 Chromium 判 WIN、WebKit 判 LOSE 的分歧。
原因寫在同一條裡:ECMAScript 不規定 Math.sin、Math.cos、Math.atan2、Math.hypot 的最後一個 ulp。蜜蜂每一步轉向都要呼叫這幾個函式,600 步之後那點誤差足以決定某一隻蜜蜂鑽不鑽得過防線。
而修法不是去改決定性——是改關卡。原本的高空拱形防線在 12 個 seed 裡只贏 11 個,它一直是邊緣解,跨引擎分歧只是把這件事顯影出來;換成兩端落在平台上的圓頂防線之後是 12/12。結論落地成一條護欄測試(tests/unit/levelOutcome.test.js:120-133),12 個 seed 跑同一條示範防線全部要 WIN——剛好守住的線會在這裡先失敗,而不是等到瀏覽器上偶發。
所以「在你的手機上也要輸」靠的不是位元一致,是留裕度。 決定性負責讓同一個引擎裡的重播可信,跨引擎的一致性是關卡設計的責任——這兩件事被明確分開,是我在這個專案裡最不後悔的一次範圍界定。
大綱原本替這篇設計的開場是「同一條線,在 60Hz 螢幕上贏,在 120Hz 螢幕上輸」。我不能這樣寫,因為那件事沒有量過:撰稿用的 tasks.md 4.2(跨幀率一致性:兩台裝置各跑一次相同操作,不一致要錄影)到今天仍未勾選,專案 dev-log.md 證據索引第 4 項「不同幀率跑出不同勝負的實測」狀態是 ⬜ 可補【皆實查】。
我能說的只有機制論證:固定步長讓模擬層看不見幀率,GameLoop.test.js:23-38 證明長幀會被切成整數個固定步而不是拉長步長,所以跨幀率的勝負一致在理論上有保證——這是推論,不是量測結果。
畫面層要說得更難聽一點。RenderSyncSystem.sync()(:32-42)逐項把 body 的 position 與 angle 寫進顯示物件,沒有 alpha 參數,沒有插值。design.md 把代價寫下來了:加插值要改 GameLoop 的簽章,而那份契約已經 archive,在還沒量到問題之前不划算;代價是 120Hz 裝置上蜜蜂仍以 60Hz 的節奏更新位置。
完整的誠實版本是三句:模擬層跨幀率應該安全(推論);畫面平滑度在高刷新率裝置上不會變好(實查);兩者都還沒在實機上被驗證過。
五個關卡的 seed 是五個固定字串:first-shield-2026、steady-footing-2026、flanking-swarm-2026、heavy-shield-2026、pincer-attack-2026(src/levels/level01.js:27 至 level05.js:38)。不是每局隨機,不是時間戳。
這讓「可重現 = 可除錯」強得多:玩家回報第三關過不了,我手上跑的就是同一批蜜蜂。 重播不是要另外做的功能,是預設狀態;剩下的變因只有玩家畫的那條線,而那條線在 window.__SAVE_THE_DOG_DEBUG__ 裡讀得到。
這一段沒有「AI 寫了什麼、我怎麼改」的故事可以講——誰打了哪一行,這個 repo 沒有留下可查的紀錄,我不編。
可以講的是分工的形狀。決定性這種約束有一個特性:它不會在你違反它的當下報錯。 寫下 Math.random() 的那一刻,遊戲照跑、測試照綠,代價要到某個人回報「我明明照做卻過不了」才浮出來。這種約束交給 code review 守是守不住的,尤其當產出速度快到你來不及逐行讀。
所以這個專案把它變成三個會失敗的東西:一條 AST lint 規則、一個掃自己原始碼的測試、一個跑滿 600 步比對整場模擬的測試,三個都在 CI 裡。我要求 AI 遵守的規則有幾十條,配了執行者的只有極少數,而決定性是有配的那幾條之一——這張對照表 Day 29 會整個攤開。誠實補一句:這條規則具體擋下過哪一次產出,我沒有紀錄(跟 Day 2、Day 8 同一個問題),我能證明的只有它存在、涵蓋別名、會讓 CI 紅。
一句話:
決定性是一條鏈,不是一條規則。亂數、容器順序、時鐘、時間步長,任何一環漏掉都會讓重播失效,而且失效的時候不會有人報錯。
三件今天就能做的事:
明天 Day 20,把這個模組收在一個我大綱裡沒想到的問題上:同一個固定步長裡,蜜蜂碰到狗而且十秒剛好走完,算贏還是算輸?這個專案為它寫了一個具名常數與一個純函式,寫得很漂亮——然後我 grep 了一次呼叫端,發現真正在跑的路徑不是它。順便講重試為什麼是整個場景銷毀重建,以及那個在 destroy() 裡漏掉沒清的全域變數。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理