一次回拉,暴露了“谁说了算”
《裂隙战场》是 5v5 竞技产品,房间服按 30Hz 推进。跨区玩家反馈:松开方向键后角色还会往前滑半步,然后被“拽”回原位;技能动画已经播完,伤害却延迟一帧才出现。录制回放显示,客户端一直在按本地输入移动,服务端按自己的判定修正,两边对“现在在哪”没有共识。
这不是网络库的问题。即使把传输换成可靠 UDP、把 tick 提到 60Hz,只要客户端先动、服务端后判,就一定存在一段“客户端已经相信、服务端还没确认”的时间。架构要回答的是:这段时间里,哪些状态允许客户端先写,写错了怎么收回,收回的代价由谁承担。
《泡泡工坊》的约束完全不同。它是一款休闲小游戏,房间生命周期短,玩家对几十毫秒的回拉不敏感,但团队只有两名后端。给它套用竞技级的预测回滚,只会增加故障面。所以本章给出两档权威模型,而不是一个“最佳实践”。
三种权威模型与它们的代价
| 模型 | 客户端能写什么 | 服务端职责 | 适用场景 | 主要代价 |
|---|---|---|---|---|
| 客户端权威 | 位置、命中、结算输入 | 转发与落库 | 单机、弱联网、信任房主 | 作弊零成本,无法做竞技 |
| 纯状态同步 | 什么都不写 | 计算并广播全量状态 | 回合制、SLG、棋牌 | 输入延迟直接可见 |
| 预测-回滚-重放 | 只写表现层与自身预测 | 裁决、确认、纠正 | 5v5、FPS、动作 | 实现复杂,回拉需要设计 |
《群岛纪元》的大地图行军采用异步结算,玩家下发指令后等待服务端回执,属于纯状态同步的变体:慢一点可以接受,算错不可接受。《裂隙战场》必须选第三档,因为 100ms 以上的输入延迟会直接毁掉手感。
选第三档不等于把所有状态都交给预测。下面这条边界比算法更重要。
表现层预测与状态层裁决
把客户端状态分成三类,每类的写入权限不同:
| 类别 | 例子 | 客户端权限 | 服务端权限 |
|---|---|---|---|
| 权威状态 | 生命、伤害、命中、冷却、货币、掉落、比赛结果 | 只读 | 唯一写入者 |
| 预测状态 | 自身位置、朝向、动画相位、本地特效 | 可先写,可被覆盖 | 最终裁决 |
| 本地状态 | 相机、音量、按键映射、UI 展开 | 完全自写 | 不关心 |
边界只有一句话:凡是能变成经济收益或比赛结果的状态,客户端永远不能成为第一写入者。 位置之所以可以预测,是因为它会被服务端覆盖,而且覆盖的代价只是视觉回拉;伤害不行,因为一次错误的伤害确认会改变整局结果,事后回滚要补偿两边的玩家体验。
这条边界也解释了为什么“客户端算伤害、服务端抽查”是危险的。抽查率再高,未抽中的那部分仍然是客户端说了算;而攻击者只需要找到不抽中的路径。
预测、确认、重放:一条可验证的链路
完整链路有四个角色:输入序号、服务端确认、客户端历史、偏差统计。
客户端 服务端
|-- Input{seq=7,tick=120} ----->|
| 本地预测:位置 += move | 校验 tick/seq,按规则裁决
|<-- Ack{tick=120,state} -------|
| 丢弃 tick<=120 的待确认输入
| 以 Ack.state 为基准,重放 tick>120 的输入
| 记录本次偏差,超过阈值计为硬纠正
四个关键决定:
第一,输入带单调 Seq 和世界 Tick。Seq 只用于去重,Tick 才是排序依据;乱序到达时按 Tick 重放,不能按到达顺序。
第二,服务端只接受更新的 Tick 和 Seq。旧包直接拒绝并计数,不能“宽容地再算一次”,否则一次重放攻击就能让角色多走几步。
第三,确认包携带该 tick 的完整状态,而不是增量。丢一个确认包后,客户端可以用下一个确认包直接对齐,不需要服务端保留历史。
第四,客户端保存“每个 tick 预测后的状态”。收到确认时,比较的是该 tick 当时的预测与权威状态,而不是当前预测。否则,客户端对未来的合法预测会被误判成偏差,纠正统计完全失真。
偏差、阈值与降级
偏差用曼哈顿距离累计,单位是世界坐标的最小格。它不追求物理精确,只回答“这次纠正玩家能不能看出来”。
| 偏差 | 处理 | 玩家感知 |
|---|---|---|
| 0 | 无纠正 | 无 |
| 1 到阈值 | 软纠正,平滑插值到权威位置 | 轻微滑动 |
| 超过阈值 | 硬纠正,直接吸附并记事件 | 明显回拉,需要告警 |
阈值不是常数。角色速度、碰撞半径、相机距离都会影响可感知程度,应作为房间配置而不是全局常量。
高延迟时继续激进预测,会让偏差和重放成本同时上升。降级策略按 RTT 分档,示例值如下:
| RTT | 预测窗口 | 效果 |
|---|---|---|
| ≤ 60ms | 8 tick | 接近本地手感 |
| 61–120ms | 4 tick | 减少超前预测 |
| > 120ms | 1 tick | 只预测当前输入,优先一致 |
窗口只限制“最多领先确认多少 tick”,不改变已经发出的输入。超出窗口的输入仍要进入缓冲区,等确认到达后按顺序重放;丢弃它们会造成输入丢失,比回拉更糟。
代码:authority 包
下面的实现只做四件事:服务端拒绝陈旧输入并裁决、客户端按窗口预测、按 tick 历史计算偏差、确认后重放未确认输入。坐标使用整数,避免浮点重放顺序带来的伪偏差。
// file: internal/authority/authority.go
// tier: L1
// verify: go test ./internal/authority/
package authority
import (
"errors"
"sort"
"time"
)
// Vec is a position in integer world units. Integers keep replay stable:
// floating point addition is not associative, so replay order could drift.
type Vec struct{ X, Y int }
func (v Vec) Add(o Vec) Vec { return Vec{X: v.X + o.X, Y: v.Y + o.Y} }
func (v Vec) Sub(o Vec) Vec { return Vec{X: v.X - o.X, Y: v.Y - o.Y} }
func (v Vec) Manhattan() int { return abs(v.X) + abs(v.Y) }
func abs(v int) int {
if v < 0 {
return -v
}
return v
}
// Input is one client command. Seq is for deduplication; Tick orders it.
type Input struct {
Seq uint32
Tick uint64
Move Vec
}
var (
ErrStaleTick = errors.New("authority: tick not newer than last applied")
ErrStaleSeq = errors.New("authority: sequence not newer than last applied")
)
// Ack carries the full server state at Tick, never a delta, so a client that
// lost a packet can resynchronise without replaying server history.
type Ack struct {
Tick uint64
Seq uint32
State Vec
}
// Server owns the only writable copy of the character state.
type Server struct {
state Vec
tick uint64
seq uint32
applied int
rejected int
// Limit clamps one axis of a submitted move; zero means unlimited. It
// stands in for any server rule that overrides client intent.
Limit int
}
func NewServer(start Vec) *Server { return &Server{state: start} }
func (s *Server) Submit(in Input) (Ack, error) {
if in.Tick <= s.tick {
s.rejected++
return Ack{}, ErrStaleTick
}
if in.Seq <= s.seq {
s.rejected++
return Ack{}, ErrStaleSeq
}
move := in.Move
if s.Limit > 0 {
move = Vec{X: clamp(move.X, s.Limit), Y: clamp(move.Y, s.Limit)}
}
s.tick = in.Tick
s.seq = in.Seq
s.state = s.state.Add(move)
s.applied++
return Ack{Tick: in.Tick, Seq: in.Seq, State: s.state}, nil
}
func (s *Server) State() Vec { return s.state }
func (s *Server) Applied() int { return s.applied }
func (s *Server) Rejected() int { return s.rejected }
// Aggression controls how far ahead of the last acknowledgement a client may
// predict. Window is measured in ticks.
type Aggression struct{ Window int }
// Tune lowers prediction aggressiveness as RTT grows. The thresholds are
// deployment policy, not protocol constants.
func Tune(rtt time.Duration) Aggression {
switch {
case rtt <= 60*time.Millisecond:
return Aggression{Window: 8}
case rtt <= 120*time.Millisecond:
return Aggression{Window: 4}
default:
return Aggression{Window: 1}
}
}
type Stats struct {
Acks int
Replays int
Corrections int
HardCorrections int
MaxDeviation int
}
// Replay describes what happened when one acknowledgement was applied.
type Replay struct {
Before Vec
Authoritative Vec
Deviation int
Corrected bool
Replayed []Input
}
// Client keeps a speculative copy plus per-tick prediction history. History is
// what makes deviation meaningful: comparing the current prediction with an old
// ack would charge the client for honest future movement.
type Client struct {
predicted Vec
ackedTick uint64
buffer map[uint32]Input
history map[uint64]Vec
agg Aggression
tolerance int
stats Stats
}
func NewClient(start Vec, tolerance int) *Client {
if tolerance < 1 {
tolerance = 1
}
return &Client{
predicted: start,
buffer: make(map[uint32]Input),
history: make(map[uint64]Vec),
agg: Tune(0),
tolerance: tolerance,
}
}
func (c *Client) SetAggression(a Aggression) { c.agg = a }
// Predict buffers an input and applies it locally only inside the prediction
// window. Out-of-window inputs wait for an ack and are applied during replay.
func (c *Client) Predict(in Input) (Vec, bool) {
if _, ok := c.buffer[in.Seq]; !ok {
c.buffer[in.Seq] = in
}
if in.Tick <= c.ackedTick || in.Tick > c.ackedTick+uint64(c.agg.Window) {
return c.predicted, false
}
c.predicted = c.predicted.Add(in.Move)
c.history[in.Tick] = c.predicted
return c.predicted, true
}
// Reconcile compares the stored prediction at the acknowledged tick with the
// server state, snaps to the server, then replays every still-pending input.
func (c *Client) Reconcile(ack Ack) Replay {
base, ok := c.history[ack.Tick]
if !ok {
base = ack.State
}
dev := base.Sub(ack.State).Manhattan()
c.stats.Acks++
if dev > c.stats.MaxDeviation {
c.stats.MaxDeviation = dev
}
c.ackedTick = ack.Tick
for seq, in := range c.buffer {
if in.Tick <= ack.Tick {
delete(c.buffer, seq)
}
}
for tick := range c.history {
if tick <= ack.Tick {
delete(c.history, tick)
}
}
pending := c.pending()
c.predicted = ack.State
for _, in := range pending {
c.predicted = c.predicted.Add(in.Move)
c.history[in.Tick] = c.predicted
}
r := Replay{Before: base, Authoritative: ack.State, Deviation: dev, Replayed: pending}
if dev > 0 {
c.stats.Corrections++
r.Corrected = true
if dev > c.tolerance {
c.stats.HardCorrections++
}
}
c.stats.Replays += len(pending)
return r
}
func (c *Client) pending() []Input {
out := make([]Input, 0, len(c.buffer))
for _, in := range c.buffer {
out = append(out, in)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Tick != out[j].Tick {
return out[i].Tick < out[j].Tick
}
return out[i].Seq < out[j].Seq
})
return out
}
func (c *Client) Predicted() Vec { return c.predicted }
func (c *Client) Pending() int { return len(c.buffer) }
func (c *Client) Stats() Stats { return c.stats }
func clamp(v, limit int) int {
if v > limit {
return limit
}
if v < -limit {
return -limit
}
return v
}
Reconcile 先从 history 取出被确认 tick 的预测值;没有预测过的 tick 退回使用确认状态本身,偏差记为 0。重放使用缓冲区里的原始输入且不再检查预测窗口,因为这些输入已经发出、只是尚未确认。Limit 代表服务端规则对客户端意图的覆盖,例如速度上限或击退修正,它让测试可以在不引入战斗系统的前提下制造一次真实的权威偏差。
测试覆盖了什么
lab 里的 internal/authority 有 6 个测试函数,其中 4 个是表驱动:
| 测试 | 覆盖的失败模式 |
|---|---|
TestServerRejectsStaleInput | 同 tick、旧 tick、旧 seq 三类陈旧输入 |
TestPendingReplayOrder | 待确认输入按 tick 升序重放,与到达顺序无关 |
TestReconcileDeviationTable | 干净确认、服务端限速、阈值内软纠正 |
TestTuneLadder | 三档 RTT 对应的预测窗口边界 |
TestReconcileDropsAckedAndReplaysFuture | 确认后丢弃已确认输入并重放未来输入 |
TestAggressionWindowLimitsPrediction | 高延迟下窗口外的输入不进入本地预测 |
本机运行结果:
$ go test -count=1 -v ./internal/authority/
--- PASS: TestServerRejectsStaleInput (0.00s)
--- PASS: TestReconcileDropsAckedAndReplaysFuture (0.00s)
--- PASS: TestReconcileDeviationTable (0.00s)
--- PASS: TestPendingReplayOrder (0.00s)
--- PASS: TestAggressionWindowLimitsPrediction (0.00s)
--- PASS: TestTuneLadder (0.00s)
PASS
ok gsarchlab/internal/authority 0.287s
失败模式与兜底
确认包持续丢失或迟迟不到。 缓冲区和历史表都会增长。必须给待确认输入设上限,超过后停止预测并等待重同步;历史只保留可能被确认的 tick,并与缓冲区使用同一水位删除。
重放成本超过帧预算。 一次确认可能触发几十个 tick 的重放。重放必须在客户端帧预算内完成,超出时应降低预测窗口,而不是在主线程里补算。
服务端规则变更。 限速、碰撞、击退规则一改,所有在线客户端的预测都会偏差。规则变更应与版本号绑定,客户端在版本不匹配时退回纯状态同步。
常见错误与边界
第一,把经济结算放进预测链路。位置可以回拉,货币不能;一旦客户端先写货币,服务端的所有校验都变成事后追认。
第二,用“当前预测”与旧确认比较。这样每次确认都会产生一笔虚假偏差,硬纠正率随预测窗口线性上升。
第三,用浮点坐标做重放。浮点加法不满足结合律,输入顺序一变,结果就不同;用整数或定点数,重放才是可复现的。
第四,相信客户端上报的 tick。客户端可以把 tick 改大来跳过校验;tick 必须由服务端世界时钟推进,客户端只能回显。
第五,把回拉当成 bug 全部抹平。回拉是权威模型的正常成本,完全抹平意味着客户端重新获得裁决权。
小结
预测-回滚-重放的本质不是“让客户端算得更快”,而是把客户端的不确定性限制在一段可收回的时间里。输入序号负责去重,tick 负责排序,确认包负责建立唯一事实,历史表负责让偏差可解释,阈值与降级负责把回拉的代价控制在玩家可接受的范围。
internal/authority 把这套链路压缩成一个可测试的包。它不包含战斗规则,也不包含网络传输,因为这两部分应该由房间服和网络库负责;它只回答“客户端先写的那部分,如何在服务端确认后被正确地收回”。
下一节换一个角度:既然服务端是唯一裁决者,反作弊应该放在这条链路的哪一层,为什么没有任何单层能“杜绝作弊”。
阅读导航:上一节:5.3 会话、重连、顶号与连接迁移 · 下一节:6.2 反作弊应该放在架构的哪一层 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。