Adding a player
There are two kinds of player to add:
- A minimax player — a new evaluation function on top of the existing search. This is the
easiest and usually what you want for a new computer opponent. Implement
MinimaxPlayer. - Any other player — a different algorithm (Monte Carlo tree search, a random mover, a player
over the network, a keyboard-driven human…). Implement
Playerdirectly.
Then, either way, make it selectable on the command line.
Read Players first for what each trait method is for.
Option A: a minimax player
Say we want a player that only counts dead sheep and tiger mobility. Create
src/greedy-player.rs:
use std::time::Duration;
use crate::{board::{GameBoard, Turn}, player::{Killers, MinimaxPlayer, ScoreCache, SearchReport, TranspositionTable}};
/// A computer player that searches like `ClaudePlayer` but only counts dead sheep and tiger moves.
pub struct GreedyPlayer
{
pub time_limit: Duration,
cache: TranspositionTable,
killers: Killers,
last_search: Option<SearchReport>,
}
impl GreedyPlayer
{
pub fn new(time_limit: Duration) -> Self
{
Self { time_limit, cache: TranspositionTable::new(20), killers: Killers::default(), last_search: None }
}
}
impl MinimaxPlayer for GreedyPlayer
{
/// Positive favours tigers, whichever side is to move.
fn evaluate(&mut self, board: &GameBoard, _turn: Turn) -> f32
{
let dead = 20 - board.sheep_count() - board.sheep_left();
100.0 * dead as f32 + board.get_moves(Turn::Tiger).len() as f32
}
fn name(&self) -> String { "Greedy".to_string() }
fn time_limit(&self) -> Duration { self.time_limit }
fn cache(&mut self) -> Option<&mut dyn ScoreCache> { Some(&mut self.cache) }
fn killers(&mut self) -> Option<&mut Killers> { Some(&mut self.killers) }
fn set_last_search(&mut self, report: Option<SearchReport>) { self.last_search = report; }
fn last_search(&self) -> Option<SearchReport> { self.last_search }
}
That's all: the blanket impl<P: MinimaxPlayer> Player for P gives it pick_move with iterative
deepening, the "Thinking…" panel and the last-turn report. Don't also write impl Player for
GreedyPlayer; it would conflict with the blanket impl.
Things to get right in evaluate:
- Sign. Positive is good for the tigers, negative good for the sheep, regardless of
turn. - Finite values.
±f32::INFINITYmeans a won game to the search; an evaluation that returns it would be read as a forced win and stop iterative deepening. - Symmetry. With a cache, the search shares one entry between the 8 symmetric boards, so
evaluateshould give them the same score. Anything built from counts (pieces, moves, jumps) is symmetric automatically; something like "sheep on the left half" is not. - Speed.
evaluateruns at every leaf, which is most of the nodes.get_movesallocates aVec; the bitmask accessors (tiger_points,sheep_points,sheep_count) are much cheaper.
cache, killers and the last_search pair are optional. Leave them out and the player still
works, just searching more slowly and with an empty last-turn panel.
Option B: any other player
Implement Player. A computer player does its work in pick_move and returns Some. Here is a
player that moves at random, using the crate's own Rng from network.rs
(src/random-player.rs):
use crate::{board::{GameBoard, Move, Turn}, network::Rng, player::Player};
/// Plays a uniformly random legal move.
pub struct RandomPlayer
{
rng: Rng,
}
impl RandomPlayer
{
pub fn new(seed: u64) -> Self
{
Self { rng: Rng::new(seed) }
}
}
impl Player for RandomPlayer
{
fn name(&self) -> String { "Random".to_string() }
fn pick_move(&mut self, board: GameBoard, turn: Turn, _previous: &[GameBoard]) -> Option<Move>
{
// pick_move is only called while the game is on, so there is always a move
let moves = board.get_moves(turn);
Some(moves[self.rng.below(moves.len())])
}
}
Things to keep in mind:
- Return only legal moves. The coordinator
unwrapsmake_move, so an illegal move panics. Pick fromboard.get_moves(turn), or check withboard.check_move(mv). - Don't block forever. The coordinator can't redraw or react to
qwhilepick_moveruns. Input that arrives meanwhile is handled as soon as it returns. Keep a time limit. - Repetitions.
previousholds every earlier board in the game.board::repetitions(previous, &next)says how often a position came up before;REPETITION_DRAW(2) earlier occurrences make a draw. - Interactive players return
Nonefrompick_moveand produce their move fromhandle_input, asLocalPlayerdoes. Useupdateto remember the position forrender,highlightsto show a selection, and clear your own selection when you return a move.resetis called after the user reverts, so drop any selection there too. - Panels. Override
renderfor the panel shown on your turn andrender_last_turnfor comments on your last move, shown during the opponent's turn. Both draw with ratatui into the givenareaofbuf, e.g.Paragraph::new(text).render(area, buf)(withratatui::widgets::Widgetin scope).
Wiring it into the command line
All in src/main.rs.
1. Declare the module. File names with a hyphen need #[path]:
#[path = "greedy-player.rs"]
mod greedy_player;
and import the type: use crate::greedy_player::GreedyPlayer; (or add it to the existing
use crate::{…} list).
2. Add a PlayerKind variant:
enum PlayerKind
{
Human,
Claude,
Greedy,
Neural(PathBuf),
}
3. Parse its name in parse_player, before the file-path case, and mention it in the error:
"greedy" => Ok(PlayerKind::Greedy),
…
_ => Err(format!("expected `human`, `claude`, `greedy` or a weights file, but `{s}` is none of these")),
4. Construct it in make_player:
PlayerKind::Greedy => Box::new(GreedyPlayer::new(time_limit)),
5. Update the help text on the sheep and tiger fields of PlayArgs (their doc comments are
what --help shows), and the player list in the top-level README.md.
Then:
cargo run --release -- -S human -T greedy
cargo run --release -- -S greedy -T claude --time 1
If the new type is only used from main.rs through make_player, everything is reachable and there
are no dead-code warnings. A constructor or field that nothing uses yet will warn; that's a sign to
either use it or remove it.
Testing it
Put tests in a #[cfg(test)] mod tests at the bottom of the player's file, as the other players do.
Useful checks:
pick_movefrom a few positions returns a move withboard.check_move(m) == Ok(()).- For a minimax player,
last_search()reports the move it returned. - A player with a clear preference picks the obvious move:
player.rshas a position where one tiger jump wins the game (tiger_takes_the_winning_kill) that is easy to copy.
To see how it plays, play it against ClaudePlayer in the TUI with both sides computer-controlled:
cargo run --release -- -S greedy -T claude
For a proper strength comparison over many games, train.rs's match_score and play show how to
run games without the TUI: play takes a closure that returns (move, score) for a position, so
any Player can be plugged in.
Using it in training
train.rs plays its matches against ClaudePlayer (claude(depth)). To measure the network
against your player instead, add a constructor like claude for it and pass it to match_score,
which accepts any MinimaxPlayer + Player. Keep the match opponents out of the training data:
they are a yardstick, and the network is meant to learn from self-play alone.
