一次“我明明在掩体后”的争议
《裂隙战场》的主模式是 5v5 MOBA,同产品下还有一个 5v5 射击玩法。两者共用房间、匹配和观战平台,但战斗服模型完全不同:MOBA 模式可以把输入聚合成帧,射击玩法却不能只靠输入广播,因为子弹、弹道和命中需要服务端在极短时间内给出权威结论。
争议来自一段很常见的对局。防守方玩家已经退到掩体后,客户端画面上子弹打在墙上;击杀提示却显示他被命中。进攻方玩家则坚称“我开枪时他还没进掩体”。双方都没有说谎,他们看到的是不同时刻的世界。
这类争议无法用“谁的网络更好”裁决。它要求架构先回答三个问题:
- 这一枪的命中由谁判定?
- 服务端拿哪个时刻的位置来判定?
- 这个时刻最多能往回退多久?
专用服池:把“开一局”变成资源问题
射击玩法没有“房主”。如果让某个玩家托管服务器,延迟、作弊和主机迁移都会变成产品问题。北辰工作室采用专用服池:平台预置一批战斗服进程,房间创建时从池里分配,结束后回收。
专用服池的关键指标不是“有多少台机器”,而是:
- 分配延迟:从匹配成功到玩家进入可操作状态的时间。池里没有热实例时,冷启动会把这段延迟拉长到不可接受。
- 填充率:已分配实例占池容量的比例。太低浪费成本,太高没有缓冲应对突发。
- 回收时延:对局结束后进程要清理房间状态、释放端口、回到池里。回收慢会让池子看起来“满”,实际却在漏。
- 地域与网络:同一局玩家应尽量落在同一区域,否则延迟补偿窗口会被跨区 RTT 吃光。
池化不是简单的进程复用。一个战斗服进程如果保留上一局的玩家引用、随机数状态或缓存,下一局就可能出现“幽灵命中”或不可复现的结算。每次分配必须走一次完整的房间初始化,回收必须显式清理,不能依赖进程重启。
tick rate 与带宽预算
射击玩法的 tick rate 决定三件事:服务端模拟的粒度、快照下发的频率、以及延迟补偿能回滚的最小单位。三档常见配置的取舍如下,具体数字是示例值,不是本机实测;固定步长与帧预算的完整口径见 4.2 tick、帧预算与世界时钟 。
| tick rate | 帧间隔 | 单客户端下行(示例值) | 相对 CPU(示例值) | 适合场景 |
|---|---|---|---|---|
| 20Hz | 50ms | 约 6 KB/s | 1.0x | 休闲射击、移动端、成本敏感 |
| 30Hz | 33ms | 约 9 KB/s | 1.4x | 主流竞技、5v5、混合玩法 |
| 60Hz | 16ms | 约 18 KB/s | 2.6x | 高精度竞技、赛事、硬核玩家 |
这些数字只用于比较量级,不能直接写进容量承诺。真实带宽取决于快照字段、压缩、可见性裁剪和丢包重传策略;真实 CPU 取决于物理、命中检测和反作弊开销。上线前必须用目标机型、目标地图和目标人数做压力测试,而不是拿表格当预算。
tick rate 也不是越高越好。60Hz 让回滚粒度更细,但也让每个快照更小、更频繁,网络抖动下的突发流量更难吸收;20Hz 省资源,但 50ms 的位置误差会让“掩体后”的争议更频繁。选择时要和延迟补偿上限一起算,而不是单独追求高 tick。
命中判定归谁
射击玩法的第一原则:命中由服务端判定,客户端只负责表现。 它与 7.1 MOBA 的房间、tick 与帧同步 的输入聚合共享同一套房间时间轴,但权威点从“输入集合”前移到了“命中结果”。
客户端可以做即时反馈:开枪动画、弹道拖尾、命中标记。这些反馈让操作手感成立,但它们不是权威。服务端收到射击请求后,用自己的世界状态做射线检测,再把结果广播给所有客户端。客户端显示“命中”而服务端判定“未命中”时,以服务端为准,客户端要能平滑地撤回反馈。
把命中交给客户端,会立刻产生两类问题。第一,作弊者可以伪造命中包;第二,不同客户端对同一发子弹的判定不一致,观战和回放无法复现。服务端判定虽然会增加一次网络往返的感知延迟,但它是竞技产品唯一可辩护的选择。
服务端判定也要明确输入:射击者的位置、方向、射击时刻、武器射程和散布。方向必须由服务端根据客户端上报的视角和自身校验后的状态重建,不能直接信任客户端给出的“命中目标 ID”。
回滚窗口:延迟补偿的边界
服务端收到射击请求时,世界已经前进了。客户端看到的是它本地时间线上的世界,通常是服务端过去某个时刻的状态。延迟补偿要做的,就是把目标位置回滚到“射击者开枪时看到的那一刻”,再做命中检测。
回滚需要三样东西:
- 环形快照缓冲:按服务端时间保存每个 tick 的玩家位置和碰撞半径。
- 时间戳:射击请求带上客户端认为的射击时刻,服务端把它换算到自己的时间轴。
- 窗口上限:超过上限的请求直接拒绝,不允许无限回滚。
窗口上限是公平性的核心。窗口越大,高延迟玩家越容易命中“我已经看到的位置”;窗口越小,高延迟玩家越吃亏。常见的折中是 100 到 200ms(示例值),具体值要用目标玩家的 RTT 分布来定,而不是拍脑袋。
这里有一个绕不开的不公平:延迟补偿天然偏向高 ping 玩家。低 ping 玩家看到的是较新的世界,高 ping 玩家却可以让服务端回滚到更早的位置,形成所谓的“peeker’s advantage”。完全消除它做不到,因为消除它就等于取消延迟补偿,让高 ping 玩家无法命中。工程上能做的是:
- 设定明确的最大回滚窗口,超过就拒绝,不搞“特殊照顾”。
- 拒绝未来时间戳和明显异常的负时间戳,防止客户端伪造时间。
- 对同一玩家的射击频率做服务端校验,防止用高频请求刷判定。
- 把回滚窗口、拒绝率和命中率按延迟分桶监控,发现某一延迟段异常优势时再调整参数。
延迟补偿不是“让所有人公平”,而是“让不公平有上限、可观测、可解释”。
可运行片段:环形快照与回滚命中
下面的 internal/lagcomp 实现了一个固定容量的快照环、按时间回滚、窗口校验和射线命中。它不包含网络层,也不包含武器散布;这些应该在调用方按玩法规则补齐。
// file: internal/lagcomp/world.go
// tier: L1
// verify: go test ./internal/lagcomp/
package lagcomp
import (
"errors"
"fmt"
"math"
"time"
)
var (
ErrEmptyWorld = errors.New("lagcomp: no snapshots recorded")
ErrTooOld = errors.New("lagcomp: shot timestamp outside rewind window")
ErrFuture = errors.New("lagcomp: shot timestamp ahead of newest snapshot")
ErrNonMonotonic = errors.New("lagcomp: snapshot time is not increasing")
ErrBadShot = errors.New("lagcomp: zero direction or non-positive range")
ErrNoHit = errors.New("lagcomp: no target on the rewound ray")
)
// Vec3 is a world-space position in meters. The book keeps it plain so the
// rewind logic stays independent of any engine math library.
type Vec3 struct{ X, Y, Z float64 }
func (v Vec3) Add(o Vec3) Vec3 { return Vec3{v.X + o.X, v.Y + o.Y, v.Z + o.Z} }
func (v Vec3) Sub(o Vec3) Vec3 { return Vec3{v.X - o.X, v.Y - o.Y, v.Z - o.Z} }
func (v Vec3) Scale(s float64) Vec3 { return Vec3{v.X * s, v.Y * s, v.Z * s} }
func (v Vec3) Dot(o Vec3) float64 { return v.X*o.X + v.Y*o.Y + v.Z*o.Z }
func (v Vec3) Len() float64 { return math.Sqrt(v.Dot(v)) }
// PlayerState is the minimum server-authoritative data needed for hitscan:
// position at a timestamp plus the target's collision radius.
type PlayerState struct {
ID uint32
Pos Vec3
Radius float64
}
// Snapshot is the world at one server time. Time is measured from match start,
// not wall clock, so replay and audit do not depend on the machine's clock.
type Snapshot struct {
At time.Duration
Players []PlayerState
}
// World is a fixed-size ring of snapshots. Capacity is the number of snapshots
// retained; Window is the gameplay rule for how far back a shot may rewind.
// They are separate because storage may outlive the rule, but a shot must not.
type World struct {
window time.Duration
ring []Snapshot
head int
count int
}
// New builds a world that keeps up to capacity snapshots and permits rewinds
// no older than window.
func New(window time.Duration, capacity int) *World {
if window < 0 {
window = 0
}
if capacity < 1 {
capacity = 1
}
return &World{window: window, ring: make([]Snapshot, capacity)}
}
// Record appends a snapshot. Times must increase; a duplicate or backwards
// timestamp is a writer bug, not a network reorder, and is rejected.
func (w *World) Record(at time.Duration, players []PlayerState) error {
if w.count > 0 && at <= w.at(w.count-1).At {
return fmt.Errorf("%w: %s after %s", ErrNonMonotonic, at, w.at(w.count-1).At)
}
snap := Snapshot{At: at, Players: append([]PlayerState(nil), players...)}
if w.count == len(w.ring) {
w.ring[w.head] = snap
w.head = (w.head + 1) % len(w.ring)
return nil
}
w.ring[(w.head+w.count)%len(w.ring)] = snap
w.count++
return nil
}
func (w *World) at(i int) Snapshot {
return w.ring[(w.head+i)%len(w.ring)]
}
// Rewind returns the newest snapshot at or before at. The snapshot is copied
// so a caller cannot mutate history while resolving a shot.
func (w *World) Rewind(at time.Duration) (Snapshot, error) {
if w.count == 0 {
return Snapshot{}, ErrEmptyWorld
}
newest := w.at(w.count - 1)
if at > newest.At {
return Snapshot{}, fmt.Errorf("%w: shot=%s newest=%s", ErrFuture, at, newest.At)
}
if newest.At-at > w.window {
return Snapshot{}, fmt.Errorf("%w: age=%s window=%s", ErrTooOld, newest.At-at, w.window)
}
var best Snapshot
found := false
for i := 0; i < w.count; i++ {
s := w.at(i)
if s.At > at {
break
}
best, found = s, true
}
if !found {
return Snapshot{}, fmt.Errorf("%w: shot=%s oldest=%s", ErrTooOld, at, w.at(0).At)
}
return Snapshot{At: best.At, Players: append([]PlayerState(nil), best.Players...)}, nil
}
// Shot is a client-reported hitscan. At is the client's view of when the shot
// happened; the server rewinds targets to that instant. Origin and Dir are in
// the same world space as PlayerState.Pos.
type Shot struct {
Shooter uint32
Origin Vec3
Dir Vec3
MaxRange float64
At time.Duration
}
// Hit reports the nearest target along the ray after rewinding.
type Hit struct {
Target uint32
Point Vec3
Distance float64
RewoundTo time.Duration
}
// Hitscan rewinds the world to the shot time and intersects the ray against
// every other player's collision sphere. The nearest target along the ray wins;
// this is the server's authority, not the client's claim.
func (w *World) Hitscan(shot Shot) (Hit, error) {
if shot.MaxRange <= 0 || shot.Dir.Len() == 0 {
return Hit{}, ErrBadShot
}
dir := shot.Dir.Scale(1 / shot.Dir.Len())
snap, err := w.Rewind(shot.At)
if err != nil {
return Hit{}, err
}
var best Hit
found := false
for _, p := range snap.Players {
if p.ID == shot.Shooter {
continue
}
t := p.Pos.Sub(shot.Origin).Dot(dir)
if t < 0 {
t = 0
} else if t > shot.MaxRange {
t = shot.MaxRange
}
point := shot.Origin.Add(dir.Scale(t))
if point.Sub(p.Pos).Len() > p.Radius {
continue
}
if !found || t < best.Distance {
best = Hit{Target: p.ID, Point: point, Distance: t, RewoundTo: snap.At}
found = true
}
}
if !found {
return Hit{}, ErrNoHit
}
return best, nil
}
// Bounds exposes the retained time range for metrics and for rejecting shots
// before the expensive loop.
func (w *World) Bounds() (oldest, newest time.Duration, ok bool) {
if w.count == 0 {
return 0, 0, false
}
return w.at(0).At, w.at(w.count - 1).At, true
}
代码里有三个边界值得强调。第一,Record 拒绝非递增时间,快照写入是单调的,回滚才能用二分或顺序扫描。第二,Rewind 先检查未来和窗口,再找“不晚于目标时刻的最近快照”,不会用未来状态倒推历史。第三,Hitscan 只接受服务端重建后的射线,并且永远排除射击者自己。
测试覆盖了精确命中、快照之间回滚、超出窗口、未来时间戳、最近目标选择、掩体外的偏离目标、射程外目标、零方向拒绝,以及环形缓冲覆盖最旧快照后的边界。go test ./internal/lagcomp/ 的实际结果会在验证记录中给出。
失败模式与兜底
| 失败模式 | 现象 | 处理 | 边界 |
|---|---|---|---|
| 快照缺失 | 回滚窗口内没有可用状态 | 拒绝射击或退化为最近状态并记录 | 不能无限等待 |
| 时间戳过旧 | 高延迟或伪造请求 | 拒绝并计数,按延迟分桶观察 | 不扩大窗口迁就 |
| 时间戳未来 | 客户端时钟错乱或作弊 | 拒绝,持续出现则踢出 | 不做“宽容回滚” |
| 快照被覆盖 | 环形缓冲容量不足 | 扩大容量或缩短窗口 | 容量与窗口要一起算 |
| 命中争议 | 客户端显示与服务端不一致 | 以服务端为准,客户端平滑撤回 | 不用客户端结果覆盖 |
| 战斗服崩溃 | 对局中断 | 平台重建或判负补偿 | 不把半局状态当完整局 |
快照容量和回滚窗口必须一起设计。容量按“窗口 / tick 间隔 + 余量”计算,例如 200ms 窗口、30Hz tick,至少需要 7 个快照,实际应留出一倍余量应对抖动。只扩大窗口不扩大容量,会在最需要回滚的时候发现最旧的快照已经被覆盖。
常见错误与边界
第一,把命中判定交给客户端。短期手感好,长期无法反作弊,也无法解释观战与回放。
第二,回滚窗口只按“最大 ping”设置。最大 ping 是极端值,不是常态;窗口应该按目标分位数和可接受的不公平程度定。
第三,用墙上时间做快照键。NTP 调整、进程重启和跨机器会让时间轴断裂;应使用对局开始后的单调时间。
第四,快照只存位置不存碰撞半径。角色下蹲、缩放或载具状态变化时,历史命中体积会算错。
第五,忽略高 ping 优势。只监控平均命中率不够,要按 RTT 分桶看命中率和被命中率。
第六,把 tick rate 当成命中精度。60Hz 只让采样更细,不能替代服务端权威判定和合理的回滚窗口。
小结
射击玩法的架构主线是:专用服池解决“谁来算”,服务端命中判定解决“谁说了算”,延迟补偿解决“按哪个时刻算”。三者缺一不可,而且都必须有明确上限。
延迟补偿的边界不是技术细节,而是产品公平性的一部分。能被解释、能被监控、能被拒绝的补偿,才是可运营的补偿。
下一节把镜头转向观众:观战为什么必须延迟,回放应该存事件还是存快照,赛事系统如何与比赛服隔离。
阅读导航:上一节:7.1 MOBA 的房间、tick 与帧同步 · 下一节:7.3 观战、回放与赛事架构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。