Vibe Coding 多人游戏(六)—— Phase 1:basic1 帧同步(Lockstep)
选型:为什么选帧同步
做多人实时联机,第一个面临的问题就是同步模型。当时市面上的方案主要两个:
| 维度 | 帧同步(Lockstep) | 状态同步(Authoritative) |
|---|---|---|
| 服务器角色 | 命令中继,不处理逻辑 | 状态权威,跑全部逻辑 |
| 客户端工作量 | 大(执行全量逻辑) | 小(只负责表现和输入) |
| 代码维护 | 只维护一份客户端逻辑 | 服务器客户端两份 |
| 延迟 | 低(命令极小) | 高(传全量状态) |
| 防作弊 | 弱 | 强 |
我选择帧同步,理由有两條:
1. 服务端简单,无需在服务端、客户端维护两套代码
帧同步的服务端只是一个命令中继——收到 A 的命令就广播给 B,收到 B 的就广播给 A,不需要理解任何游戏逻辑。这意味着:服务端代码量极少,不需要加班的碰撞检测、不需要在两端移植移动逻辑、不需要担心服务端和客户端给同一套算法跑出不同结果。
对于一个人开发来说,这个诱惑太大了。少写一个服务端的游戏循环,可能省 3-5 天。
2. 低延迟,适合动作游戏的即时反馈
帧同步只传按键指令,一个数据包可能就几个字节。服务端广播一下,不做任何处理。相比状态同步要打包几十个位置/旋转/动画字段、服务端跑一遍全量逻辑再广播,帧同步的端到端延迟理论上可以做到很低。
动作游戏(格斗、射击、RTS)天然偏好帧同步,因为玩家的每一个操作都需要立刻有反馈。如果按下攻击键要等 100ms 服务端确认才挥刀,手感直接废了。
basic1:纯手写的第一版
basic1 是这条时间线上的唯一一个完全由我手写的版本——没有 AI 辅助,没有自动补全,一行行自己敲。我花了整整 两周 时间。
这个决定在当时是自然的:多人联网的逻辑太复杂了,我需要先自己搞清楚每一行是干什么的,才有资格用工具辅助。不了解问题就去用 AI,跟"不会游泳就跳进深水区"一样——AI 不仅解决不了问题,反而会让问题更隐蔽。
basic1 的技术栈:
- Meta3D 引擎(自研引擎)做场景渲染
- TSRPC 做全链路通信(WebSocket + HTTP,TypeScript 类型安全)
- Immutable.js 做状态管理(为了支持 rollback 回滚)
- 自定义 fNumber 定点数系统(解决跨平台浮点一致性问题)
很多人以为 first commit 应该很粗糙,但 basic1 的结构已经相当完整——30+ 个源文件,从 UI(React)到逻辑到渲染到网络通信,麻雀虽小五脏俱全。
单机到多人的架构跳跃
在做 basic1 之前,单机版本「巨大娘的玩耍」已经是一个成熟的商业产品。但我很快发现:单机和多人的代码思维方式完全不同。
单机游戏的数据流是一条直线:
玩家输入 → 游戏逻辑(移动/碰撞/伤害) → 更新状态 → 渲染
↑ 全在同一个进程里 ↓
└──────────────────────────────────────────┘
多人游戏的数据流是两条线,中间隔着一张网:
客户端 A: 输入 → 发命令 → 收状态 → 渲染
↕ 网络
服务端: 收命令 → 执行逻辑 → 广播状态
↕ 网络
客户端 B: 输入 → 发命令 → 收状态 → 渲染
这带来了三个单机里不存在的问题:
1. 谁是权威? 单机里客户端就是上帝,说什么是什么。多人里必须有一个人说了算——通常是服务端。这就催生了服务端权威模型:客户端发命令,服务端执行逻辑,状态广播回来。
2. 纯逻辑必须可分离。 单机时代,碰撞检测和渲染写在同一个函数里很正常。多人时代不行——服务端没有渲染器,它需要纯数学函数:输入两个位置,输出是否碰撞。于是就有了 Logic 共享层:把纯逻辑抽出来,前后端加载同一份代码。
3. 命令 vs 状态。 单机开发时,我从来没想过「发送命令」这件事——按下 W,角色直接移动。多人里,按下 W 只是产生了一条命令,服务端收到了才能移动。同步模型(帧同步/状态同步)本质上就是在回答:什么时候发什么、什么时候信什么。
这三个问题构成了这个系列后面所有文章的理论基础。
帧同步循环
帧同步的思路很直接:每个人把自己做了什么告诉所有人,各端各自算结果。
每个逻辑帧:
客户端 A → [命令] → 服务器 → 广播给所有客户端
客户端 B → [命令] → 服务器 → 广播给所有客户端
每个客户端收到所有人的命令 → 各自执行 → 状态理应一致
basic1 的帧同步循环:
┌──────────────┐ ┌──────────────────┐
│ Logic Loop │─────▶│ Collect Commands │
│ (15 FPS) │ │ send AllCommands │
└──────┬───────┘ └────────┬─────────┘
│ │
│ ┌──────▼──────┐
│ │ Server │
│ │ broadcast │
│ └──────┬──────┘
│ │
┌──────▼───────────────────────▼──────────┐
│ syncServer(): │
│ 1. rollback predict data │
│ 2. execute all server commands │
│ 3. re-execute pending self commands │
└─────────────────────────────────────────┘
核心文件:
LogicManager.ts —— 整个帧同步的心脏。syncServer() 三步走:回滚→执行服务端命令→重放本地未确认命令。
MultiPlayerManager.ts —— 客户端-服务端通信。15 FPS 逻辑循环收集命令发送,60 FPS requestAnimationFrame 渲染。逻辑帧和渲染帧分离——这是一个经典的游戏引擎设计,没有 AI 帮忙,是我自己参考游戏开发资料写出来的。
Immutable.js: 所有多人状态用 Map、List 管理。每次变更生成新对象,回滚时只需指针指回旧状态。没有它,rollback 的复杂度会螺旋上升——最初我用普通对象 + 深拷贝,性能直接爆炸。第三天改成 Immutable.js。
浮点数噩梦:手写 fNumber
帧同步有一个隐藏假设:所有客户端执行同样的命令,必须得出同样的结果。
一个简单的 3D 移动:
if (按了A) 角色.x -= speed * deltaTime
问题出在 JavaScript 的 Number(IEEE 754 双精度浮点)——加法不满足结合律:
0.1 + 0.2 + 0.3 // 0.6000000000000001
0.3 + 0.2 + 0.1 // 0.6
命令到达顺序不同→加法顺序不同→累积误差→位置不同步。
这个 bug 我 debug 了整整一个晚上,最终才定位到浮点数。解法是自己写了一套 fNumber 定点数系统:
type fNumber = number // 语义上表示"定点数"
let add = (a: fNumber, b: fNumber) => { /* 定点数加法保证一致性 / }
let sub = (a: fNumber, b: fNumber) => { / 定点数减法 / }
let fromFloat = (n: number): fNumber => { / 浮点转定点 / }
let toFloat = (n: fNumber): number => { / 定点转浮点 */ }
但带来了额外复杂度——不能用原生 + -,所有运算得过自定义函数。后来我让 AI 帮忙写代码时,AI 经常忘记这层包装,直接 positionX += speed,然后花半天 debug。
放弃帧同步的两个原因
basic1 我手写了大概两周。它确实能跑——两个玩家可以在场景里看到对方移动。但到了功能验证阶段,两个致命问题暴露了:
原因一:浮点数误差难以处理
上面说了,fNumber 能解决基础的加法和乘法一致性问题。但碰撞检测是开关逻辑(碰/不碰),微小数值差异会触发完全不同的代码路径。
- 客户端 A 认为玩家在 (0.3, 0, 0),检测到碰撞
- 客户端 B 认为同一个人在 (0.30000001, 0, 0),没碰撞
一个判断为碰撞导致位移被阻断,另一个继续移动。几个帧之后,两个客户端完全不在一个世界。
原因二:后期容易出同步问题,极难调试
帧同步出 bug 时,症状不在出 bug 的那台机器上,而是别人的机器上。你在自己电脑上看到角色瞬移了,原因可能在对面客户端的浮点精度。没有日志、没有复现手段,全靠猜。
更致命的是,每次修改移动逻辑(比如加了跳跃、改了速度公式),都需要验证所有客户端的计算结果一致。两周的开发时间里,我至少有 3-4 个晚上花在"为什么两台机器位置不一样"的调试上。
关键认知转变
两周后,我做了一个决定:放弃帧同步,切换到状态同步。
驱动力很简单——项目只有两个玩家,不是 8 个人的星际争霸。带宽不是瓶颈,但代码可维护性是。帧同步的带宽优势在 2 人场景用不上,反而被它的复杂度拖死。
状态同步意味着:服务器是老大,客户端是笨蛋。 服务器计算一切,客户端只做渲染。这彻底解除了浮点数确定性的紧箍咒——每个客户端各自渲染权威位置,有点小误差无伤大雅。同时,调试也有了权威参考系——服务端状态 = 标准答案,客户端不对就是客户端的问题。
不过我没有直接从 basic1 跳到最终版。下一步是让 AI 参考 basic1 的代码,改出一个状态同步版本——这就是 new_basic1。
basic1 的代码后来基本废弃,但这两周的手写经历,踩的每一个坑,都直接决定了技术选型的方向。没有这两周的帧同步痛苦,我根本不知道怎么描述需求——知道「不要什么」和知道「要什么」同等重要。
浙公网安备 33010602011771号