The ExpectimaxSearch algorithm (dicechess.engine.search.ExpectimaxSearch) provides two-ply lookahead search capability in the Dice Chess engine, moving beyond single-turn heuristic bots (Levels 1–5) to reason about future turns and opponent replies.
Unlike Minimax which assumes deterministic turns, Expectimax is designed for games with chance nodes — in Dice Chess, the stochastic dice rolls that determine which moves the opponent can play.
graph TD
A["Root: Current Position (Our Turn)"] --> B["Pre-Ranking (Material or Value Model)"]
B --> C["Top-K Candidates (candidateLimit)"]
C --> D["Candidate Turn Path 1..K"]
D --> E["Chance Node: 56 weighted dice rolls"]
E --> F["Opponent Best Reply (Minimax over deduplicated leaves)"]
F --> G["Expected Value (Weighted Sum)"]
At each chance node, the algorithm computes the expected value by weighting the opponent’s best reply for each of the 56 unique dice combinations by its combinatorial probability:
If any of the active player’s legal turn paths captures the opponent’s king, the search immediately returns that turn with SearchScoring.TerminalWinScore. No candidate ranking or chance-node expansion is required.
Dice Chess positions often offer hundreds of legal turn paths for a single roll. Expanding every path through 56 dice outcomes would be prohibitively slow.
Before expanding chance nodes, all legal paths are scored using a fast batched pre-ranker (preRank, defaulting to material balance via ExpectimaxSearch.materialBatch). Only the top config.candidateLimit candidates are expanded through full chance nodes.
All legal opponent reply paths are generated under the rolled dice pool.
If any reply captures our king, that roll immediately yields LossValue (−109) — below any evaluator’s scale so the opponent always chooses it and we rank that line last.
Otherwise, resulting board positions are generated and deduplicated in-place using LeafKey.
[!NOTE]
Leaf Deduplication vs Transposition Tables: Dice Chess turns consist of 1–3 micro-moves. Independent micro-moves played in different orders often reach identical board states (~78% duplicate leaves per chance node). Because the opponent minimizes over leaves (min(S)=min(distinct(S))), duplicate boards can be dropped with zero loss of precision.
Deduplication uses LeafKey, which packs 11 primitives (piece bitboards, en-passant, flags, full-move counter) and hashes them in 64-bit CPU registers without heap allocations. This is per-chance-node leaf compaction, not a cross-ply Transposition Table.
The distinct leaf states under a roll are scored in a single call to evalBatch(leaves, color). Scoring in batches eliminates per-leaf call overhead and enables vectorized or hardware-accelerated evaluation (e.g. via ONNX Runtime in OnnxExpectimaxSearch).
An optional RootRescore blends the chance-node search value with a second, tactically sharp but leaf-prohibitive evaluator computed once on the resulting candidate positions (before the opponent’s roll):
score=(1−w)×Vsearch+w×Vrescore
This allows expensive evaluations (such as 216-outcome King Capture Probability features) to run at the root (K states) without burdening the thousands of leaves under chance nodes. Candidates tainted by an unavoidable king capture on any opponent roll are never rescored and remain ranked last.
ExpectimaxSearch extends TimeBudgetedSearch and coordinates with TimeManager:
Fine-grained clock checks: The deadline is checked between dice rolls inside the chance node (~1/56 of a candidate), not merely between candidates.
Anytime contract: Truncated candidates (cut mid-expansion) are abandoned and discarded rather than compared against completed candidates. If the deadline expires before even one candidate completes, the search falls back to the pre-ranker’s top pick.
Telemetry sink (RootSearchStats): An optional statsSink receives search diagnostics per move (legalTurns, candidatesSelected, candidatesCompleted, candidatesAbandoned), reporting whether the deadline truncated candidate expansion.
ExpectimaxSearch is not included in BotRegistry’s default built-in entries because it requires an injected evalBatch function. Hosts instantiate and register custom instances: