# I shipped a neural-network opponent into the browser: no backend, no accounts, 120 ms per move

> Source: <https://dev.to/selectany/i-shipped-a-neural-network-opponent-into-the-browser-no-backend-no-accounts-120-ms-per-move-3l09>
> Published: 2026-07-26 19:55:51+00:00

I have a side project: a four-in-a-row board game where the opponent is a neural network I trained myself. It has been on Google Play for a while, and last week I shipped a browser version of it.

The interesting constraint was this: **the browser version had to be a folder of static files.** No backend, no database, no serverless functions, no analytics, no accounts. Partly to keep hosting at the price of a shared plan, partly because a game that phones home to score your moves is not a game I want to ship.

That means the neural network has to run on the player's machine, in the browser, and it has to make the same moves as the native engine. This post is about how that worked out, and where it got uncomfortable.

Almost every four-in-a-row game uses 7 columns by 6 rows. Mine uses 9 by 8. That is not a cosmetic change:

| Classic 7x6 | This game 9x8 | |
|---|---|---|
| Cells | 42 | 72 (+71%) |
| Possible four-in-a-row lines | 69 | 153 (2.2x) |
| Central columns | 1 | 3 |

Counting every straight run of four cells - horizontal, vertical, both diagonals - a 7x6 grid allows 69 of them and a 9x8 grid allows 153. Every disc sits on more potential lines, so threats are much harder to see coming, and the single all-powerful centre column of the classic board becomes a three-column band you have to fight over.

The practical consequence for me: 7x6 is a solved game with published opening theory, and none of it transfers. The opponent had to be trained for this board specifically.

The network is a small policy + value model: it takes the position and returns a move preference for each of the 9 columns plus a single scalar evaluation of who stands better. Just under a million parameters - the exported float ONNX file is 3.9 MB.

It was trained with self-play, using a native C++ solver as a teacher for positions where exact answers were cheap. I will skip the training details here because the browser story is the interesting part, but one number matters for the rest of this post: the four difficulty levels sit on a measured Elo-style ladder, calibrated over 96 games per adjacent pair, spanning roughly 800 points from the weakest to the strongest.

Importantly, the strengths are not four different networks. They are one network with different amounts of search on top:

Every level takes a win when one exists and blocks yours when one exists. That rail is non-negotiable; weakness is only ever introduced *after* those checks. A bot that misses a one-move win feels broken, not easy.

ONNX Runtime Web does the heavy lifting. Three decisions made it painless:

**1. Import the wasm-only build.** The full package pulls in execution providers I do not need:

``` js
const ort = await import("onnxruntime-web/wasm");
```

**2. Stay single-threaded.**

``` js
ort.env.wasm.numThreads = 1;
const session = await ort.InferenceSession.create(modelUrl, {
  executionProviders: ["wasm"],
  graphOptimizationLevel: "all",
});
```

Multi-threaded WASM needs `SharedArrayBuffer`

, which needs `crossOriginIsolated`

, which needs COOP/COEP response headers. On shared hosting that is a fight I did not need: a single move costs at most ten inferences, and one inference lands around 120 ms on a normal desktop. Single-threaded is fine.

**3. Run everything in a Web Worker.** Not just inference - the whole move selector, including the tree search. The UI thread never blocks, the "champion thinking" indicator animates, and cancellation is a message away:

``` js
const worker = new Worker(new URL("./champion.worker.ts", import.meta.url), {
  type: "module",
});
```

One thing I would flag for anyone doing this: **do not enable ORT's built-in proxy worker** if you are bundling with Vite. Its internal worker loader has known bundler incompatibilities. Owning the worker yourself is less code than debugging that, and you want your own worker anyway to keep search logic off the main thread.

Also: let your bundler own the `.wasm`

file. With the bundler entry point, the binary is emitted as a hashed asset of your own site and served from your origin - no CDN, no `wasmPaths`

juggling, and the JS and the WASM can never drift apart because they come from the same pinned build.

The native app is Kotlin. The browser engine is a TypeScript port. Two implementations of the same engine, and the failure mode is not a crash - it is a browser opponent that plays *slightly* differently and nobody notices for months.

Feature encoding is where this bites first. The model input is 153 floats and the order is not negotiable:

```
// 72 cells for the player to move, 72 for the opponent, 9 legal-column flags
// Rows are walked top to bottom, columns left to right.
export function encodeFeatures(board: Board, current: Disc): Float32Array {
  const f = new Float32Array(8 * 9 * 2 + 9);
  let i = 0;
  for (let row = 0; row < 8; row++)
    for (let col = 0; col < 9; col++) f[i++] = board.at(row, col) === current ? 1 : 0;
  for (let row = 0; row < 8; row++)
    for (let col = 0; col < 9; col++) f[i++] = board.at(row, col) === other(current) ? 1 : 0;
  for (let col = 0; col < 9; col++) f[i++] = board.isPlayable(col) ? 1 : 0;
  return f;
}
```

Get one of those loops wrong and the model still returns confident-looking numbers. It just plays worse. There is no error to catch.

So I did not trust review. The native build ships a diagnostic executable that prints, for a given position, the selected column, *why* it was selected, and a hash of the position. I generated a fixture of 64 sampled positions through it, then replayed the same positions through the TypeScript engine in the test suite - with `onnxruntime-node`

as the host runtime, so the real network is actually running:

``` js
for (const position of fixture.positions) {
  const board = rebuild(position.moves);
  expect(positionKey(board, "champion", position.firstPlayer))
    .toBe(position.expected.position_key);

  const move = await selectAdvancedMove(board, runtime, openingBook);
  expect([move.column, move.reason])
    .toEqual([position.expected.column, position.expected.reason]);
}
```

64 of 64 matched on all three: column, reason, and position hash. That test is the reason I can claim the browser version plays the same engine, rather than hoping it does.

A related trick worth stealing: the opening book is keyed by a **SHA-256 hash of a canonical position string**, and it is looked up twice - once directly, once mirrored, with the returned column flipped back. Two implementations agreeing on a hash is a very cheap, very strict proof that they agree on the position.

Alongside the main game there are tactics puzzles: positions where exactly one move forces a win. These are not hand-made. An exact solver generates and verifies them, so "win in 2" is a proof, not an estimate, and the opponent's replies are the strongest available defence rather than something convenient.

The one engineering detail I like here: the puzzle pack is content-addressed. A manifest carries the SHA-256 of the pack file; the build fails if they disagree, and the browser re-checks the hash before using the data. If a byte rots on the way to the CDN, the game says "puzzles unavailable" instead of silently serving a position whose "unique" solution no longer wins.

That last pair are product decisions dressed as technical ones, and I would rather state them plainly than pretend the web build is the full game.

The game is [Line4 9x8](https://line4-9x8.com/) - free, no sign-up, desktop browser. There is also a [free Android app](https://play.google.com/store/apps/details?id=com.line4.ninex8) with the full 500-puzzle set and the strongest difficulty level.

If you are putting a model in a browser: the runtime part is genuinely easy now. Budget your time for proving that your port plays the same as your original.
