《游戏服务端编程实践》7.2.3 客户端与服务端同步路径

比较客户端预表现与服务端权威两种寻路分工,说明路点、方向序列与压缩下发格式的取舍,给出按时间、速度与地形校验路径段的 Validator 实现,覆盖超速、穿墙、时间不连续与路径过期,并讨论重算抖动、路径过大、传送与客户端和解流程的落地细节。

一、谁负责寻路

寻路可以放在客户端、服务端,或两边都做但职责不同。竞技和 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 更大的结果。否则一个晚到的旧响应会把角色拉回之前的位置,表现为“瞬移回去再走一遍”。

四、移动校验

服务端校验一段路径,至少检查四件事:

  1. 速度:距离 ≤ 最大速度 × 持续时间 + 容忍值。容忍值用于吸收浮点误差和网络抖动,不能大到可以让加速外挂通过。
  2. 地形:沿线段采样,检查经过的格子是否阻挡。采样步长应小于半个格子,常用 0.25 格;只检查端点会漏掉中间穿墙。
  3. 连续性:下一段的起点必须等于上一段的终点,下一段的开始时间必须等于上一段的结束时间。允许少量时间抖动,但不能允许坐标跳变。
  4. 时间:路径不能来自未来,也不能太旧。未来时间戳意味着客户端在伪造 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 与地图版本,旧包覆盖新结果,角色来回瞬移。
  • 路径窗口过长,阻挡变化后仍按旧路径移动。
  • 把单位占位当作地形阻挡,队友擦肩被判定穿墙。
  • 传送走客户端路径,速度校验被绕过。
  • 每次阻挡变化都全量重算并下发,客户端持续抖动。
  • 容忍值设置过大,加速外挂只需把每段位移控制在容忍范围内。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist