《移动游戏产品的架构·网络、资源与热更新》5.3 延迟补偿的客户端边界

延迟补偿的判定权在服务端,但补偿是否可信取决于客户端给出的时间与历史。本节用方舟 62/240ms 的 RTT 分布、10Hz 快照与 20Hz 输入,说明时间戳该从哪里来、回看窗口怎么定、历史缓冲保留多久,以及客户端绝不能越过的四条红线。

场景:打中了,服务端说没中

《潮汐方舟》里最常见的一类争议是「我明明打中了」。玩家在屏幕上看到技能命中,服务端回放却判定未命中。原因通常不是谁在说谎,而是两边看的是不同时刻的世界:玩家看到的是 RTT 之前的状态,服务端判定的是收到输入时的状态,中间隔着几十到几百毫秒的位移。

延迟补偿就是把这个时间差补回来。服务端在判定命中时,把其他角色的位置回看到「攻击者当时看到的那一帧」,再算碰撞。这个回看需要两样东西:攻击输入携带的时间戳,以及服务端保存的历史状态。两样都由服务端消费,但时间戳由客户端产生,历史可信度也依赖客户端没有乱来。

所以延迟补偿表面上是一个服务端算法,实际上有一条很长的客户端责任链。客户端报错时间、伪造时间、或者对历史状态做手脚,都会让补偿结果失真。本节讨论的不是「怎么实现回看」,而是「客户端在这条链上必须守住什么」。

延迟补偿在服务端,边界在客户端

先把职责摆清楚,避免把补偿写成客户端逻辑。

环节归属客户端能做什么客户端不能做什么
记录输入时刻客户端用校准后的时间给输入打戳用设备本地时间直接当权威时刻
保存历史状态服务端无用本地历史替代服务端历史
选择回看目标服务端上报时间戳供参考指定要回看到哪一帧
命中判定服务端播放表现、预演反馈本地判定后直接写结算
补偿上限服务端遵守并提示异常绕过上限要求更多回看

这张表的关键在最后一行:补偿必须有上限。无上限的回看意味着服务端要保存任意久的历史,也意味着一个伪造的极旧时间戳可以把判定拉回到任意时刻。上限既是性能约束,也是安全约束。

客户端在这条链上的角色是「提供可信的输入时间」,而不是「决定补偿结果」。一旦客户端开始决定回看到哪一帧,延迟补偿就变成了客户端裁决,服务端的权威性随之瓦解。

时间戳从哪来

时间戳是整条链上最容易出错的一环。设备本地时钟不可靠:玩家可以手动改时间,系统可能在省电时跳变,不同设备的时钟漂移也不一致。直接把 Date.now() 写进输入包,等于把判定权交给了一个可被随意修改的来源。

可行的做法是用服务端时间做校准。连接建立或重连时,客户端通过若干次往返估算本机时间与服务端时间的偏移,之后所有输入时间戳都用「本机单调时钟 + 偏移」生成。单调时钟不会因为系统改时间而跳变,偏移则由服务端校准,两者结合才能得到既稳定又可信的时间。

校准本身也有误差。RTT 越大,单次估算的偏差越大;因此校准应该取多次采样的中位数或最小值,并在网络条件明显变化时重新校准。方舟的 RTT P50 是 62ms、P95 是 240ms,同一套校准策略在两种网络下得到的精度差别很大,不能用一次校准覆盖整个会话。

时间戳还要跟输入序号配合。序号保证「哪些输入已被处理」,时间戳保证「这条输入代表哪个时刻的操作」。只有序号没有时间戳,服务端无法回看;只有时间戳没有序号,客户端无法做回滚对账。两者缺一不可。

重连是时间戳最容易断档的场景。方舟的断线率是 1.4%,重连成功率 98.1%,重连后需要补状态的比例 31%。重连后如果继续沿用旧的偏移,时间戳会整体漂移;正确做法是重新校准,并把断线期间的输入标记为「不可补偿」,而不是假装它们发生在断线前。

回看窗口与历史缓冲

回看窗口的长度由「半个 RTT 加一个快照间隔」估算。方舟 P50 下是 62 ÷ 2 + 100 = 131ms,P95 下是 240 ÷ 2 + 100 = 220ms。按 100ms 一个快照折算,分别需要覆盖 2 个和 3 个快照。

指标P50P95说明
RTT62ms240ms国内 Wi-Fi/4G 混合
半个 RTT31ms120ms单向延迟近似
快照间隔100ms100ms10Hz
回看窗口131ms220ms半 RTT + 一个快照间隔
覆盖快照数23向上取整

「向上取整」不是细节,而是边界。131ms 的窗口按 100ms 一帧算是 1.31 帧,向下取整只覆盖 1 帧,会漏掉窗口尾部的那部分时间;向上取整到 2 帧才真正覆盖。窗口宁可比需要的略长,也不能短,因为短窗口会让合法的补偿请求落到历史之外,表现为「偶尔判不中」。

历史缓冲只需要保留窗口上限对应的帧数再加一点余量。保留太多会浪费内存,保留太少会在抖动时查不到目标帧。缓冲应该按环形结构管理,新帧覆盖旧帧,而不是无限追加后定期清理。

历史状态的精度也影响补偿。服务端通常按快照帧保存位置,回看目标往往落在两帧之间,需要插值。插值得到的是近似位置,不是精确位置;对高速移动的目标,两帧之间的位移可能已经超过命中判定框的宽度。这时要么提高快照频率,要么接受一定误差,不能假装插值是精确的。

窗口与缓冲的关系可以用一句话概括:窗口决定「要回看多久」,缓冲决定「能回看多久」。窗口是业务需求,缓冲是实现余量;缓冲小于窗口时,合法的补偿请求会失败;缓冲远大于窗口时,多出来的内存没有收益。合理的关系是缓冲恰好覆盖窗口上限,再加一到两帧的抖动余量。

补偿失真会以什么形式出现

补偿一旦出问题,玩家看到的不是「补偿失败」四个字,而是几种具体而迷惑的现象。把它们列出来,有助于在日志里定位。

现象可能原因排查方向
偶尔打不中,重试又中回看窗口短于实际延迟检查窗口是否按 P95 配置、帧数是否向上取整
明显没打中却判中时间戳偏旧或历史位置错误检查校准偏移与历史写入顺序
同一操作在不同设备结果不同时间源不一致或插值实现不同检查单调时钟与服务端校准流程
重连后命中率骤降旧偏移未更新、断线输入被错误补偿检查重连后的重新校准与输入标记

这张表里没有「客户端算错」这一项,因为客户端根本不参与判定。所有现象最终都要回到两个输入:时间戳是否可信、历史是否覆盖。把这两个输入守住,补偿的失真就只剩下服务端算法本身的问题,范围会小很多。

日志也要按这两个输入组织。每条战斗日志至少应该能回答:这条输入的时间戳是多少、当时的历史窗口覆盖到哪个 tick、补偿请求有没有被上限截断。缺了这三项,排查时就只能靠猜,而「偶尔打不中」恰恰是最难复现的一类问题。

客户端的四条红线

把上面的责任收拢成四条不可越过的红线。

  1. 时间戳必须来自校准后的单调时钟。设备本地时间可以被用户修改,也可以被系统跳变;用 Date.now() 直接打戳,等于把判定权交给不可信来源。
  2. 回看窗口必须有上限。超过上限的请求要被夹取或拒绝,不能无限回溯;上限既是性能保护,也是防止伪造旧时间戳的安全边界。
  3. 历史只保留短窗口,不落盘、不跨局。历史状态是判定依据,落到本地存储就可能被篡改;它应该只存在于内存,并在对局结束后释放。
  4. 客户端只上报时间戳,不决定补偿结果。回看目标、命中判定与伤害结算都由服务端完成;客户端可以做表现预演,但不能把预演当成事实。

这四条的共同点是「客户端提供输入,服务端做决定」。越过任何一条,延迟补偿都会从「还原玩家看到的世界」退化成「让客户端定义世界」。

红线不能只写在文档里,要落到接口上。输入包的时间戳字段应该由统一的时间源生成,而不是让每个业务模块自己取时钟;历史缓冲的读写应该封装成一个只接受 tick 的组件,业务层拿不到任意回看的能力;窗口上限应该在服务端校验,客户端传超限值时明确拒绝并记录。接口形状决定了红线能不能被绕过,光靠代码评审守不住。

可运行模型:lag-comp.ts

下面的模型把帧间隔换算、回看窗口、历史记录与插值采样放在一个文件里。它不访问网络,也不持久化;配置与历史都由调用方注入,默认值取自方舟的 10Hz 快照与 62/240ms 的 RTT 分布。

// file: src/net/lag-comp.ts
// tier: L1
// verify: node --test test/net/lag-comp.test.ts

export interface TimedState {
  readonly tick: number;
  readonly x: number;
  readonly y: number;
}

export interface LagConfig {
  readonly snapshotHz: number;
  readonly rttP50Ms: number;
  readonly rttP95Ms: number;
  readonly maxRewindMs: number;
}

export const ARK_LAG: LagConfig = {
  snapshotHz: 10,
  rttP50Ms: 62,
  rttP95Ms: 240,
  maxRewindMs: 400,
};

export function tickMs(config: LagConfig = ARK_LAG): number {
  if (!Number.isFinite(config.snapshotHz) || config.snapshotHz <= 0) {
    throw new RangeError(`snapshotHz must be positive: ${config.snapshotHz}`);
  }
  return 1000 / config.snapshotHz;
}

export function rewindWindowMs(rttMs: number, config: LagConfig = ARK_LAG): number {
  if (!Number.isFinite(rttMs) || rttMs < 0) {
    throw new RangeError(`rttMs must be a non-negative finite number: ${rttMs}`);
  }
  if (!Number.isFinite(config.maxRewindMs) || config.maxRewindMs < 0) {
    throw new RangeError(`maxRewindMs must be non-negative: ${config.maxRewindMs}`);
  }
  return Math.min(rttMs / 2 + tickMs(config), config.maxRewindMs);
}

export function rewindTicks(rttMs: number, config: LagConfig = ARK_LAG): number {
  return Math.ceil(rewindWindowMs(rttMs, config) / tickMs(config));
}

export function recordState(
  history: readonly TimedState[],
  state: TimedState,
  limitTicks: number,
): TimedState[] {
  if (!Number.isInteger(limitTicks) || limitTicks < 1) {
    throw new RangeError(`limitTicks must be a positive integer: ${limitTicks}`);
  }
  if (!Number.isInteger(state.tick)) {
    throw new RangeError(`state.tick must be an integer: ${state.tick}`);
  }
  const merged = new Map<number, TimedState>();
  for (const entry of history) merged.set(entry.tick, entry);
  merged.set(state.tick, state);
  return [...merged.values()]
    .sort((a, b) => a.tick - b.tick)
    .slice(-limitTicks);
}

export function sampleAt(history: readonly TimedState[], targetTick: number): TimedState {
  if (history.length === 0) {
    throw new RangeError("history is empty");
  }
  if (!Number.isFinite(targetTick)) {
    throw new RangeError(`targetTick must be finite: ${targetTick}`);
  }
  const ordered = [...history].sort((a, b) => a.tick - b.tick);
  const first = ordered[0];
  const last = ordered[ordered.length - 1];
  if (targetTick < first.tick || targetTick > last.tick) {
    throw new RangeError(`targetTick ${targetTick} outside [${first.tick}, ${last.tick}]`);
  }
  for (let index = 0; index < ordered.length; index += 1) {
    const entry = ordered[index];
    if (entry.tick === targetTick) return entry;
    if (entry.tick > targetTick) {
      const previous = ordered[index - 1];
      const span = entry.tick - previous.tick;
      const ratio = (targetTick - previous.tick) / span;
      return {
        tick: targetTick,
        x: previous.x + (entry.x - previous.x) * ratio,
        y: previous.y + (entry.y - previous.y) * ratio,
      };
    }
  }
  return last;
}

export function describeRewind(rttMs: number, config: LagConfig = ARK_LAG): string {
  const window = rewindWindowMs(rttMs, config);
  return (
    `RTT ${rttMs}ms 的回看窗口 ${window.toFixed(1)}ms,` +
    `约 ${rewindTicks(rttMs, config)} 个 ${tickMs(config)}ms 快照`
  );
}

模型导出六个入口:帧间隔、回看窗口、覆盖帧数、历史记录、插值采样与摘要。rewindWindowMs 用「半个 RTT 加一个快照间隔」再按上限夹取,rewindTicks 向上取整保证窗口不被低估,recordState 按 tick 去重并只保留最近若干帧,sampleAt 在目标帧落于两帧之间时做线性插值、越界则抛错。测试覆盖默认帧间隔、P50/P95 窗口、上限截断、去重裁剪、非法参数、整帧命中、区间插值与越界拒绝。

sampleAt 在越界时抛错而不是外推,是刻意的:外推会给出一个看起来合理、实际上没有依据的位置,正好掩盖「历史缓冲不够」这个真问题。客户端应该让错误暴露出来,再决定是补缓冲还是拒绝本次补偿。

双案例对照:消消的补偿更轻

消消是回合制提交,RTT P50 78ms、P95 310ms,弱网会话占比 22%,但单局只有 14–22 次提交,延迟补偿主要作用于提交顺序与结算时刻,不需要逐帧回看。

方舟是持续战斗,RTT P50 62ms、P95 240ms,弱网会话 14%,丢包 0.6%,每一帧的命中都可能需要回看;补偿窗口、历史精度与时间戳可信度都直接影响战斗公平性。

两者都要遵守同一条原则:补偿由服务端执行、受上限约束、依赖客户端提供可信时间。区别只在窗口长度与历史精度,而不是责任归属。

与相邻章节的关系

5.2 讨论客户端如何预测与回滚,本节讨论服务端回看时客户端必须提供什么、不能做什么;两者共同构成「客户端先跑、服务端裁决」的闭环。3.3 的重连状态校准为时间戳重校准提供了基线,4.3 的字段合并则处理战斗之外的状态一致性。跨设备的结算一致性可以参考云存档 ,本节只讨论战斗内的回看边界。

小结

  • 延迟补偿的判定权在服务端,客户端只负责提供可信的输入时间戳,不决定回看目标与命中结果。
  • 时间戳应来自「单调时钟 + 服务端校准偏移」,不能直接用设备本地时间;重连后必须重新校准。
  • 回看窗口按「半个 RTT 加一个快照间隔」估算:方舟 P50 131ms、P95 220ms,分别覆盖 2 与 3 个快照,且要向上取整。
  • 历史缓冲只保留窗口上限对应的短窗口,按环形结构管理,不落盘、不跨局;越界应报错而不是外推。
  • 四条红线:可信时间源、窗口上限、短窗口内存历史、客户端不裁决;越过任何一条,补偿就失去可信度。

阅读导航:上一节:5.2 预测、回滚与显示平滑 · 下一节:6.1 战报结构:可复盘的最小数据集 。

常见错误与边界

  1. 直接用设备本地时间打戳。玩家可以改系统时间,系统也可能跳变;必须用单调时钟加服务端校准偏移。
  2. 一次校准用整个会话。网络条件变化后偏移会漂移;应在明显变化或重连后重新校准。
  3. 回看窗口没有上限。既浪费历史内存,也允许伪造旧时间戳把判定拉回任意时刻;必须有硬上限。
  4. 窗口帧数向下取整。131ms 按 100ms 一帧只覆盖 1 帧,会漏掉尾部;应向上取整到 2 帧。
  5. 历史缓冲无限追加。内存会随对局时长增长;应按窗口上限做环形覆盖。
  6. 把历史状态落到本地存储。落盘的历史可被篡改,也会跨局污染;判定依据应只存在于内存。
  7. 目标帧越界时外推。外推给出看似合理但没有依据的位置,掩盖缓冲不足;应抛错并暴露问题。
  8. 客户端指定回看帧。回看目标是服务端判定的一部分,客户端只能上报时间戳,不能替服务端选择。
  9. 重连后沿用旧偏移。断线期间时钟与网络都变了,旧偏移会让时间戳整体漂移;应重新校准并标记不可补偿输入。
  10. 把插值位置当成精确位置。高速目标两帧之间的位移可能超过判定框宽度;提高快照频率或接受误差,不能假装精确。
  11. 边界:本节不讨论服务端历史存储与反作弊。补偿窗口与历史格式是服务端实现细节;输入伪造、速度校验与战报复核属于风控与结算范畴。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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