场景:一场 10 分钟的战斗,两个时钟
《潮汐方舟》的一场战斗是 8–12 分钟。玩家看到的角色以 60fps 在跑,客户端每 50ms 采样一次操作,服务端每 100ms 广播一次快照。也就是说,屏幕上每闪过 6 帧,才有一条权威状态到达;而每两条快照之间,客户端已经发出去了 2 条输入。
这套节奏在国内混合网络下的代价是明确的:RTT P50 62ms、P95 240ms,丢包率 0.6%,断线率 1.4%,重连成功率 98.1%,重连后需要补状态的比例 31%。数字不算吓人,但足以让「客户端只负责渲染」这种说法站不住脚——如果客户端不采样、不插值、不预测,玩家会在 240ms 的 P95 里反复看到角色停住再跳。
同步模型决定职责切分。状态同步和帧同步不是两种「网络库」,而是两种权力分配:谁算结果、谁只需要表现、谁必须在本地先跑一步。这一节先把这个分配讲清楚,再落到方舟的具体节奏上。
状态同步与帧同步,差在谁算结果
把两种模型放在同一张表里,差异比定义更直观。
| 维度 | 状态同步 | 帧同步 |
|---|---|---|
| 权威方 | 服务端计算并广播状态 | 各端按同一指令序列自行推进 |
| 上行内容 | 输入、意图、少量请求 | 每一逻辑帧的指令 |
| 下行内容 | 世界快照、增量、事件 | 指令的确认与转发 |
| 客户端计算 | 插值、预测、表现 | 完整模拟与校验和 |
| 带宽特征 | 下行大于上行 | 上下行接近,随人数增长 |
| 失败表现 | 位置跳变、命中回退 | 校验和不一致、整局重同步 |
| 适合场景 | 中重度动作、战斗 | 确定性模拟、回合与小规模对局 |
状态同步把「算」留在服务端,客户端拿到的是结果,所以它必须解决「结果来得慢」;帧同步把「算」复制到每一端,服务端只保证大家收到同样的指令,所以它必须解决「算得不一样」。两者的客户端责任都重,但重的方向相反:一个重在呈现与纠偏,一个重在确定性与校验。
方舟选择状态同步,原因不是技术偏好,而是可复盘性。战斗结果要能回放、要能申诉、要能被风控复核,服务端必须持有唯一真相。客户端可以做预测,但不能成为真相的来源。
方舟的节奏:10Hz 快照与 20Hz 输入
方舟的战斗同步参数是:服务端 10Hz 快照,每条 350B;客户端上行输入 20Hz,每条 48B。这两个频率不是随便定的,它们要跟 60fps 的渲染节奏对齐,又要控制移动网络下的流量。
| 项目 | 数值 | 推导 |
|---|---|---|
| 快照间隔 | 100ms | 1 ÷ 10Hz |
| 输入间隔 | 50ms | 1 ÷ 20Hz |
| 每个快照对应的输入 | 2 条 | 20Hz ÷ 10Hz |
| 下行速率 | 3500B/s | 350B × 10Hz |
| 上行速率 | 960B/s | 48B × 20Hz |
| 下行/上行 | 约 3.65 倍 | 3500 ÷ 960 |
下行是上行的三倍多,这在状态同步里很常见:服务端要把整个世界的变化告诉每个客户端,而客户端只上报自己的意图。真正需要警惕的不是这个比值,而是快照体积随人数和实体数增长。350B 是当前战斗形态下的设定值,一旦同屏实体翻倍,下行会先于上行成为瓶颈。
频率也不能简单调高。把快照提到 20Hz 会让下行翻倍,却未必改善手感,因为玩家感知到的延迟主要来自 RTT 和插值缓冲,而不是快照频率本身。反过来,把输入降到 10Hz 会直接损伤操作精度,因为移动和技能瞄准都依赖更细的采样。上行便宜、下行贵,这个不对称决定了「输入密、快照疏」是合理的默认。
心跳要跟快照分开看。方舟战斗内 5s 一次心跳、大厅 20s 一次,心跳只证明连接还活着,不承载世界状态。把心跳当成同步节奏,或者用快照频率去反推心跳,都会把两个独立的机制混在一起。
客户端必须自己做的四件事
状态同步下,客户端不是被动接收器。下面四件事如果做不好,服务端算得再对,玩家看到的也是错的。
- 采样与编号。按固定频率采集输入,给每一条一个单调递增的序号和可对齐的时间戳。序号用于回滚时判断哪些输入已被确认,时间戳用于服务端的延迟补偿。采样频率要稳定,不能跟着渲染帧率抖动。
- 插值呈现。收到的快照带有时间信息,渲染时要在两条快照之间插值,而不是收到就瞬移。插值会引入一个固定延迟,这个延迟是手感与平滑之间的交换,必须显式配置,不能隐含在代码里。
- 本地预测。在等待服务端确认的窗口里,客户端先按自己的输入推进本地状态,让操作立刻有反馈。预测的边界由玩法决定:移动可以预测,伤害结算通常不行。5.2 会展开回滚与平滑。
- 偏差纠正。当权威快照到达且与预测不一致时,客户端要以快照为准修正,并把修正过程做得不刺眼。修正不是「把角色拽回去」,而是「在若干帧内混过去」,误差过大时才允许直接切换。
这四件事的共同点是:它们都发生在客户端,但都不改变权威结果。客户端做的是「让等待变得可接受」,不是「替服务端做决定」。一旦这条界线模糊,预测就会变成第二套规则,后续的对账、复盘和反作弊都会变得困难。
服务端不可让渡的三条裁决
有些东西可以下放给客户端做预测,有些东西连预测都不该做。方舟把下面三类裁决牢牢留在服务端。
| 裁决 | 为什么不能下放 | 客户端的正确做法 |
|---|---|---|
| 命中判定 | 涉及位置、朝向、技能判定框与延迟补偿 | 本地播放前摇与特效,命中结果等服务端 |
| 伤害与掉落 | 影响数值平衡与随机序列 | 显示「预估伤害」但不写进结算 |
| 战斗结果与奖励 | 涉及货币、排名与风控 | 展示「战斗中」,结果以服务端战报为准 |
命中判定是最容易越界的一项。客户端为了让打击感更好,往往会本地先判一次命中并播放受击动画;这本身没问题,问题是不能把本地判定当成事实去扣血、去触发连击、去发奖励。正确的分层是:本地判定只驱动表现,服务端判定才驱动状态。两者不一致时,表现层回退,状态层不动。
随机与掉落同理。帧同步要求所有端共享同一随机种子和同一调用顺序;状态同步则干脆把随机结果放进快照。两种模型的做法不同,但原则一致:随机数不能由客户端自己取。客户端一旦能决定掉落,经济系统就失去了可审计性。
可运行模型:sync-roles.ts
下面的模型把职责表、节奏参数、流量推导与缓冲规划放在一个文件里。它不访问网络,也不假设渲染帧率,所有频率和字节数都由调用方注入;默认值取自方舟的战斗同步设定。
// file: src/net/sync-roles.ts
// tier: L1
// verify: node --test test/net/sync-roles.test.ts
export type SyncMode = "state-sync" | "frame-sync";
export interface Duty {
readonly area: string;
readonly stateSync: string;
readonly frameSync: string;
readonly owner: "client" | "server" | "both";
}
export const CLIENT_DUTIES: readonly Duty[] = [
{
area: "输入采样",
stateSync: "按本地帧采样,带序号与时间戳上行",
frameSync: "按逻辑帧打包,进入待确认指令队列",
owner: "client",
},
{
area: "权威裁决",
stateSync: "服务端计算移动、命中与伤害",
frameSync: "各端按同一帧推进,服务端只收集与转发",
owner: "server",
},
{
area: "状态呈现",
stateSync: "快照插值,短缺口用外推顶住",
frameSync: "本地立即执行,等待他人的确认",
owner: "client",
},
{
area: "偏差纠正",
stateSync: "以快照为准,平滑收敛到权威位置",
frameSync: "周期性校验和比对,不一致就重同步",
owner: "both",
},
{
area: "随机与掉落",
stateSync: "服务端生成,随快照一起下发",
frameSync: "同一种子、同一序列,禁止本地取随机",
owner: "server",
},
];
export const MODE_NOTES: Readonly<Record<SyncMode, string>> = {
"state-sync": "客户端负责采样、插值与表现,服务端负责裁决与广播快照",
"frame-sync": "客户端负责按帧执行与校验和上报,服务端负责收集与转发指令",
};
export interface CadenceConfig {
readonly serverHz: number;
readonly inputHz: number;
readonly snapshotBytes: number;
readonly inputBytes: number;
}
export const ARK_CADENCE: CadenceConfig = {
serverHz: 10,
inputHz: 20,
snapshotBytes: 350,
inputBytes: 48,
};
export interface Traffic {
readonly inputsPerSnapshot: number;
readonly upBytesPerSec: number;
readonly downBytesPerSec: number;
readonly downUpRatio: number;
}
function assertPositive(value: number, name: string): void {
if (!Number.isFinite(value) || value <= 0) {
throw new RangeError(`${name} must be a positive finite number: ${value}`);
}
}
export function trafficOf(config: CadenceConfig = ARK_CADENCE): Traffic {
assertPositive(config.serverHz, "serverHz");
assertPositive(config.inputHz, "inputHz");
if (!Number.isFinite(config.snapshotBytes) || config.snapshotBytes < 0) {
throw new RangeError(`snapshotBytes must be non-negative: ${config.snapshotBytes}`);
}
if (!Number.isFinite(config.inputBytes) || config.inputBytes < 0) {
throw new RangeError(`inputBytes must be non-negative: ${config.inputBytes}`);
}
const upBytesPerSec = config.inputBytes * config.inputHz;
const downBytesPerSec = config.snapshotBytes * config.serverHz;
return {
inputsPerSnapshot: config.inputHz / config.serverHz,
upBytesPerSec,
downBytesPerSec,
downUpRatio: upBytesPerSec === 0 ? Number.POSITIVE_INFINITY : downBytesPerSec / upBytesPerSec,
};
}
export function dutyFor(area: string): Duty | undefined {
return CLIENT_DUTIES.find((duty) => duty.area === area);
}
export function clientOwnedAreas(): readonly string[] {
return CLIENT_DUTIES.filter((duty) => duty.owner !== "server").map((duty) => duty.area);
}
export function planSnapshotBuffer(bufferMs: number, config: CadenceConfig = ARK_CADENCE): number {
if (!Number.isFinite(bufferMs) || bufferMs < 0) {
throw new RangeError(`bufferMs must be a non-negative finite number: ${bufferMs}`);
}
assertPositive(config.serverHz, "serverHz");
const intervalMs = 1000 / config.serverHz;
return Math.max(2, Math.ceil(bufferMs / intervalMs) + 1);
}
export function describeCadence(config: CadenceConfig = ARK_CADENCE): string {
const traffic = trafficOf(config);
return (
`服务端 ${config.serverHz}Hz 快照、客户端 ${config.inputHz}Hz 输入:` +
`每个快照对应 ${traffic.inputsPerSnapshot} 条输入,` +
`上行 ${traffic.upBytesPerSec}B/s、下行 ${traffic.downBytesPerSec}B/s`
);
}
模型导出了六个入口:职责常量、模式说明、流量推导、职责查询、客户端职责清单与缓冲规划。trafficOf 把频率与消息体量相乘,得到上行的 960B/s 与下行的 3500B/s;planSnapshotBuffer 按「预算 ÷ 快照间隔,向上取整再加一」估算需要持有的快照数,并把下限锁在 2,避免单帧缓冲导致无帧可插值。配套测试覆盖了正常节奏、零字节输入、非法频率、负预算、职责查询与摘要文本。
缓冲规划的函数名容易让人以为它决定了渲染延迟,其实它只回答「至少要留几帧」。真正的渲染延迟还要加上插值窗口和网络抖动预算,属于 5.2 的显示平滑范畴。
双案例对照:同一套职责,不同的节奏
消消的同步压力小得多:弱联网、回合制提交,每次提交 600B–1.2KB,单局 14–22 次,客户端的主要责任是保证提交幂等与断线后可补,而不是高频插值。
方舟则是另一头:单场 8–12 分钟、60fps、10Hz 快照与 20Hz 输入,客户端必须持续做采样、插值与纠偏,任何一项缺席都会直接反映为手感问题。
把两者放在一起看,职责表的形状是一样的,只是权重不同:消消的「呈现」几乎不需要插值,「纠偏」退化为重连后的状态校准;方舟的「呈现」和「纠偏」则是最重的两块。架构上不该为两种强度写两套同步框架,而应该让职责可配置、节奏可注入。
与相邻章节的关系
4.3 讨论的是离线改动的字段级合并,本节讨论的是在线战斗中客户端与服务端的职责切分;两者的共同前提都是「服务端持有权威」。5.2 会接着展开预测、回滚与显示平滑,把本节提到的「本地先跑一步」讲成可实现的算法;5.3 则处理延迟补偿中客户端必须守住的边界。渲染预算与帧率的关系可以参考帧预算 ,本节不展开渲染管线。
小结
- 状态同步与帧同步的差别不在传输,而在谁算结果:前者服务端算、客户端呈现,后者各端算、服务端转发。
- 方舟的节奏是 10Hz 快照、20Hz 输入,对应下行 3500B/s、上行 960B/s;下行贵、上行便宜,决定了「输入密、快照疏」。
- 客户端必须自己做四件事:稳定采样与编号、快照插值、本地预测、平滑纠偏;它们只改善等待体验,不改变权威结果。
- 命中、伤害与掉落、战斗结果与奖励三类裁决不能下放;本地判定只能驱动表现,不能驱动状态。
- 两种产品强度共用一套职责表,但权重不同;把节奏做成可注入参数,比为每个产品写一套框架更可持续。
阅读导航:上一节:4.3 冲突解决与可见性延迟 · 下一节:5.2 预测、回滚与显示平滑 。
常见错误与边界
- 把客户端当纯渲染器。不采样、不插值、不预测,240ms 的 P95 会直接变成角色停顿与跳变;客户端的职责是让等待可接受。
- 把心跳当成同步节奏。战斗内 5s 心跳、大厅 20s 心跳只证明连接存活,世界状态仍由 10Hz 快照承载,两者不能互相替代。
- 只提高快照频率来改善手感。快照翻倍会让下行翻倍,而手感主要受 RTT 与插值缓冲影响;先算清瓶颈再调频率。
- 让客户端本地判定直接扣血。本地命中只应驱动特效与受击表现,扣血、连击与奖励必须等服端结果,否则会出现两套真相。
- 客户端自行取随机数。掉落与暴击一旦由客户端决定,经济系统就不可审计;随机必须来自服务端或共享种子。
- 插值延迟隐含在代码里。没有显式配置的延迟无法调优,也无法向策划解释手感变化;应把它作为参数暴露。
- 快照缓冲只留一帧。单帧缓冲在网络抖动时无帧可插值,只能外推或停住;缓冲下限应至少两帧。
- 纠正时直接把角色拽回权威位置。大误差瞬移可以接受,小误差瞬移会破坏手感;应按误差大小选择混合或切换。
- 上下行用同一套限流策略。下行是上行的三倍多,限流与压缩应分别对待,不能用一个阈值同时卡两端。
- 边界:本节不讨论反作弊与战报校验。职责划分只说明谁该算什么,不保证客户端上报的输入真实可信;输入合法性、速度校验与战报复核属于服务端与风控的范畴。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。