《游戏服务端架构·品类、状态与拓扑》7.1 MOBA 的房间、tick 与帧同步

从《裂隙战场》5v5 房间一次输入迟到 80ms 导致技能错位讲起,用确定性要求、带宽、断线重连、观战与反作弊五条判据比较帧同步和状态同步,说明选定帧同步后成本如何转移到输入窗口、空帧推进与滚动哈希,并给出 internal/moba 的可测试聚合器、失败模式表与常见错误边界。

一次迟到 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 的专用服、命中判定与延迟补偿 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查