# How to protect JavaScript game source code: a multiplayer case study

Protect JavaScript game source code by obfuscating the client build. BIGBOARD.GAMES ships AfterPack-protected multiplayer games without dropping frames.

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

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

To protect JavaScript game source code, obfuscate the client build, leave third-party engines and string tables out of the obfuscator, and keep secrets and every check a server can make on the server. AfterPack ([afterpack.dev](https://www.afterpack.dev/)), the JavaScript obfuscator, rewrites a built game into a structurally different program on every release. Its Free engine runs locally through [`npx afterpack`](https://www.afterpack.dev/docs/cli) or a [Vite](https://www.afterpack.dev/docs/frameworks/vite), [webpack](https://www.afterpack.dev/docs/frameworks/webpack) or [Rollup](https://www.afterpack.dev/docs/frameworks/rollup) plugin. [BIGBOARD.GAMES](https://bigboard.games), a peer-to-peer multiplayer game, ships with it at the frame rate the browser allows.

I launched [BIGBOARD.GAMES](https://bigboard.games) on October 6. It's a set of free browser games for 2 to 4 phones and tablets pushed together on a table: every screen becomes part of one board, and in [Seam Hockey](https://bigboard.games/games/seam-hockey) the puck slides from one device to the next across the seam. It launched with Seam Hockey, [Spillover](https://bigboard.games/games/spillover), [Whack-a-Mole](https://bigboard.games/games/whack-a-mole) and [Islands](https://bigboard.games/games/islands). Gameplay runs device to device over [WebRTC](https://en.wikipedia.org/wiki/WebRTC), with no game server in the loop; I wrote up [how the game itself is built](https://nikitaeverywhere.com/posts/bigboard-games/) on my own blog. The repository's first commit already had AfterPack in `vite.config.ts`, so the game has never shipped unprotected. This post is what that cost the game in frames, bytes and build time, with every number taken from the real build.

Games are where developers worry most that [obfuscation](https://en.wikipedia.org/wiki/Obfuscation_(software)) will eat their frame rate, and also where shipped code gets read first, because the rules, the physics and the network protocol all run on the player's device. AI made that reading cheap: an agent turned two popular obfuscators' own demos [back into clean source in 10 and 20 minutes](https://www.afterpack.dev/blog/ai-deobfuscates-javascript). The [same tools](https://claude.com/product/claude-code) that let me build BIGBOARD.GAMES in about ten days let someone else read it just as fast.

## What does a JavaScript game's code give away?

All of it. The rules, the physics, the scoring and the network protocol a cheat would need to speak are in the bundle, and [minification](https://developer.mozilla.org/en-US/docs/Glossary/Minification) only shortens local names: strings, constants and structure stay readable, as [this walkthrough of a minified bundle](https://www.afterpack.dev/blog/protect-javascript-source-code#what-does-minified-javascript-expose) shows line by line.

In a [peer-to-peer](https://en.wikipedia.org/wiki/Peer-to-peer) game the client is also the referee. In [Seam Hockey](https://bigboard.games/games/seam-hockey) the device the puck is on simulates it and streams its position to the others, and ownership passes to the next device at a seam. [Spillover](https://bigboard.games/games/spillover) and [Shoal](https://bigboard.games/games/shoal) run in [deterministic lockstep](https://gafferongames.com/post/deterministic_lockstep/): every device runs the same simulation from the same inputs and must arrive at the same state. A [Cloudflare Worker](https://developers.cloudflare.com/workers/) with two [Durable Objects](https://developers.cloudflare.com/durable-objects/) only sets up the table, holds seats and records results.

| What ships to every player | What reading it gives |
| --- | --- |
| Puck physics and the ownership hand-off at seams | Where a goal is decided, and on which device |
| Lockstep simulations and their deterministic trigonometry | The exact state every device must agree on |
| The message codec on the [WebRTC data channels](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel) | The protocol a modified client would need to speak |
| Screen calibration in real millimetres (device profiles, or a [bank card](https://en.wikipedia.org/wiki/ISO/IEC_7810) held to the glass) | The part a clone would need most, and the hardest to rebuild |

## Does JavaScript obfuscation slow down a game?

Not visibly in BIGBOARD.GAMES. At AfterPack's default [`light` preset](https://www.afterpack.dev/docs/presets#the-default-is-light), Seam Hockey's frame loop runs at whatever rate the browser gives [`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame): between 60 and 144 fps on a [OnePlus Pad 3](https://www.oneplus.com/global/oneplus-pad-3), and 60 fps on iPads, where every browser is [WebKit](https://webkit.org/) and WebKit holds pages near 60 fps unless the player turns off Safari's "Prefer Page Rendering Updates near 60fps" feature flag. The game's diagnostics read that rate from the obfuscated build players get.

A frame rate is a coarse measure, so I also timed the simulation code itself: the same functions the games ship, bundled with [esbuild](https://esbuild.github.io/), plain and obfuscated at `light` with the engine BIGBOARD launched on (0.2.1) and the current one (0.2.3), in [V8](https://v8.dev/) and in [JavaScriptCore](https://docs.webkit.org/Deep%20Dive/JSC/JavaScriptCore.html).

| Game code (unit) | Runtime | Plain | Engine 0.2.1 | Engine 0.2.3 |
| --- | --- | ---: | ---: | ---: |
| Deterministic `sinCos` (ns per call) | V8 | 44.2 | 104.2 (2.36x) | 44.1 (1.00x) |
| Deterministic `sinCos` (ns per call) | JavaScriptCore | 9.5 | 57.4 (6.04x) | 20.6 (2.17x) |
| Shoal simulation (µs per tick) | V8 | 26.5 | 58.2 (2.20x) | 49.7 (1.88x) |
| Shoal simulation (µs per tick) | JavaScriptCore | 21.7 | 36.9 (1.71x) | 26.7 (1.23x) |
| Seam Hockey puck physics (µs per step) | V8 | 0.52 | 1.01 (1.94x) | 0.78 (1.50x) |
| Seam Hockey puck physics (µs per step) | JavaScriptCore | 0.39 | 0.66 (1.69x) | 0.64 (1.63x) |

Measured on 2026-10-08 on an Apple M2 Max with [Node.js](https://nodejs.org/) 24.21.0 (V8) and [Playwright](https://playwright.dev/)'s WebKit 26.6 (JavaScriptCore): the median of 60 to 120 timed runs across 4 to 8 fresh processes per variant, with the seed the shipped build used. On V8, one of the eight Shoal processes on engine 0.2.3 settled at about 67 µs instead of 50, depending on how V8 chose to optimize that run.

So obfuscated game code does run slower, 1.0x to 2.2x plain on the current engine, and the frame rate still doesn't move, because the simulation is a sliver of the frame. The slowest case in the table, [Shoal](https://bigboard.games/games/shoal) on V8 with engine 0.2.1, takes 58.2 µs per tick: 0.35% of a 16.7 ms frame at 60 Hz, 0.84% of a 6.9 ms frame at 144 Hz. A tablet is slower than an M2 Max, but it would take a CPU more than 100 times slower for that tick to fill a 144 Hz frame.

[`light`](https://www.afterpack.dev/docs/presets#the-default-is-light) renames identifiers, routes string literals through a runtime decoder and rewrites syntax, and it adds no structural layers. Since engine 0.2.3 it also leaves loops as loops: earlier engines rewrote them into callback helpers, which cost 2-5x in hot loops ([changelog](https://www.afterpack.dev/changelog)). Property and global names still go through encoded constants, which [has some cost in very hot code](https://www.afterpack.dev/docs/presets#the-default-is-light). The [presets above it](https://www.afterpack.dev/docs/presets#the-ladder), `medium` to `extreme`, add structural work on every token. A game that wants them should aim them at the code worth the most, like the netcode, through a [Pro directive](https://www.afterpack.dev/docs/directives), and keep the frame loop on `light`.

## Does obfuscation break deterministic multiplayer?

It must not, and in BIGBOARD.GAMES it doesn't: the lockstep games need bit-identical state on every device, across Safari's JavaScriptCore and Chrome's V8, and they get it from the obfuscated build.

That bar is higher than "the game still runs". [`Math.sin`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sin) isn't guaranteed to return the same bits in every engine (the [spec](https://tc39.es/ecma262/#sec-math.sin) calls its result implementation-approximated), so [Spillover](https://bigboard.games/games/spillover) pairs a [deterministic build](https://rapier.rs/docs/user_guides/javascript/determinism) of the [Rapier](https://rapier.rs/) physics engine with its own `sinCos`, within 5e-14 of `Math.sin`. That `sinCos` and the simulations that call it are obfuscated like the rest of the game code. In every timed run above, I hashed the full simulation state, every float by its exact bits, and every obfuscated variant matched the plain one in both V8 and JavaScriptCore. Shoal's state and the `sinCos` outputs also matched across the two engines, which is the property lockstep depends on. Every night the Playwright multi-device suite plays the games against staging, which serves the same obfuscated build players get, including a three-device [Shoal](https://bigboard.games/games/shoal) match that drops a player mid-game and checks the other two for desync.

What obfuscated output keeps the same, and the few deliberate differences, is on the [semantic contract](https://www.afterpack.dev/docs/semantic-contract) page.

## How to obfuscate a Vite game build

Install [`@afterpack/vite`](https://www.afterpack.dev/docs/frameworks/vite), add `afterpackVite()` to `plugins`, and exclude the chunks that aren't yours. `vite dev` stays untouched; `vite build` emits obfuscated chunks.

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

The config below is BIGBOARD's, trimmed to two excludes. I built it in a clean project on 2026-10-08 with [Vite](https://vite.dev/) 8.3.3, `@afterpack/vite` 0.2.2 (engine 0.2.3), Rapier's [`@dimforge/rapier2d-deterministic-compat`](https://www.npmjs.com/package/@dimforge/rapier2d-deterministic-compat) 0.21.0 and Node.js 24.21.0. The receipt marked the Rapier and i18n chunks untouched and the game chunk obfuscated, and two builds of the same commit came out byte-identical.

```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[\\/]/ },
          ],
        },
      },
    },
  },
});
```

The [`paths.exclude`](https://www.afterpack.dev/docs/config#paths-exclude) globs match output file names, so the chunking matters as much as the globs. Left to itself, Vite can merge a library into a chunk that also holds your code, and that chunk gets obfuscated whole. Vite 8 splits chunks with Rolldown's [`codeSplitting`](https://rolldown.rs/reference/OutputOptions.codeSplitting); on Vite 7 and older the same split is Rollup's [`manualChunks`](https://rollupjs.org/configuration-options/#output-manualchunks).

These are the chunks BIGBOARD.GAMES leaves out, 21 of its 140 JavaScript files, with the reasons as measured on engine 0.2.1:

| Chunk | What it is | Why it stays out |
| --- | --- | --- |
| `rapier-*.js` | [Rapier](https://rapier.rs/) physics, deterministic build | Third-party: 3.4 MB of [WebAssembly](https://developer.mozilla.org/en-US/docs/WebAssembly) glue with nothing of ours in it |
| `analytics-posthog-*.js`, `openapi-fetch-*.js`, `qr-*.js` | [PostHog](https://posthog.com/) analytics, the [openapi-fetch](https://openapi-ts.dev/openapi-fetch/) REST client, the QR encoder | Third-party. Obfuscated, the QR encoder alone tripled, to about 25 KB gzipped |
| `i18n-*.js` | English, Ukrainian and Spanish strings | Plain strings grew about 10x when obfuscated, and none of them is a secret |
| `brand-marks-*.js`, `brand-glyphs-*.js` | Logo outlines and SVG markup | Path data behaves like strings: obfuscated, it grew the entry tenfold |

`paths.exclude` is a [Free](https://www.afterpack.dev/docs/tiers) feature. Skipping one function inside a file, rather than a whole file, takes a [Pro directive](https://www.afterpack.dev/docs/directives); in a Free build a directive is an error.

## How much bigger does obfuscation make a game?

About 1.65x gzipped on the first load with the current engine, and 2.2x with the one BIGBOARD launched on. `/play`, the page players open, loads 20 chunks:

| Served, gzipped | Plain | Engine 0.2.1 | Engine 0.2.3 |
| --- | ---: | ---: | ---: |
| `/play` first load | 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) |

Measured on 2026-10-08 at `light`, the obfuscated columns as the median of 7 builds. The upgrade cost nothing but a version bump: same config, same excludes, and the gate, `afterpack verify` and the multi-device end-to-end run all passed on it. That's obfuscation's honest cost: it adds bytes, and a game that counts kilobytes has to budget for them. Build time is the cheaper part: the whole build, app, landing and admin, takes 1.5 s plain and 3.6 s obfuscated on the M2 Max, on either engine. AfterPack's [performance page](https://www.afterpack.dev/docs/performance) has the engine's own measurements.

One lesson from the size budget is worth copying. BIGBOARD's build stamps the build time into its entry chunk, so every build renames the entry and every chunk that imports it, and any changed chunk comes out of AfterPack completely reshuffled. The first load moved between 176 and 182 KB across builds of the same commit, and a budget on served bytes failed at random. BIGBOARD budgets sizes measured before obfuscation instead, with a loose 256 KB ceiling on what is served. Taking the timestamp from the commit ([`git log -1 --format=%cI`](https://git-scm.com/docs/git-log)) would fix the cause.

## Why ship a different program on every release?

So that a cheat, a patch or a hook written against one build stops matching the next. With [`seed: "git"`](https://www.afterpack.dev/docs/config#seed), every commit produces structurally different output, and the same seed with the same input [gives the same bytes](https://www.afterpack.dev/docs/builds#random-seed-by-default-pin-for-reproducibility). With the build timestamp pinned, two BIGBOARD builds of one commit matched in all 140 files. A pipeline that rebuilds for production can then ship exactly the bytes staging tested.

AfterPack's [threat model](https://www.afterpack.dev/docs/threat-model#what-afterpack-does-not-replace) describes the effect at the `hard` preset: a [cheat](https://en.wikipedia.org/wiki/Cheating_in_online_games) author "redoes real analysis every release instead of patching a known offset once". At `light` the names, offsets and string decoders still move with every seed; each round of analysis is just cheaper. Either way, a game that ships often makes the cheat author pay again with every release. A script that needs a new shape more often than you deploy can get one [on every request from a Cloudflare Worker](https://www.afterpack.dev/blog/obfuscate-javascript-cloudflare-workers).

Per-build output has one trap worth knowing. AfterPack runs after Vite has named the chunks, so a chunk can change its bytes without changing its file name. A [service worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) cache shared across builds could then serve a stale chunk next to a fresh one. BIGBOARD's service worker names its cache after the build, and drops an old cache only once no open page runs that build.

## How do you prove every shipped file was obfuscated?

Run [`npx afterpack verify dist`](https://www.afterpack.dev/docs/cli#afterpack-verify-dir) as the last step before deploy. It re-hashes every file the build's protection receipt names, and fails if a file changed, if the receipt is missing or if it's from another build.

BIGBOARD runs it on every pull request and again inside its deploy script, right after the build, as the [CI guide](https://www.afterpack.dev/docs/builds#the-command-surface) recommends. The receipt also records what was left out on purpose:

```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 }
  ]
}
```

## What doesn't obfuscation stop in a multiplayer game?

It doesn't stop a determined cheater. It makes reading and patching the client slower, and makes that work start over with each release; it doesn't decide who scored. In a peer-to-peer game there is no server to check, so a client modified by someone with enough time can still lie about where the puck is.

For BIGBOARD.GAMES the stakes are low, and the anti-cheat is social: the other player is sitting across the table, watching the same puck. A game with rankings or money on the line needs [server-authoritative](https://www.gabrielgambetta.com/client-server-game-architecture.html) checks of hits, moves and outcomes behind the client, as AfterPack's [threat model](https://www.afterpack.dev/docs/threat-model#not-a-substitute-for-server-authoritative-validation) says too. Obfuscation raises the cost of reading the code those checks can't cover, and nothing protects a secret shipped to the browser: an API key in the bundle belongs on the server.

## Try it on your game

Add the plugin to your build, or run `npx afterpack@latest dist/` on the output of any bundler ([quickstart](https://www.afterpack.dev/docs/quickstart)), then open the result next to the original in [DevTools](https://developer.chrome.com/docs/devtools). The free [security scanner](https://www.afterpack.dev/security-scanner) shows what your live game exposes today. [BIGBOARD.GAMES](https://bigboard.games) is free, and it plays best with two tablets and a friend.

## FAQ

### Can I obfuscate a Phaser, PixiJS or Three.js game?

Yes. AfterPack works on the built JavaScript, whatever engine or framework produced it, [Phaser](https://phaser.io/), [PixiJS](https://pixijs.com/) and [Three.js](https://threejs.org/) included. Treat the engine library like BIGBOARD treats Rapier: put it in its own chunk, [exclude it](https://www.afterpack.dev/docs/config#paths-exclude), and obfuscate your game code.

### Does AfterPack obfuscate WebAssembly?

Not yet. Today AfterPack transforms JavaScript, and [WebAssembly](https://developer.mozilla.org/en-US/docs/WebAssembly) is where we intend to take it. BIGBOARD's physics runs in Rapier's WebAssembly, which is third-party and ships as it is; the game logic around it is JavaScript, and that is what gets obfuscated.

### Does obfuscation work with a PWA and a service worker?

Yes. BIGBOARD.GAMES installs as a [progressive web app](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) and opens offline. Name the service worker's cache after the build, because obfuscated chunks can change bytes without changing their file names.

### Is AfterPack free for game developers?

The [Free engine](https://www.afterpack.dev/docs/tiers) runs locally with no account and no limit, at every preset, and BIGBOARD.GAMES builds with it. [Pro](https://www.afterpack.dev/docs/pro#what-pro-buys) adds per-region [directives](https://www.afterpack.dev/docs/directives), such as a heavier preset on your netcode alone, and two [hardening transforms](https://www.afterpack.dev/docs/config#transforms-kind-enabled), from $49 a month or as one-time top-ups ([pricing](https://www.afterpack.dev/docs/tiers#pricing)).

### Which preset should a game use?

Start with [`light`](https://www.afterpack.dev/docs/presets#the-default-is-light) for the whole bundle, the default, which is what BIGBOARD.GAMES ships. I haven't yet measured [`medium`](https://www.afterpack.dev/docs/presets#the-ladder) inside a frame loop. [Tilt Run](https://bigboard.games/games/tilt-run), the next game, reads the gyroscope every frame on up to four devices at once, so it will be the test.
