Configurable two- or three-ply expectimax search for Dice Chess.
Unlike a one-ply evaluator (GreedySearch, OnnxEvalSearch), this looks one full turn ahead and so sees tactical punishments — a capture that hangs a bigger piece to the recapture — that a static evaluation cannot. The layer between the two plies is a chance node: the opponent's roll is unknown when we move, so the value of our turn is the expectation over all DiceRolls outcomes of the opponent's best (for them) reply.
The evaluation function is injected as a batch (evalBatch(states, color) scores every state from color's perspective) so the same search works with any leaf evaluator — the engine's material score, or an externally trained model — and so the many leaves under one chance node can be scored in a single call. Non-terminal leaf scores must not exceed ExpectimaxSearch.UpperScoreBound because Star pruning uses that ceiling; values below zero are supported down to the terminal-loss sentinel.
Two terminal cases sit outside the evaluator, because a king capture ends the game and material scores never see the king:
if one of our own turns captures the opponent's king, we play it immediately (an outright win);
a leaf where the opponent captures our king is worth ExpectimaxSearch.LossValue — below any real score on any scale — so the opponent always takes it and we always rank that line last.
At depth 3, every opponent reply is followed by a chance node over our next roll and a MAX node over our legal replies before leaf evaluation. As a TimeBudgetedSearch the implementation honours a wall-clock deadline at every chance-node roll boundary, expanding pre-ranked root candidates until time runs out.
Value parameters
chanceCollapse
when present, replaces every root candidate's exact chance-node expansion with one batched model call — see ChanceCollapse for what that keeps and what it gives up. Absent by default, and absent means the exact search runs unchanged: the collapsed path is a separate branch that the default configuration never enters.
preRank
batched evaluator used to rank the mover's own legal turns before the (expensive) chance-node expansion — only the top ExpectimaxConfig.candidateLimit are explored. Defaults to material (ExpectimaxSearch.materialBatch — the historical, hardcoded behaviour). Widening candidateLimit compensates for a crude pre-ranker at linear search-cost growth; a sharper pre-ranker (e.g. the same value model already driving the chance node) attacks the actual bottleneck instead — candidateLimit=16 vs material pre-ranking measured +4.8pp purely from surfacing turns the material proxy had buried outside the top 8.
statsSink
receives one RootSearchStats per findBestMove call. Defaults to a no-op so the hot path pays nothing beyond a single call; a real sink lets a host (arena runner, production bot) observe how many candidates the deadline actually allowed — see RootSearchStats for why that matters.
Candidates are expanded in material-ranked order, and the deadline is honoured between dice rolls inside a candidate's chance node — not merely between candidates. Even the top candidate can therefore be abandoned before it yields a comparable value, which is the point: one candidate routinely costs more than the whole per-turn budget, so a coarser check made the deadline advisory rather than binding (#496).
The result is the best completed candidate, or — when the deadline left no candidate time to finish — the pre-ranker's own top pick, so the anytime contract still delivers a legal turn. A partially expanded candidate is never ranked: its expectation covers only the rolls the clock allowed, so comparing it against complete ones would let the instant the deadline landed choose the move. RootSearchStats reports both cases.
All of this matters because a single roll can generate thousands of opponent replies (the Dice Chess branching tail).
Estimates the winning probability in [0.0, 1.0] for the side to move. Convenience over winProbability for offer decisions, where the deciding side is the active colour; do not override it, override winProbability.
Estimates the winning probability in [0.0, 1.0] for the side to move. Convenience over winProbability for offer decisions, where the deciding side is the active colour; do not override it, override winProbability.
Determines whether the bot should accept (Take) or decline (Drop) a double from the opponent, taking the side to move as the responder. Kept for source and binary compatibility; it delegates to the three-argument overload.
Determines whether the bot should accept (Take) or decline (Drop) a double from the opponent, taking the side to move as the responder. Kept for source and binary compatibility; it delegates to the three-argument overload.
Value parameters
currentStake
the proposed double stake (e.g. 2, 4...)
state
current game state (dice pool is empty)
Attributes
Returns
true to accept the double (Take), false to resign the current stake (Drop)
Note
Perspective hazard: this overload evaluates the win probability of state.activeColor. In a double decision state, state.activeColor is the player who offered the double, NOT the responder, so a caller that feeds the delivered position here asks whether the offerer should take. Callers must prefer shouldAcceptDouble(state, currentStake, responder); strategies must override that overload, not this one.
Determines whether the bot should accept (Take) or decline (Drop) a double from the opponent, evaluated from the perspective of responder.
Determines whether the bot should accept (Take) or decline (Drop) a double from the opponent, evaluated from the perspective of responder.
This is the single policy point for the take/drop decision: strategies that want their own threshold or estimator override this overload. The two-argument form below only delegates here, so a policy written against it is bypassed whenever a caller names the responder explicitly (which every caller holding a doubleDecision state must, see the hazard note there).
Value parameters
currentStake
the proposed double stake (e.g. 2, 4...)
responder
the colour of the player responding to the double offer
state
current game state (dice pool is empty); its active colour is the side to move, which in a double decision is the offerer, not the responder
Attributes
Returns
true to accept the double (Take), false to resign the current stake (Drop)
Estimates the winning probability in [0.0, 1.0] of color, whichever side is to move.
Estimates the winning probability in [0.0, 1.0] of color, whichever side is to move.
This is the estimator hook: a strategy with its own equity model (for example a Monte-Carlo rollout) overrides this method, so that both the offer decision (active colour) and the take/drop decision (responder colour) see the same estimate. The default maps the centipawn evaluation to a probability with a logistic sigmoid.