Architecture
The crate is a single binary, tigers-and-sheep-rs. It has three modes: play a game in the
terminal, benchmark the search, or train a neural network.
Modules
main.rs ─── command line (clap) ──┬── game.rs ─────── GameHandler: the TUI and game loop
│ └── board-view.rs BoardView widget, pos_at
├── bench.rs ────── fixed-depth search timings
└── train.rs ────── self-play training
player.rs ───── Player trait, MinimaxPlayer trait + search, ScoreCache, TranspositionTable, Killers
├── local-player.rs LocalPlayer a human with a mouse
├── claude-player.rs ClaudePlayer minimax + hand-written evaluation
└── neural-player.rs NeuralPlayer minimax + network evaluation
network.rs ──── Network (MLP), Adam, Rng, Sample
board.rs ────── GameBoard, Move, Turn, Outcome, rules, move generation, symmetries, repetitions
| File | Module | Contents |
|---|---|---|
src/main.rs |
crate root | Cli and its subcommands, parse_player, make_player, play |
src/board.rs |
board |
All the game logic and its unit tests. See The board. |
src/player.rs |
player |
The Player trait, and the MinimaxPlayer trait with the whole search. See Players and The minimax search. |
src/game.rs |
game |
GameHandler, which owns the move history and runs the TUI loop |
src/board-view.rs |
board_view |
BoardView, the board widget, and pos_at, which maps a mouse click back to a point |
src/local-player.rs |
local_player |
LocalPlayer |
src/claude-player.rs |
claude_player |
ClaudePlayer |
src/neural-player.rs |
neural_player |
NeuralPlayer |
src/network.rs |
network |
The neural network, its optimiser and file format. See Training. |
src/train.rs |
train |
Self-play training (Config, run) |
src/bench.rs |
bench |
The bench command. See Benchmarking. |
Files with a hyphen in their name are pulled in with #[path = "local-player.rs"] mod local_player;
in main.rs, since a module name can't contain a hyphen.
The command line
main.rs parses arguments with clap's derive API:
Clihas an optional subcommand and, flattened in, thePlayArgs, sotigers-and-sheep-rs -S humanworks without writingplay.args_conflicts_with_subcommandsstops the two being mixed.Command::Play(PlayArgs)—--sheep/-Sand--tiger/-Teach take aPlayerKind, parsed byparse_player:human,claude, or the path of an existing file (taken as network weights).--time/-tis each computer player's time limit in seconds (default 2).Command::Bench { depth }— runsbench::run(depth).Command::Train(TrainArgs)— every field oftrain::Configis an argument, and each default is read fromConfig::default(), so the defaults live in one place.impl From<TrainArgs> for Configconverts them. Keep the two in step when adding a setting.
make_player turns a PlayerKind into a Box<dyn Player>, and play hands the two boxes to
GameHandler::play_game and prints the result.
How a game runs
GameHandler (game.rs) holds the start board and the history, a
Vec<(Move, GameBoard)> with the board after each move. Everything else is derived from it:
board()is the last history entry's board, or the start board.turn()comes from the history's length: even means sheep to move.previous()is every board before the current one, oldest first, used to detect repetitions.outcome()is a win ifboard.check_win(turn)(the side that just moved has won), otherwise a draw if the position has come upREPETITION_DRAW= 2 times before, otherwiseNone.
play_game enters the alternate screen with ratatui::init(), turns on mouse capture, runs the
loop, and restores the terminal however the loop ends. Each pass of the loop:
- Calls
update(board, turn)on the player to move, so it can show the position in its panel. - Draws the whole screen. This happens before asking for a move, so a computer player's "Thinking…" panel is on screen while it searches.
- If the game isn't over, calls
pick_move(board, turn, &previous). - A computer player searches and returnsSome(move). Before playing it,pending_actionhandles any key presses and clicks that arrived during the search, without blocking. A quit or a revert there wins over the move. This is what makes quitting work in a computer-against-computer game, where nothing else ever waits for input. - A human player returnsNone, and the loop blocks onevent::read()for the next event. - Handles the resulting
Action: - Quit (q,Esc,Ctrl+C): returns the outcome. - Play(move):make_move(which panics on an illegal move, so players must only return legal ones) and push onto the history. - Revert(i): a click on history rowitruncates the history to before movei, then callsreset()on both players so they drop selections and stale comments. - A click on the board goes to the player to move asInput::Click(pos); other keys go asInput::Key. Ifhandle_inputreturns a move, it is played.
Once the game is over the loop keeps running, showing the result, until the user quits or reverts.
Screen layout
┌ Board ──────────────────────────────┬ Moves ───────────┐
│ │ 1. sc3 [↶] │
│ (BoardView, scaled) │ 2. ta5b4 [↶] │
│ │ … │
├ click: select / move · q, Esc: quit ┼──── ↶ reverts ───┤
│ current player's panel (70%) │ last turn (30%) │
└───────────── Sheep: You ────────────┴ Last turn: … ────┘
- Neighbouring panes overlap by one cell (
Layout::spacing(-1)) and are built withpanel(), which usesMergeStrategy::Exact, so shared borders join into┬ ┴ ┼. - The bottom row is drawn first and keeps its titles on its bottom border, because its top border
is the same line as the board's bottom border, which lists the keybinds. If you change the keys in
handle_event, change that title too. - The current player's panel is drawn by
Player::render; the other panel is the previous mover'sPlayer::render_last_turn(for minimax players: the move, depth reached and score). - The history pane is a fixed 20 columns and scrolls to keep the newest move visible. Sheep moves are white, tiger moves yellow.
BoardViewpicks the largest whole-number scale that fits (scale_to_fit), and the coordinator sizesboard_areato exactlyBoardView::size(scale).pos_atuses the same calculation to map a click back to the nearest point, so drawing and clicking always agree. Pieces grow with the scale:Tat scale 1,[T]at 2, a 3×3 box from 3 on.
Data flow between the pieces
GameHandler ──update/pick_move/handle_input──▶ dyn Player
│ │
│ make_move, check_win, repetitions │ get_moves, after_move_unchecked,
▼ ▼ check_win, evaluate
GameBoard ◀──────────────────────────────── MinimaxPlayer search
│
ScoreCache / Killers / Network
The board is Copy and 8 bytes, so it is passed by value everywhere and nothing shares mutable
state. Players never see the GameHandler; they only get the board, the side to move and the
earlier boards.
Training (train.rs) doesn't use GameHandler at all. It has its own small game loop, play,
that calls players directly, so it runs without a terminal and in parallel. bench.rs likewise
calls pick_move directly.
