《Cocos 游戏联机实战》8.1 客户端权威边界

把《方块冲刺》的每个游戏事实逐一归类到权威方:位置、速度、输入、计时、得分、掉落、胜负各自归谁管、客户端被改会怎样,给出「客户端只发意图、不发结果」的原则,讲清客户端预测为什么不是客户端权威,再用一张权威边界表与可跑的单测把边界钉进协议与代码。

8.1 客户端权威边界

前七章把联机跑通了:协议、同步、预测、重连、匹配、调试。从这一章起换一个视角——这个跑通的联机里,谁说了算。答案是服务端,但「服务端说了算」不是一句口号,它要落到每一条具体数据上:位置归谁、得分归谁、胜负归谁。

这一章的三节是一条线:8.1 划边界(哪些数据客户端根本不该碰),8.2 讲客户端怎么校验服务端下发的状态(不是校验自己,因为客户端不可信),8.3 讲客户端侧的加密配合与它守不住的地方。三节合起来回答一个工程问题:在一个客户端随时可能被改的游戏里,客户端能做的正当防御是什么。

需要先说清分工。站内 game/server/ 已有 315 篇、书稿《游戏服务端编程实践》另有 43 篇,服务端怎么做风控、怎么做战斗裁决,那里讲透了。本节不碰服务端实现,只讲客户端侧的心智模型与边界。真正把作弊者挡在门外的永远是服务端,客户端能做的只是「降低被篡改的面」。

8.1.1 一句话原则:客户端只发意图,不发结果

整章可以浓缩成一句话:客户端只发意图(intent),不发结果(result)。

  • 意图是「我想做什么」:这一帧我按了跳、我按了右、我松开了加速。这是玩家的真实操作,只有客户端知道,必须由客户端发给服务端。
  • 结果是「发生了什么」:我移动到了 (12000, 0)、我加了 10 分、我赢了这局。这是世界状态的演进,只能由服务端裁决后广播。

初学者最容易犯的错,是让客户端把结果也发出去——「我算好了我这一帧的位置,发给服务端吧,省得它再算」。这条路一旦走通,作弊者把那个数字改掉就赢了。凡是「客户端算完再上报」的东西,都是把裁决权交了出去。

换个角度看这件事:意图是「输入」,结果是对「输入」的「解释」。同一份输入,服务端和客户端可能解释出不同的结果——这恰恰是同步要解决的问题(第 4 章的预测与和解),而不是让客户端把它的解释强加给服务端。所以分工是:客户端交输入,服务端给解释,客户端再把自己的显示对齐到服务端的解释上。三方各司其职,谁都越不了界。

一个反直觉的推论:客户端「知道得多」不等于「有权决定」。本地预测跑得比服务端还快,本地状态甚至比服务端更新,但这只说明它「暂时领先」,不说明它「说了算」。领先的代价是随时可能被覆盖——这正是 8.1.3 要讲的事。

8.1.2 《方块冲刺》的每个游戏事实归谁管

把《方块冲刺》(联机形态是双人竞速)拆到最小,游戏事实只有七类。下面这张表是本章的锚点,后面 8.2、8.3 都围着它转:

游戏事实权威方客户端被改会怎样客户端能做什么
位置服务端本地瞬移/穿墙,但只在自己屏幕上成立;服务端下一帧快照会把你拉回本地预测先动,收到快照后和解
速度服务端本地跑得更快,但服务端按自己的速度推进,你的「快」进不了对局结果只上报「加速」这个意图
输入客户端只能伪造自己的输入,伪造不了对手的采集并上报;这是唯一合理归客户端的一类
计时服务端本地把倒计时改慢,不改服务端的判定时钟展示服务端下发的剩余时间
得分服务端本地刷分,不会进入服务端排行榜,也不会改变胜负展示服务端下发的分数
掉落服务端伪造「我拾取了道具」,会被服务端拒绝并可能标记异常上报「我碰到了道具」这一碰撞意图
胜负服务端本地判自己赢,服务端结算结果仍由服务端给出展示服务端下发的名次

七类里只有「输入」归客户端,其余六类全归服务端。这不是保守,而是「谁离真相最近」的必然结论:位置、速度、得分这些量,服务端自己就能算出来,根本不需要客户端告诉它。客户端告诉服务端的,只应该是服务端算不出来的东西——玩家的意图。

一个常被忽略的细节:掉落的归属。碰撞检测客户端当然可以做(否则没法做本地表现),但「碰到道具」这件事的判定权在服务端。客户端上报的是「我在第 N 帧碰到了道具 X 的碰撞盒」,服务端复核后再决定发不发这个道具。区别在于:客户端说的是「我可能碰到了」,不是「我拿到了」。

判断一条数据到底归谁,有一套简单的「三问」检验,照着走就不会错:

  1. 服务端能不能自己算出来? 能(位置、速度、得分都由服务端模拟得到),就不该由客户端上报——它上报了也只会增加一个可被篡改的入口。
  2. 改了它会不会影响对手看到的世界? 会(位置、掉落、胜负都同步给对手),就必须归服务端;不会(本地音量、画质),才可以归客户端。
  3. 服务端能不能复核? 能复核的东西(「我碰到了道具」可由服务端重算碰撞)可以收下但以服务端为准;不能复核的东西(「玩家此刻在想什么」)才考虑交给客户端,并限制它的影响面。

三问里只要有任意一问指向服务端,这条数据就归服务端。按这个标准过一遍七类事实,会发现唯一「三问都指向客户端」的只有输入——服务端算不出你按了哪个键,也复核不了,但它又必须拿到,所以它只能由客户端发、且只发这一个。输入归客户端不是因为它可信,而是因为它不可替代。

8.1.3 客户端预测不等于客户端权威

第 4 章讲了客户端预测:本地立即应用输入、立即推进位置。有人会问——既然本地已经算出位置了,那位置不就是客户端说了算吗?

不是。预测与权威的区别,是**「暂时成立」与「最终成立」**的区别:

  • 预测是本地为了手感先动一下,它随时会被服务端快照覆盖。第 4.2 节的「和解与重演」就是覆盖机制:收到快照后,把本地状态拉回服务端版本,再把没被确认的输入重演一遍。预测的位置是「我以为我在哪」,服务端的位置是「我在哪」。
  • 权威是最终裁决。服务端说你没捡到那个道具,你就没捡到,客户端再「预测」也没用。

用一句更狠的话:客户端预测是「先斩后奏」,但奏完之后,奏折是服务端批的。 一旦你接受这个前提,就会明白为什么客户端绝不该把预测结果当成事实上报——上报的永远只能是输入。

举一个具体数字把「暂时成立」和「最终成立」分开。假设玩家在第 100 帧按了右,本地预测立刻把位置从 x=12000 推到 x=12800。这一瞬间:

时刻本地显示服务端权威值谁说了算
第 100 帧(预测刚发生)1280012000(还没收到输入)谁都不是,本地先动
第 106 帧(快照到达)1280012800(已应用输入)服务端,且与预测一致
第 106 帧(快照到达)1280012600(服务端有别的修正)服务端,本地回滚到 12600 再重演

三行的区别不在「谁算得快」,而在「最终以谁为准」。第一行本地领先,第二行本地对了,第三行本地错了——但无论对错,本地都要以服务端的值为基准重新对齐。这就是预测与权威的分界线:预测决定「当下画什么」,权威决定「最终是什么」。

8.1.4 客户端可以合理决定的东西

边界不是「客户端什么都不能定」。有三类东西归客户端天经地义,因为它们不影响任何其他玩家,也不影响对局结果:

  1. UI 状态:当前打开了哪个面板、看的是排行榜还是设置页、教程引导走到第几步。这些是本地视图,服务端不关心。
  2. 画质档位:分辨率缩放、粒子密度、阴影开关。同一台机器上跑不同画质,不该被服务端统一。
  3. 本地音量:BGM 与音效的独立音量、是否静音。

这三类的共同点:它们的结果不进入同步状态。判断一个数据该不该归客户端,最简单的自检是问一句——「这个值如果被改,会不会影响对手看到的世界?」会,就必须归服务端;不会,可以归客户端。

把它们和「看起来像本地偏好、实际必须归服务端」的东西对照,边界会更清楚:

数据归谁为什么
当前打开的 UI 面板客户端纯本地视图,不同步
画质档位 / 分辨率缩放客户端只影响本机渲染
本地音量 / 静音客户端只影响本机播放
输入映射(自定义按键)客户端是「怎么采集意图」,不改变意图本身
角色外观(皮肤)服务端对手也要看到,必须由服务端广播
加速道具是否在用服务端影响移动与判定,是对局状态

分界线在最后一列:只要对手看得见、或影响对局判定,它就不再是「本地偏好」,而是一条需要权威的数据。自定义按键之所以能归客户端,是因为它改变的是「哪个键触发跳」,而不是「跳了多远」——它只影响意图的采集方式,不影响意图本身。

8.1.5 把边界写成可测的代码(L1)

边界表如果只写在文档里,很快就会在代码里漂移。更稳的做法是把它变成一份可被单测的常量。下面这段纯 TypeScript 不依赖 cc,能在 Node + vitest 里直接跑:

// net/protocol/authority.ts —— 纯逻辑,无 cc 依赖
export const Authority = {
  SERVER: 'server',
  CLIENT: 'client',
} as const;
export type Authority = (typeof Authority)[keyof typeof Authority];

export interface FactRule {
  readonly fact: string;
  readonly authority: Authority;
  readonly ifTampered: string;
}

// 与 8.1.2 的表格一一对应:改表就要改这里,反之亦然
export const AUTHORITY_TABLE: readonly FactRule[] = [
  { fact: '位置', authority: Authority.SERVER, ifTampered: '本地瞬移,仅本地可见,快照会拉回' },
  { fact: '速度', authority: Authority.SERVER, ifTampered: '本地更快,但不进对局结果' },
  { fact: '输入', authority: Authority.CLIENT, ifTampered: '只能伪造自己的输入' },
  { fact: '计时', authority: Authority.SERVER, ifTampered: '本地改倒计时不改服务端判定' },
  { fact: '得分', authority: Authority.SERVER, ifTampered: '本地刷分不进排行榜' },
  { fact: '掉落', authority: Authority.SERVER, ifTampered: '伪造拾取会被服务端拒绝' },
  { fact: '胜负', authority: Authority.SERVER, ifTampered: '本地判赢无效,以服务端结算为准' },
];

export function authorityOf(fact: string): Authority | undefined {
  return AUTHORITY_TABLE.find((r) => r.fact === fact)?.authority;
}

export function isClientAuthoritative(fact: string): boolean {
  return authorityOf(fact) === Authority.CLIENT;
}

再用一段「出站纪律」把「只发意图」钉住。客户端在开发期就应该在发送前自查:这条消息是不是结果型?

// net/protocol/authority.ts(续)
export const MsgKind = {
  INTENT: 'intent',
  RESULT: 'result',
} as const;
export type MsgKind = (typeof MsgKind)[keyof typeof MsgKind];

export interface OutboundMessage {
  readonly kind: MsgKind;
  readonly type: string;
}

// 开发期纪律:客户端只允许发意图型消息。
// 注意:这不是安全边界——攻击者能改掉这个判断,它的价值是让「手滑发结果」在测试期就暴露。
export function assertClientMaySend(msg: OutboundMessage): void {
  if (msg.kind === MsgKind.RESULT) {
    throw new Error(`客户端不得发送结果型消息:${msg.type}`);
  }
}

配 vitest 断言。注意最后一条测试的注释——它验证的是纪律,不是安全:

// authority.test.ts
import { describe, expect, it } from 'vitest';
import {
  AUTHORITY_TABLE, Authority, MsgKind,
  assertClientMaySend, isClientAuthoritative,
} from './authority';

describe('权威边界表', () => {
  it('七类事实里只有输入归客户端', () => {
    const clientFacts = AUTHORITY_TABLE
      .filter((r) => r.authority === Authority.CLIENT)
      .map((r) => r.fact);
    expect(clientFacts).toEqual(['输入']);
  });

  it('位置、得分、胜负都归服务端', () => {
    expect(isClientAuthoritative('位置')).toBe(false);
    expect(isClientAuthoritative('得分')).toBe(false);
    expect(isClientAuthoritative('胜负')).toBe(false);
  });
});

describe('客户端出站纪律', () => {
  it('意图消息放行', () => {
    expect(() => assertClientMaySend({ kind: MsgKind.INTENT, type: 'input' })).not.toThrow();
  });

  it('结果消息被拦下', () => {
    expect(() => assertClientMaySend({ kind: MsgKind.RESULT, type: 'score' }))
      .toThrow(/结果型/);
  });
});

把这张表接进协议定义,好处是新增一条消息时会被强制分类:它是意图还是结果?一旦写成结果型,测试立刻红。这比事后 review 靠人记得住可靠得多。

8.1.6 工程决策:边界要钉在协议里

最后给三条落地建议:

  1. 每条消息在协议层标注 kind。input、pickup-intent 是意图,score、result 是结果。客户端的发送函数只接受意图型,结果型只在服务端→客户端方向出现。
  2. 权威表与协议常量放在同一个目录(net/protocol/)。边界不是文档,是代码——它必须能被 grep、能被测试引用。
  3. 把「客户端不可信」写进注释与代码审查清单。凡是看到「客户端算好 X 再上报」,先问一句:这个 X 服务端能不能自己算?能,就不该上报。

8.1.7 常见误区与自检清单

边界这件事,团队里最容易在三个地方走偏:

  • 误区一:「先本地算,再上报校验」。 听起来稳妥,其实已经把结果交给了客户端。服务端该做的是自己从输入重算,而不是「校验客户端算的对不对」——后者的前提是「相信客户端算的东西」,而它根本不可信。
  • 误区二:「把密钥放客户端就没问题了」。 密钥在客户端里等于公开(8.3 会展开)。它只能用来做链路完整性,不能用来做身份或裁决。
  • 误区三:「客户端预测这么准,干脆让它权威算了」。 预测准不代表可信任——它准是因为两端逻辑一致,而攻击者可以破坏这个一致性。

上线前拿这张清单过一遍,能挡住大部分「把裁决权交出去」的改动:

  1. 客户端发出的每条消息,是意图还是结果?结果型的一律删掉,改成由服务端产生。
  2. 每个「客户端算完的数值」,服务端能不能自己算?能,就改成服务端算。
  3. 每个「本地存档里的进度」,服务端有没有独立记录?没有,就补上(存档只做本地缓存)。
  4. 每个「本地能改的开关」,改了会不会影响对手或对局?会,就挪到服务端。
  5. 每个「写在客户端里的密钥」,是不是被当成了身份或裁决依据?是,就移除。

这五条里,前两条管「客户端发什么」,后三条管「客户端存什么、藏什么」。它们都不涉及服务端怎么实现,纯客户端侧就能自查——这也正是本卷的边界:把客户端该守的规矩守好,剩下的交给服务端。

更进一步,这五条可以做成 CI 里的检查项。比如用 8.1.5 的那组单测断言「协议常量里不存在结果型消息」,或者加一条 lint 规则扫描「客户端赋值后再上报」的调用形态。边界一旦变成测试,就不会随时间漂移——这是把文档约定升级成工程约束的标准做法。

回到本章的主线:真正的裁决在服务端,客户端只能降低被篡改的面。这句话在 8.1 是原则,到 8.2 会变成「客户端怎么优雅地处理一份可疑的服务端快照」,到 8.3 会变成「客户端侧的加密到底能防住什么、防不住什么」。

下一节进入 8.2:客户端既然不可信,那它能做的校验就只剩一件事——校验服务端下发的状态。我们会写一个快照校验器,处理越界、瞬移、序号断裂与预测偏差四类异常,并明确它的目的不是阻止作弊,而是可用性与健壮性。

阅读导航:上一节:目录 · 下一节:8.2 关键数据校验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画