← 返回全部文章

让游戏变得确定性,用了两次尝试

发布于:
candyrushdeterminismagent-runevidenceunity

2025 年 12 月,CandyRush 不再是一个"碰巧两次看起来一样"的 Unity 游戏,而变成了 一个模拟:给定相同的种子和输入,它就以相同的方式运行。这前后用了两次尝试,相隔 一周。下面的每个数字都来自 GitHub API 或 git。

第一次尝试:#155第二次尝试:#160
标题Feature/strict determinismcompound seeds, strict determinism, performance tests
开启、合并(UTC)12 月 17 日 20:00,12 月 18 日 14:4712 月 25 日 09:58,12 月 25 日 10:16
规模+28,393/-2,192,273 个文件+50,455/-6,492,714 个文件
提交数86117
新增行中的测试特性485 个 [Test]651 个 [Test]、168 个 [UnityTest]、111 个 [TestCase]
合并时的 CIEditMode 测试失败,退出码 2EditMode 和 PlayMode 测试失败,退出码 2
结果约 14 小时后被回滚保留;此后一切的基础

测试数量是各个 squash diff 中以测试特性开头的新增行数,因此包含了被移动或重写的 测试。请把它们当作近似值。

"严格确定性"指的是什么

这款游戏原本是按通常的 Unity 方式写的。随机数来自 UnityEngine.Random。移动按 Time.deltaTime 缩放。延时用 UniTask.Delay。碰撞处理在 OnTriggerEnter2D 里 执行。字典按其内部碰巧的顺序遍历。每一项都是分歧的来源:同一种子的两次运行会逐渐 偏离,录下的一局也无法回放。

这项工作先被写成一份 60 个编号任务的待办清单,TASK-001 到 TASK-060,然后由 AI agent 在我的指挥下执行,大致每个任务一个提交。在分支历史里,这条轨迹读起来就像 一张检查清单:

TASK-001  deterministic RNG + fixed-timestep simulation loop
TASK-003  tick-based scheduler
TASK-004  entity ID system
TASK-005  input command buffer
TASK-006  time scale / pause handling
TASK-009..029  replace Random everywhere, then audit (029)
TASK-030..035  replace Time.deltaTime and UniTask.Delay
TASK-036..043  sort dictionaries before iterating
TASK-044..050  buffer OnTrigger callbacks
TASK-058..060  test harness, replay system, fixed-point math

这些加起来是:

  • 固定的 60 Hz 循环。 模拟以 1/60 秒为一个 tick 推进,由一个以 Time.unscaledDeltaTime 累加的累加器驱动,每帧最多十个 tick,这样一帧卡顿 不会引发追帧的恶性循环。帧率只改变游戏绘制的频率,从不改变模拟计算的内容。
  • 带种子、分作用域的随机数。 一个玩家种子,拆分成 13 个相互独立的作用域 (生成、能力、掉落、敌人、投射物、道具等)。一个作用域里的抽取永远不会移动 另一个作用域的序列,所以在掉落代码里多加一次随机调用,不会改变哪些敌人出现。
  • 用 tick 代替计时器。 延时变成 tick 调度器,所以"0.5 秒后"在每台机器上都 意味着"在第 N+30 个 tick"。
  • 有序的结算。 Unity 触发 trigger 回调的顺序没有定义。现在回调只记录接触; 模拟在 tick 内部按实体 ID 排序结算这个缓冲区。会改变状态的字典循环按排序后的 顺序迭代。
  • 定点数学。 Fixed64 是存放在 long 里的 Q32.32 数值,其上还有 FixedVector2 和 FixedQuaternion,所以位置不取决于某块 CPU 如何对浮点数 取整。
  • 证明工具。 一个回放录制器和播放器、一个 StateSnapshot,以及一个 DeterminismTestHarness,它用相同输入运行两次并比较状态。

第一次尝试:带红合并,然后回滚

#155 包含了 TASK-001 到 TASK-060。它的分支历史显示,86 个提交中有 85 个日期是 12 月 17 日。它于 12 月 18 日 UTC 14:47 合并进 dev。

合并提交自己的 CI 运行说明了问题所在:

Tests failed with exit code: 2
EditMode tests completed with failures (Exit code: 2)

dev 在它之前的那个提交上是绿的。第二天早上 UTC 04:27,合并后的状态被推到一个 dev_backup 分支,几秒钟后 dev 被回滚(提交 a3523ba32,完全的逆操作: 同样的 273 个文件,+2,192/-28,393)。它是直接提交的,没有走拉取请求。回滚上的 push CI 结果是绿的。

我无法告诉你是哪些测试失败了。GitHub 那段时期的运行日志已经过期(API 返回 HTTP 410),只有注解保留了下来。我不会凭记忆去重构测试名。

我能说的是回滚保护了什么。一个改动游戏所有计算方式、涉及 273 个文件的变更,红着 停在集成分支上,会挡住它后面的每一个变更。先做快照意味着什么都没丢;回滚意味着 dev 重新可以发布,而工作在它该在的地方,也就是它自己的分支上继续。

两次尝试之间

几小时内,分支上的工作就恢复了。回滚之后的第一个提交在 12 月 19 日,修复了被新的 基于 tick 的计时截断的投射物寿命:2.0 秒的寿命在浮点运算里是 119.999... 个 tick,一个 (uint) 强制转换把它变成了 119 而不是 120,于是投射物早一个 tick 消失。从那里开始,工作分出了两个新分支 feature/playmode-tests 和 feature/more-playmode-tests,把主菜单和关卡流程纳入 PlayMode 测试并接入 CI; 还有一个 feature/performance-tests 分支,包含帧时间、GC 分配和 draw call 的 采集器、基线比较以及压力场景(12 月 25 日作为 #159 合并进 playmode 分支)。

RNG 本身有两处设计改变。

每个 tick 的种子不再丢失位。 第一个版本这样推导每次抽取的种子:

uint combinedSeed = _baseRng.state ^ (uint)((_currentTick << 16) | (scopeId & 0xFFFF));

对于 32 位的 tick,左移 16 位会丢掉它的高 16 位,所以第 65,536 个 tick(60 Hz 下 大约 18 分钟)产生的种子与第 0 个 tick 完全相同。第二个版本给每个作用域一个自己的 基础种子,用稳定的 FNV-1a 哈希计算(绝不用 string.GetHashCode,它在不同运行时 之间并不稳定),并且对 tick 做循环移位而不是普通移位:

uint combinedSeed = baseSeed ^ (uint)((currentTick << 16) | (currentTick >> 16));

复合种子。 种子现在可以写成 GLOBAL.SCOPE1.SCOPE2,例如 ABC.HLT.FBL: 作用域 1(生成)的种子来自 HLT,作用域 2(能力)来自 FBL,其他所有作用域都 从 ABC 推导。像 CANDY123 这样的普通种子仍然可用。重点在于搜索:要找一个生成 序列好、能力序列也好的种子,可以分别在每个作用域里单独搜索,再把胜出者组合起来, 而不必搜索每一种组合。

第二次尝试:落地

#160 于 12 月 25 日合并,带着上面的一切:60 个任务、各项修复、PlayMode 和性能 测试套件,以及复合种子。

它合并时的 CI 也是红的,EditMode 和 PlayMode 都报告退出码 2。与第一次尝试的区别 并不是一个绿色的检查。在随后的几个月里,这个检查几乎从不变绿:在 GitHub 仍保留的 2026 年 1 月至 7 月共 216 次 dev push 运行中,只有 3 次成功。一个通常是红的 CI 提供的信息非常少,而那时红色已经不再能阻止合并。测试套件直到 2026 年 8 月的流水线重构才成为可信的闸门。

它带来了什么,以及后来的事

确定性本身并不是目的。确定性的模拟可以从一个种子和一份输入日志回放,而回放可以由 一个不信任玩家的人来校验。八个月后,这变成了一个重新模拟所提交对局的服务器,以及 一个只接收已验证分数的每周排行榜;那个故事在 The Verified Score 里。

有三件事直接源于这项工作:

  • 一个可复用的包。 12 月 22 日开启的 issue #158 提议把确定性系统提取成一个 UPM 包。它现在位于我的 UTools 包中,由 CandyRush 使用。
  • 一条编译器规则。 12 月 30 日,我启动了 AI-Analyzers,一个公开的 Roslyn 分析器包,针对我在 agent 编写的 Unity 代码里反复发现的模式。ISU4000 会把 ISimulatable.Simulate 内部的非确定性 API(例如 Random 和 Time.deltaTime)报为错误。这次改造靠人工执行的规则,变成了由编译器执行的规则。
  • 后来对 RNG 的一次修复。 两次尝试在同一 tick、同一作用域的每次抽取时都会 新建一个子生成器,所以同一 tick 内从同一作用域抽取两次会得到相同的值。2026 年 4 月,一个按作用域的调用计数器修复了这一点。

可以推广的经验

先把横切性的变更写成一份编号清单。 六十个任务,每个一个提交,让一个 273 个 文件的变更可以逐个任务审查,也让审计(TASK-029)应该放在哪里一目了然。

先快照,再回滚,然后继续。 当工作已保存在分支上时,回滚一个红色的集成分支 代价很低。让它一直红着,对排在后面的每个人代价都很高。

一个总是红的闸门不是闸门。 两次尝试都是在检查为红的情况下合并的。真正的修复 不是在合并时更加小心,而是让检查变得有意义。

种子推导是一个设计面。 对 tick 做移位、每次抽取都重新播种,看起来都无害。一个 在 65,536 个 tick 后重复;另一个让同一 tick 内的抽取完全相同。按作用域划分、稳定 哈希、基于循环移位的推导,才是如今回放所依赖的。