# 如何保护 JavaScript 游戏源代码：一个多人游戏案例

保护 JavaScript 游戏源代码的办法是混淆客户端构建。BIGBOARD.GAMES 的多人游戏都用 AfterPack 保护后发布，没有掉帧。

Source: https://www.afterpack.dev/zh/blog/protect-javascript-game-source-code

Published: 2026-10-08 · Author: Nikita Savchenko · Tags: guide, security, games

要保护 JavaScript 游戏源代码，就混淆客户端构建，把第三方引擎和字符串表排除在混淆之外，并把密钥和一切能在服务端做的校验都留在服务端。AfterPack（[afterpack.dev](https://www.afterpack.dev/)）是一款 JavaScript 混淆器，每次发布都会把构建好的游戏改写成一个结构不同的程序。它的 Free 引擎在本地运行，可以通过 [`npx afterpack`](https://www.afterpack.dev/docs/cli) 调用，也可以用 [Vite](https://www.afterpack.dev/docs/frameworks/vite)、[webpack](https://www.afterpack.dev/docs/frameworks/webpack) 或 [Rollup](https://www.afterpack.dev/docs/frameworks/rollup) 插件接入。点对点多人游戏 [BIGBOARD.GAMES](https://bigboard.games) 就是用它保护后发布的，帧率仍能达到浏览器允许的上限。

我在 10 月 6 日上线了 [BIGBOARD.GAMES](https://bigboard.games)。它是一组免费的浏览器游戏，玩法是把 2 到 4 台手机或平板并排拼在桌上：每块屏幕都成为同一块大屏的一部分，在 [Seam Hockey](https://bigboard.games/games/seam-hockey) 里，冰球会越过屏幕之间的接缝，从一台设备滑到下一台。上线时有 Seam Hockey、[Spillover](https://bigboard.games/games/spillover)、[Whack-a-Mole](https://bigboard.games/games/whack-a-mole) 和 [Islands](https://bigboard.games/games/islands) 四款。对局数据通过 [WebRTC](https://en.wikipedia.org/wiki/WebRTC) 在设备之间直接传输，中间没有游戏服务器；[游戏本身是怎么做出来的](https://nikitaeverywhere.com/posts/bigboard-games/)，我在自己的博客上写过。仓库的第一个提交里，`vite.config.ts` 就已经接入了 AfterPack，所以这个游戏从来没有以未受保护的状态发布过。本文要讲的是，这件事让游戏在帧率、字节数和构建时间上付出了多少代价，每个数字都来自真实构建。

开发者最担心[混淆](https://en.wikipedia.org/wiki/Obfuscation_(software))吃掉帧率的地方是游戏；发布出去的代码最先被人读的地方，也是游戏，因为规则、物理和网络协议全都运行在玩家的设备上。AI 让这种阅读变得很便宜：一个智能体[分别用 10 分钟和 20 分钟](https://www.afterpack.dev/blog/ai-deobfuscates-javascript)，把两款流行混淆器官方的 demo 还原成了干净的源码。帮我用大约十天做出 BIGBOARD.GAMES 的[同一批工具](https://claude.com/product/claude-code)，同样能让别人这么快读懂它。

## JavaScript 游戏的代码会暴露什么？

全部。规则、物理、计分，以及作弊工具必须实现的网络协议，都在 bundle 里；而[压缩](https://developer.mozilla.org/en-US/docs/Glossary/Minification)只会缩短局部变量名：字符串、常量和结构依然可读，[这篇对压缩后 bundle 的拆解](https://www.afterpack.dev/blog/protect-javascript-source-code#what-does-minified-javascript-expose)逐行展示了这一点。

在[点对点](https://en.wikipedia.org/wiki/Peer-to-peer)游戏里，客户端同时也是裁判。在 [Seam Hockey](https://bigboard.games/games/seam-hockey) 里，冰球在哪台设备上，就由哪台设备模拟它，并把位置实时发给其他设备；到了接缝处，冰球的归属权交给下一台设备。[Spillover](https://bigboard.games/games/spillover) 和 [Shoal](https://bigboard.games/games/shoal) 采用[确定性锁步](https://gafferongames.com/post/deterministic_lockstep/)：每台设备用相同的输入运行同一套模拟，必须得到相同的状态。一个 [Cloudflare Worker](https://developers.cloudflare.com/workers/) 加两个 [Durable Objects](https://developers.cloudflare.com/durable-objects/) 只负责开桌、保留座位和记录结果。

| 发给每个玩家的内容 | 读懂它能得到什么 |
| --- | --- |
| 冰球物理，以及冰球在接缝处的归属交接 | 进球在哪里判定，由哪台设备判定 |
| 锁步模拟及其确定性三角函数 | 每台设备必须达成一致的精确状态 |
| [WebRTC 数据通道](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel)上的消息编解码器 | 篡改过的客户端必须实现的协议 |
| 以真实毫米为单位的屏幕校准（设备配置文件，或贴在屏幕上的一张[银行卡](https://en.wikipedia.org/wiki/ISO/IEC_7810)） | 仿制者最需要、也最难重做的部分 |

## JavaScript 混淆会影响游戏性能吗？

在 BIGBOARD.GAMES 里看不出来。在 AfterPack 默认的 [`light` 预设](https://www.afterpack.dev/docs/presets#the-default-is-light)下，Seam Hockey 的帧循环跑的就是浏览器给 [`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) 的频率：在 [OnePlus Pad 3](https://www.oneplus.com/global/oneplus-pad-3) 上是 60 到 144 fps，在 iPad 上是 60 fps。iPad 上的所有浏览器都基于 [WebKit](https://webkit.org/)，而 WebKit 会把页面限制在 60 fps 左右，除非玩家在 Safari 里关掉「Prefer Page Rendering Updates near 60fps」这个功能开关。这些帧率是游戏自带的诊断功能从玩家拿到的那份混淆构建里读出来的。

帧率是个粗略的指标，所以我还给模拟代码本身计了时：用的是游戏实际发布的那些函数，用 [esbuild](https://esbuild.github.io/) 打包，分别测未混淆的版本，以及用 BIGBOARD 上线时的引擎（0.2.1）和当前引擎（0.2.3）在 `light` 下混淆的版本，在 [V8](https://v8.dev/) 和 [JavaScriptCore](https://docs.webkit.org/Deep%20Dive/JSC/JavaScriptCore.html) 中各测一遍。

| 游戏代码（单位） | 运行时 | 未混淆 | 引擎 0.2.1 | 引擎 0.2.3 |
| --- | --- | ---: | ---: | ---: |
| 确定性 `sinCos`（每次调用，ns） | V8 | 44.2 | 104.2 (2.36x) | 44.1 (1.00x) |
| 确定性 `sinCos`（每次调用，ns） | JavaScriptCore | 9.5 | 57.4 (6.04x) | 20.6 (2.17x) |
| Shoal 模拟（每 tick，µs） | V8 | 26.5 | 58.2 (2.20x) | 49.7 (1.88x) |
| Shoal 模拟（每 tick，µs） | JavaScriptCore | 21.7 | 36.9 (1.71x) | 26.7 (1.23x) |
| Seam Hockey 冰球物理（每步，µs） | V8 | 0.52 | 1.01 (1.94x) | 0.78 (1.50x) |
| Seam Hockey 冰球物理（每步，µs） | JavaScriptCore | 0.39 | 0.66 (1.69x) | 0.64 (1.63x) |

2026-10-08 在 Apple M2 Max 上测得，使用 [Node.js](https://nodejs.org/) 24.21.0（V8）和 [Playwright](https://playwright.dev/) 的 WebKit 26.6（JavaScriptCore）：每个版本在 4 到 8 个全新进程中共计时 60 到 120 次，取中位数，种子与实际发布的构建相同。在 V8 上，引擎 0.2.3 的八个 Shoal 进程里有一个稳定在约 67 µs，而不是 50 µs，这取决于 V8 在那次运行里选择了怎样的优化。

所以混淆后的游戏代码确实更慢，在当前引擎上是未混淆版本的 1.0 到 2.2 倍，但帧率还是没变，因为模拟只占一帧里很小的一部分。表中最慢的情况是 [Shoal](https://bigboard.games/games/shoal) 在 V8 上跑引擎 0.2.1，每个 tick 58.2 µs：只占 60 Hz 下一帧 16.7 ms 的 0.35%，144 Hz 下一帧 6.9 ms 的 0.84%。平板比 M2 Max 慢，但 CPU 得慢 100 倍以上，这个 tick 才会占满 144 Hz 的一帧。

[`light`](https://www.afterpack.dev/docs/presets#the-default-is-light) 会重命名标识符，把字符串字面量交给运行时解码器，并重写语法，但不添加结构层。从引擎 0.2.3 起，它还会让循环保持为循环：之前的引擎会把循环改写成回调辅助函数，在热点循环里会慢 2-5 倍（[更新日志](https://www.afterpack.dev/changelog)）。属性名和全局名仍然要经过编码常量，这[在非常热的代码里会有一定开销](https://www.afterpack.dev/docs/presets#the-default-is-light)。它[之上的预设](https://www.afterpack.dev/docs/presets#the-ladder)，从 `medium` 到 `extreme`，会在每个 token 上增加结构层面的处理。想用这些预设的游戏，应该通过 [Pro 指令](https://www.afterpack.dev/docs/directives)只把它们用在最有价值的代码上，比如网络代码，帧循环则继续用 `light`。

## 混淆会破坏多人游戏的确定性同步吗？

不能破坏，而在 BIGBOARD.GAMES 里它也确实没有：锁步游戏要求每台设备上的状态逐位一致，在 Safari 的 JavaScriptCore 和 Chrome 的 V8 之间也一样，而混淆构建做到了这一点。

这个要求比「游戏还能跑」更高。[`Math.sin`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sin) 并不保证在每个引擎里返回相同的位（[规范](https://tc39.es/ecma262/#sec-math.sin)说它的结果是「由实现近似」的），所以 [Spillover](https://bigboard.games/games/spillover) 把 [Rapier](https://rapier.rs/) 物理引擎的[确定性构建](https://rapier.rs/docs/user_guides/javascript/determinism)和自己写的 `sinCos` 搭配使用，后者与 `Math.sin` 的误差在 5e-14 以内。这个 `sinCos` 和调用它的模拟代码，和其余游戏代码一样经过了混淆。在上面的每一次计时运行中，我都对完整的模拟状态做了哈希，每个浮点数都按其精确的位计算，结果每个混淆版本在 V8 和 JavaScriptCore 中都与未混淆版本一致。Shoal 的状态和 `sinCos` 的输出在两个引擎之间也一致，而锁步依赖的正是这一点。每天夜里，Playwright 多设备测试套件都会在 staging 环境上玩这些游戏，staging 提供的就是玩家拿到的同一份混淆构建，其中包括一局三台设备的 [Shoal](https://bigboard.games/games/shoal) 对局：中途让一名玩家掉线，再检查另外两台是否出现不同步。

混淆后的输出在哪些方面保持不变，又有哪几处是刻意不同的，见[语义契约](https://www.afterpack.dev/docs/semantic-contract)页面。

## 如何混淆 Vite 构建的游戏代码

安装 [`@afterpack/vite`](https://www.afterpack.dev/docs/frameworks/vite)，把 `afterpackVite()` 加进 `plugins`，再把不属于你的 chunk 排除掉。`vite dev` 不受影响；`vite build` 输出的是混淆后的 chunk。

```bash
npm install -D @afterpack/vite
```

下面是 BIGBOARD 的配置，精简到只剩两条排除规则。2026-10-08 我在一个干净的项目里构建了它，用的是 [Vite](https://vite.dev/) 8.3.3、`@afterpack/vite` 0.2.2（引擎 0.2.3）、Rapier 的 [`@dimforge/rapier2d-deterministic-compat`](https://www.npmjs.com/package/@dimforge/rapier2d-deterministic-compat) 0.21.0 和 Node.js 24.21.0。保护回执把 Rapier 和 i18n 的 chunk 标为未改动、游戏 chunk 标为已混淆，同一个提交的两次构建逐字节一致。

```ts
// vite.config.ts (Vite 8)
import { afterpackVite } from "@afterpack/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    afterpackVite({
      // One program per commit; the same commit and input give the same bytes.
      seed: "git",
      // Output files to leave as they are.
      paths: { exclude: ["**/rapier-*.js", "**/i18n-*.js"] },
    }),
  ],
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [
            // Give each excluded part its own chunk, or Vite merges it into one of yours.
            { name: "rapier", test: /[\\/]node_modules[\\/]@dimforge[\\/]/ },
            { name: "i18n", test: /[\\/]locales[\\/]/ },
          ],
        },
      },
    },
  },
});
```

[`paths.exclude`](https://www.afterpack.dev/docs/config#paths-exclude) 的 glob 匹配的是输出文件名，所以 chunk 怎么拆和 glob 怎么写同样重要。不加干预的话，Vite 可能把某个库合并进一个同时装着你代码的 chunk，这个 chunk 就会被整个混淆。Vite 8 用 Rolldown 的 [`codeSplitting`](https://rolldown.rs/reference/OutputOptions.codeSplitting) 拆分 chunk；在 Vite 7 及更早的版本里，同样的拆分要用 Rollup 的 [`manualChunks`](https://rollupjs.org/configuration-options/#output-manualchunks)。

下面是 BIGBOARD.GAMES 排除在外的 chunk，是它 140 个 JavaScript 文件中的 21 个，原因按引擎 0.2.1 上的实测给出：

| Chunk | 是什么 | 为什么排除在外 |
| --- | --- | --- |
| `rapier-*.js` | [Rapier](https://rapier.rs/) 物理引擎，确定性构建 | 第三方：3.4 MB 的 [WebAssembly](https://developer.mozilla.org/en-US/docs/WebAssembly) 胶水代码，里面没有一行是我们自己的 |
| `analytics-posthog-*.js`、`openapi-fetch-*.js`、`qr-*.js` | [PostHog](https://posthog.com/) 统计分析、[openapi-fetch](https://openapi-ts.dev/openapi-fetch/) REST 客户端、二维码编码器 | 第三方。混淆后，光是二维码编码器就涨到原来的三倍，gzip 后约 25 KB |
| `i18n-*.js` | 英语、乌克兰语和西班牙语文案 | 纯字符串混淆后膨胀了约 10 倍，而且其中没有一条是秘密 |
| `brand-marks-*.js`、`brand-glyphs-*.js` | Logo 轮廓和 SVG 标记 | 路径数据的表现和字符串一样：混淆后，入口 chunk 涨到了原来的十倍 |

`paths.exclude` 在 [Free](https://www.afterpack.dev/docs/tiers) 层级就能用。要跳过文件里的某一个函数而不是整个文件，需要 [Pro 指令](https://www.afterpack.dev/docs/directives)；在 Free 构建里，指令会直接报错。

## 混淆会让游戏体积变大多少？

用当前引擎，首次加载的 gzip 体积约为原来的 1.65 倍；用 BIGBOARD 上线时的引擎则是 2.2 倍。玩家打开的页面 `/play` 会加载 20 个 chunk：

| 实际传输（gzip 后） | 未混淆 | 引擎 0.2.1 | 引擎 0.2.3 |
| --- | ---: | ---: | ---: |
| `/play` 首次加载 | 107.9 KB | 239.3 KB (2.22x) | 178.5 KB (1.65x) |
| [Seam Hockey](https://bigboard.games/games/seam-hockey) chunk | 20.5 KB | 36.8 KB (1.80x) | 29.5 KB (1.44x) |
| [Spillover](https://bigboard.games/games/spillover) chunk | 34.0 KB | 59.6 KB (1.75x) | 47.7 KB (1.40x) |

2026-10-08 在 `light` 下测得，混淆列取 7 次构建的中位数。这次升级只是改了个版本号：配置不变，排除规则不变，门禁检查、`afterpack verify` 和多设备端到端测试全都通过。这就是混淆实实在在的代价：它会增加字节，对每 KB 都斤斤计较的游戏得为此留出预算。构建时间是代价小的那一部分：整个构建，包括应用、落地页和管理后台，在 M2 Max 上未混淆需要 1.5 s，混淆后需要 3.6 s，两个引擎都一样。AfterPack 的[性能页面](https://www.afterpack.dev/docs/performance)有引擎自己的测量数据。

体积预算这件事上，有个教训值得照搬。BIGBOARD 的构建会把构建时间写进入口 chunk，于是每次构建都会让入口以及所有导入它的 chunk 改名，而任何一个变了的 chunk 经过 AfterPack 之后都会被彻底重排。同一个提交的多次构建之间，首次加载在 176 到 182 KB 之间浮动，针对实际传输字节的预算检查时过时不过。所以 BIGBOARD 改为按混淆前测得的体积做预算，对实际传输的内容只设一个宽松的 256 KB 上限。如果改用提交里的时间戳（[`git log -1 --format=%cI`](https://git-scm.com/docs/git-log)），就能消除问题的根源。

## 为什么每次发布都要换一个不同的程序？

为了让针对某一次构建写的作弊程序、补丁或 hook，到了下一次构建就对不上。用 [`seed: "git"`](https://www.afterpack.dev/docs/config#seed)，每个提交都会产生结构不同的输出，而相同的种子加相同的输入，[会得到相同的字节](https://www.afterpack.dev/docs/builds#random-seed-by-default-pin-for-reproducibility)。把构建时间戳固定之后，同一个提交的两次 BIGBOARD 构建，全部 140 个文件都一致。这样一来，为生产环境重新构建的流水线，发布的就正是 staging 测过的那些字节。

AfterPack 的[威胁模型](https://www.afterpack.dev/docs/threat-model#what-afterpack-does-not-replace)描述了 `hard` 预设下的效果：[作弊](https://en.wikipedia.org/wiki/Cheating_in_online_games)工具的作者「每次发布都要重新做一遍真正的分析，而不是一次性地修补一个已知偏移量」。在 `light` 下，名称、偏移量和字符串解码器同样会随每个种子变化，只是每一轮分析的成本更低。无论哪种情况，经常发布的游戏每发布一次，都会让作弊工具的作者重新付出一次代价。如果某个脚本需要比你部署得更频繁地换形态，可以[在 Cloudflare Worker 里每次请求都换一个](https://www.afterpack.dev/blog/obfuscate-javascript-cloudflare-workers)。

每次构建都不同的输出有一个值得了解的坑。AfterPack 在 Vite 给 chunk 命名之后才运行，所以一个 chunk 可能字节变了，文件名却没变。这时，跨构建共用的 [service worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) 缓存就可能把旧 chunk 和新 chunk 混在一起提供。BIGBOARD 的 service worker 按构建给缓存命名，并且只有在没有任何打开的页面还在运行某个构建时，才删除那个构建的旧缓存。

## 如何证明发布的每个文件都经过了混淆？

在部署前的最后一步运行 [`npx afterpack verify dist`](https://www.afterpack.dev/docs/cli#afterpack-verify-dir)。它会对构建的保护回执里列出的每个文件重新计算哈希；只要有文件变了、回执缺失，或者回执来自另一次构建，它就会失败。

BIGBOARD 在每个 pull request 上都运行它，并在部署脚本里、构建刚结束时再运行一次，正如 [CI 指南](https://www.afterpack.dev/docs/builds#the-command-surface)所建议的那样。回执还会记录哪些文件是有意排除在外的：

```json
{
  "tool": "afterpack-vite",
  "engine": "local",
  "engineVersion": "0.2.3",
  "seedOrigin": "git",
  "files": [
    { "path": "assets/field-hockey-CK8_TNd4.js", "sha256": "0e5bf72c…", "transformed": true },
    { "path": "assets/rapier-B0bbuRDd.js", "sha256": "0f45e3d1…", "transformed": false }
  ]
}
```

## 在多人游戏里，混淆防不住什么？

它挡不住铁了心的作弊者。它让阅读和修改客户端变得更慢，并让这些工作每次发布都得从头再来；但它不会判定谁得了分。点对点游戏里没有服务器来做校验，所以一个有足够时间的人改过的客户端，仍然可以谎报冰球的位置。

对 BIGBOARD.GAMES 来说，利害关系不大，反作弊靠的是人：对手就坐在桌子对面，盯着同一个冰球。涉及排名或真金白银的游戏，需要在客户端背后做[服务端权威](https://www.gabrielgambetta.com/client-server-game-architecture.html)校验，核验命中、操作和结果，AfterPack 的[威胁模型](https://www.afterpack.dev/docs/threat-model#not-a-substitute-for-server-authoritative-validation)也是这么说的。混淆提高的是阅读那些校验覆盖不到的代码的成本；而发到浏览器里的秘密，什么都保护不了：bundle 里的 API 密钥应该放在服务端。

## 在你的游戏上试一试

把插件加进你的构建，或者对任意打包工具的输出运行 `npx afterpack@latest dist/`（[快速上手](https://www.afterpack.dev/docs/quickstart)），然后在 [DevTools](https://developer.chrome.com/docs/devtools) 里把结果和原始代码并排打开对比。免费的[安全扫描器](https://www.afterpack.dev/security-scanner)能告诉你，你已上线的游戏现在暴露了什么。[BIGBOARD.GAMES](https://bigboard.games) 是免费的，最好的玩法是两台平板加一个朋友。

## 常见问题

### Phaser、PixiJS 或 Three.js 做的游戏能混淆吗？

能。AfterPack 处理的是构建出来的 JavaScript，不管它出自哪个引擎或框架，[Phaser](https://phaser.io/)、[PixiJS](https://pixijs.com/) 和 [Three.js](https://threejs.org/) 都包括在内。像 BIGBOARD 对待 Rapier 那样对待引擎库：把它放进单独的 chunk，[排除掉](https://www.afterpack.dev/docs/config#paths-exclude)，然后混淆你自己的游戏代码。

### AfterPack 能混淆 WebAssembly 吗？

暂时还不能。目前 AfterPack 处理的是 JavaScript，我们打算以后把它扩展到 [WebAssembly](https://developer.mozilla.org/en-US/docs/WebAssembly)。BIGBOARD 的物理运行在 Rapier 的 WebAssembly 里，那是第三方代码，原样发布；围绕它的游戏逻辑是 JavaScript，被混淆的正是这部分。

### 混淆后的代码能用在 PWA 和 service worker 里吗？

能。BIGBOARD.GAMES 可以作为[渐进式 Web 应用](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)安装，离线也能打开。按构建给 service worker 的缓存命名，因为混淆后的 chunk 可能字节变了，文件名却没变。

### AfterPack 对游戏开发者免费吗？

[Free 引擎](https://www.afterpack.dev/docs/tiers)在本地运行，不需要账号，没有用量限制，所有预设都能用，BIGBOARD.GAMES 就是用它构建的。[Pro](https://www.afterpack.dev/docs/pro#what-pro-buys) 增加按区域生效的[指令](https://www.afterpack.dev/docs/directives)，比如只对网络代码用更重的预设，还有两项[加固变换](https://www.afterpack.dev/docs/config#transforms-kind-enabled)，每月 49 美元起，也可以一次性充值（[价格](https://www.afterpack.dev/docs/tiers#pricing)）。

### 游戏应该用哪个混淆预设？

整个 bundle 先用默认的 [`light`](https://www.afterpack.dev/docs/presets#the-default-is-light)，BIGBOARD.GAMES 发布的就是它。我还没有在帧循环里测过 [`medium`](https://www.afterpack.dev/docs/presets#the-ladder)。下一款游戏 [Tilt Run](https://bigboard.games/games/tilt-run) 每一帧都要读取陀螺仪，最多四台设备同时读，所以就拿它来测。
