
试玩地址:http://101.133.145.229:7100/。打开两个标签页,各自点一下 Start Matchmaking,匹配上就是一场双人帧同步对局:WASD 移动、空格跳跃,双方看到的是同一个物理世界。
上一篇文章聊了单机物理引擎怎么让物体动起来。这篇再进一步:两个人在两台机器上玩同一个物理世界,怎么保证两边算出来的结果分毫不差?答案就是帧同步(lockstep)——不传状态,只传输入,让两端用相同的规则各自重放。
状态同步与帧同步的分工
多人游戏同步状态有两条路线。
状态同步:服务器跑权威模拟,把实体位置速度下发给客户端,客户端做插值和预测。带宽随状态量增长,服务器承担全部计算,FPS 和 MMO 基本都是这条路。
帧同步:服务器只转发输入,所有客户端各自跑一份确定性模拟。带宽只有输入量(每帧几个数),一致性靠确定性保证而不是靠纠正。代价同样明确:任何一端的分歧都会累积成画面不一致;玩家输入要等广播到齐才生效,天然对延迟敏感。RTS 的传统强项——星际争霸、帝国时代都是帧同步,《王者荣耀》也选了帧同步方案。
本项目是帧同步的完整最小实现:Go 中继服务器加基于 Bounce 确定性物理引擎的浏览器客户端。
确定性:帧同步的地基
帧同步成立的唯一前提:同一时刻,两台机器上的世界状态逐位相同。工程上守住三条规则:
- 固定步长:所有客户端用相同的 dt 调 takeOneStep(1/30),一步都不能差;
- 避开超越函数:物理相关逻辑不碰 Math.sin/cos/tan,这类函数在不同平台的实现不保证逐位一致;
- 保持运算顺序:浮点加法不满足结合律,(a+b)+c 可能不等于 a+(b+c),两端所有计算顺序必须一致。
Bounce 是纯 TypeScript 实现的确定性物理引擎,任何 IEEE 754 兼容平台上,相同输入产出相同结果。这是整个 demo 能帧同步的前提。
架构:一个中继、两个确定性世界

客户端 A、B 各跑一份相同的物理世界。中继服务器只做四件事:匹配(1Hz 轮询,凑满 2 人开局)、房间生命周期管理、30Hz 帧广播、状态哈希校验。它完全不运行游戏模拟。
输入流动方向:客户端在按键变化时才把 PlayerInput 发给服务器;服务器每个 tick(每秒 30 次)把房间内全部玩家的最近输入打包成 FrameInput 广播给所有人。两端收到同样的帧序列,用同样的步长重放,画面自然一致。
匹配与开局

流程很直白:双方各发 MatchmakingRequest 进队,服务器 1Hz 轮询发现队列凑够 2 人,建房间、分配 playerId,然后发 GameStart 带上随机种子。种子是帧同步的关键——两端的物理世界用同一个种子初始化,之后每一步才可能一致。开局后进入 30Hz 循环:客户端不预测、不回滚,收到一帧就推进一步物理。
线协议
WebSocket 上跑一种极简线路格式,每帧 = 4 字节小端长度 + 1 字节类型标签 + JSON 正文:
[4 字节小端长度][1 字节类型标签][JSON UTF-8]
| 标签 | 方向 | 消息 | 用途 |
|---|---|---|---|
| 5 | C→S | MatchmakingRequest | 进入匹配队列 |
| 2 | C→S | PlayerInput | moveX / moveY / jump |
| 3 | C→S | StateHash | 确定性校验 |
| 6 | S→C | MatchmakingStarted | 排队确认 |
| 1 | S→C | JoinResponse | 分配 playerId |
| 2 | S→C | GameStart | 随机种子、玩家列表 |
| 3 | S→C | FrameInput | 30Hz 输入广播 |
| 4 | S→C | GameOver | 胜者信息 |
| 5 | S→C | PlayerEliminated | 断线淘汰 |
服务器:Go 中继
服务器代码量很小:匹配队列、房间状态机、输入缓冲、30Hz tick 循环。核心是这两段:
服务器完整源码:github.com/beijian128/lockstep-server(Go)
// tick/tick.go —— 30Hz 定时循环
const TickRateHz = 30
func (t *TickLoop) Start() {
ticker := time.NewTicker(time.Second / TickRateHz)
go func() {
for {
select {
case <-ticker.C:
tick := t.current.Add(1)
t.onTick(tick) // 即 dispatch.BroadcastFrame(tick)
case <-t.stopCh:
ticker.Stop()
return
}
}
}()
}
// dispatch/dispatch.go —— 每个 tick 打包全部玩家输入并广播
func (d *Dispatch) BroadcastFrame(tick uint32) {
players := make([]*pb.PlayerInput, 0, len(d.players))
for pid := range d.players {
inp := d.inputBuf[pid] // 该玩家最近一次输入;没发过就是空输入
inp.Tick = tick
players = append(players, inp)
}
msg := &pb.ServerMessage{Payload: &pb.FrameInput{Tick: tick, Players: players}}
for _, session := range d.players {
session.Send(msg)
}
}
值得注意的三个取舍:
- 输入缓冲按玩家存最近一次输入,某玩家这一 tick 没发输入,就沿用他上一次的——按键没变化等于持续按住;
- 断线 30 秒超时,移除玩家并广播 PlayerEliminated,没有重连机制;
- 每 300 tick(10 秒)收集各客户端状态哈希,至少两份哈希可对比时做校验,不一致记一条 DIVERGENCE 日志——只发现分歧,不负责修复。
客户端:收到帧才步进
客户端完整源码:github.com/beijian128/lockstep-cli(TypeScript,含 Bounce 引擎源码与全部示例工程)
// game.ts —— FrameSync:帧队列,收到一帧推进一步物理
const TIME_STEP = 1 / 30;
consumeAll(world, playerManager) {
while (this._queue.length > 0) {
const frame = this._queue.shift()!;
for (const input of frame.players) {
const c = playerManager.getController(input.playerId);
c?.setTargetMovement(input.moveX, input.moveY, input.jump);
c?.update(TIME_STEP);
}
world.takeOneStep(TIME_STEP); // 每帧全体执行同一步物理
}
}
帧队列攒下服务器广播,渲染循环里逐帧取出:输入灌给对应玩家的角色控制器,然后世界统一前进一步。这种服务器驱动步进把节奏问题整体外包给了网络——帧到得快就快进,到得慢就等,但每一步物理都是相同的 1/30 秒。
两个输入细节:WASD 是相机相对输入,发送前按相机水平角旋转,保证 W 永远是”远离镜头”;状态哈希用 FNV-1a 对全部玩家角色的位置和速度做摘要,每 300 tick 上报一次。
帧循环与一致性体检

一帧的完整闭环:采集输入、上行、服务器打包广播、客户端消费并步进。副线是每 10 秒一次的体检:两端各自哈希自己的世界,服务器比对,一致静默通过,不一致打日志。
部署与试玩
部署形态很简单:中继服务作为常驻进程运行,客户端是一个纯静态网页,打开浏览器就能玩。
再放一次试玩地址:http://101.133.145.229:7100/。开两个标签页,各自点 Start Matchmaking,匹配上就开局——没有对手的话,两个标签页就是两个人。
局限与下一步
- 无预测无回滚:每个 tick 都要等广播到齐,跨地域高延迟下输入响应会明显变慢;
- 断线无重连:30 秒后判负,只能重新匹配;
- 哈希校验只检测不纠正:真出分歧,只能靠日志排查;
- 广播量随人数平方增长:每 tick 把所有人的输入发给所有人,2 人 demo 无压力,扩到 N 人需要输入汇总优化。
适合接手的方向:GGPO 式回滚 netcode——Bounce 的 world.toArray / fromArray 序列化就是为快照回滚准备的;断线重连;以及扩房间规模时的输入分发优化。



