一、谁负责寻路
寻路可以放在客户端、服务端,或两边都做但职责不同。竞技和 MMO 的主流做法是“客户端预表现 + 服务端校验”:客户端立即沿预测路径移动,服务端按自己的地图与速度上限校验,发现异常就纠正或拒绝。
移动同步的威胁模型并不复杂:客户端可以改内存、改封包、重放旧包,也可以直接伪造坐标。凡是“客户端说了算”的字段,都必须假定它会被篡改。因此服务端的校验对象不是客户端的“当前位置”,而是可独立复核的路径声明。
权威边界也不是全有全无。可以让客户端负责表现层的插值、朝向和动画,但位移、碰撞、传送和伤害判定必须由服务端复核。把表现权限和状态权限分开,既能保留跟手的手感,又不会把正确性交给客户端。
| 模式 | 客户端职责 | 服务端职责 | 适用 | 主要风险 |
|---|---|---|---|---|
| 客户端预表现 + 服务端校验 | 预测移动、平滑表现 | 校验路径、纠正 | 大多数在线游戏 | 校验遗漏等于客户端权威 |
| 服务端权威 | 只播放结果 | 完整寻路与移动 | 强反作弊、回合制 | 输入延迟、表现抖动 |
| 客户端权威 | 自行寻路与移动 | 转发、弱校验 | 单机、合作 | 加速、穿墙、瞬移 |
| 服务端寻路 + 客户端跟随 | 跟随路点 | 下发路径 | 点击移动 | 路径过大、重算抖动 |
“服务端校验”不是一句口号。只要服务端接受了客户端上报的坐标,而没有独立检查速度、时间和地形,客户端就已经是权威。校验需要服务端自己维护一份可信的阻挡数据,不能复用客户端传来的地图。
落地时,服务端至少要独立维护四样东西:
- 与当前地图版本一致的阻挡数据,包括静态层和动态层。
- 每个单位的速度、体型与允许的移动模式。
- 最近若干 tick 的轨迹,用于回放和异常检测。
- 路径 ID 与地图版本的单调序列,用于拒绝旧包。
这四样东西缺一项,校验就会出现可以被利用的缺口。例如只校验速度、不校验地形,加速外挂被挡住了,穿墙仍然畅通。
二、路径下发格式
| 格式 | 每条大小 | 随机访问 | 带宽 | 适用 |
|---|---|---|---|---|
| 路点列表 | 每个 8–16 字节 | 容易 | 中 | 点击移动、少量路点 |
| 方向序列 | 每步 3 bit | 需顺序解码 | 低 | 格子移动、连续帧 |
| 量化增量 + varint | 每点 2–6 字节 | 需解码 | 低到中 | 大地图、长路径 |
| 样条控制点 | 每段数十字节 | 容易 | 低 | 平滑曲线、载具 |
路点列表最直观:每个点包含坐标、时间戳或到达 tick,客户端按时间插值。它的缺点是路点一多,单条消息就变大;一条 500 个路点的路径,按每点 12 字节计算约 6 KB,在高频移动时很可观。
方向序列把路径压成每步 3 bit 的八方向编码,带宽很低,但客户端必须顺序解码,且不方便从中间某点开始播放。量化增量适合大地图:记录相对于上一个点的 dx、dy,用 varint 编码,常见情况下每点 2–4 字节。
无论哪种格式,消息里都应有路径 ID、地图版本、起始 tick 和校验和。没有路径 ID,客户端无法判断收到的旧包是不是已经过期的重算结果;没有地图版本,客户端会沿一条服务端已经不认可的路径移动。
一种常见的二进制布局如下,字段顺序固定、长度固定,便于快速解析和校验:
path_id uint32 单调递增,用于丢弃旧包
map_version uint16 与地图数据握手
start_tick uint32 路径生效的服务器 tick
point_count uint16 后续路点数量
each point int16 x, int16 y, uint16 tick
crc32 uint32 覆盖以上字段
量化坐标时要注意精度。格子坐标用 int16 足够覆盖大多数地图,但世界坐标做毫米级量化后可能溢出;先确定地图的最大范围,再选择位宽,不要等到线上出现负坐标回绕才回头改协议。
三、路径过期与重算
路径不是永久有效的。服务端应给每条路径一个时间窗,例如从下发时刻起 2 秒;超过窗口的路径不再接受,客户端必须重新请求。时间窗太短会导致频繁重算,太长则会让旧路径在阻挡变化后仍然生效。
重算的触发条件应明确:
- 路径被动态阻挡截断。
- 地图版本变化。
- 目标点移动超过阈值。
- 客户端上报的路径与服务器模拟偏差超过容忍值。
- 路径超过最大长度或最大时长。
| 触发条件 | 建议动作 | 注意 |
|---|---|---|
| 前方格子变为阻挡 | 从当前位置重算剩余路径 | 不要回退已完成的路段 |
| 地图版本变化 | 丢弃缓存并重算 | 先完成版本握手 |
| 目标移动超过 2 格 | 重算到新目标 | 设最小间隔,避免追着目标抖 |
| 偏差超过 0.5 格 | 服务端纠正并下发 | 纠正要带新路径 ID |
| 路径超时 | 拒绝并请求新路径 | 不要延长旧路径的有效期 |
重算要带路径 ID,客户端只接受比当前 ID 更大的结果。否则一个晚到的旧响应会把角色拉回之前的位置,表现为“瞬移回去再走一遍”。
四、移动校验
服务端校验一段路径,至少检查四件事:
- 速度:
距离 ≤ 最大速度 × 持续时间 + 容忍值。容忍值用于吸收浮点误差和网络抖动,不能大到可以让加速外挂通过。 - 地形:沿线段采样,检查经过的格子是否阻挡。采样步长应小于半个格子,常用 0.25 格;只检查端点会漏掉中间穿墙。
- 连续性:下一段的起点必须等于上一段的终点,下一段的开始时间必须等于上一段的结束时间。允许少量时间抖动,但不能允许坐标跳变。
- 时间:路径不能来自未来,也不能太旧。未来时间戳意味着客户端在伪造 tick;过旧路径则可能已经与当前阻挡数据不一致。
速度校验可以写成一行:
allowed = maxSpeed × duration + tolerance
distance = hypot(to.x - from.x, to.y - from.y)
reject if distance > allowed
tolerance 的选择需要克制。它用来吸收浮点误差和网络抖动,单位应与地图坐标一致;把容忍值设成半张地图,等于取消了速度校验。经验上它应远小于一个格子的宽度,并根据移动模式单独配置。
传送是服务端的显式事件,不应作为客户端路径的一部分。客户端上报一段跨越半张地图的位移,即使持续时间很长,也应被速度上限拒绝;真正的传送由服务端修改位置并下发,客户端只播放特效。
单位占位不应参与地形校验。两个玩家擦肩而过时,格子可能短暂被占,但这不构成穿墙;把单位占位混进 Blocker 会让合法路径被误判。
五、客户端修正与和解
服务端判定路径非法时,不能只把客户端坐标改成一个新值就结束。正确做法是下发“服务器 tick + 权威位置 + 新路径 ID”,客户端从该 tick 开始重放尚未被确认的输入;否则客户端下一帧仍会按旧预测继续走,纠正变成一次性补丁。
客户端应保留一个按 tick 编号的输入缓冲。服务端确认某个 tick 之前的输入后,客户端丢弃这些输入;被拒绝的输入则从缓冲中移除,并从权威位置重新预测。没有输入缓冲,客户端无法区分“本地还没发出的输入”和“服务端已经拒绝的输入”。
逻辑位置必须立即对齐,视觉位置可以插值。只把角色模型平滑拉过去、逻辑坐标仍停在旧位置,下一次校验还会失败;反过来,逻辑位置瞬移、模型也瞬移,玩家会看到明显跳帧。常见做法是逻辑位置立即生效,模型在几百毫秒内追上。
纠正要限流。每个 tick 都下发纠正,说明预测模型、地图版本或速度配置至少有一项不一致;此时应优先排查根因,而不是靠高频纠正把表面现象压下去。
和解消息本身要幂等并带序号。重复收到同一条纠正不应让角色倒退,晚到的旧纠正也不应覆盖新状态;客户端只接受序号更大的和解结果。
两个常见反模式:
- 只修正表现、不修正逻辑位置,角色持续偏移并被反复拉回。
- 服务端每个 tick 都纠正一次,客户端看起来一直在抖。
六、失败场景分析
只信客户端坐标。 服务端收到“当前位置”就直接广播,客户端可以把速度改成 100 格每秒,或者直接跳到 Boss 背后。修复是让客户端上报路径段而不是当前位置,服务端按时间与距离独立校验,并保留最近若干 tick 的轨迹用于回放。
路径下发过大。 服务端一次性下发整张地图的 800 个路点,每条移动消息数 KB,移动频繁时带宽被路径占满。修复是限制单条路径的最大路点数,只下发前方一段,剩余部分在角色接近时再补发;或者改用方向序列与增量编码。
服务端每个 tick 重算导致抖动。 服务端因为一次阻挡变化,每个 tick 都重新寻路并下发修正,客户端不断被拉回。修复是路径 ID 加滞后阈值:只有新路径与旧路径的偏差超过阈值才下发,且同一 tick 内只保留最后一次重算结果。
七、Go 实现
下面的 Validator 接收由若干匀速直线段组成的路径声明,按速度、地形采样、端点和时间顺序逐段校验;任何一段失败都整体拒绝。
// file: ch07/path/syncpath.go
// tier: L1
// verify: go test ./ch07/path/
package path
import (
"errors"
"fmt"
"math"
"time"
)
var (
ErrEmptyPath = errors.New("syncpath: empty path")
ErrBadDuration = errors.New("syncpath: non-positive duration")
ErrTooFast = errors.New("syncpath: speed exceeds limit")
ErrThroughWall = errors.New("syncpath: segment crosses blocked cell")
ErrDiscontinuous = errors.New("syncpath: segment endpoints do not match")
ErrTimeOrder = errors.New("syncpath: segment time out of order")
ErrExpired = errors.New("syncpath: path too old")
)
// Blocker 只回答地形是否阻挡;单位占位应在移动解算层单独处理,
// 否则两个玩家互相占格会把合法路径误判为穿墙。
type Blocker interface {
Blocked(p Point) bool
}
// Segment 是一段匀速直线移动。服务端不接收“当前位置”,只接收
// 起点、终点、开始时间和持续时间,由服务端自行推导轨迹。
type Segment struct {
From Point
To Point
Start time.Time
Duration time.Duration
}
type PathClaim struct {
Tick uint32
SentAt time.Time
Segments []Segment
}
type Validator struct {
blocker Blocker
maxSpeed float64
tolerance float64
maxAge time.Duration
sampleStep float64
futureSkew time.Duration
}
func NewValidator(blocker Blocker, maxSpeed float64) *Validator {
if maxSpeed <= 0 {
maxSpeed = 8
}
return &Validator{
blocker: blocker,
maxSpeed: maxSpeed,
tolerance: 0.75,
maxAge: 2 * time.Second,
sampleStep: 0.25,
futureSkew: 500 * time.Millisecond,
}
}
// Validate 返回整条路径的累计距离。任何一段超速、穿墙、时间倒退或
// 端点不连续都会整体拒绝,而不是只截断到出错的那一段。
func (v *Validator) Validate(claim PathClaim, now time.Time) (float64, error) {
if len(claim.Segments) == 0 {
return 0, ErrEmptyPath
}
if claim.SentAt.IsZero() {
return 0, ErrTimeOrder
}
if now.Sub(claim.SentAt) > v.maxAge {
return 0, ErrExpired
}
if claim.SentAt.After(now.Add(v.futureSkew)) {
return 0, ErrTimeOrder
}
var total float64
var prevTo Point
var prevEnd time.Time
for i, seg := range claim.Segments {
if seg.Duration <= 0 {
return 0, ErrBadDuration
}
if i == 0 {
if v.blocker.Blocked(seg.From) {
return 0, ErrThroughWall
}
} else {
if seg.From != prevTo {
return 0, ErrDiscontinuous
}
if !seg.Start.Equal(prevEnd) {
return 0, ErrTimeOrder
}
}
if seg.Start.Add(seg.Duration).After(now.Add(v.futureSkew)) {
return 0, ErrTimeOrder
}
dist := math.Hypot(float64(seg.To.X-seg.From.X), float64(seg.To.Y-seg.From.Y))
allowed := v.maxSpeed*seg.Duration.Seconds() + v.tolerance
if dist > allowed {
return 0, fmt.Errorf("%w: %.2f cells in %.2fs, allowed %.2f",
ErrTooFast, dist, seg.Duration.Seconds(), allowed)
}
if v.crossesWall(seg.From, seg.To) {
return 0, ErrThroughWall
}
total += dist
prevTo = seg.To
prevEnd = seg.Start.Add(seg.Duration)
}
return total, nil
}
func (v *Validator) crossesWall(from, to Point) bool {
dist := math.Hypot(float64(to.X-from.X), float64(to.Y-from.Y))
steps := int(math.Ceil(dist / v.sampleStep))
if steps < 1 {
steps = 1
}
for i := 0; i <= steps; i++ {
t := float64(i) / float64(steps)
p := Point{
X: int(math.Round(float64(from.X) + (float64(to.X-from.X))*t)),
Y: int(math.Round(float64(from.Y) + (float64(to.Y-from.Y))*t)),
}
if v.blocker.Blocked(p) {
return true
}
}
return false
}
八、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch07/path && go vet ./ch07/path/ && go test ./ch07/path/ -count=1
ok gspractice/ch07/path 0.262s
测试覆盖合法两段路径的累计距离、超速拒绝、穿墙拒绝、端点不连续、段时间不连续、过期与未来时间戳、零持续时间、容忍范围内的抖动,以及起点被阻挡。
小结
客户端寻路解决“看起来跟手”,服务端校验解决“实际上合规”。路径下发要控制大小并携带路径 ID、地图版本和起始 tick;校验要同时看速度、地形、连续性和时间,任何一项缺失都会留下可利用的缺口。重算要有滞后和去重,否则修正本身会变成抖动来源。
常见错误与边界
- 服务端只接收客户端坐标,不校验速度与地形,等于客户端权威。
- 只检查路径端点,不沿线段采样,角色从墙中间穿过去。
- 采样步长大于一格,斜穿墙角或薄墙时漏检。
- 路径没有 ID 与地图版本,旧包覆盖新结果,角色来回瞬移。
- 路径窗口过长,阻挡变化后仍按旧路径移动。
- 把单位占位当作地形阻挡,队友擦肩被判定穿墙。
- 传送走客户端路径,速度校验被绕过。
- 每次阻挡变化都全量重算并下发,客户端持续抖动。
- 容忍值设置过大,加速外挂只需把每段位移控制在容忍范围内。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。