A 9x8 Board, Not 7x6

Most Connect Four games use a 7x6 grid. This one uses 9x8. That's not cosmetic: the larger board has 72 cells (vs 42), 153 possible four-in-a-row lines (vs 69), and three central columns to fight over instead of one. The classic game is solved and has published opening theory; none of it transfers. The opponent had to be trained specifically for this board.

The Model: Small, Fast, One Network for Four Difficulties

The neural network is a policy + value model with under a million parameters. The exported ONNX file is 3.9 MB. It takes the board position and returns a move preference for each of the 9 columns plus a scalar evaluation of who stands better.

Crucially, the four difficulty levels are not separate networks. They are one network with different amounts of search:

  • Beginner – No network calls. Immediate win/block checks, then a shallow heuristic with softmax sampling.
  • Intermediate – Blends the network's raw policy with that heuristic.
  • Advanced – Value-guided search: evaluate every legal reply with the network and pick the move that leaves the opponent worst off.
  • Grandmaster – 256-simulation MCTS over the same network (native app only).

All levels always take a winning move and block your winning move. Weakness is introduced only after those checks. A bot that misses a one-move win feels broken, not easy.

The difficulty ladder spans roughly 800 Elo points, calibrated over 96 games per adjacent pair.

Browser Deployment: Static Files Only

The constraint: the browser version had to be a folder of static files. No backend, no database, no serverless functions, no analytics, no accounts. Hosting on a shared plan, and the developer didn't want a game that phones home to score your moves.

That means the neural network must run on the player's machine in the browser and produce identical moves to the native engine.

ONNX Runtime Web: Three Decisions Made It Painless

  1. Import the wasm-only build. The full package pulls in execution providers not needed:

    const ort = await import("onnxruntime-web/wasm");
    
  2. Stay single-threaded. Multi-threaded WASM needs SharedArrayBuffer, which requires crossOriginIsolated, which needs COOP/COEP response headers. On shared hosting, that's a fight not worth having. A single move costs at most ten inferences, and one inference lands around 120 ms on a normal desktop. Single-threaded is fine:

    ort.env.wasm.numThreads = 1;
    const session = await ort.InferenceSession.create(modelUrl, {
      executionProviders: ["wasm"],
      graphOptimizationLevel: "all",
    });
    
  3. Run everything in a Web Worker. Not just inference—the whole move selector, including tree search. The UI thread never blocks, the "thinking" indicator animates, and cancellation is a message away:

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

One gotcha: don't enable ORT's built-in proxy worker if bundling with Vite. Its internal worker loader has known bundler incompatibilities. Own the worker yourself.

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 WASM can never drift apart.

The Scary Part: Proving Parity

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's 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. Get one loop wrong and the model still returns confident-looking numbers. It just plays worse. No error to catch.

The solution: a diagnostic executable in the native build prints for a given position the selected column, why it was selected, and a hash of the position. The developer 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 runs:

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.

A related trick: the opening book is keyed by a SHA-256 hash of a canonical position string, and it's 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.

Puzzles: Content-Addressed for Integrity

Alongside the main game are tactics puzzles: positions where exactly one move forces a win. An exact solver generates and verifies them, so "win in 2" is a proof, not an estimate. 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.

What Was Deliberately Not Done

  • No backend. The deployed artifact is a folder. Uploading it over FTP is the entire deployment process.
  • No analytics, no cookies, no accounts. Server logs are enough to know people showed up.
  • No mobile web build. The web version detects phones and points them at the native app. A cramped 9x8 board on a phone browser is a worse first impression.
  • No strongest level in the browser. 256-simulation MCTS on single-threaded WASM is not the experience for a first-time visitor. It stays in the app.

Numbers for Calibration

  • Shell (HTML + CSS + JS, no engine): ~16 KB gzipped. Beginner is playable before the engine has finished downloading.
  • ONNX Runtime WASM: 13.5 MB raw, ~3.5 MB gzipped, fetched only when a model-backed strength is needed.
  • Model: 3.9 MB raw, 2.2 MB gzipped. Opening book: 552 KB raw, 285 KB gzipped.
  • One inference: ~120 ms single-threaded on a normal desktop. A full value-guided move: ~1.2 s.
  • Test suite: 76 unit tests plus the parity fixture, all running in Node.
  • Hosting: one shared plan. No servers, no scaling, no bill that grows with players.

Try It

The game is Line4 9x8—free, no sign-up, desktop browser. There's also a free Android app with the full 500-puzzle set and the strongest difficulty level.

If you're 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.