《移动游戏产品的架构·网络、资源与热更新》5.1 状态同步与帧同步:客户端各做什么

强联网里客户端该算多少,取决于同步模型而不是团队习惯。本节把状态同步与帧同步拆成输入、裁决、呈现、纠偏与随机五类职责,用方舟 10Hz 快照、20Hz 输入以及 350B 快照、48B 输入的体量算清双向流量,并划出客户端不可让渡的边界与可运行模型。

场景:一场 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 的渲染节奏对齐,又要控制移动网络下的流量。

项目数值推导
快照间隔100ms1 ÷ 10Hz
输入间隔50ms1 ÷ 20Hz
每个快照对应的输入2 条20Hz ÷ 10Hz
下行速率3500B/s350B × 10Hz
上行速率960B/s48B × 20Hz
下行/上行约 3.65 倍3500 ÷ 960

下行是上行的三倍多,这在状态同步里很常见:服务端要把整个世界的变化告诉每个客户端,而客户端只上报自己的意图。真正需要警惕的不是这个比值,而是快照体积随人数和实体数增长。350B 是当前战斗形态下的设定值,一旦同屏实体翻倍,下行会先于上行成为瓶颈。

频率也不能简单调高。把快照提到 20Hz 会让下行翻倍,却未必改善手感,因为玩家感知到的延迟主要来自 RTT 和插值缓冲,而不是快照频率本身。反过来,把输入降到 10Hz 会直接损伤操作精度,因为移动和技能瞄准都依赖更细的采样。上行便宜、下行贵,这个不对称决定了「输入密、快照疏」是合理的默认。

心跳要跟快照分开看。方舟战斗内 5s 一次心跳、大厅 20s 一次,心跳只证明连接还活着,不承载世界状态。把心跳当成同步节奏,或者用快照频率去反推心跳,都会把两个独立的机制混在一起。

客户端必须自己做的四件事

状态同步下,客户端不是被动接收器。下面四件事如果做不好,服务端算得再对,玩家看到的也是错的。

  1. 采样与编号。按固定频率采集输入,给每一条一个单调递增的序号和可对齐的时间戳。序号用于回滚时判断哪些输入已被确认,时间戳用于服务端的延迟补偿。采样频率要稳定,不能跟着渲染帧率抖动。
  2. 插值呈现。收到的快照带有时间信息,渲染时要在两条快照之间插值,而不是收到就瞬移。插值会引入一个固定延迟,这个延迟是手感与平滑之间的交换,必须显式配置,不能隐含在代码里。
  3. 本地预测。在等待服务端确认的窗口里,客户端先按自己的输入推进本地状态,让操作立刻有反馈。预测的边界由玩法决定:移动可以预测,伤害结算通常不行。5.2 会展开回滚与平滑。
  4. 偏差纠正。当权威快照到达且与预测不一致时,客户端要以快照为准修正,并把修正过程做得不刺眼。修正不是「把角色拽回去」,而是「在若干帧内混过去」,误差过大时才允许直接切换。

这四件事的共同点是:它们都发生在客户端,但都不改变权威结果。客户端做的是「让等待变得可接受」,不是「替服务端做决定」。一旦这条界线模糊,预测就会变成第二套规则,后续的对账、复盘和反作弊都会变得困难。

服务端不可让渡的三条裁决

有些东西可以下放给客户端做预测,有些东西连预测都不该做。方舟把下面三类裁决牢牢留在服务端。

裁决为什么不能下放客户端的正确做法
命中判定涉及位置、朝向、技能判定框与延迟补偿本地播放前摇与特效,命中结果等服务端
伤害与掉落影响数值平衡与随机序列显示「预估伤害」但不写进结算
战斗结果与奖励涉及货币、排名与风控展示「战斗中」,结果以服务端战报为准

命中判定是最容易越界的一项。客户端为了让打击感更好,往往会本地先判一次命中并播放受击动画;这本身没问题,问题是不能把本地判定当成事实去扣血、去触发连击、去发奖励。正确的分层是:本地判定只驱动表现,服务端判定才驱动状态。两者不一致时,表现层回退,状态层不动。

随机与掉落同理。帧同步要求所有端共享同一随机种子和同一调用顺序;状态同步则干脆把随机结果放进快照。两种模型的做法不同,但原则一致:随机数不能由客户端自己取。客户端一旦能决定掉落,经济系统就失去了可审计性。

可运行模型: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 预测、回滚与显示平滑 。

常见错误与边界

  1. 把客户端当纯渲染器。不采样、不插值、不预测,240ms 的 P95 会直接变成角色停顿与跳变;客户端的职责是让等待可接受。
  2. 把心跳当成同步节奏。战斗内 5s 心跳、大厅 20s 心跳只证明连接存活,世界状态仍由 10Hz 快照承载,两者不能互相替代。
  3. 只提高快照频率来改善手感。快照翻倍会让下行翻倍,而手感主要受 RTT 与插值缓冲影响;先算清瓶颈再调频率。
  4. 让客户端本地判定直接扣血。本地命中只应驱动特效与受击表现,扣血、连击与奖励必须等服端结果,否则会出现两套真相。
  5. 客户端自行取随机数。掉落与暴击一旦由客户端决定,经济系统就不可审计;随机必须来自服务端或共享种子。
  6. 插值延迟隐含在代码里。没有显式配置的延迟无法调优,也无法向策划解释手感变化;应把它作为参数暴露。
  7. 快照缓冲只留一帧。单帧缓冲在网络抖动时无帧可插值,只能外推或停住;缓冲下限应至少两帧。
  8. 纠正时直接把角色拽回权威位置。大误差瞬移可以接受,小误差瞬移会破坏手感;应按误差大小选择混合或切换。
  9. 上下行用同一套限流策略。下行是上行的三倍多,限流与压缩应分别对待,不能用一个阈值同时卡两端。
  10. 边界:本节不讨论反作弊与战报校验。职责划分只说明谁该算什么,不保证客户端上报的输入真实可信;输入合法性、速度校验与战报复核属于服务端与风控的范畴。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查