场景:打中了,服务端说没中
《潮汐方舟》里最常见的一类争议是「我明明打中了」。玩家在屏幕上看到技能命中,服务端回放却判定未命中。原因通常不是谁在说谎,而是两边看的是不同时刻的世界:玩家看到的是 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 个快照。
| 指标 | P50 | P95 | 说明 |
|---|---|---|---|
| RTT | 62ms | 240ms | 国内 Wi-Fi/4G 混合 |
| 半个 RTT | 31ms | 120ms | 单向延迟近似 |
| 快照间隔 | 100ms | 100ms | 10Hz |
| 回看窗口 | 131ms | 220ms | 半 RTT + 一个快照间隔 |
| 覆盖快照数 | 2 | 3 | 向上取整 |
「向上取整」不是细节,而是边界。131ms 的窗口按 100ms 一帧算是 1.31 帧,向下取整只覆盖 1 帧,会漏掉窗口尾部的那部分时间;向上取整到 2 帧才真正覆盖。窗口宁可比需要的略长,也不能短,因为短窗口会让合法的补偿请求落到历史之外,表现为「偶尔判不中」。
历史缓冲只需要保留窗口上限对应的帧数再加一点余量。保留太多会浪费内存,保留太少会在抖动时查不到目标帧。缓冲应该按环形结构管理,新帧覆盖旧帧,而不是无限追加后定期清理。
历史状态的精度也影响补偿。服务端通常按快照帧保存位置,回看目标往往落在两帧之间,需要插值。插值得到的是近似位置,不是精确位置;对高速移动的目标,两帧之间的位移可能已经超过命中判定框的宽度。这时要么提高快照频率,要么接受一定误差,不能假装插值是精确的。
窗口与缓冲的关系可以用一句话概括:窗口决定「要回看多久」,缓冲决定「能回看多久」。窗口是业务需求,缓冲是实现余量;缓冲小于窗口时,合法的补偿请求会失败;缓冲远大于窗口时,多出来的内存没有收益。合理的关系是缓冲恰好覆盖窗口上限,再加一到两帧的抖动余量。
补偿失真会以什么形式出现
补偿一旦出问题,玩家看到的不是「补偿失败」四个字,而是几种具体而迷惑的现象。把它们列出来,有助于在日志里定位。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 偶尔打不中,重试又中 | 回看窗口短于实际延迟 | 检查窗口是否按 P95 配置、帧数是否向上取整 |
| 明显没打中却判中 | 时间戳偏旧或历史位置错误 | 检查校准偏移与历史写入顺序 |
| 同一操作在不同设备结果不同 | 时间源不一致或插值实现不同 | 检查单调时钟与服务端校准流程 |
| 重连后命中率骤降 | 旧偏移未更新、断线输入被错误补偿 | 检查重连后的重新校准与输入标记 |
这张表里没有「客户端算错」这一项,因为客户端根本不参与判定。所有现象最终都要回到两个输入:时间戳是否可信、历史是否覆盖。把这两个输入守住,补偿的失真就只剩下服务端算法本身的问题,范围会小很多。
日志也要按这两个输入组织。每条战斗日志至少应该能回答:这条输入的时间戳是多少、当时的历史窗口覆盖到哪个 tick、补偿请求有没有被上限截断。缺了这三项,排查时就只能靠猜,而「偶尔打不中」恰恰是最难复现的一类问题。
客户端的四条红线
把上面的责任收拢成四条不可越过的红线。
- 时间戳必须来自校准后的单调时钟。设备本地时间可以被用户修改,也可以被系统跳变;用
Date.now()直接打戳,等于把判定权交给不可信来源。 - 回看窗口必须有上限。超过上限的请求要被夹取或拒绝,不能无限回溯;上限既是性能保护,也是防止伪造旧时间戳的安全边界。
- 历史只保留短窗口,不落盘、不跨局。历史状态是判定依据,落到本地存储就可能被篡改;它应该只存在于内存,并在对局结束后释放。
- 客户端只上报时间戳,不决定补偿结果。回看目标、命中判定与伤害结算都由服务端完成;客户端可以做表现预演,但不能把预演当成事实。
这四条的共同点是「客户端提供输入,服务端做决定」。越过任何一条,延迟补偿都会从「还原玩家看到的世界」退化成「让客户端定义世界」。
红线不能只写在文档里,要落到接口上。输入包的时间戳字段应该由统一的时间源生成,而不是让每个业务模块自己取时钟;历史缓冲的读写应该封装成一个只接受 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 战报结构:可复盘的最小数据集 。
常见错误与边界
- 直接用设备本地时间打戳。玩家可以改系统时间,系统也可能跳变;必须用单调时钟加服务端校准偏移。
- 一次校准用整个会话。网络条件变化后偏移会漂移;应在明显变化或重连后重新校准。
- 回看窗口没有上限。既浪费历史内存,也允许伪造旧时间戳把判定拉回任意时刻;必须有硬上限。
- 窗口帧数向下取整。131ms 按 100ms 一帧只覆盖 1 帧,会漏掉尾部;应向上取整到 2 帧。
- 历史缓冲无限追加。内存会随对局时长增长;应按窗口上限做环形覆盖。
- 把历史状态落到本地存储。落盘的历史可被篡改,也会跨局污染;判定依据应只存在于内存。
- 目标帧越界时外推。外推给出看似合理但没有依据的位置,掩盖缓冲不足;应抛错并暴露问题。
- 客户端指定回看帧。回看目标是服务端判定的一部分,客户端只能上报时间戳,不能替服务端选择。
- 重连后沿用旧偏移。断线期间时钟与网络都变了,旧偏移会让时间戳整体漂移;应重新校准并标记不可补偿输入。
- 把插值位置当成精确位置。高速目标两帧之间的位移可能超过判定框宽度;提高快照频率或接受误差,不能假装精确。
- 边界:本节不讨论服务端历史存储与反作弊。补偿窗口与历史格式是服务端实现细节;输入伪造、速度校验与战报复核属于风控与结算范畴。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。