Othello legal-move generation is seven lines of bitboard bitwise ops. I knew this existed; I'd never actually written it. So I built a Reversi engine in Rust, compiled it to raw WebAssembly without wasm-bindgen, and ended up with a 3.7 KB
.wasmthat plays a credible game against you in the browser.
📦 GitHub: https://github.com/sen-ltd/reversi-wasm
🔗 Demo: https://sen.ltd/portfolio/reversi-wasm/
Stack summary:
- Rust,
no_std, zero heap allocations - Raw
WebAssembly.instantiate— no wasm-bindgen, no generated JS glue - Vanilla JS + HTML + CSS for the board UI
- Alpha-beta negamax AI, configurable depth 1–5
- Final wasm: 3,717 bytes
Bitboards: two u64s carry the entire game
The board is two bitboards, one per color:
static mut BLACK: u64 = 0;
static mut WHITE: u64 = 0;
Bit row * 8 + col — so bit 0 is a1 (top-left) and bit 63 is h8 (bottom-right). Starting position:
const INIT_WHITE: u64 = (1u64 << 27) | (1u64 << 36); // d4, e5
const INIT_BLACK: u64 = (1u64 << 28) | (1u64 << 35); // e4, d5
State is 17 bytes total: two u64s + one byte of turn flag. Legal moves, application of a move, evaluation, all become bitwise operations on these two numbers.
Direction shifts
Each of Othello's eight directions becomes a one-line function:
const NOT_A_FILE: u64 = 0xFEFEFEFEFEFEFEFE; // all bits except column 0
const NOT_H_FILE: u64 = 0x7F7F7F7F7F7F7F7F; // all bits except column 7
fn n(x: u64) -> u64 { x >> 8 } // north
fn s(x: u64) -> u64 { x << 8 } // south
fn e(x: u64) -> u64 { (x & NOT_H_FILE) << 1 } // east
fn w(x: u64) -> u64 { (x & NOT_A_FILE) >> 1 } // west
fn ne(x: u64) -> u64 { (x & NOT_H_FILE) >> 7 } // NE (up-right)
fn nw(x: u64) -> u64 { (x & NOT_A_FILE) >> 9 } // NW (up-left)
fn se(x: u64) -> u64 { (x & NOT_H_FILE) << 9 } // SE (down-right)
fn sw(x: u64) -> u64 { (x & NOT_A_FILE) << 7 } // SW (down-left)
The file masks applied before the shift prevent column wraparound — a bit in column 7 shifted right by 1 would otherwise appear in column 0 of the next row. Row overflow takes care of itself because shifting pushes bits off the ends into oblivion.
Legal moves in seven lines
The definition of an Othello legal move: "a square, empty, reached from one of my disks through a run of one-or-more opponent disks in any direction."
In bitboard form, one direction:
fn legal_moves_dir(me: u64, opp: u64, empty: u64, shift: Shift) -> u64 {
let mut run = shift(me) & opp; // opp disks adjacent to me
run |= shift(run) & opp; // extend further
run |= shift(run) & opp;
run |= shift(run) & opp; // max run is 6 (middle 6 of 8)
run |= shift(run) & opp;
run |= shift(run) & opp;
shift(run) & empty // the empty square past the run
}
Seven lines, zero branches. The OR'd run variable accumulates all opponent-disk positions reachable from me along this direction; the final shift(run) & empty picks out squares that extend one past the run and are empty — i.e., legal moves.
All eight directions get OR'd together:
fn legal_moves(me: u64, opp: u64) -> u64 {
let empty = !(me | opp);
let mut moves = 0u64;
for shift in DIRECTIONS {
moves |= legal_moves_dir(me, opp, empty, shift);
}
moves
}
This returns a u64 where every set bit is a legal move square. The JS side takes that u64 and uses it to draw the gold hint dots.
Applying a move: the same pattern, different terminator
Computing which opponent disks flip when I play at pos:
fn flips_dir(pos: u64, me: u64, opp: u64, shift: Shift) -> u64 {
let mut run = shift(pos) & opp;
run |= shift(run) & opp;
run |= shift(run) & opp;
run |= shift(run) & opp;
run |= shift(run) & opp;
run |= shift(run) & opp;
// If the run terminates at one of our disks, flip everything in between.
if shift(run) & me != 0 { run } else { 0 }
}
Structurally identical to legal-move detection. The only change: check if the run lands on me (sandwich complete, flip) versus empty (open terminus, no flip).
OR across directions → flips_total → apply:
let new_me = me | pos | flips;
let new_opp = opp & !flips;
That's the entire move-application logic.
AI: negamax + alpha-beta
Othello is a determinate, two-player, zero-sum, perfect-information game — textbook minimax territory. Negamax with alpha-beta pruning:
fn negamax(me: u64, opp: u64, depth: u32, mut alpha: i32, beta: i32) -> (i32, u64) {
if depth == 0 { return (evaluate(me, opp), 0); }
let moves = legal_moves(me, opp);
if moves == 0 { /* handle pass / terminal */ }
let mut best_score = i32::MIN + 1;
let mut best_move = 0u64;
let mut bits = moves;
while bits != 0 {
let mv = bits & bits.wrapping_neg(); // extract lowest set bit
bits ^= mv;
let flips = flips_total(mv, me, opp);
let new_me = me | mv | flips;
let new_opp = opp & !flips;
let (s, _) = negamax(new_opp, new_me, depth - 1, -beta, -alpha);
let score = -s;
if score > best_score { best_score = score; best_move = mv; }
if score > alpha { alpha = score; }
if alpha >= beta { break; }
}
(best_score, best_move)
}
bits & bits.wrapping_neg() is the Brian Kernighan/LSB isolation trick — extracts the lowest set bit, which we then XOR off. The entire move enumeration is allocation-free.
Evaluation is the classic position weight table + mobility:
const WEIGHTS: [i32; 64] = [
100, -25, 10, 5, 5, 10, -25, 100,
-25, -50, -1, -1, -1, -1, -50, -25,
10, -1, 3, 2, 2, 3, -1, 10,
5, -1, 2, 1, 1, 2, -1, 5,
5, -1, 2, 1, 1, 2, -1, 5,
10, -1, 3, 2, 2, 3, -1, 10,
-25, -50, -1, -1, -1, -1, -50, -25,
100, -25, 10, 5, 5, 10, -25, 100,
];
Corners are +100, X-squares (diagonal adjacents to corners) are -50 because occupying them too early lets the opponent take the corner. Add a 5-points-per-legal-move mobility term. Result: the AI plays corner-hunting, X-square-avoiding, mobility-aware Othello at depth 4.
Why skip wasm-bindgen
wasm-bindgen is lovely when you need to pass strings and structs across the Rust/JS boundary. My engine's API is:
#[no_mangle] pub extern "C" fn reset();
#[no_mangle] pub extern "C" fn legal_moves_bits() -> u64;
#[no_mangle] pub extern "C" fn apply_move(pos: u32) -> u32;
#[no_mangle] pub extern "C" fn ai_choose_move(depth: u32) -> u32;
#[no_mangle] pub extern "C" fn black_discs() -> u64;
#[no_mangle] pub extern "C" fn white_discs() -> u64;
Pure POD, no references, no strings. The JS side just calls the exports:
const res = await fetch('./assets/reversi.wasm');
const { instance } = await WebAssembly.instantiate(await res.arrayBuffer(), {});
const engine = instance.exports;
engine.reset();
const legal = engine.legal_moves_bits(); // returns a BigInt for u64
engine.apply_move(pos);
const ai = engine.ai_choose_move(4);
No generated .js glue file, no import maps, no npm dependencies. The .wasm is all there is.
Release profile + no_std
Cargo.toml release profile is what squeezes 3.7 KB out of the binary:
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
strip = true
Plus #![no_std] and #![no_main] drop the entire std runtime. You provide your own panic handler:
#[cfg(not(test))]
#[panic_handler]
fn panic(_: &PanicInfo) -> ! { loop {} }
The #[cfg(not(test))] exists because cargo test needs std (test's own harness is built on std), and std already defines panic_handler. The conditional lets the same source file build both ways:
#![cfg_attr(not(test), no_std)]
#![cfg_attr(not(test), no_main)]
I want cargo test to work so I can run unit tests on the pure game logic without needing a browser.
Tests as the bug-catcher
Seven unit tests covering:
- Initial position has 4 disks, 2-2
- Black's opening has exactly four legal moves (d3, c4, f5, e6)
- Playing d3 flips d4 (3 black, 1 white, white's turn)
- An illegal move returns 0 and doesn't change state
- AI at depth 2 returns a legal move from the starting position
- AI takes a corner when only that move is available
- Pass fails when legal moves exist
I wrote the direction-shift mask (NOT_A_FILE vs NOT_H_FILE) wrong on the first try and the "opening has four legal moves" test caught it in 200 ms. Bitboard bugs are merciless — an off-by-one file mask wraps bits across rows and the whole game gets surreal — but they also tend to fail catastrophically the moment you test them, which is great for fast iteration.
Takeaways
- Othello is a showcase for bitboards. Seven lines of shift-and-AND give you legal moves in all eight directions, and the same structure does disc flipping.
-
Raw WASM is viable for POD-only APIs.
WebAssembly.instantiateis three lines; you don't need a toolchain pipeline for a small, pure engine. -
no_std+strip = true+panic = abortcuts Rust binaries by an order of magnitude when you don't need the runtime. -
#[cfg(not(test))]on your panic handler lets you dual-build: wasm-no_std for the browser, std-with-tests for cargo. -
BigInt is your friend on the JS side: wasm
u64exports come back as BigInt, so use(legal & (1n << BigInt(i))) !== 0nfor bit testing.
Repository: https://github.com/sen-ltd/reversi-wasm

Top comments (0)