一、先看原子:一次行动还是一帧
回合制和实时制的区别,不是“有没有动画”,也不是“节奏快不快”,而是服务器把什么当作最小推进单位。
回合制的原子是一次行动。玩家 A 选择技能、目标,服务器校验后结算,然后把结果广播给所有参与者;在 A 的行动结束之前,B 的输入通常不被接受。实时制的原子是一帧。服务器按固定频率推进逻辑帧,每帧收集所有玩家输入、更新世界状态、生成快照或增量,再下发给客户端。
这个差别会一路传导到数据结构、网络协议、回放格式和反作弊策略。把两者混为一谈,常见的后果是用实时制的广播频率去驱动回合制,或者用回合制的“等待确认”去阻塞实时战斗。
判据可以写得很简单:如果服务器在“某个玩家没有提交输入”时仍然必须推进世界,它就是实时制;如果服务器可以停在“等待当前行动者”的状态而不影响正确性,它就是回合制。
二、两种模型的账
| 维度 | 回合制 | 实时制 |
|---|---|---|
| 推进原子 | 一次行动 | 一个逻辑帧 |
| 状态规模 | 通常较小,可完整保存在内存 | 实体多、状态大,常需分区与兴趣管理 |
| 同步频率 | 每个行动广播一次 | 每帧或每隔若干帧广播 |
| 回放能力 | 行动序列即回放,天然可复现 | 需要记录输入流或状态快照,回放成本高 |
| 作弊面 | 集中在行动合法性校验 | 输入时序、位置、命中判定都可能被篡改 |
| 断线恢复 | 重连后补当前行动即可 | 需要补帧、快照或让客户端重新拉取状态 |
| 服务器负载 | 峰值由行动结算决定 | 持续 CPU 与带宽开销,和在线人数线性相关 |
| 开发复杂度 | 规则与流程复杂,网络相对简单 | 网络、预测、纠偏复杂,规则常被实时性约束 |
这张表说明的不是“哪个更高级”,而是成本落在哪里。回合制的难点在规则设计和行动合法性;实时制的难点在同步、延迟补偿和确定性。选型时应该问:玩法是否允许世界在等待某个玩家时暂停?如果允许,回合制能把大量并发问题消掉;如果不允许,就必须为每帧推进付出代价。
状态规模也影响部署。回合制战斗可以把一场战斗完整放在单个进程内,按 battleID 路由即可;实时制的大世界往往需要按区域切分,玩家跨区时要迁移实体状态。前者更容易做水平扩展,后者更容易出现“跨服战斗”这类边界问题。
容量规划也不一样。回合制的峰值通常出现在大量战斗同时结算的时刻,CPU 花在规则计算和数据库写入上;实时制的负载更平滑,但和在线人数、视野内实体数强相关。前者适合按房间做弹性调度,后者需要按区域预留固定算力,避免跨区迁移带来的抖动。
还有一个容易被忽略的维度:失败恢复。回合制的战斗状态小,进程崩溃后可以从最近一次行动记录重建;实时制的世界状态大,通常需要快照加增量日志,恢复时间更长。选型时不能只看正常运行的成本,还要看故障时能否在可接受时间内恢复。
回合制也不是“没有实时性”。它的表现层仍然按帧渲染,技能动画、镜头、粒子都在客户端跑;只是这些表现不参与服务器判定。把渲染帧率当成逻辑推进频率,是很多性能问题的起点。
三、服务器权威与客户端预测
两种模型都可以做服务器权威,但“权威”的含义不同。
回合制里,服务器权威体现为“行动是否合法”。客户端可以提前播放技能动画、显示伤害预估,但最终伤害、暴击、抵抗必须由服务器结算。客户端预测的收益主要是手感,预测错了回滚一次动画即可,代价可控。
实时制里,服务器权威体现为“世界状态以谁为准”。客户端为了掩盖往返延迟,会本地先移动、先出招,服务器收到输入后按自己的时间线结算,再把纠正后的位置或状态发回。预测越激进,纠偏越明显;预测越保守,操作越粘手。这里没有免费选项,只有延迟预算的分配。
一个常见误区是“服务器权威就等于客户端什么都不做”。实时制如果完全不做预测,100 毫秒以上的 RTT 会让移动和攻击明显滞后;反之,如果客户端完全自算,服务器只做转发,作弊面会迅速扩大。正确做法是明确哪些量允许预测、哪些量必须等服务器确认,并把纠偏策略写进协议。
回合制也有延迟问题,只是位置不同。玩家点击技能后要等服务器返回结果才能播放伤害,如果 RTT 高,操作会显得“点了没反应”。常见缓解是本地先播放前摇和特效,等服务器确认后再播放命中与数字;如果服务器拒绝,再撤销表现。这里预测的是表现,不是结果,代价比实时制的状态预测小得多。
权威粒度也值得明确。服务器不必对所有字段都做实时校验:行动合法性、资源扣除、伤害结果必须权威;播放顺序、镜头位置、非关键特效可以交给客户端。把“权威”理解成“所有字段都同步”,会让协议变得又大又慢。
四、混合制怎么归类
很多游戏并不纯。判断归类时,仍然看“世界在没有新输入时是否继续推进”,而不是看界面像不像回合制。
| 形态 | 推进方式 | 归类 | 服务端要点 |
|---|---|---|---|
| 传统回合制 | 等待当前行动者 | 回合制 | 行动队列、超时跳过、重复行动拒绝 |
| 行动条(ATB) | 时间条走满才轮到行动 | 混合偏回合 | 用逻辑时间推进行动条,暂停时冻结 |
| 半即时 | 双方同时读条,可随时打断 | 混合偏实时 | 需要帧推进与输入缓冲,规则仍按行动结算 |
| 自走棋式 | 准备阶段回合制,战斗阶段自动演算 | 两段式 | 准备阶段用行动模型,战斗阶段用确定性模拟 |
| 实时战术 | 世界持续走,玩家可暂停下令 | 实时制 | 暂停是单机语义,联机需统一时钟或房主权威 |
行动条是典型的混合制:它有时间推进,但“谁先行动”由逻辑时间决定,而不是由每帧的位置碰撞决定。实现上可以让服务器以固定 tick 推进行动条,条满时才进入行动结算;这样既有时间感,又保留了回合制的合法性校验。
半即时则更接近实时制:玩家可以在对方行动过程中插入技能,服务器必须处理输入到达顺序、打断窗口和结算优先级。它的复杂度不在“读条”,而在“同时发生的行动如何定序”。如果规则说不清两个行动在同一 tick 到达时谁先结算,就不要把它当成简单的回合制加动画。
行动条实现时,逻辑时间要由服务器统一推进,客户端只做插值显示。暂停、加速、减速都应该是服务器状态,而不是客户端本地倍速;否则不同玩家看到的“谁先满条”会不一致。行动条满之后仍然进入一次行动结算,这一步可以复用回合制的合法性校验。
两段式架构要特别小心状态边界。准备阶段结束时的属性、装备、羁绊必须完整冻结成一份战斗快照,战斗阶段只读这份快照,不再回查玩家实时数据。否则玩家在战斗中途换装备,可能出现“客户端显示旧属性、服务器结算新属性”的分叉。
还有一种“伪实时”形态:服务器按固定 tick 接收输入,但只在行动真正发生时做结算。它的同步频率像实时制,规则模型像回合制。归类时仍然看世界是否在没有新输入时继续改变:如果只是读条推进,规则结果仍由行动触发,它可以按混合制处理,但网络层要按实时制设计输入缓冲。
五、失败场景:三种常见错配
第一种错配是回合制套帧同步。某项目为了“统一架构”,让回合制战斗也走固定帧输入同步,每帧广播所有玩家的准备状态。结果是:玩家思考的十几秒里,服务器仍在空转生成帧,回放文件里塞满无意义帧;更糟的是,客户端开始用帧号推断行动顺序,而规则实际按速度排序,两边对“谁先手”的理解逐渐分叉。
第二种错配是实时制每帧全量广播。某个 200 人场景把完整实体列表每 50 毫秒发一遍,单帧几兆字节,网关带宽先于服务器 CPU 打满。正确做法是兴趣管理、增量同步和优先级裁剪:只发视野内、状态有变化、对玩家有意义的实体。全量广播不是“实现简单”,而是把成本推迟到线上爆发。
第三种错配是超时不处理。回合制战斗里,某个玩家掉线或挂机,服务器一直停在“等待该玩家行动”。如果没有超时跳过,一场战斗可以永久占住内存和房间槽位;如果超时后直接判负,又可能误伤网络抖动的玩家。合理做法是分层:短超时自动跳过本回合,长超时托管或判负,并把每次跳过写入战斗记录,方便客服复核。
超时还要区分“客户端没提交”和“服务器没收到”。前者可能是玩家挂机,后者可能是网络问题。如果服务器只按自己的计时器判负,网络抖动期间玩家可能连续丢回合。更稳妥的做法是让客户端在本地显示倒计时,服务器以收到行动的时间为准,并在重连时补发当前回合的剩余时间。
实时制的超时问题则变成“输入迟到”。如果服务器按帧号处理输入,迟到的输入不能直接塞进已经结算的帧,否则会改变历史结果。常见做法是给输入留一个小的缓冲窗口,超过窗口的输入丢弃或折算到下一帧,并在协议里明确告诉客户端。
这三种错配的共同点是:把一种模型的默认假设带进了另一种模型。架构可以复用,假设不能复用。
六、Go 实现:带回超时跳过的行动队列
下面的 TurnQueue 只做三件事:维护固定行动顺序、校验当前行动者、在超时时跳过。它不计算伤害,也不负责网络,这样规则层可以单独测试。
// file: ch08/model/turn.go
// tier: L1
// verify: go test ./ch08/model/
package model
import "errors"
var (
ErrEmptyOrder = errors.New("turn: order is empty")
ErrDuplicateActor = errors.New("turn: duplicate actor in order")
ErrUnknownActor = errors.New("turn: actor is not in this battle")
ErrNotYourTurn = errors.New("turn: it is not this actor's turn")
ErrActorActed = errors.New("turn: actor already acted in this round")
ErrTurnQueueClosed = errors.New("turn: queue closed")
)
// TurnRecord 是一回合内的一次行动或超时跳过,Seq 用于回放排序。
type TurnRecord struct {
Round int
Actor uint64
Seq uint64
Skipped bool
}
// TurnQueue 维护固定行动顺序。每个 actor 每回合只能提交一次;
// 当前 actor 超时由调用方推进 Skip,而不是让队列无限等待。
type TurnQueue struct {
order []uint64
pos int
round int
acted map[uint64]bool
records []TurnRecord
seq uint64
closed bool
}
func NewTurnQueue(order []uint64) (*TurnQueue, error) {
if len(order) == 0 {
return nil, ErrEmptyOrder
}
seen := make(map[uint64]bool, len(order))
for _, id := range order {
if id == 0 {
return nil, ErrUnknownActor
}
if seen[id] {
return nil, ErrDuplicateActor
}
seen[id] = true
}
return &TurnQueue{
order: append([]uint64(nil), order...),
round: 1,
acted: make(map[uint64]bool, len(order)),
}, nil
}
// Current 返回当前应行动的 actor 与回合号;队列空或已关闭时 ok 为 false。
func (q *TurnQueue) Current() (actor uint64, round int, ok bool) {
if q.closed || q.pos >= len(q.order) {
return 0, 0, false
}
return q.order[q.pos], q.round, true
}
// Submit 接收当前 actor 的行动。行动序号由队列分配,客户端传入的值只做校验。
func (q *TurnQueue) Submit(actor uint64, actionSeq uint64) error {
if q.closed {
return ErrTurnQueueClosed
}
current, _, ok := q.Current()
if !ok {
return ErrTurnQueueClosed
}
if !q.contains(actor) {
return ErrUnknownActor
}
// 已经行动过的 actor 再次提交,优先报重复而不是“没轮到你”。
if q.acted[actor] {
return ErrActorActed
}
if actor != current {
return ErrNotYourTurn
}
q.acted[actor] = true
q.seq++
q.records = append(q.records, TurnRecord{Round: q.round, Actor: actor, Seq: q.seq})
q.advance()
return nil
}
// Skip 处理当前 actor 超时:记录一次跳过并推进到下一个 actor。
func (q *TurnQueue) Skip() (uint64, bool) {
if q.closed || q.pos >= len(q.order) {
return 0, false
}
actor := q.order[q.pos]
q.seq++
q.records = append(q.records, TurnRecord{Round: q.round, Actor: actor, Seq: q.seq, Skipped: true})
q.advance()
return actor, true
}
func (q *TurnQueue) advance() {
q.pos++
if q.pos < len(q.order) {
return
}
q.pos = 0
q.round++
q.acted = make(map[uint64]bool, len(q.order))
}
func (q *TurnQueue) contains(actor uint64) bool {
for _, id := range q.order {
if id == actor {
return true
}
}
return false
}
// Records 返回行动历史的副本,调用方修改切片不会污染队列。
func (q *TurnQueue) Records() []TurnRecord {
out := make([]TurnRecord, len(q.records))
copy(out, q.records)
return out
}
func (q *TurnQueue) Close() {
q.closed = true
}
几个设计点值得说明。Submit 先检查“是否已经行动过”,再检查“是否轮到你”,这样重复提交会得到明确的 ErrActorActed,而不是含糊的 ErrNotYourTurn。Skip 与 Submit 共用 advance,保证无论正常行动还是超时,回合推进逻辑只有一份。Records 返回副本,避免调用方修改历史影响回放。
测试覆盖三条主线:顺序(非当前行动者被拒绝、行动后轮到下一个)、超时(Skip 记录 skipped 并推进)、重复(同一回合第二次提交被拒绝)。单 actor 的队列会在提交后立即进入下一回合,这是有意的:单人战斗不需要“等待自己”,但多人队列中重复提交仍会被 acted 表拦住。
七、选型清单
在写第一行战斗代码之前,可以先回答下面几个问题:
- 世界能否在等待某个玩家时暂停?能,优先考虑回合制或两段式。
- 是否需要精确回放?回合制记录行动序列即可;实时制要记录输入流和随机种子。
- 延迟预算是否允许客户端预测?不允许,就减少实时交互,或把判定放到服务器。
- 战斗是否跨进程?跨进程时,行动队列和帧循环都要有明确的归属节点。
- 掉线、挂机、恶意拖延是否常见?常见就把超时与托管做进第一版。
选型不是站队。很多商业项目是“准备阶段回合制、战斗阶段自动演算”的两段式:准备阶段用行动队列,战斗阶段用确定性模拟,两者共享同一套单位属性与技能配置,但推进模型分开。这比强行统一成一种模型更容易维护。
八、容量与成本估算
下面是一张示例估算表,数字只用于说明口径,不是真实测量值。真实项目应按自己的战斗时长、并发房间数和结算写入量替换。
| 项目 | 回合制(示例值) | 实时制(示例值) |
|---|---|---|
| 单房间内存 | 几十 KB 到几百 KB | 几 MB 到几十 MB |
| 单房间 CPU | 行动结算时短时占用 | 每帧持续占用 |
| 单玩家带宽 | 每次行动几百字节 | 每秒几十 KB 起 |
| 回放存储 | 行动序列,KB 级 | 输入流或快照,MB 级 |
| 扩容单位 | 房间 | 区域或分片 |
估算时先算峰值并发房间数,再乘单房间资源。回合制的房间可以按需创建、战斗结束立即释放,适合用池化对象降低 GC 压力;实时制的区域通常常驻,扩容要考虑玩家迁移和数据预热。
还要把结算写入算进去。回合制一场战斗只写一次战绩和奖励,但同一时间可能有大量战斗结束,数据库会出现尖峰;实时制的写入更分散,却可能因为伤害事件频繁而需要批量合并。两种模型都要做写入限流,不能把数据库当成无限缓冲。
成本不只看服务器。回放文件会长期占用对象存储,客服查询会带来读放大;如果回放里包含完整状态快照,存储成本可能远高于行动序列。选择回放格式时,先问清楚“谁来看、看多久、要不要逐帧定位”。
降级策略也要提前设计。回合制在结算写入变慢时,可以先把奖励放进队列,稍后补发;实时制在服务器过载时,通常只能降低同步频率或裁剪视野,不能停止推进,否则玩家会看到世界卡住。两种模型的降级手段不同,不能照搬。
监控指标同样不同。回合制关注房间创建失败率、行动处理延迟、结算积压和超时跳过次数;实时制关注帧耗时、每帧实体数、带宽、输入迟到率和纠偏频率。指标定义清楚了,才能判断是容量不足还是逻辑退化。
最后,选型要留出回退路径。如果先做回合制,后续想加实时战斗,最好把单位属性、技能配置、结算接口抽成共享层,避免两套规则各写一遍;如果先做实时制,想加回合制玩法,则要把输入帧和行动队列的边界划清,防止实时循环把回合逻辑拖进每帧调用。
压测时要按模型各自的特征构造场景。回合制压的是“同时结算”的尖峰,可以用批量创建房间、瞬间结束战斗来模拟;实时制压的是“持续在线”的均值,需要让玩家保持移动、释放技能、跨视野切换。用同一套压测脚本套两种模型,得到的数字没有可比性。
上线后还要观察“长尾”。回合制可能出现个别房间因为超时或异常永远不结束,实时制可能出现个别区域因为实体堆积而帧耗时升高。长尾问题不会体现在平均指标里,却会持续消耗资源,需要单独的巡检与清理任务。
小结
回合制以行动为原子,实时制以帧为原子;前者把复杂度放在规则与合法性,后者把复杂度放在同步与延迟。服务器权威在两种模型里都成立,但客户端预测的边界不同。混合制仍可用“无输入时世界是否继续推进”来归类。实现层面,先把行动队列、超时和重复提交这些边界做对,再谈技能与表现。
常见错误与边界
- 用帧同步实现回合制,导致空转帧和行动顺序分叉。回合制的同步单位应是行动,不是 tick。
- 实时制每帧全量广播。先做兴趣管理与增量,再考虑提高频率。
- 超时不处理。至少要有“跳过本回合”的兜底,并把跳过写入战斗记录。
- 把“客户端能操作”当成“客户端能决定”。伤害、掉落、胜负必须由服务器结算。
- 认为行动条一定是回合制。行动条如果有持续时间和打断,就要按实时制的定序问题处理。
- 忽略单 actor 队列的回合自增。单人场景可以简化,但多人队列的重复提交校验不能省。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。