一、帧号是战斗的时钟,也是状态的版本号
帧同步战斗把时间切成固定长度的逻辑帧,服务器每帧收集所有玩家输入、按固定顺序执行,再把结果广播给客户端。帧号因此承担两个职责:它既是时间轴上的位置,也是“这份状态属于哪一版”的版本号。
只要帧号只增不减,比较大小就很自然。但网络协议里的帧号通常是 32 位无符号整数,连续运行会回绕。一局持续数小时的战斗不一定跑到 40 亿帧,可跨战斗复用进程、压测脚本快进、或者把帧号当作全局递增序号时,回绕就会真实出现。
更常见的问题不是回绕本身,而是代码里散落的 a < b。一旦帧号从 0xFFFFFFFF 变成 0,所有直接比较都会得出相反结论:新帧被当成旧帧丢弃,回滚目标被判断为“未来帧”,战斗在毫无征兆的情况下卡死。
二、回绕比较:不要直接比大小
正确做法是把差值解释为有符号数,只使用“半个编号空间”以内的距离。int32(a-b) > 0 表示 a 在 b 之后;只要两帧真实距离小于 2^31,结论就与回绕无关。
| 写法 | 回绕时的行为 | 是否可用 |
|---|---|---|
a < b | 0 小于最大值,新帧被判为旧帧 | 否 |
int64(a) < int64(b) | 同样把回绕当成倒退 | 否 |
int32(a-b) > 0 | 差值在半个空间内仍然正确 | 是 |
| 每次回绕时重置计数器 | 需要全局协调,容易漏掉旧包 | 不推荐 |
前提是“同一时刻比较的两帧距离远小于 21 亿”。如果消息在缓冲区里躺了几年,或者帧号被随机化,这个前提不成立;那种场景应改用 64 位帧号加会话纪元,而不是继续修补 32 位比较。
三、服务器帧与客户端本地帧不是同一个东西
服务器帧是权威时间轴,客户端本地帧是渲染与预测用的本地计数。两者在正常运行时保持一个稳定偏移:客户端通常领先服务器若干帧,用这点提前量掩盖网络延迟。
必须明确三条规则。第一,输入包携带的是“这笔输入属于哪个服务器帧”,不是客户端当前帧。第二,客户端本地帧只用于驱动预测和插值,不能写进存档或战斗结果。第三,服务器只相信输入里的目标帧,并且只接受合理窗口内的帧,过期输入直接丢弃。
如果两端都把“当前帧”当成同一个概念,重连、暂停、追帧之后偏移就会漂移。表现是玩家看到自己在原地来回抖,服务器却认为一切正常。
还有一个常见误解:客户端收到权威帧就立刻把自己的本地帧“对齐”过去。这会让本地时间轴突然倒退或跳跃,预测输入的目标帧全部错位。正确做法是保持稳定的本地领先量,只在累计偏差超过阈值时缓慢调整,而不是每帧硬对齐。
四、客户端预测:先执行,再校正
没有预测时,玩家按下技能要等一个往返延迟才看到动作,手感像在泥里操作。预测的做法是:客户端在本地帧立即用“自己的输入 + 对他人输入的估计”执行一遍,先渲染结果;收到服务器权威帧后,如果本地预测与权威状态不一致,就回滚到最后一个确认帧,重新应用确认输入和仍在途的本地输入。
流程可以拆成五步:
- 客户端记录每个本地帧的输入,并保留最近若干帧的状态。
- 本地立即执行输入,渲染预测结果,同时把输入发给服务器。
- 服务器按帧号收集输入,执行权威逻辑,广播“帧号 + 权威状态或校验值”。
- 客户端收到权威帧后,找到自己最后一个被确认的帧。
- 若预测状态与权威状态不一致,回滚到确认帧,按顺序重放其后的输入。
关键在第五步:回滚不能只把位置改回去,还必须重放输入。否则状态里会残留“已经放过技能”的冷却、已经扣除的蓝量,下一次校正时误差更大。
五、回滚需要保存什么
最小集合是“帧号 + 完整可恢复状态”。位置、朝向、血量、冷却、Buff、随机数种子都算状态;只存位置的回滚是不完整的。输入日志则单独保存,按帧号索引,回滚后从确认帧的下一帧开始逐帧重放。
保存窗口要有上限。客户端不可能永久保留所有历史帧,服务器也不会为每个玩家保留整场战斗。窗口大小由“最大可接受回滚距离”决定:超过窗口的校正只能走全量快照或重连流程,不能假装还能逐帧重放。
判断一份数据要不要进回滚状态,可以问三个问题:它是否影响后续帧的结算?它是否能从其他数据推导?丢失它是否会造成可见差异?三个问题里有一个答案是“是”,就应该保存。
- 位置、速度、朝向:影响碰撞和技能判定,必须保存。
- 血量、蓝量、冷却:影响合法性判断,必须保存。
- Buff 层数与剩余时间:影响伤害公式,必须保存。
- 随机数种子与已消费次数:影响暴击和掉落,必须保存。
- 纯展示特效、摄像机偏移:可由状态推导,不必保存。
| 数据 | 用途 | 典型保留范围 |
|---|---|---|
| 每帧状态 | 回滚起点 | 最近 1 到 2 秒 |
| 本地输入 | 回滚后重放 | 与状态窗口相同或略长 |
| 权威帧摘要 | 判断是否需要校正 | 最近若干秒 |
| 全量快照 | 窗口外的兜底恢复 | 低频、独立保存 |
六、失败场景:预测错误为什么表现为抖动和瞬移
假设客户端预测角色向右移动了 3 帧,服务器因为丢包只收到第 1 帧输入。权威帧到达后,客户端回滚到确认帧,重放时发现后两帧输入没被服务器接受,角色被拉回原位;下一批输入到达后又向前跳。玩家看到的是角色每 100 毫秒抖一下。
瞬移通常来自更大的偏差:客户端在本地执行了一个服务器判定不合法的手术(距离不够、目标已死亡),权威结果直接否定,回滚距离超过一帧。若此时没有插值,画面会在两帧之间硬切。
掩盖手段不是篡改权威状态,而是渲染层插值:逻辑状态仍然按帧跳变,渲染位置用短时间平滑逼近目标。插值时间要小于一个校正周期,否则输入延迟会被放大。对无法插值的离散事件,例如技能被拒绝,应播放明确的失败反馈,而不是让角色悄悄回弹。
还要区分“预测错误”和“显示错误”。前者是本地逻辑真的算错了,必须回滚重放;后者只是渲染插值参数不合适,逻辑状态其实一致。把显示问题当预测问题处理,会频繁回滚,画面反而更抖;把预测错误当显示问题处理,则会积累误差直到瞬移。
监控上至少记录三个量:每秒回滚次数、平均回滚帧数、回滚后仍需二次校正的比例。回滚次数突然上升通常意味着输入延迟或丢包恶化;平均回滚帧数持续变大,说明客户端领先量设置得不合理。
这三个指标要按战斗、按玩家维度聚合。只看全局平均值,会被大量正常玩家稀释,个别持续回滚的玩家很难被发现。
七、Go 实现:带容量上限的 FrameBuffer
下面的 FrameBuffer 保存最近 N 帧权威状态,Put 只接受连续的下一帧,Rollback 对过期帧和超前帧分别报错。帧号运算全部走 After 和 Sub,因此天然支持回绕。
// file: ch08/lockstep/frame.go
// tier: L1
// verify: go test ./ch08/lockstep/
package lockstep
import "errors"
// Frame 是逻辑帧号,允许自然回绕。比较必须使用 After/Sub,不能直接用 <。
type Frame uint32
// Sub 返回 f-o 的有符号差值,跨回绕仍然正确。
func (f Frame) Sub(o Frame) int32 { return int32(f - o) }
// After 报告 f 是否严格晚于 o。
func (f Frame) After(o Frame) bool { return int32(f-o) > 0 }
var (
ErrFrameStale = errors.New("frame: stale or duplicate frame")
ErrFrameExpired = errors.New("frame: requested frame expired")
ErrFrameAhead = errors.New("frame: requested frame ahead of latest")
)
// FrameBuffer 保存最近 capacity 帧的权威状态,帧号必须连续递增。
type FrameBuffer[T any] struct {
capacity int
latest Frame
started bool
states map[Frame]T
order []Frame
}
func NewFrameBuffer[T any](capacity int) *FrameBuffer[T] {
if capacity < 1 {
capacity = 1
}
return &FrameBuffer[T]{capacity: capacity, states: make(map[Frame]T)}
}
// Put 写入下一帧状态。帧号必须严格等于 latest+1,否则返回 ErrFrameStale。
func (b *FrameBuffer[T]) Put(f Frame, state T) error {
if b.started && !f.After(b.latest) {
return ErrFrameStale
}
if b.started && f != b.latest+1 {
return ErrFrameStale
}
b.states[f] = state
b.order = append(b.order, f)
b.latest = f
b.started = true
for len(b.order) > b.capacity {
old := b.order[0]
b.order = b.order[1:]
delete(b.states, old)
}
return nil
}
// Get 返回指定帧的状态,已被容量淘汰时 ok 为 false。
func (b *FrameBuffer[T]) Get(f Frame) (T, bool) {
s, ok := b.states[f]
return s, ok
}
// Latest 返回最新帧。
func (b *FrameBuffer[T]) Latest() (Frame, bool) { return b.latest, b.started }
// Rollback 返回确认帧的权威状态;过旧返回 ErrFrameExpired,超前返回 ErrFrameAhead。
func (b *FrameBuffer[T]) Rollback(f Frame) (T, error) {
var zero T
if !b.started {
return zero, ErrFrameExpired
}
if f.After(b.latest) {
return zero, ErrFrameAhead
}
s, ok := b.states[f]
if !ok {
return zero, ErrFrameExpired
}
return s, nil
}
Put 要求帧号连续,是刻意的限制:允许跳跃会让回滚拿到不连续的状态,调用方必须显式补齐缺失帧。容量淘汰最旧帧,Get 对已淘汰帧返回 false,Rollback 则把它视为过期,要求上层改走快照恢复。
八、测试:回绕、回滚重放与过期拒绝
测试覆盖三类边界:帧号从最大值回绕到 0、客户端预测到第 8 帧后回滚到第 5 帧重放、以及容量为 3 时请求已被淘汰的帧。
// file: ch08/lockstep/frame_test.go
// tier: L1
// verify: go test ./ch08/lockstep/
package lockstep
import (
"errors"
"math"
"testing"
)
func TestFrameWrapComparison(t *testing.T) {
last := Frame(math.MaxUint32)
if !Frame(0).After(last) {
t.Fatalf("frame 0 should be after %d", last)
}
if Frame(0).Sub(last) != 1 {
t.Fatalf("wrap diff = %d, want 1", Frame(0).Sub(last))
}
if Frame(last).After(Frame(0)) {
t.Fatal("last frame must not be after frame 0")
}
if Frame(10).After(Frame(10)) {
t.Fatal("equal frame must not be after itself")
}
}
func TestFrameBufferRollbackReplay(t *testing.T) {
deltas := []uint64{0, 3, 5, 2, 7, 4, 6, 1, 2}
buf := NewFrameBuffer[uint64](8)
var pos uint64
for f := Frame(1); f <= 5; f++ {
pos += deltas[f]
if err := buf.Put(f, pos); err != nil {
t.Fatal(err)
}
}
// 客户端本地已经预测执行到第 8 帧。
local := pos
for f := Frame(6); f <= 8; f++ {
local += deltas[f]
}
confirmed, err := buf.Rollback(5)
if err != nil {
t.Fatal(err)
}
replay := confirmed
for f := Frame(6); f <= 8; f++ {
replay += deltas[f]
}
if replay != local {
t.Fatalf("replay = %d, local prediction = %d", replay, local)
}
if got, ok := buf.Get(4); !ok || got != 17 {
t.Fatalf("frame 4 = %d, %v; want 17, true", got, ok)
}
}
func TestFrameBufferRejectsExpiredAndAhead(t *testing.T) {
buf := NewFrameBuffer[uint64](3)
for f := Frame(1); f <= 6; f++ {
if err := buf.Put(f, uint64(f)*10); err != nil {
t.Fatal(err)
}
}
if _, err := buf.Rollback(1); !errors.Is(err, ErrFrameExpired) {
t.Fatalf("want ErrFrameExpired, got %v", err)
}
if _, err := buf.Rollback(9); !errors.Is(err, ErrFrameAhead) {
t.Fatalf("want ErrFrameAhead, got %v", err)
}
if _, ok := buf.Get(2); ok {
t.Fatal("frame 2 should be evicted by capacity 3")
}
if got, ok := buf.Get(6); !ok || got != 60 {
t.Fatalf("frame 6 = %d, %v; want 60, true", got, ok)
}
if err := buf.Put(6, 0); !errors.Is(err, ErrFrameStale) {
t.Fatalf("want ErrFrameStale, got %v", err)
}
if err := buf.Put(8, 0); !errors.Is(err, ErrFrameStale) {
t.Fatalf("gap write: want ErrFrameStale, got %v", err)
}
}
回滚重放的测试不是比较“回滚后的状态等于第 5 帧”,而是比较“从第 5 帧重放 6 到 8 帧的结果,等于客户端最初预测的结果”。这才是回滚正确性的实际判据;只恢复起点而不重放,测试会通过,线上却会漂移。
九、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch08/lockstep && go vet ./ch08/lockstep/ && go test ./ch08/lockstep/ -count=1
ok gspractice/ch08/lockstep 0.371s
小结
帧号必须用差值比较来支持回绕;服务器帧是权威时间轴,客户端本地帧只服务预测和渲染。预测的正确流程是“本地先执行、收到权威帧后回滚到确认帧、重放其后的输入”,而不是只把位置改回去。FrameBuffer 用连续写入和容量淘汰守住状态窗口,过期与超前都显式报错,让上层决定走重放还是快照恢复。
常见错误与边界
- 用
<比较帧号,回绕后把新帧当旧帧,战斗静默卡死。 - 回滚只恢复位置,不重放输入,冷却、Buff 和资源残留误差。
- 把客户端本地帧写进存档或战斗结果,重连后时间轴漂移。
- 状态窗口无限增长,客户端内存随战斗时长线性上涨。
- 允许
Put跳帧,回滚时拿到不连续状态,重放结果与权威不一致。 - 把超过窗口的校正也交给逐帧重放,实际数据早已被淘汰。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。