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.ts、bundle-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 三层的具体配置。
浙公网安备 33010602011771号