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 的碰撞盒」,服务端复核后再决定发不发这个道具。区别在于:客户端说的是「我可能碰到了」,不是「我拿到了」。
判断一条数据到底归谁,有一套简单的「三问」检验,照着走就不会错:
- 服务端能不能自己算出来? 能(位置、速度、得分都由服务端模拟得到),就不该由客户端上报——它上报了也只会增加一个可被篡改的入口。
- 改了它会不会影响对手看到的世界? 会(位置、掉落、胜负都同步给对手),就必须归服务端;不会(本地音量、画质),才可以归客户端。
- 服务端能不能复核? 能复核的东西(「我碰到了道具」可由服务端重算碰撞)可以收下但以服务端为准;不能复核的东西(「玩家此刻在想什么」)才考虑交给客户端,并限制它的影响面。
三问里只要有任意一问指向服务端,这条数据就归服务端。按这个标准过一遍七类事实,会发现唯一「三问都指向客户端」的只有输入——服务端算不出你按了哪个键,也复核不了,但它又必须拿到,所以它只能由客户端发、且只发这一个。输入归客户端不是因为它可信,而是因为它不可替代。
8.1.3 客户端预测不等于客户端权威
第 4 章讲了客户端预测:本地立即应用输入、立即推进位置。有人会问——既然本地已经算出位置了,那位置不就是客户端说了算吗?
不是。预测与权威的区别,是**「暂时成立」与「最终成立」**的区别:
- 预测是本地为了手感先动一下,它随时会被服务端快照覆盖。第 4.2 节的「和解与重演」就是覆盖机制:收到快照后,把本地状态拉回服务端版本,再把没被确认的输入重演一遍。预测的位置是「我以为我在哪」,服务端的位置是「我在哪」。
- 权威是最终裁决。服务端说你没捡到那个道具,你就没捡到,客户端再「预测」也没用。
用一句更狠的话:客户端预测是「先斩后奏」,但奏完之后,奏折是服务端批的。 一旦你接受这个前提,就会明白为什么客户端绝不该把预测结果当成事实上报——上报的永远只能是输入。
举一个具体数字把「暂时成立」和「最终成立」分开。假设玩家在第 100 帧按了右,本地预测立刻把位置从 x=12000 推到 x=12800。这一瞬间:
| 时刻 | 本地显示 | 服务端权威值 | 谁说了算 |
|---|---|---|---|
| 第 100 帧(预测刚发生) | 12800 | 12000(还没收到输入) | 谁都不是,本地先动 |
| 第 106 帧(快照到达) | 12800 | 12800(已应用输入) | 服务端,且与预测一致 |
| 第 106 帧(快照到达) | 12800 | 12600(服务端有别的修正) | 服务端,本地回滚到 12600 再重演 |
三行的区别不在「谁算得快」,而在「最终以谁为准」。第一行本地领先,第二行本地对了,第三行本地错了——但无论对错,本地都要以服务端的值为基准重新对齐。这就是预测与权威的分界线:预测决定「当下画什么」,权威决定「最终是什么」。
8.1.4 客户端可以合理决定的东西
边界不是「客户端什么都不能定」。有三类东西归客户端天经地义,因为它们不影响任何其他玩家,也不影响对局结果:
- UI 状态:当前打开了哪个面板、看的是排行榜还是设置页、教程引导走到第几步。这些是本地视图,服务端不关心。
- 画质档位:分辨率缩放、粒子密度、阴影开关。同一台机器上跑不同画质,不该被服务端统一。
- 本地音量: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 工程决策:边界要钉在协议里
最后给三条落地建议:
- 每条消息在协议层标注 kind。
input、pickup-intent是意图,score、result是结果。客户端的发送函数只接受意图型,结果型只在服务端→客户端方向出现。 - 权威表与协议常量放在同一个目录(
net/protocol/)。边界不是文档,是代码——它必须能被 grep、能被测试引用。 - 把「客户端不可信」写进注释与代码审查清单。凡是看到「客户端算好 X 再上报」,先问一句:这个 X 服务端能不能自己算?能,就不该上报。
8.1.7 常见误区与自检清单
边界这件事,团队里最容易在三个地方走偏:
- 误区一:「先本地算,再上报校验」。 听起来稳妥,其实已经把结果交给了客户端。服务端该做的是自己从输入重算,而不是「校验客户端算的对不对」——后者的前提是「相信客户端算的东西」,而它根本不可信。
- 误区二:「把密钥放客户端就没问题了」。 密钥在客户端里等于公开(8.3 会展开)。它只能用来做链路完整性,不能用来做身份或裁决。
- 误区三:「客户端预测这么准,干脆让它权威算了」。 预测准不代表可信任——它准是因为两端逻辑一致,而攻击者可以破坏这个一致性。
上线前拿这张清单过一遍,能挡住大部分「把裁决权交出去」的改动:
- 客户端发出的每条消息,是意图还是结果?结果型的一律删掉,改成由服务端产生。
- 每个「客户端算完的数值」,服务端能不能自己算?能,就改成服务端算。
- 每个「本地存档里的进度」,服务端有没有独立记录?没有,就补上(存档只做本地缓存)。
- 每个「本地能改的开关」,改了会不会影响对手或对局?会,就挪到服务端。
- 每个「写在客户端里的密钥」,是不是被当成了身份或裁决依据?是,就移除。
这五条里,前两条管「客户端发什么」,后三条管「客户端存什么、藏什么」。它们都不涉及服务端怎么实现,纯客户端侧就能自查——这也正是本卷的边界:把客户端该守的规矩守好,剩下的交给服务端。
更进一步,这五条可以做成 CI 里的检查项。比如用 8.1.5 的那组单测断言「协议常量里不存在结果型消息」,或者加一条 lint 规则扫描「客户端赋值后再上报」的调用形态。边界一旦变成测试,就不会随时间漂移——这是把文档约定升级成工程约束的标准做法。
回到本章的主线:真正的裁决在服务端,客户端只能降低被篡改的面。这句话在 8.1 是原则,到 8.2 会变成「客户端怎么优雅地处理一份可疑的服务端快照」,到 8.3 会变成「客户端侧的加密到底能防住什么、防不住什么」。
下一节进入 8.2:客户端既然不可信,那它能做的校验就只剩一件事——校验服务端下发的状态。我们会写一个快照校验器,处理越界、瞬移、序号断裂与预测偏差四类异常,并明确它的目的不是阻止作弊,而是可用性与健壮性。
阅读导航:上一节:目录 · 下一节:8.2 关键数据校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。