summaryrefslogtreecommitdiff
path: root/docs/board.md
diff options
context:
space:
mode:
authorDylan <boss@tehbox.org>2026-11-09 23:32:43 +1300
committerDylan <boss@tehbox.org>2026-11-09 23:32:43 +1300
commitf17308e3c132ee18cc4cbd458329958c99a712ed (patch)
tree098800a2f4efd37fd3ffd0c639b732fdc8227a65 /docs/board.md
parent4eea7f726b5a7e0a55716a5c87e398dfb65fceb7 (diff)
downloadtigers-and-sheep-0.1.0.tar.gz
tigers-and-sheep-0.1.0.zip
docs: Add a docs folder on building, the code's structure, the search and trainingHEADv0.1.0master
Covers building and dependencies, the architecture and game loop, the board representation, the Player and MinimaxPlayer traits, the minimax search, the network and self-play training, benchmarking, and how to add a player. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/board.md')
-rw-r--r--docs/board.md163
1 files changed, 163 insertions, 0 deletions
diff --git a/docs/board.md b/docs/board.md
new file mode 100644
index 0000000..8719d2b
--- /dev/null
+++ b/docs/board.md
@@ -0,0 +1,163 @@
+# The board
+
+Everything about the rules lives in [`src/board.rs`](../src/board.rs), along with its unit tests at
+the bottom of the file.
+
+## Types
+
+| Type | Meaning |
+|------|---------|
+| `Pos = (u8, u8)` | A point `(x, y)`, both in `0..5`. Out-of-range points panic in `bit_idx`. |
+| `PieceType` | `Tiger`, `Sheep` or `Empty`; what `get(pos)` returns |
+| `Turn` | `Tiger` or `Sheep`, the side about to move. `next_turn()` flips it. |
+| `Move` | `PlaceSheep { pos }`, `MoveSheep { from, to }` or `MoveTiger { from, to }`. A tiger jump is a `MoveTiger` of two points; `is_jump` tells them apart. |
+| `MoveError` | Why `check_move` rejected a move, with a human-readable `Display` |
+| `Outcome` | `Tiger`, `Sheep` or `Draw`; `value()` is +1, -1 or 0, matching the sign of scores |
+| `GameBoard` | The position, including the sheep still in hand. `Copy`, 8 bytes. |
+
+## The bitboard
+
+A `GameBoard` is two `u32`s:
+
+```text
+tiger_mask: [ unused (7 bits) ][ 25 board bits ]
+sheep_mask: [ sheep in hand ][ 25 board bits ]
+ bits 25..32 bits 0..25
+```
+
+Point `(x, y)` is bit `x + 5*y`, so `(0, 0)` (a5, top left) is bit 0 and `(4, 4)` (e1) is bit 24:
+
+```text
+ a b c d e
+5 0 1 2 3 4
+4 5 6 7 8 9
+3 10 11 12 13 14
+2 15 16 17 18 19
+1 20 21 22 23 24
+```
+
+The high 7 bits of `sheep_mask`, from `SHEEP_COUNT_SHIFT` = 25, aren't points: they hold the
+number of sheep still in hand, 20 at the start. `sheep_left()` reads them, and placing a sheep sets
+its board bit and subtracts `ONE_SHEEP` (`1 << 25`) in one go. Killing a sheep only clears its board
+bit; the in-hand count is unplaced sheep, not living ones. So the number of dead sheep is
+`20 - sheep_count() - sheep_left()`.
+
+The default board has tigers on bits 0, 4, 20 and 24 (the corners) and 20 sheep in hand.
+`GameBoard::EMPTY` has nothing at all and is used as the "no entry" board in the transposition table.
+
+Useful accessors:
+
+- `tiger_points()`, `sheep_points()` — the 25 board bits alone.
+- `sheep_count()` — sheep on the board (`popcount` of the board bits).
+- `key()` — the whole board as one `u64` (`sheep_mask << 32 | tiger_mask`). Different boards give
+ different keys, so it serves as a hash key and for ordering.
+
+## Lines
+
+Every point has horizontal and vertical lines. Diagonals only run through points where `x + y` is
+even (the corners, the centre and so on), as on a Bagh-Chal board:
+
+```text
+5 T───·───·───·───T
+ │ ╲ │ ╱ │ ╲ │ ╱ │
+4 ·───·───·───·───·
+ │ ╱ │ ╲ │ ╱ │ ╲ │
+3 ·───·───·───·───·
+ …
+```
+
+`on_line(from, to, dist)` checks this for a single move: orthogonal and `dist` away, or diagonal,
+`dist` away and starting from an even point. A diagonal step keeps `x + y`'s parity, so a diagonal
+never leaves the even points.
+
+## Move generation with bitmasks
+
+`get_moves(turn)` doesn't loop over pieces. It works on all pieces of a side at once, one direction
+at a time.
+
+- `DIRS` lists the 8 directions as `(dx, dy)`: 4 orthogonal, then 4 diagonal. `DELTAS` is the
+ matching change in bit index, `dx + 5*dy`.
+- `STEP_SOURCES[d]` is a mask, built at compile time by `line_sources`, of the points from which
+ one step in direction `d` stays on the board *and* follows a line. `JUMP_SOURCES[d]` is the same
+ for two steps.
+- `shift(mask, delta)` moves every point in a mask by `delta` bits. Shifting can wrap a point from
+ the end of one row to the start of the next, but the source masks exclude every point for which
+ that would happen.
+
+Then:
+
+```text
+sheep steps in direction d = shift(sheep & STEP_SOURCES[d], δ) & empty
+tiger steps in direction d = shift(tigers & STEP_SOURCES[d], δ) & empty
+jumped sheep = shift(tigers & JUMP_SOURCES[d], δ) & sheep
+tiger jumps in direction d = shift(jumped sheep, δ) & empty
+```
+
+`sheep_steps()` and `tiger_targets()` compute these masks, and `get_moves` turns each set bit back
+into a `Move` (the source is the destination minus `δ`, or minus `2δ` for a jump). Tiger jumps are
+listed before tiger steps, since the search wants to try kills first. While sheep are in hand, the
+sheep's only moves are placements on every empty point.
+
+`has_moves(turn)` uses the same masks but only asks whether any is non-zero, without building a
+`Vec`. `check_win` uses it, since it's called at every node of the search.
+
+## Playing moves
+
+- `check_move(mv)` returns `Ok(())` or the specific `MoveError`: placing with none in hand, onto an
+ occupied point, moving a sheep while some are still in hand, moving something that isn't there,
+ moving off a line, jumping over something that isn't a sheep, and so on. The tests assert on the
+ exact variant.
+- `make_move(mv)` checks first and leaves the board unchanged on an error.
+- `make_move_unchecked(mv)` skips the checks. Use it only for moves from `get_moves` (the search
+ does, for speed). An illegal move here leaves the board in a state the rules can't reach.
+- `after_move_unchecked(mv)` returns a new board instead of changing this one.
+
+A tiger move of two points always kills the sheep in between (`move_tiger` uses the `midpoint`).
+
+## Winning and drawing
+
+`check_win(turn)` is called with the side **about to move**, and `true` means the side that **just
+moved** has won:
+
+- Tigers have won once 5 sheep are dead: `sheep_count() + sheep_left() <= 15`.
+- Otherwise, the side that just moved has won if `turn` has no legal move. This is how sheep win,
+ by leaving every tiger without a move. (Tigers could in principle win this way too, by leaving the
+ sheep with no moves.)
+
+So `Outcome::win(turn.next_turn())` is the winner when `check_win(turn)` is true.
+
+### Repetitions
+
+A game is drawn on a threefold repetition, as in chess: the same position with the same side to
+move, for the third time. `repetitions(previous, board)` counts how many times `board` came up
+before with the same side to move, where `previous` is every earlier board, oldest first. Since the
+last board in `previous` had the other side to move, it looks at every second board going back:
+`previous.iter().rev().skip(1).step_by(2)`.
+
+Two shortcuts keep it cheap:
+
+- Placing and killing can't be undone, so nothing can repeat while sheep are in hand: it returns 0.
+- For the same reason, it stops scanning at the first earlier board with a different number of
+ sheep on it.
+
+`REPETITION_DRAW` = 2: a position that has come up twice before is a draw. The search uses a
+stricter rule internally (one earlier occurrence scores as a draw); see [The minimax search](search.md#repetitions).
+
+## Symmetries
+
+The board has 8 symmetries: 4 rotations, each optionally mirrored. All of them map lines to lines,
+because they keep the parity of `x + y`, so a transformed position is just as legal and equally good
+for either side. The search uses this to share cache entries between symmetric positions, and
+training uses it to show the network each position in a random orientation.
+
+- `transform_pos(pos, sym)` with `sym` in `0..SYMMETRIES` (8): bit 2 of `sym` swaps x and y, then
+ bit 0 mirrors x and bit 1 mirrors y.
+- `inverse_symmetry(sym)` undoes it. Plain mirrors undo themselves; with the swap first, undoing
+ means swapping which axis is mirrored.
+- `transform_move(mv, sym)` transforms both ends of a move.
+- `GameBoard::transformed(sym)` transforms a whole board. Doing that point by point would be slow,
+ so `ROW_MAPS` (built at compile time) holds, for each symmetry and each of the 5 rows, where each
+ of the 32 possible sets of points in that row end up. A mask then transforms in 5 table lookups
+ ORed together. The sheep-in-hand bits are carried over unchanged.
+- `canonical()` returns the transform with the smallest `key()` and the `sym` that reaches it. All 8
+ symmetric boards have the same canonical board.