Investigation Report: Scala.js WebAssembly Backend for Dice Chess Engine

This report details the investigation into compiling the Dice Chess engine to WebAssembly (Wasm) using Scala.js 1.21.0+ capabilities, as requested in Issue #352.


1. Package Size Comparison

One of the most notable advantages of the WebAssembly backend is the compact binary size compared to optimized JavaScript.

Package / Build TargetMain File(s)Uncompressed SizeNotes
Pure JS (@rabestro/dicechess-engine)dicechess-engine.js1.37 MB (1,367,573 bytes)Verbose JS generated by Scala.js linker.
Wasm (@rabestro/dicechess-engine-wasm)main.wasm + main.js + __loader.js487 KB total (472,833 bytes .wasm)~64% smaller download size.

TIP

The WebAssembly binary is significantly smaller because it represents compiler instructions in compact single-byte opcodes, bypassing Javascript’s syntax overhead and verbose symbol mangling.


2. Performance Benchmark Results

We compared the Pure JS and Wasm targets locally using Node.js. Warmup iterations were performed to allow the V8 JIT compiler to optimize the JS hot paths.

Benchmark Setup

  • Environment: Node.js v26.0.1 on macOS (Apple Silicon).
  • Test DFEN: rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 P

Results

Benchmark TypeIterationsPure JS TimeWasm TimeWasm Speedup
Legal Move Gen5,000112.36 ms85.38 ms1.32x
Best Move Greedy2,00048.64 ms44.55 ms1.09x
Estimate Equity (Monte-Carlo)10 (100 rollouts/each)7.38s2.87s2.58x

JS-to-Wasm Boundary Crossing Analysis

  • Why is the speedup lower for Move Gen and Greedy Search (1.09x – 1.32x)? For fast, short-lived calls (< 0.02 ms per call), the overhead of crossing the JS-to-Wasm boundary dominates. The input FEN strings must be written to Wasm memory, and the resulting arrays/objects must be marshaled back from Wasm memory to JS objects.
  • Why is the speedup higher for Monte-Carlo Equity Estimation (2.58x)? estimateEquity takes a single FEN string and performs 100 plies of random game simulations internally within Wasm. It runs thousands of path generations and evaluations inside the Wasm runtime without crossing back to JS. The boundary-crossing overhead is negligible, and the true computation speedup of Wasm (no JS GC allocations during the loop, optimized bitboard logic) shines through.

3. Publishing and Distribution Strategy

Separate Packages (Current Strategy)

The repository is currently configured to build and publish two separate npm packages:

  1. @rabestro/dicechess-engine (compiled to Pure JS)
  2. @rabestro/dicechess-engine-wasm (compiled to Wasm)

We recommend retaining this separate packages strategy rather than publishing a unified package or migrating completely to Wasm.

Rationale:

  • Synchronous Main-Thread Use: The Pure JS engine is fully synchronous and doesn’t require async load promises. Downstream frontends can import it directly:
    import { DiceChess } from '@rabestro/dicechess-engine';
    // Fully synchronous legal moves and validation
    const moves = DiceChess.getLegalUciMoves(dfen);
    If we migrated completely to Wasm, main-thread code would be forced to handle asynchronous initialization (via top-level await or a loader promise), complicating UI-level integration.
  • Asynchronous worker-based Wasm: Downstream clients can use @rabestro/dicechess-engine-wasm inside background Web Workers for heavy computations (Monte Carlo / Expectimax), where asynchronous loading is acceptable and doesn’t block the main thread.

4. Integration Guidelines for Downstream Frontend Clients

Due to the nature of ES module loaders and bundlers (like Vite/Webpack) resolving .wasm files inside node_modules during production builds, dynamic worker chunks can fail to locate the binary.

The recommended integration pattern (as implemented in dicechess-analytics-ui) is:

1. Copy Wasm Assets as Static Files

Create a script (e.g. scripts/copy-wasm.mjs) to run before vite dev and vite build that copies the runtime assets from node_modules into a public folder:

import { mkdir, copyFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
 
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const src = join(root, 'node_modules', '@rabestro', 'dicechess-engine-wasm');
const dest = join(root, 'public', 'engine-wasm');
const files = ['main.js', 'main.wasm', 'main.wasm.map', '__loader.js'];
 
await mkdir(dest, { recursive: true });
for (const file of files) {
  await copyFile(join(src, file), join(dest, file));
}

2. Dynamically Import Wasm in the Worker

Since Scala.js Wasm compiles with top-level await, load the module lazily on the first worker message to prevent dropping incoming messages before the Wasm resolves:

let enginePromise;
const loadEngine = () => (enginePromise ??= import(`${location.origin}/engine-wasm/main.js`));
 
self.onmessage = async (event) => {
  if (event.data.type === 'start') {
    const engine = await loadEngine();
    // Run CPU-intensive calculations
    const r = engine.estimateEquity(event.data.dfen, { rollouts: 100 });
    self.postMessage({ type: 'result', data: r });
  }
};

5. Conclusions & Next Steps

  • Upgrade/Setup validation: The Scala.js 1.21.0 compilation and Wasm configuration in build.sbt are fully correct and functional.
  • Workflow verification: GitHub Actions (publish.yaml and release.yaml) are already configured to build both JS and Wasm targets and publish them to the package registry.
  • Resolution: No code changes are required. The current structure is optimal and ready for production releases. We should close the issue as successfully investigated and documented.