一次迟到 80ms 的输入
《裂隙战场》是北辰工作室的 5v5 竞技产品,房间服按 30Hz 推进,一帧 33ms。某个版本上线后,客服收到一类很难复现的投诉:玩家明明看到自己躲开了,结算却判定被命中;回放里双方的位置又和任何一方的客户端截图都对不上。
房间日志最后指向一种很普通的网络现象:一名玩家的输入包晚到了 80ms(示例值)。80ms 不到三帧,但服务器的 tick 已经提交并广播了这三帧。不同客户端对“这一帧到底有没有这个输入”给出了不同答案,于是同一场战斗在不同屏幕上长成了两个样子。
这类问题不是“网络差”三个字能带过的。它逼着团队回答一个架构问题:谁拥有这一帧的最终输入集合,其他参与者如何以相同的顺序看到它。 这就是帧同步与状态同步之争的起点。
五条判据:先把成本排开
帧同步和状态同步不是“新技术/旧技术”,而是把一致性成本放在不同位置。对《裂隙战场》这种 5v5、单局 15 到 25 分钟、需要观战与赛事的品类,至少要排开五条判据。
| 判据 | 帧同步 | 状态同步 |
|---|---|---|
| 确定性要求 | 高:所有客户端必须逐帧算出相同结果 | 低:客户端只消费服务端状态 |
| 下行带宽 | 小:广播输入,不广播完整世界 | 大:按可见性广播状态或增量 |
| 断线重连 | 难:要补齐缺失输入或从关键帧重放 | 易:直接下发最新快照 |
| 观战 | 需要额外延迟与信息过滤 | 可按观战视角裁剪 |
| 反作弊 | 弱:客户端拿到全部输入即可推演全图 | 强:服务端决定客户端能看到什么 |
带宽这一栏经常被误读。帧同步并不是“不传状态”,它把状态传输换成了输入传输加本地模拟;单帧输入确实小,但观战、重连、裁判复核都要重新构造状态,这些成本只是从带宽账本挪到了计算和工程账本。
确定性这一栏更不能含糊。只要有一个 map 遍历顺序、一次浮点求值、一个随机数种子在不同平台上不一致,帧同步就会“看起来能跑,偶尔不同步”。这类 bug 的定位成本远高于多买几台服务器。
反作弊是《裂隙战场》的硬约束。纯帧同步意味着每个客户端都能拿到全部玩家的输入,透视、自动瞄准和全图信息只需要一个本地模拟器。对竞技产品,这不是可以接受的代价。
为什么《裂隙战场》选混合模型
团队最终没有走“纯帧同步”,也没有走“纯状态同步”,而是把一局拆成两个一致性域:
- 战斗输入域用帧同步。房间服按 tick 聚合 10 名玩家的输入,排序后广播;客户端只在这个域内做确定性推演,技能前摇、位移和碰撞按同一套规则结算。
- 视野与关键状态域用服务端权威状态同步。敌方位置、草丛可见性、经济与装备等由服务端裁剪后下发,客户端不能用本地推演替代。
这条边界是本章最重要的决定。它承认了帧同步的价值(输入小、回放天然可重演、赛事复核简单),也承认了它的代价(确定性、重连、信息泄露),并用状态同步补上反作弊和视野这两个帧同步不擅长的部分。
混合模型的代价是两套时序要对齐。输入帧必须先于状态校正到达,否则客户端会先看到旧位置再被拉回。工程上要把输入帧和服务端状态帧放进同一条房间时间轴,而不是各自维护一个“大致同步”的时钟。固定步长 tick 的做法见 4.2 tick、帧预算与世界时钟 。
房间、tick 与分配器的边界
房间服不是“一台跑匹配逻辑的机器”,它是有明确生命周期的状态机:创建、加载配置、等待玩家、倒计时、进行中、结算、销毁。分配器只负责找到一台有容量的机器并创建房间,它不驱动房间内的 tick。
这条边界要和 3.2 大厅、房间与战斗服的边界 保持一致:
- 大厅负责组队、匹配、进入房间的凭证;它不知道房间内第几帧。
- 分配器负责放置房间、回收机器、处理扩容;它不参与任何一帧的输入聚合。
- 房间服独占自己的 tick 和时间轴;它只向平台上报心跳、指标和结算结果。
把 tick 放进分配器,会得到一个无法水平扩展、也无法解释“为什么这一帧慢了”的系统。房间服重启时,进行中的对局必须有明确归宿:要么由平台按对局 ID 重建,要么整局判负并补偿。这个决定应该在 ADR 里写清楚,而不是等故障时现场讨论,参见 2.2 ADR:把架构决定写下来 。
输入窗口:把迟到变成可计数的拒绝
帧同步房间需要回答一个看似简单的问题:一个 tick 的输入要等多久?
答案是:等到这个 tick 提交为止,一毫秒都不多等。 房间不能在每个 tick 上等“还没到的 10 个玩家”,否则一个丢包就能让整局卡住。正确做法是给输入设一个未来窗口,窗口内的输入可以缓冲,窗口外的输入直接拒绝并计数。
窗口应该用 tick 表示,而不是毫秒。用毫秒会在 tick rate 改变时悄悄改变语义,也会让“3 帧”和“100ms”在 20Hz、30Hz、60Hz 下对应不同的游戏时间。
当前 tick = 100,窗口 = 3
可接受:100、101、102、103
迟到: 99 及更早 -> 拒绝,late++
超窗: 104 及更远 -> 拒绝,reject++,通常是客户端作弊或时钟错乱
重复: 同一 tick 同一玩家第二次 -> 拒绝,dupe++,第一份生效
这里有一个容易被忽略的细节:迟到输入不能“补进去重算”。这一帧已经广播,部分客户端已经推演并显示,服务端再回填只会制造第二套结果。拒绝并计数,让上层用预测和插值消化,是更诚实的选择。
窗口也不是越大越好。窗口越大,服务端为未来帧保留的缓冲越多,恶意客户端越容易用大量未来输入占用内存。窗口大小应当和 tick rate、最大可接受 RTT、房间人数一起做预算,而不是拍一个“500ms 应该够”。
推进 tick:空帧也要前进
帧同步房间最常见的错误是“没收到输入就不推进”。这会把网络抖动直接变成游戏卡顿:一个玩家掉线,其他九个人的世界一起停住。
正确的语义是:tick 是房间的时间,不是输入的时间。 到点就提交,空帧也是一帧。空帧表示“这一帧没有任何可信输入”,模拟仍然前进,指标上记为 missing。客户端可以用上一帧的输入做短暂外推,但服务端的权威时间不能停。
这带来一个需要明确的边界:空帧推进后,迟到的输入只能被拒绝,不能改变已经提交的帧。玩家感知到的“我明明按了”由客户端预测负责,服务端的职责是保证所有观察者看到同一段历史。
如果一局中空帧比例持续升高,说明问题不在单个包,而在链路、房间负载或客户端帧率。此时应该告警并考虑降频或结束对局,而不是无限期等待。
确定性哈希:一致性问题在网络之外
帧同步的验证不能只看“有没有报错”。服务端需要一个可比较的指纹,回答“这局到底有没有分叉”。
做法是每提交一帧,就把这一帧的输入按稳定顺序折叠进一个滚动哈希:
- 玩家按 ID 排序,不能依赖
map遍历顺序。 - 整数使用固定字节序,避免大小端差异。
- 不使用浮点作为哈希输入;位置和方向在进入哈希前先量化成整数。
- 每一帧都更新哈希,而不是只算最终状态,这样能尽早发现分叉。
滚动哈希的价值在于可比较:服务端、客户端、观战端和回放工具各自重放同一段输入,最终哈希必须一致。不一致时不要“自动修一下”,而应该保留现场、标记对局异常,并让回放工具复现。哈希一致不代表规则正确,但哈希不一致一定意味着有一方看到了不同的历史。
可运行片段:输入聚合器
下面的 internal/moba 把输入窗口、迟到/重复/超窗计数、空帧推进和滚动哈希写成了一个可直接测试的聚合器。它不负责网络收发,也不负责房间生命周期,只负责“给定每帧输入,产出一致的历史”。
// file: internal/moba/aggregator.go
// tier: L1
// verify: go test ./internal/moba/
package moba
import (
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"sort"
)
var (
ErrStaleInput = errors.New("moba: input tick already committed")
ErrFutureInput = errors.New("moba: input tick beyond buffer window")
ErrDuplicate = errors.New("moba: duplicate input for player in tick")
)
// Input is one player's command for one simulation tick. The fields are small
// integers on purpose: frame-sync packets must be cheap to serialize, and a
// fixed layout keeps the digest reproducible across languages.
type Input struct {
Player uint32
MoveX int16
MoveY int16
Action uint16
}
// Frame is the committed input set for a single tick. Inputs are sorted by
// Player so two servers that received the same packets in different orders
// produce byte-identical frames and the same digest.
type Frame struct {
Tick uint64
Inputs []Input
Digest uint32
}
// Aggregator buffers per-tick inputs and commits them one tick at a time.
// Window is how many ticks ahead a client may submit. Anything further is a
// protocol violation, not a slow network: accepting it would let one client
// grow the server's buffer without bound.
type Aggregator struct {
window uint64
current uint64
pending map[uint64]map[uint32]Input
roll uint32
frames uint64
missing uint64
late uint64
dupes uint64
rejects uint64
}
// New returns an aggregator whose future window is measured in ticks.
func New(window uint64) *Aggregator {
if window == 0 {
window = 1
}
return &Aggregator{
window: window,
pending: make(map[uint64]map[uint32]Input),
}
}
// Submit buffers one input. tick is the simulation tick the input belongs to,
// not the wall-clock time the packet arrived. Late inputs are counted and
// rejected because the frame has already been committed and sent to clients.
func (a *Aggregator) Submit(tick uint64, in Input) error {
if tick < a.current {
a.late++
return fmt.Errorf("%w: tick=%d current=%d", ErrStaleInput, tick, a.current)
}
if tick > a.current+a.window {
a.rejects++
return fmt.Errorf("%w: tick=%d current=%d window=%d", ErrFutureInput, tick, a.current, a.window)
}
bucket := a.pending[tick]
if bucket == nil {
bucket = make(map[uint32]Input)
a.pending[tick] = bucket
}
if _, exists := bucket[in.Player]; exists {
a.dupes++
return fmt.Errorf("%w: tick=%d player=%d", ErrDuplicate, tick, in.Player)
}
bucket[in.Player] = in
return nil
}
// Advance commits the current tick and returns its frame. An empty frame means
// no input arrived for that tick: the simulation still advances, and Missing
// counts the gap. Never stall the world waiting for a packet that may never
// arrive.
func (a *Aggregator) Advance() Frame {
tick := a.current
bucket := a.pending[tick]
delete(a.pending, tick)
inputs := make([]Input, 0, len(bucket))
for _, in := range bucket {
inputs = append(inputs, in)
}
sort.Slice(inputs, func(i, j int) bool { return inputs[i].Player < inputs[j].Player })
if len(inputs) == 0 {
a.missing++
}
a.current++
a.frames++
a.roll = chain(a.roll, tick, inputs)
return Frame{Tick: tick, Inputs: inputs, Digest: a.roll}
}
// chain folds one committed frame into the rolling digest. Sort order is part
// of the contract: unsorted input would make the hash depend on map iteration
// and turn a determinism bug into an intermittent one.
func chain(prev uint32, tick uint64, inputs []Input) uint32 {
h := crc32.NewIEEE()
var b [8]byte
binary.BigEndian.PutUint32(b[:4], prev)
h.Write(b[:4])
binary.BigEndian.PutUint64(b[:], tick)
h.Write(b[:])
for _, in := range inputs {
binary.BigEndian.PutUint32(b[:4], in.Player)
h.Write(b[:4])
binary.BigEndian.PutUint16(b[:2], uint16(in.MoveX))
h.Write(b[:2])
binary.BigEndian.PutUint16(b[:2], uint16(in.MoveY))
h.Write(b[:2])
binary.BigEndian.PutUint16(b[:2], in.Action)
h.Write(b[:2])
}
return h.Sum32()
}
// Digest is the rolling hash over every committed frame in order. Two rooms
// that received the same inputs, possibly in different packet order, must end
// with the same digest. A mismatch is a determinism bug, not a network bug.
func (a *Aggregator) Digest() uint32 { return a.roll }
// Tick is the next tick that Advance will commit.
func (a *Aggregator) Tick() uint64 { return a.current }
// Frames is the number of committed ticks, including empty ones.
func (a *Aggregator) Frames() uint64 { return a.frames }
// Stats is a fixed-width snapshot for metrics and replay headers.
type Stats struct {
Frames uint64
Missing uint64
Late uint64
Dupe uint64
Reject uint64
Digest uint32
}
// Stats reports counters without exposing the pending buffer.
func (a *Aggregator) Stats() Stats {
return Stats{
Frames: a.frames,
Missing: a.missing,
Late: a.late,
Dupe: a.dupes,
Reject: a.rejects,
Digest: a.roll,
}
}
这段代码有三个刻意为之的边界。第一,Submit 只缓冲,不结算;结算发生在 Advance,因此一帧的提交点唯一。第二,空帧也走 Advance,missing 只作为指标,不阻塞时间。第三,chain 在排序之后计算,输入到达顺序不影响结果。
go test ./internal/moba/ 覆盖了乱序到达、空帧、迟到、超窗、重复、到达顺序无关的哈希一致性,以及滚动哈希与单帧哈希的一致性。测试数量与退出码会在本章的验证记录里给出。
失败模式与兜底
| 失败模式 | 现象 | 处理 | 不该做什么 |
|---|---|---|---|
| 输入迟到 | 某一方看到技能在旧位置生效 | 拒绝并计数,客户端预测消化 | 回填已经提交的帧 |
| 整帧缺失 | 玩家短暂“定住” | 空帧推进,标记 missing,客户端外推 | 停住整个房间等待 |
| 重复输入 | 同一玩家同一帧出现两次 | 第一份生效,后续拒绝并计数 | 后者覆盖前者 |
| 未来输入 | 客户端时钟或作弊导致超窗 | 拒绝并计数,持续出现则踢出 | 扩大窗口容忍 |
| 哈希分叉 | 不同端重放结果不同 | 保留输入日志,标记异常,离线复现 | 线上自动“修正”状态 |
| 断线重连 | 玩家回到房间但缺历史 | 从最近关键帧加后续输入重建 | 直接把当前状态当历史 |
断线重连是帧同步最容易被低估的成本。状态同步可以发一份最新快照,帧同步必须让重连者补齐“从哪个 tick 开始、缺了哪些输入、如何在不破坏确定性的前提下追上”。如果房间不保存关键帧,就只能从开局重放;一局 25 分钟、30Hz 就是 45000 帧,重连延迟会直接劝退玩家。
常见错误与边界
第一,把帧同步当成“省服务器”。服务端仍然要聚合、排序、校验、广播、存储和支撑观战;省下的是状态下行带宽,不是服务器数量。
第二,用毫秒定义输入窗口。窗口是游戏时间,应该用 tick;毫秒会在 tick rate 调整时改变语义。
第三,用 map 直接遍历生成哈希。到达顺序一变,哈希就变,最后把确定性问题误判成网络问题。
第四,让空帧阻塞 tick。网络抖动会放大成全局卡顿,而且卡顿期间到达的输入又会造成新的积压。
第五,忽略观战与回放的信息边界。实时广播全部输入,等于把全部视野交给每一个客户端;观战必须延迟并按视角裁剪,这部分在 7.3 观战、回放与赛事架构 展开。
第六,把 tick 频率当成端到端延迟。30Hz 只说明模拟步长是 33ms,玩家从按键到看到结果还包含上行、排队、下行和渲染,预算口径见 1.2 延迟预算 。
小结
《裂隙战场》的结论不是“帧同步更好”,而是:在 5v5 竞技里,把战斗输入做成帧同步、把视野和关键状态留给服务端权威,能同时拿到可重演性和反作弊能力,代价是必须认真处理输入窗口、空帧推进、确定性哈希和断线重连。
帧同步不是省服务器,而是把一致性成本从带宽转移到了确定性和重连。承认这一点,架构讨论才有意义。
下一节换到第一人称射击:专用服如何分配,命中判定归谁,延迟补偿为什么必须有上限。
阅读导航:上一节:6.3 经济系统与随机数的可信边界 · 下一节:7.2 FPS 的专用服、命中判定与延迟补偿 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。