Complete Yahtzee rules and bots: a multiplayer state machine with the official forced-joker scoring, a fast heuristic player, and an exact expectimax solver that knows the best move — and its expected value — in every position.
The design triangle:
State: the compact rules authority. Which categories are legal, what they pay, joker forcing, both bonuses — one implementation shared by the scorecard, the game, and the solver, so they can never disagree.Game: the table-level state machine. Deterministic dice injection is the primitive (replays and front ends stay trivial); illegal moves are rejected without changing anything. AStrategydecides from aView, and drivers run whole turns and games.Solver: exact expected values by backward induction over every scorecard state. Under the official rules the empty card is worth 254.5877 points; the widely cited 254.5896 (Verhoeff, 1999) uses a laxer joker convention that the same engine reproduces when the forcing is lifted.
HeuristicBot: deterministic rules of thumb, microsecond decisions, no tables. Averages 216 points over 10 000 seeded games.OptimalBot: provably perfect solo play backed by the solver. Solves lazily by default;OptimalBot::presolved()computes the whole table up front (seconds in a release build, spread across cores with theparallelfeature).
The rules need no features — inject dice, hold, and score:
use yahtzee_engine::{Category, Game, TurnAction};
let mut game = Game::new(1);
game.roll("13446".parse()?)?;
game.act(TurnAction::Reroll("44".parse()?))?;
game.roll("24464".parse()?)?;
game.act(TurnAction::Reroll("444".parse()?))?;
game.roll("44446".parse()?)?;
let delta = game.act(TurnAction::Score(Category::FourOfAKind))?;
assert_eq!(delta.expect("scored").value, 22);
# Ok::<(), Box<dyn std::error::Error>>(())Neither does the solver — ask it what any position is worth:
use yahtzee_engine::{Category, Solver, State};
let mut solver = Solver::new();
// With only Chance open, optimal play is worth exactly 70/3 points.
let state = State::new(!Category::Chance.bit(), 0, false);
assert!((solver.value(state) - 70.0 / 3.0).abs() < 1e-9);With the (default) rand feature, roll for real and pit bots against
each other:
# #[cfg(feature = "rand")]
# fn main() -> Result<(), yahtzee_engine::EngineError> {
use yahtzee_engine::{Game, HeuristicBot, play_game};
let mut game = Game::new(2);
let (mut alice, mut bob) = (HeuristicBot::new(), HeuristicBot::new());
let totals = play_game(&mut game, &mut [&mut alice, &mut bob], &mut rand::rng())?;
println!("final scores: {totals:?}");
# Ok(())
# }
# #[cfg(not(feature = "rand"))]
# fn main() {}Writing your own bot is implementing Strategy's two decisions
against a View; the driver handles all bookkeeping.
rand(default): dice-rolling helpers (Game::roll_with,play_turn,play_game) and theplay/arenaexamples. The rules and the solver are deterministic; disable it for a dependency-light build.parallel: solves scorecard tiers across the CPU cores via rayon. The table is bit-identical to the serial build, it just arrives faster. Off by default.
solve: build the full table and print the optimal expected score —cargo run --release --example solve --features parallelplay: play against the heuristic bot in the terminal —cargo run --release --example playarena: bot strength measurements —cargo run --release --example arena -- 10000 --optimal
The web/ crate wraps the engine in WebAssembly with the
solver as a live advisor: https://jdh8.github.io/yahtzee-engine/.