Vibe Coding 多人游戏(二十)—— 决策记录精要(ADR)

正确决策(什么是值得的)

1. 状态同步 > 帧同步(2026-06-05)

最关键的决策,没有之一。项目开始第一周我选了帧同步——因为大量多人游戏教程都在讲它,看起来"正确"。

但一周内我就遇到了三个问题:

  • 浮点数不一致:相同的输入在不同的机器上产生不同的计算结果。Three.js 的矩阵运算在不同浏览器上有微小差异,导致 A 看到 B 的位置和 B 自己看到的不一样
  • 回滚调试困难:帧同步的回滚逻辑是 AI 最难理解的。AI 永远搞不清楚"什么时候该 rollback,回滚到什么状态",每次改回滚逻辑都会引入新 bug
  • 网络要求高:2 人场景用帧同步增加了不必要的复杂性——帧同步需要严格的时序保证和固定 tick 率,而我们的 2 人小规模场景根本不需要这种复杂度

还有一条深层原因:帧同步的工具链生态极为匮乏。 没有成熟的帧同步框架,所有实现都是手写——回滚、确定性、快照、时序——每一步都要自己造轮子。而状态同步有 TSRPC、Colyseus、Photon 等成熟方案,踩过的坑少得多。

状态同步下服务端是权威,AI 只需要保证服务端逻辑正确。之前担心 SCF 不能做 WebSocket 长连接,但 TSRPC 框架天然支持 SCF 的 WebSocket gateway,心跳保活后非常稳定。

具体实现细节:

  • Tick 率:10fps 下发全量状态,客户端 60fps 插值渲染
  • 状态格式:每人约 200 字节(位置 xyz、旋转、血量、状态标记),2 人广播约 1-2KB
  • 带宽:约 10-20KB/s,WebSocket 无压力
  • 丢包处理:丢包不修复,下次 tick 自动恢复。客户端不做预测和插值合并

2. 服务端权威 + 绝对状态(2026-06-07)

不增量、不回滚、不预测(在服务端)。全量状态每 tick 下发,客户端只展示。

另一个团队的人问我:"全量下发会不会带宽太高?" 实际测试结果是:2 人场景下,每人状态约 200 字节,每次广播 1-2KB,tick 10fps,带宽约 10-20KB/s。对于现代 WebSocket 来说毫无压力。而且全量状态意味着客户端永远不需要做"状态合并"——丢包了下次 tick 自动恢复,简单到 AI 不会搞错。

3. 开闭原则(隔离层设计)(2026-06-08)

把所有多人代码放在 frontend/src/business_layer/multiplayer/ 下,单机代码 zero-touch。

这条出现在 ADR 里的场景很特别:有一次 AI 为了"让等待大厅显示玩家列表",直接在 MainScene.tsx(单机代码)里加了一个 if (isMultiplayer) 分支。我说不要这样,应该新建一个 MultiplayerHall.tsx 来承载多人大厅的逻辑。

AI 一开始不理解:为什么要为了一个 if 分支新建一个文件?我说:如果下次再有人多功能要加,MainScene.tsx 会变成一个巨型 if-else 嵌套地狱。你应该对扩展开放,对修改关闭——新增多人能力时,不修改单机代码。

后来 AI 自己写代码时,新增功能的第一件事就是确认"要不要新建多人模块"。这个习惯养成后,单机代码的稳定性和多人代码的迭代速度都大幅提升。

4. Logic 纯函数共享层(2026-06-10)

ReScript 编译到 49KB,两端加载同一份。

这个决策的起因是一个 bug:前端和后端的碰撞检测行为不一致。前端说"踩到了",后端说"没踩到"。排查发现,前端的浮点数精度和后端不同,而且两端的 computeCollisionDamage 实现有细微差异。

纯函数共享层的核心思路:把游戏逻辑(Movement、Collision、Status)全部在 ReScript 中实现,编译成 .js 后前端和后端加载同一个 bundle。两端行为 100% 一致。

5. TDD + BDD 双轨测试(2026-06-12)

TDD 确保修改方向正确,BDD 确保业务场景完整。两者配合后,回归测试的成本几乎为零。

第一次大规模重构"朝向前处理逻辑"时,我十分担心会引入新 bug。但 TDD + BDD 的覆盖率让重构变成了"改代码 → 跑测试 → 所有通过 → 上线",整个过程不到 30 分钟。

6. Goal 量化验收标准(2026-06-中旬)

之前写需求都是自然语言:"修复玩家移动时位置不同步"。AI 修了一轮,单测过了,它说"修复完成"。我玩游戏发现位置还是跳。

改成量化标准后效果完全不同:

目标:玩家移动时位置误差 < 0.1 单位
完成标准:
- 集成测试 3 个场景全通过(直线、转弯、停止)
- 移动插值 60fps 不卡顿
- 无新增 tsc 编译警告

AI 只用了 2 轮就收敛了。量化标准让 AI 知道"什么时候该停",而不是在"我修好了"和"还有一个边界条件"之间来回摇摆。

7. 状态外置 > 对话历史(2026-06-下旬)

核心原则:"Agent 会忘,仓库不会忘。"

早期所有关键信息都存在 AI 的对话上下文里。AI 说"我记得",我也信了。直到 context overflow 后 AI 完全忘了前 3 次修复都做了什么,重新开始分析,浪费大量时间。

解决方案是把所有关键信息外置到仓库:

  • 决策记录 → 笔记/决策记录/
  • 项目知识 → MEMORY.md + memory/*
  • 每日日志 → 笔记/daily/
  • 技术方案 → 笔记/方案/

AI 每次启动新对话后,第一件事就是搜索锚点词,从仓库里读取历史,而不是依赖"我记得"。

8. 三层编码规则体系(2026-06-下旬)

从单体 500 行规则文件拆成三层(基础/模块/工作流),再加 agent-context.md 作为红线宪法。

这个决策的价值体现在:基础规则几乎不变("改 .ts 再 tsc"从项目第一天到现在没改过),模块规则在技术栈变动时更新,工作流规则几乎每周都在进化。如果还留在 500 行单体文件里,每次 AI 修小 bug 都要加载所有层级的规则,token 浪费严重。


反面决策(选了后悔的)

1. 帧同步(第 1 周,2026-06-01 ~ 2026-06-05)

如果重来一次,我第一天就直接上状态同步。

但值得记住的教训不光是"别选帧同步",更是**"不要在第一天做不可逆的技术决策"**。我应该在 basic1 阶段用最朴素的方式验证"网络能通",再去想同步方式。

2. Immutable.js(用了 2 周,2026-06-01 ~ 2026-06-14)

引入 Immutable.js 的理由听起来很合理:"多人游戏的状态不可变,用 Immutable 保证不会意外修改"。实际上呢?

  • 50KB 的包直接加到前端 bundle
  • API 极其不直观:Immutable.Map 不能用 Object.keys(永远返回 [])、不能用 for...of、不能用 map.get 以外的任何原生方法
  • 内存 GC 飙升:每次状态变更都创建新的 Map,GC 频繁触发

最有意思的一次坑:Object.keys(Immutable.Map({a: 1})) 返回 [],AI 花了一小时在调试为什么"keys 拿不到"。后来发现是 Immutable.js 的 Map 内部用 _root 存储数据,Object.keys 自然拿不到。

3. 自制 HashMap(用了 1 周,2026-06-14 ~ 2026-06-21)

Immutable.js 放弃后,AI 提议"自制一个 HashMap"。结果 hash 冲突 bug 让玩家 HP 串号——玩家 A 的 HP 显示在玩家 B 身上。

自制基础设施的风险:你自己也是第一次写,你也不知道哪里有坑。 测试的时候可能没测到 hash 冲突的边界情况,但上线后 100% 会遇到。

后来发现 ReScript 自带的 Js.Dict 完全够用——本地测试过了才切过去。

4. Module._load hook 加载 bundle(2026-06-30)

为了在 SCF 部署时让 bundle-logic.js 能被原生 require() 加载,AI 提出了用 Module._load hook 的方案。

优点听起来很好:不改 zip 结构,不增加文件。实际:需要修改 txcloud-scf.tsbundle-logic.js、设置 require hook——复杂度极高。部署后 room 服务无法启动,WebSocket 连接失败,错误日志只显示 HTTP 446/443,完全无法定位问题。

教训:不要和 Node.js 的模块加载系统斗智斗勇。简单直接的方式——把模块文件直接注入 zip 的 node_modules/——最可靠。

5. 不区分重构和修 bug(长期踩坑)

没有 ADR 记录这个决策——因为它根本不是一个"决策",而是一个长期的低效习惯。每次 AI 修 bug 时"顺手"重构一下相关代码,结果:

  • bug 是否真正被修复了不确定(因为重构同时改变了代码路径)
  • 重构是否有副作用不确定(因为改动了多个函数签名)
  • 回归测试时不知道是新 bug 还是重构引入

直到有一次 OBB 残留 bug 修完后,AI 顺带改了 clearAllOBBDebugBoxes 的参数签名,导致另一个调用方编译失败,花了两小时定位。

最后写入 workflow-rules.md 的硬规则:修 bug 的 PR 里一行重构代码都没有,重构的 PR 里一个 bug 也不修。

6. Maker 和 Checker 同模型(2026-07-04 发现)

严格来说这不是一个"选了"的决策——而是默认用了同一个模型,根本没有意识到有问题。

GTS-Play 中 Maker 和 Checker 都是 DeepSeek Flash。结果 AI 修完 bug 说"测试全通过,完成"。我跑了一次游戏,HUD 还是有问题。但 AI 的 check 过程里明明测了那个场景。

后来发现:Checker 默认信任 Maker 的实现,不会深挖。两者都忽略了同一个边界情况,因为用的是同一套理解逻辑。

正确的做法:Maker 用快速模型(DeepSeek Flash),Checker 用更强的模型(DeepSeek Pro 或 Claude)。只在关键节点用强模型检查,整体 token 增量不超过 10%,但换来独立审计的保障。


什么是值得记录的

不是每个决策都需要 ADR。判断标准:

  • 影响范围大(改了需要 10+ 个文件同步修改的)
    • 比如状态同步方式的选择,影响前端渲染、网络协议、服务端逻辑、测试框架——几乎全部重构。
  • 不可逆或难回退(选了以后改回来要花大力气的)
    • 技术栈选择(ReScript/Immutable.js/TSRPC)是典型的"选了就不好回头"。
  • 讨论中有分歧(最后选了方案 A 但方案 B 也有道理)
    • Module._load vs zip inject 就是典型——两个方案各有利弊,决策取决于"你愿意为简洁付出多少部署复杂性"。

ADR 的演化

ADR 的格式也经历了迭代:

v1(自由格式):每个人写自己的风格。优点是快速,缺点是别人看不懂——有人只写结论不写原因,有人写得太啰嗦。

v2(模板化):统一了标题/日期/状态/决策/原因/选的方案/放弃的方案。但发现 AI 有时候写不完整,原因部分草草了事。

v3(带标签和别名):加了 YAML 前置元数据(tags、aliases),方便搜索。状态用 ✅/❌ 一眼识别。

---
created: 2026-06-27
tags:
  - bug-fix
  - code-review
  - room-service
aliases:
  - 代码审核8项修复
---

加 tags 的效果很好:搜"code-review"能一次性找到所有和代码审核相关的决策,不管它们分布在多少个不同的 ADR 里。


下期讲 P21:测试策略体系——单元/集成/E2E 三层的具体配置。

下一篇:Vibe Coding 多人游戏(二十一)—— 测试策略体系

posted @ 2026-07-08 11:38  杨元超  阅读(6)  评论(0)    收藏  举报