Build bigger projects with your coding agent.

Magistr keeps the roadmap, the reasons behind decisions and the checks with your project, so the next session can continue where this one stopped.

Works with Codex and Claude Code. Other agents connect through MCP.

A real build, start to finish.

Codex built a complete tic-tac-toe game in Bevy 0.20.0 with Magistr in 35 minutes. Follow the session, open the roadmap at any moment, and see why the opponent looks ahead.

The brief

Build a tic-tac-toe game in Bevy 0.20.0. Play against the computer, show the result, then play another round with a running score.

Rust · Bevy 0.20.0

You ask for a game. Codex agrees on the scope, then saves a four-piece plan before writing any code.

Codextic-tac-toeRecorded session
Let’s build a small game in Bevy instead. Tic-tac-toe would be a good fit.

Roadmap

The finished tic-tac-toe board in the browser: X and O marks on nine squares, the computer's winning column highlighted, the running score, and a Play again button.
The finished game, Bevy 0.20.0 in the browser

This is the Codex session that built the example on this page. Recorded on October 21, 2026: 35 minutes from the first request to the finished game, with every roadmap change, decision, file write and check as Magistr saved it. The same session then published this site; those replies are in the full session, not in the walkthrough.

Read the full session
  1. Ask for a Bevy game

    You change course: a small Bevy game instead of the earlier plan. This is where the project starts.

    Let’s build a small game in Bevy instead. Tic-tac-toe would be a good fit.
  2. Agree on what to build

    Codex proposes a playable board, a computer opponent, and rematches. The walkthrough it mentions is this page.

    I’ll use a small Bevy tic-tac-toe game instead, with a playable board, an AI opponent, and a rematch flow. The walkthrough will follow the real implementation and show how Magistr records the plan, decisions, work, and checks along the way.
  3. Make the work concrete

    The agent identifies the game behavior it will build and check.

    Tic-tac-toe gives us concrete things to follow: legal moves, an opponent that can handle forks, a playable Bevy board, and restarting a finished game. I’ll keep those same pieces visible throughout the roadmap, recorded decisions, code changes, and checks so the walkthrough tells one consistent story.
  4. Start with a complete little game

    Build tic-tac-toe with an opponent, a playable Bevy board, and another round when the game ends.

    The plan
    Keep the rules separate from the screen so the same game can be tested and played.
    Build the game in examples/bevy-tic-tac-toe and make its actual recorded work the single website story.
    
    These deliverables are easy to recognize, have real dependencies, and expose a meaningful algorithm decision without requiring private project context.
  5. Plan the rules first

    The game needs to know whose turn it is, which moves are allowed, and when someone has won.

    Reject illegal moves and recognize every game ending
    • X and O alternate; occupied and out-of-range cells cannot change the board.
    • All eight winning lines and a full-board draw are recognized.
    • A finished game accepts no further moves.
  6. Give the opponent a clear goal

    The computer should never lose, whether it plays X or O.

    How to check it
    Try every legal human reply against the opponent.
    Make an opponent that never loses
    • The computer plays legal moves for either mark.
    • An exhaustive search of human replies finds no computer losses.
    • Record the comparison between a simple tactical policy and full search.
  7. Plan a board you can play

    Build nine squares in Bevy, with clicks and number keys for moves and text showing whose turn it is.

    Play a complete game through the Bevy board
    • Nine visible cells accept pointer and keyboard input.
    • Turn and game-result text reflect the shared rules.
    • The same Rust game builds for a browser with Bevy.
  8. Plan what happens after a round

    Keep the score, clear the board, and alternate who starts. This part needs both the board and the opponent.

    Play again, keep the score and alternate who starts
    • The computer responds through the same game rules.
    • Finished rounds add one score; further input cannot add another.
    • Play again clears the board, preserves the score and alternates the opening player.
  9. Start with legal moves and turns

    The agent starts the rules first. Both the opponent and the Bevy board will use them.

    Done when
    Invalid moves leave the game alone, and wins and draws are recognized.
    Reject illegal moves and recognize every game ending
    • X and O alternate; occupied and out-of-range cells cannot change the board.
    • All eight winning lines and a full-board draw are recognized.
    • A finished game accepts no further moves.
  10. Make the rules work concrete

    The agent saves a task for turns, allowed moves, and game endings.

    Why save it
    The task gives the code changes and their checks a shared place in the project.
    Implement legal moves, alternating turns and game endings
    Status: planned
  11. Begin writing the rules

    The rules task is now in progress. The next step is to create the Rust project and its board logic.

    Implement legal moves, alternating turns and game endings
    Status: active
  12. Create the Rust project

    The agent writes Cargo.toml, which describes the game package and how Rust should build it.

    Starting small
    The rules can run without opening a Bevy window.

    Cargo.toml · +12 −0

    @@ -0,0 +1,12 @@
    +[package]
    +name = "magistr-tic-tac-toe"
    +version = "0.1.0"
    +edition = "2024"
    +publish = false
    +
    +# The website example builds independently of Magistr.
    +[workspace]
    +
    +[profile.release]
    +opt-level = "s"
    +lto = "thin"
    [package]
    name = "magistr-tic-tac-toe"
    version = "0.1.0"
    edition = "2024"
    publish = false
    
    # The website example builds independently of Magistr.
    [workspace]
    
    [profile.release]
    opt-level = "s"
    lto = "thin"
    
  13. Put the game rules in place

    The new src/lib.rs handles turns, legal moves, wins, and draws.

    Checks included
    Tests cover occupied squares, all eight winning lines, a full board, and moves after a game ends.

    src/lib.rs · +134 −0

    @@ -0,0 +1,134 @@
    +//! Game rules shared by the Bevy interface and exhaustive opponent checks.
    +#[derive(Clone, Copy, Debug, Eq, PartialEq)]
    +pub enum Mark {
    +    X,
    +    O,
    +}
    +
    +impl Mark {
    +    pub fn other(self) -> Self {
    +        match self {
    +            Self::X => Self::O,
    +            Self::O => Self::X,
    +        }
    +    }
    +    pub fn label(self) -> &'static str {
    +        match self {
    +            Self::X => "X",
    +            Self::O => "O",
    +        }
    +    }
    +}
    +
    +pub const LINES: [[usize; 3]; 8] = [
    +    [0, 1, 2], [3, 4, 5], [6, 7, 8],
    +    [0, 3, 6], [1, 4, 7], [2, 5, 8],
    +    [0, 4, 8], [2, 4, 6],
    +];
    +
    +#[derive(Clone, Copy, Debug, Eq, PartialEq)]
    +pub enum Outcome {
    +    Playing,
    +    Won(Mark, [usize; 3]),
    +    Draw,
    +}
    +
    +#[derive(Clone, Copy, Debug, Eq, PartialEq)]
    +pub enum MoveError {
    +    OutsideBoard,
    +    Occupied,
    +    Finished,
    +}
    +
    +#[derive(Clone, Debug, Eq, PartialEq)]
    +pub struct Board {
    +    cells: [Option<Mark>; 9],
    +    turn: Mark,
    +}
    +
    +impl Default for Board {
    +    fn default() -> Self {
    +        Self { cells: [None; 9], turn: Mark::X }
    +    }
    +}
    +
    +impl Board {
    +    pub fn cells(&self) -> &[Option<Mark>; 9] { &self.cells }
    +    pub fn turn(&self) -> Mark { self.turn }
    +    pub fn outcome(&self) -> Outcome {
    +        for line in LINES {
    +            if let Some(mark) = self.cells[line[0]] {
    +                if line.iter().all(|&cell| self.cells[cell] == Some(mark)) {
    +                    return Outcome::Won(mark, line);
    +                }
    +            }
    +        }
    +        if self.cells.iter().all(Option::is_some) { Outcome::Draw } else { Outcome::Playing }
    +    }
    +    pub fn play(&mut self, cell: usize) -> Result<(), MoveError> {
    +        if cell >= 9 { return Err(MoveError::OutsideBoard); }
    +        if self.outcome() != Outcome::Playing { return Err(MoveError::Finished); }
    +        if self.cells[cell].is_some() { return Err(MoveError::Occupied); }
    +        self.cells[cell] = Some(self.turn);
    +        self.turn = self.turn.other();
    +        Ok(())
    +    }
    +    pub fn legal_moves(&self) -> impl Iterator<Item = usize> + '_ {
    +        (0..9).filter(|&i| self.outcome() == Outcome::Playing && self.cells[i].is_none())
    +    }
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    fn played(moves: &[usize]) -> Board {
    +        let mut board = Board::default();
    +        for &cell in moves { board.play(cell).unwrap(); }
    +        board
    +    }
    +
    +    #[test]
    +    fn illegal_moves_leave_both_board_and_turn_unchanged() {
    +        let mut board = played(&[4]);
    +        let before = board.clone();
    +        assert_eq!(board.play(4), Err(MoveError::Occupied));
    +        assert_eq!(board.play(9), Err(MoveError::OutsideBoard));
    +        assert_eq!(board, before);
    +        assert_eq!(board.turn(), Mark::O);
    +    }
    +
    +    #[test]
    +    fn recognizes_all_eight_winning_lines_for_both_players() {
    +        for line in LINES {
    +            for mark in [Mark::X, Mark::O] {
    +                let mut board = Board::default();
    +                for cell in line { board.cells[cell] = Some(mark); }
    +                assert_eq!(board.outcome(), Outcome::Won(mark, line));
    +            }
    +        }
    +    }
    +
    +    #[test]
    +    fn completed_games_reject_more_moves() {
    +        let mut board = played(&[0, 3, 1, 4, 2]);
    +        assert_eq!(board.outcome(), Outcome::Won(Mark::X, [0, 1, 2]));
    +        let before = board.clone();
    +        assert_eq!(board.play(8), Err(MoveError::Finished));
    +        assert_eq!(board, before);
    +        assert_eq!(board.legal_moves().count(), 0);
    +    }
    +
    +    #[test]
    +    fn a_full_board_without_a_line_is_a_draw() {
    +        let board = played(&[0, 4, 8, 1, 7, 6, 2, 5, 3]);
    +        assert_eq!(board.outcome(), Outcome::Draw);
    +        assert_eq!(board.legal_moves().count(), 0);
    +    }
    +
    +    #[test]
    +    fn a_win_on_the_ninth_move_is_not_a_draw() {
    +        let board = played(&[0, 1, 2, 3, 4, 5, 7, 6, 8]);
    +        assert_eq!(board.outcome(), Outcome::Won(Mark::X, [0, 4, 8]));
    +    }
    +}
    //! Game rules shared by the Bevy interface and exhaustive opponent checks.
    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    pub enum Mark {
        X,
        O,
    }
    
    impl Mark {
        pub fn other(self) -> Self {
            match self {
                Self::X => Self::O,
                Self::O => Self::X,
            }
        }
        pub fn label(self) -> &'static str {
            match self {
                Self::X => "X",
                Self::O => "O",
            }
        }
    }
    
    pub const LINES: [[usize; 3]; 8] = [
        [0, 1, 2], [3, 4, 5], [6, 7, 8],
        [0, 3, 6], [1, 4, 7], [2, 5, 8],
        [0, 4, 8], [2, 4, 6],
    ];
    
    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    pub enum Outcome {
        Playing,
        Won(Mark, [usize; 3]),
        Draw,
    }
    
    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    pub enum MoveError {
        OutsideBoard,
        Occupied,
        Finished,
    }
    
    #[derive(Clone, Debug, Eq, PartialEq)]
    pub struct Board {
        cells: [Option<Mark>; 9],
        turn: Mark,
    }
    
    impl Default for Board {
        fn default() -> Self {
            Self { cells: [None; 9], turn: Mark::X }
        }
    }
    
    impl Board {
        pub fn cells(&self) -> &[Option<Mark>; 9] { &self.cells }
        pub fn turn(&self) -> Mark { self.turn }
        pub fn outcome(&self) -> Outcome {
            for line in LINES {
                if let Some(mark) = self.cells[line[0]] {
                    if line.iter().all(|&cell| self.cells[cell] == Some(mark)) {
                        return Outcome::Won(mark, line);
                    }
                }
            }
            if self.cells.iter().all(Option::is_some) { Outcome::Draw } else { Outcome::Playing }
        }
        pub fn play(&mut self, cell: usize) -> Result<(), MoveError> {
            if cell >= 9 { return Err(MoveError::OutsideBoard); }
            if self.outcome() != Outcome::Playing { return Err(MoveError::Finished); }
            if self.cells[cell].is_some() { return Err(MoveError::Occupied); }
            self.cells[cell] = Some(self.turn);
            self.turn = self.turn.other();
            Ok(())
        }
        pub fn legal_moves(&self) -> impl Iterator<Item = usize> + '_ {
            (0..9).filter(|&i| self.outcome() == Outcome::Playing && self.cells[i].is_none())
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        fn played(moves: &[usize]) -> Board {
            let mut board = Board::default();
            for &cell in moves { board.play(cell).unwrap(); }
            board
        }
    
        #[test]
        fn illegal_moves_leave_both_board_and_turn_unchanged() {
            let mut board = played(&[4]);
            let before = board.clone();
            assert_eq!(board.play(4), Err(MoveError::Occupied));
            assert_eq!(board.play(9), Err(MoveError::OutsideBoard));
            assert_eq!(board, before);
            assert_eq!(board.turn(), Mark::O);
        }
    
        #[test]
        fn recognizes_all_eight_winning_lines_for_both_players() {
            for line in LINES {
                for mark in [Mark::X, Mark::O] {
                    let mut board = Board::default();
                    for cell in line { board.cells[cell] = Some(mark); }
                    assert_eq!(board.outcome(), Outcome::Won(mark, line));
                }
            }
        }
    
        #[test]
        fn completed_games_reject_more_moves() {
            let mut board = played(&[0, 3, 1, 4, 2]);
            assert_eq!(board.outcome(), Outcome::Won(Mark::X, [0, 1, 2]));
            let before = board.clone();
            assert_eq!(board.play(8), Err(MoveError::Finished));
            assert_eq!(board, before);
            assert_eq!(board.legal_moves().count(), 0);
        }
    
        #[test]
        fn a_full_board_without_a_line_is_a_draw() {
            let board = played(&[0, 4, 8, 1, 7, 6, 2, 5, 3]);
            assert_eq!(board.outcome(), Outcome::Draw);
            assert_eq!(board.legal_moves().count(), 0);
        }
    
        #[test]
        fn a_win_on_the_ninth_move_is_not_a_draw() {
            let board = played(&[0, 1, 2, 3, 4, 5, 7, 6, 8]);
            assert_eq!(board.outcome(), Outcome::Won(Mark::X, [0, 4, 8]));
        }
    }
    
  14. All five rules tests passPassed

    The tests confirm that legal moves work and invalid moves leave the board and turn unchanged.

    Also checked
    Wins, draws, and refusing moves after the game ends.
    $ cargo test --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --lib
    [exit 0; queue 0ms; execution 710ms]
    
    running 5 tests
    test tests::illegal_moves_leave_both_board_and_turn_unchanged ... ok
    test tests::completed_games_reject_more_moves ... ok
    test tests::a_win_on_the_ninth_move_is_not_a_draw ... ok
    test tests::recognizes_all_eight_winning_lines_for_both_players ... ok
    test tests::a_full_board_without_a_line_is_a_draw ... ok
    
    test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
    
       Compiling magistr-tic-tac-toe v0.1.0 (./examples/bevy-tic-tac-toe)
        Finished `test` profile [unoptimized + debuginfo] target(s) in 0.68s
         Running unittests src/lib.rs (examples/bevy-tic-tac-toe/target/debug/deps/magistr_tic_tac_toe-81fc08b301c7cda0)
  15. The rules code is formatted consistentlyPassed

    Rust's formatter checks the source and reports no changes needed.

    $ cargo fmt --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --check
    [exit 0; queue 0ms; execution 48ms]
    
  16. Run the rules tests once more

    All five tests passed, but that first run also created Cargo.lock, the file that saves the game's dependency versions.

    Why Magistr stopped
    The project changed during the check, so Magistr won't mark the roadmap item finished yet.
    Next
    Run the tests again with the saved versions in place.
    completion refused: 1 required gate(s) lack current evidence on task `ttt-rules` (run 1a837f38-9167-4a81-b33d-361db7f90ad7):
      - Game rules: the workspace changed while the gate ran: examples/bevy-tic-tac-toe/Cargo.lock (Added)
    Re-run the outstanding gates on `ttt-rules` against the current tree and complete again.
  17. Save the rules work

    The implementation task is marked finished. The roadmap item still needs the rules check to run again.

    The distinction
    Finishing a task does not automatically finish its roadmap item.
    Implement legal moves, alternating turns and game endings
    Status: completed
  18. Plan how to choose the opponent

    The next task compares a short set of move rules with an opponent that looks ahead.

    The goal
    Choose an approach that can handle every legal human reply.
    Compare a tactical opponent with minimax, then verify every human reply
    Status: planned
  19. Start the opponent task

    The agent begins the comparison before committing to an approach.

    Compare a tactical opponent with minimax, then verify every human reply
    Status: active
  20. Would a few simple rules be enough?

    Try winning immediately, blocking an immediate loss, then taking the center or a corner.

    The question
    Can this miss a move that creates two winning threats? An experiment will check.
    Test the short tactical policy before choosing the opponent.
    
    A concrete losing game is better evidence than assuming a familiar heuristic is sufficient.
  21. The rules pass with the saved versionsPassed

    The agent reruns the five tests with Cargo.lock in place. All five pass.

    What changed
    The result now belongs to the saved project, so the rules can be marked complete.
    $ cargo test --locked --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --lib
    [exit 0; queue 0ms; execution 30ms]
    
    running 5 tests
    test tests::a_full_board_without_a_line_is_a_draw ... ok
    test tests::completed_games_reject_more_moves ... ok
    test tests::a_win_on_the_ninth_move_is_not_a_draw ... ok
    test tests::illegal_moves_leave_both_board_and_turn_unchanged ... ok
    test tests::recognizes_all_eight_winning_lines_for_both_players ... ok
    
    test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
    
        Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
         Running unittests src/lib.rs (examples/bevy-tic-tac-toe/target/debug/deps/magistr_tic_tac_toe-81fc08b301c7cda0)
  22. The rules are finished

    The roadmap now marks rules and turns complete. The board and opponent can build on this shared code.

    Reject illegal moves and recognize every game ending
    • X and O alternate; occupied and out-of-range cells cannot change the board.
    • All eight winning lines and a full-board draw are recognized.
    • A finished game accepts no further moves.
  23. Start building the opponent

    The rules are ready, so the agent starts the next roadmap item: an opponent that cannot lose.

    Make an opponent that never loses
    • The computer plays legal moves for either mark.
    • An exhaustive search of human replies finds no computer losses.
    • Record the comparison between a simple tactical policy and full search.
  24. Write an experiment before choosing

    The agent adds a program that tries every legal human reply against the simple opponent.

    What it records
    If the human wins, keep the sequence of moves that caused it.

    examples/compare_opponents.rs · +47 −0

    @@ -0,0 +1,47 @@
    +//! Evaluate the proposed win/block/center/corner policy against every human reply.
    +use magistr_tic_tac_toe::{Board, Mark, Outcome};
    +
    +fn tactical(board: &Board) -> Option<usize> {
    +    let me = board.turn();
    +    let moves: Vec<_> = board.legal_moves().collect();
    +    for &cell in &moves {
    +        let mut next = board.clone();
    +        next.play(cell).unwrap();
    +        if matches!(next.outcome(), Outcome::Won(mark, _) if mark == me) { return Some(cell); }
    +    }
    +    for line in magistr_tic_tac_toe::LINES {
    +        let threats = line.iter().filter(|&&i| board.cells()[i] == Some(me.other())).count();
    +        if threats == 2 {
    +            if let Some(&cell) = line.iter().find(|&&i| board.cells()[i].is_none()) { return Some(cell); }
    +        }
    +    }
    +    [4, 0, 2, 6, 8, 1, 3, 5, 7].into_iter().find(|i| moves.contains(i))
    +}
    +
    +fn find_loss(board: Board, computer: Mark, history: &mut Vec<usize>) -> bool {
    +    match board.outcome() {
    +        Outcome::Won(mark, _) => return mark != computer,
    +        Outcome::Draw => return false,
    +        Outcome::Playing => {}
    +    }
    +    let moves: Vec<_> = if board.turn() == computer { tactical(&board).into_iter().collect() }
    +        else { board.legal_moves().collect() };
    +    for cell in moves {
    +        let mut next = board.clone();
    +        next.play(cell).unwrap();
    +        history.push(cell);
    +        if find_loss(next, computer, history) { return true; }
    +        history.pop();
    +    }
    +    false
    +}
    +
    +fn main() {
    +    for computer in [Mark::X, Mark::O] {
    +        let mut history = Vec::new();
    +        let loses = find_loss(Board::default(), computer, &mut history);
    +        assert!(loses, "This experiment expects a counterexample, not a production failure.");
    +        println!("Tactical policy as {} loses. Move sequence (cells 1–9): {:?}", computer.label(),
    +            history.iter().map(|i| i + 1).collect::<Vec<_>>());
    +    }
    +}
    //! Evaluate the proposed win/block/center/corner policy against every human reply.
    use magistr_tic_tac_toe::{Board, Mark, Outcome};
    
    fn tactical(board: &Board) -> Option<usize> {
        let me = board.turn();
        let moves: Vec<_> = board.legal_moves().collect();
        for &cell in &moves {
            let mut next = board.clone();
            next.play(cell).unwrap();
            if matches!(next.outcome(), Outcome::Won(mark, _) if mark == me) { return Some(cell); }
        }
        for line in magistr_tic_tac_toe::LINES {
            let threats = line.iter().filter(|&&i| board.cells()[i] == Some(me.other())).count();
            if threats == 2 {
                if let Some(&cell) = line.iter().find(|&&i| board.cells()[i].is_none()) { return Some(cell); }
            }
        }
        [4, 0, 2, 6, 8, 1, 3, 5, 7].into_iter().find(|i| moves.contains(i))
    }
    
    fn find_loss(board: Board, computer: Mark, history: &mut Vec<usize>) -> bool {
        match board.outcome() {
            Outcome::Won(mark, _) => return mark != computer,
            Outcome::Draw => return false,
            Outcome::Playing => {}
        }
        let moves: Vec<_> = if board.turn() == computer { tactical(&board).into_iter().collect() }
            else { board.legal_moves().collect() };
        for cell in moves {
            let mut next = board.clone();
            next.play(cell).unwrap();
            history.push(cell);
            if find_loss(next, computer, history) { return true; }
            history.pop();
        }
        false
    }
    
    fn main() {
        for computer in [Mark::X, Mark::O] {
            let mut history = Vec::new();
            let loses = find_loss(Board::default(), computer, &mut history);
            assert!(loses, "This experiment expects a counterexample, not a production failure.");
            println!("Tactical policy as {} loses. Move sequence (cells 1–9): {:?}", computer.label(),
                history.iter().map(|i| i + 1).collect::<Vec<_>>());
        }
    }
    
  25. The experiment's own assumption failsFailed

    The experiment expected to find a losing game for each side. It stops when it finds none for X.

    What this means
    The experiment needs to report what it finds, without assuming both sides must lose.
    Next
    Correct that assertion and run the comparison again.
    $ cargo run --locked --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --example compare_opponents
    [exit 101; queue 0ms; execution 435ms]
       Compiling magistr-tic-tac-toe v0.1.0 (./examples/bevy-tic-tac-toe)
        Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.40s
         Running `examples/bevy-tic-tac-toe/target/debug/examples/compare_opponents`
    
    thread 'main' panicked at examples/compare_opponents.rs:57:9:
    This experiment expects a counterexample, not a production failure.
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
  26. Report what the experiment found

    The first opponent looks promising when it starts. Playing second still needs checking.

    The first opponent experiment already exposed a useful distinction: the simple “win, block, center, corner” policy held up when moving first, but that doesn’t establish that it works when moving second. I’m checking both positions and keeping the actual findings in the recording. That gives the reasoning view something concrete to explain.
  27. The simple opponent loses as OPassed

    The corrected experiment finds no loss as X, but finds a winning sequence for the human when the computer plays O.

    The moves
    X: 1, O: 5, X: 8, O: 3, X: 7, O: 9, X: 4. X wins.
    $ cargo run --locked --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --example compare_opponents
    [exit 0; queue 0ms; execution 269ms]
    Tactical policy as X: no losing game found.
    Tactical policy as O loses. Move sequence (cells 1–9): [1, 5, 8, 3, 7, 9, 4]
       Compiling magistr-tic-tac-toe v0.1.0 (./examples/bevy-tic-tac-toe)
        Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s
         Running `examples/bevy-tic-tac-toe/target/debug/examples/compare_opponents`
  28. One blocked threat still leaves another

    After X takes square 7, X can win through either square 4 or square 9. O can only block one.

    Decision
    Use minimax: look ahead through the remaining games before choosing a move.
    Why it fits
    Tic-tac-toe has only nine squares, so checking those possibilities is practical.
    Reject the tactical policy for this game; implement full minimax search for either mark.
    
    Nine cells make a complete search affordable, and exhaustive human replies can check the promised behavior directly.
  29. Add an opponent that looks ahead

    The agent adds minimax to src/lib.rs. It considers the remaining games before choosing its move.

    Check the promise
    New tests try every legal human reply with the computer playing either side.

    src/lib.rs · +124 −12

    @@ -21,9 +21,14 @@
     }
     
     pub const LINES: [[usize; 3]; 8] = [
    -    [0, 1, 2], [3, 4, 5], [6, 7, 8],
    -    [0, 3, 6], [1, 4, 7], [2, 5, 8],
    -    [0, 4, 8], [2, 4, 6],
    +    [0, 1, 2],
    +    [3, 4, 5],
    +    [6, 7, 8],
    +    [0, 3, 6],
    +    [1, 4, 7],
    +    [2, 5, 8],
    +    [0, 4, 8],
    +    [2, 4, 6],
     ];
     
     #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    @@ -48,13 +53,20 @@
     
     impl Default for Board {
         fn default() -> Self {
    -        Self { cells: [None; 9], turn: Mark::X }
    +        Self {
    +            cells: [None; 9],
    +            turn: Mark::X,
    +        }
         }
     }
     
     impl Board {
    -    pub fn cells(&self) -> &[Option<Mark>; 9] { &self.cells }
    -    pub fn turn(&self) -> Mark { self.turn }
    +    pub fn cells(&self) -> &[Option<Mark>; 9] {
    +        &self.cells
    +    }
    +    pub fn turn(&self) -> Mark {
    +        self.turn
    +    }
         pub fn outcome(&self) -> Outcome {
             for line in LINES {
                 if let Some(mark) = self.cells[line[0]] {
    @@ -63,12 +75,22 @@
                     }
                 }
             }
    -        if self.cells.iter().all(Option::is_some) { Outcome::Draw } else { Outcome::Playing }
    +        if self.cells.iter().all(Option::is_some) {
    +            Outcome::Draw
    +        } else {
    +            Outcome::Playing
    +        }
         }
         pub fn play(&mut self, cell: usize) -> Result<(), MoveError> {
    -        if cell >= 9 { return Err(MoveError::OutsideBoard); }
    -        if self.outcome() != Outcome::Playing { return Err(MoveError::Finished); }
    -        if self.cells[cell].is_some() { return Err(MoveError::Occupied); }
    +        if cell >= 9 {
    +            return Err(MoveError::OutsideBoard);
    +        }
    +        if self.outcome() != Outcome::Playing {
    +            return Err(MoveError::Finished);
    +        }
    +        if self.cells[cell].is_some() {
    +            return Err(MoveError::Occupied);
    +        }
             self.cells[cell] = Some(self.turn);
             self.turn = self.turn.other();
             Ok(())
    @@ -84,7 +106,9 @@
     
         fn played(moves: &[usize]) -> Board {
             let mut board = Board::default();
    -        for &cell in moves { board.play(cell).unwrap(); }
    +        for &cell in moves {
    +            board.play(cell).unwrap();
    +        }
             board
         }
     
    @@ -103,7 +127,9 @@
             for line in LINES {
                 for mark in [Mark::X, Mark::O] {
                     let mut board = Board::default();
    -                for cell in line { board.cells[cell] = Some(mark); }
    +                for cell in line {
    +                    board.cells[cell] = Some(mark);
    +                }
                     assert_eq!(board.outcome(), Outcome::Won(mark, line));
                 }
             }
    @@ -132,3 +158,89 @@
             assert_eq!(board.outcome(), Outcome::Won(Mark::X, [0, 4, 8]));
         }
     }
    +
    +/// Choose an optimal legal move. Stable ordering makes recordings reproducible.
    +pub fn best_move(board: &Board) -> Option<usize> {
    +    if board.outcome() != Outcome::Playing { return None; }
    +    let player = board.turn();
    +    let mut best = None;
    +    let mut best_score = i16::MIN;
    +    for cell in [4, 0, 2, 6, 8, 1, 3, 5, 7] {
    +        if board.cells[cell].is_some() { continue; }
    +        let mut next = board.clone();
    +        next.play(cell).expect("an empty cell on a live board is legal");
    +        let score = minimax(&next, player, 1);
    +        if score > best_score {
    +            best_score = score;
    +            best = Some(cell);
    +        }
    +    }
    +    best
    +}
    +
    +fn minimax(board: &Board, player: Mark, depth: i16) -> i16 {
    +    match board.outcome() {
    +        Outcome::Won(mark, _) => return if mark == player { 10 - depth } else { depth - 10 },
    +        Outcome::Draw => return 0,
    +        Outcome::Playing => {}
    +    }
    +    let scores = board.legal_moves().map(|cell| {
    +        let mut next = board.clone();
    +        next.play(cell).unwrap();
    +        minimax(&next, player, depth + 1)
    +    });
    +    if board.turn() == player { scores.max().unwrap() } else { scores.min().unwrap() }
    +}
    +
    +#[cfg(test)]
    +mod opponent_tests {
    +    use super::*;
    +
    +    fn count_games(board: Board, computer: Mark) -> (usize, usize) {
    +        match board.outcome() {
    +            Outcome::Won(mark, _) => {
    +                assert_eq!(mark, computer, "computer lost: {:?}", board.cells());
    +                return (1, 0);
    +            }
    +            Outcome::Draw => return (0, 1),
    +            Outcome::Playing => {}
    +        }
    +        let moves: Vec<_> = if board.turn() == computer {
    +            vec![best_move(&board).expect("live board has a move")]
    +        } else { board.legal_moves().collect() };
    +        let mut result = (0, 0);
    +        for cell in moves {
    +            let mut next = board.clone();
    +            next.play(cell).expect("opponent only returns legal moves");
    +            let (wins, draws) = count_games(next, computer);
    +            result.0 += wins;
    +            result.1 += draws;
    +        }
    +        result
    +    }
    +
    +    #[test]
    +    fn computer_never_loses_against_any_legal_human_reply() {
    +        for computer in [Mark::X, Mark::O] {
    +            let (wins, draws) = count_games(Board::default(), computer);
    +            println!("Computer {}: {wins} wins, {draws} draws, 0 losses.", computer.label());
    +            assert!(wins > 0 && draws > 0);
    +        }
    +    }
    +
    +    #[test]
    +    fn takes_a_win_before_blocking_and_stops_after_game_over() {
    +        let mut board = Board::default();
    +        for cell in [0, 3, 1, 4] { board.play(cell).unwrap(); }
    +        assert_eq!(best_move(&board), Some(2));
    +        board.play(2).unwrap();
    +        assert_eq!(best_move(&board), None);
    +    }
    +
    +    #[test]
    +    fn answers_opposite_corners_with_an_edge() {
    +        let mut board = Board::default();
    +        for cell in [0, 4, 8] { board.play(cell).unwrap(); }
    +        assert!([1, 3, 5, 7].contains(&best_move(&board).unwrap()));
    +    }
    +}
    //! Game rules shared by the Bevy interface and exhaustive opponent checks.
    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    pub enum Mark {
        X,
        O,
    }
    
    impl Mark {
        pub fn other(self) -> Self {
            match self {
                Self::X => Self::O,
                Self::O => Self::X,
            }
        }
        pub fn label(self) -> &'static str {
            match self {
                Self::X => "X",
                Self::O => "O",
            }
        }
    }
    
    pub const LINES: [[usize; 3]; 8] = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6],
    ];
    
    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    pub enum Outcome {
        Playing,
        Won(Mark, [usize; 3]),
        Draw,
    }
    
    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    pub enum MoveError {
        OutsideBoard,
        Occupied,
        Finished,
    }
    
    #[derive(Clone, Debug, Eq, PartialEq)]
    pub struct Board {
        cells: [Option<Mark>; 9],
        turn: Mark,
    }
    
    impl Default for Board {
        fn default() -> Self {
            Self {
                cells: [None; 9],
                turn: Mark::X,
            }
        }
    }
    
    impl Board {
        pub fn cells(&self) -> &[Option<Mark>; 9] {
            &self.cells
        }
        pub fn turn(&self) -> Mark {
            self.turn
        }
        pub fn outcome(&self) -> Outcome {
            for line in LINES {
                if let Some(mark) = self.cells[line[0]] {
                    if line.iter().all(|&cell| self.cells[cell] == Some(mark)) {
                        return Outcome::Won(mark, line);
                    }
                }
            }
            if self.cells.iter().all(Option::is_some) {
                Outcome::Draw
            } else {
                Outcome::Playing
            }
        }
        pub fn play(&mut self, cell: usize) -> Result<(), MoveError> {
            if cell >= 9 {
                return Err(MoveError::OutsideBoard);
            }
            if self.outcome() != Outcome::Playing {
                return Err(MoveError::Finished);
            }
            if self.cells[cell].is_some() {
                return Err(MoveError::Occupied);
            }
            self.cells[cell] = Some(self.turn);
            self.turn = self.turn.other();
            Ok(())
        }
        pub fn legal_moves(&self) -> impl Iterator<Item = usize> + '_ {
            (0..9).filter(|&i| self.outcome() == Outcome::Playing && self.cells[i].is_none())
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        fn played(moves: &[usize]) -> Board {
            let mut board = Board::default();
            for &cell in moves {
                board.play(cell).unwrap();
            }
            board
        }
    
        #[test]
        fn illegal_moves_leave_both_board_and_turn_unchanged() {
            let mut board = played(&[4]);
            let before = board.clone();
            assert_eq!(board.play(4), Err(MoveError::Occupied));
            assert_eq!(board.play(9), Err(MoveError::OutsideBoard));
            assert_eq!(board, before);
            assert_eq!(board.turn(), Mark::O);
        }
    
        #[test]
        fn recognizes_all_eight_winning_lines_for_both_players() {
            for line in LINES {
                for mark in [Mark::X, Mark::O] {
                    let mut board = Board::default();
                    for cell in line {
                        board.cells[cell] = Some(mark);
                    }
                    assert_eq!(board.outcome(), Outcome::Won(mark, line));
                }
            }
        }
    
        #[test]
        fn completed_games_reject_more_moves() {
            let mut board = played(&[0, 3, 1, 4, 2]);
            assert_eq!(board.outcome(), Outcome::Won(Mark::X, [0, 1, 2]));
            let before = board.clone();
            assert_eq!(board.play(8), Err(MoveError::Finished));
            assert_eq!(board, before);
            assert_eq!(board.legal_moves().count(), 0);
        }
    
        #[test]
        fn a_full_board_without_a_line_is_a_draw() {
            let board = played(&[0, 4, 8, 1, 7, 6, 2, 5, 3]);
            assert_eq!(board.outcome(), Outcome::Draw);
            assert_eq!(board.legal_moves().count(), 0);
        }
    
        #[test]
        fn a_win_on_the_ninth_move_is_not_a_draw() {
            let board = played(&[0, 1, 2, 3, 4, 5, 7, 6, 8]);
            assert_eq!(board.outcome(), Outcome::Won(Mark::X, [0, 4, 8]));
        }
    }
    
    /// Choose an optimal legal move. Stable ordering makes recordings reproducible.
    pub fn best_move(board: &Board) -> Option<usize> {
        if board.outcome() != Outcome::Playing { return None; }
        let player = board.turn();
        let mut best = None;
        let mut best_score = i16::MIN;
        for cell in [4, 0, 2, 6, 8, 1, 3, 5, 7] {
            if board.cells[cell].is_some() { continue; }
            let mut next = board.clone();
            next.play(cell).expect("an empty cell on a live board is legal");
            let score = minimax(&next, player, 1);
            if score > best_score {
                best_score = score;
                best = Some(cell);
            }
        }
        best
    }
    
    fn minimax(board: &Board, player: Mark, depth: i16) -> i16 {
        match board.outcome() {
            Outcome::Won(mark, _) => return if mark == player { 10 - depth } else { depth - 10 },
            Outcome::Draw => return 0,
            Outcome::Playing => {}
        }
        let scores = board.legal_moves().map(|cell| {
            let mut next = board.clone();
            next.play(cell).unwrap();
            minimax(&next, player, depth + 1)
        });
        if board.turn() == player { scores.max().unwrap() } else { scores.min().unwrap() }
    }
    
    #[cfg(test)]
    mod opponent_tests {
        use super::*;
    
        fn count_games(board: Board, computer: Mark) -> (usize, usize) {
            match board.outcome() {
                Outcome::Won(mark, _) => {
                    assert_eq!(mark, computer, "computer lost: {:?}", board.cells());
                    return (1, 0);
                }
                Outcome::Draw => return (0, 1),
                Outcome::Playing => {}
            }
            let moves: Vec<_> = if board.turn() == computer {
                vec![best_move(&board).expect("live board has a move")]
            } else { board.legal_moves().collect() };
            let mut result = (0, 0);
            for cell in moves {
                let mut next = board.clone();
                next.play(cell).expect("opponent only returns legal moves");
                let (wins, draws) = count_games(next, computer);
                result.0 += wins;
                result.1 += draws;
            }
            result
        }
    
        #[test]
        fn computer_never_loses_against_any_legal_human_reply() {
            for computer in [Mark::X, Mark::O] {
                let (wins, draws) = count_games(Board::default(), computer);
                println!("Computer {}: {wins} wins, {draws} draws, 0 losses.", computer.label());
                assert!(wins > 0 && draws > 0);
            }
        }
    
        #[test]
        fn takes_a_win_before_blocking_and_stops_after_game_over() {
            let mut board = Board::default();
            for cell in [0, 3, 1, 4] { board.play(cell).unwrap(); }
            assert_eq!(best_move(&board), Some(2));
            board.play(2).unwrap();
            assert_eq!(best_move(&board), None);
        }
    
        #[test]
        fn answers_opposite_corners_with_an_edge() {
            let mut board = Board::default();
            for cell in [0, 4, 8] { board.play(cell).unwrap(); }
            assert!([1, 3, 5, 7].contains(&best_move(&board).unwrap()));
        }
    }
    
  30. No losses against any legal human replyPassed

    All eight tests pass. The opponent search finds only wins and draws.

    Computer plays X
    82 wins, 10 draws, 0 losses.
    Computer plays O
    368 wins, 153 draws, 0 losses.
    $ cargo test --locked --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --lib -- --nocapture
    [exit 0; queue 0ms; execution 2244ms]
    
    running 8 tests
    test tests::a_win_on_the_ninth_move_is_not_a_draw ... ok
    test tests::a_full_board_without_a_line_is_a_draw ... ok
    test tests::completed_games_reject_more_moves ... ok
    test tests::illegal_moves_leave_both_board_and_turn_unchanged ... ok
    test tests::recognizes_all_eight_winning_lines_for_both_players ... ok
    test opponent_tests::takes_a_win_before_blocking_and_stops_after_game_over ... ok
    test opponent_tests::answers_opposite_corners_with_an_edge ... ok
    Computer X: 82 wins, 10 draws, 0 losses.
    Computer O: 368 wins, 153 draws, 0 losses.
    test opponent_tests::computer_never_loses_against_any_legal_human_reply ... ok
    
    test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.06s
    
       Compiling magistr-tic-tac-toe v0.1.0 (./examples/bevy-tic-tac-toe)
        Finished `test` profile [unoptimized + debuginfo] target(s) in 0.15s
         Running unittests src/lib.rs (examples/bevy-tic-tac-toe/target/debug/deps/magistr_tic_tac_toe-81fc08b301c7cda0)
  31. Specify the Bevy version

    You add a requirement while the agent is working: use Bevy 0.20.0.

    Please use Bevy version 0.20.0 for the game.
  32. Apply your follow-up

    Codex acknowledges the version and moves on to the interface.

    I’ll pin the game to Bevy 0.20.0 and use its current APIs. The rules and opponent are already passing their checks; next is the Bevy interface.
  33. Save the choice and its reason

    The agent records the decision to use minimax, supported by the losing example and the passing tests.

    Keep one set of rules
    The Bevy board will call the same Rust code these tests checked.
    Use minimax for both marks and Bevy 0.20.0 for the interface.
    
    One rule implementation serves the visual game and exhaustive checks, so the demo cannot quietly test a different game.
  34. Close the opponent investigation

    The question, experiment, and losing example now lead to the saved decision: use minimax for either side.

    Why keep the trail
    A later session can see why the simpler opponent was rejected.
    Investigation concluded at step 1752.
  35. Confirm the original comparison still holdsPassed

    The simple opponent still loses as O and has no losing game as X. The experiment keeps that contrast available alongside the chosen approach.

    $ cargo run --locked --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --example compare_opponents
    [exit 0; queue 0ms; execution 187ms]
    Tactical policy as X: no losing game found.
    Tactical policy as O loses. Move sequence (cells 1–9): [1, 5, 8, 3, 7, 9, 4]
       Compiling magistr-tic-tac-toe v0.1.0 (./examples/bevy-tic-tac-toe)
        Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.15s
         Running `examples/bevy-tic-tac-toe/target/debug/examples/compare_opponents`
  36. The opponent is finished

    The roadmap marks the computer opponent complete. Its moves are legal and it has been checked against every legal human reply.

    Make an opponent that never loses
    • The computer plays legal moves for either mark.
    • An exhaustive search of human replies finds no computer losses.
    • Record the comparison between a simple tactical policy and full search.
  37. Finish the opponent task

    The comparison, implementation, and checks stay together in the completed task.

    Compare a tactical opponent with minimax, then verify every human reply
    Status: completed
  38. Move on to the Bevy board

    The agent starts the playable board. The rules are ready; now a person needs a way to make moves and see the result.

    Play a complete game through the Bevy board
    • Nine visible cells accept pointer and keyboard input.
    • Turn and game-result text reflect the shared rules.
    • The same Rust game builds for a browser with Bevy.
  39. Plan the screen and controls

    The board task covers nine visible squares, turn and result text, and input through clicks and number keys.

    Render and play the board in Bevy 0.20.0
    Status: planned
  40. Start building the screen

    The agent begins connecting Bevy's display and controls to the existing game rules.

    Render and play the board in Bevy 0.20.0
    Status: active
  41. Add Bevy to the project

    The project file now includes Bevy 0.20.0 and the pieces needed to build the game for a browser.

    Keep rules easy to check
    The rules tests still run without building the visual game.

    Cargo.toml · +21 −1

    @@ -2,11 +2,31 @@
     name = "magistr-tic-tac-toe"
     version = "0.1.0"
     edition = "2024"
    +rust-version = "1.97.1"
     publish = false
     
    -# The website example builds independently of Magistr.
    +# This playable website example builds independently of Magistr.
     [workspace]
     
    +[features]
    +default = []
    +game = ["dep:bevy", "dep:web-sys"]
    +
    +[dependencies]
    +bevy = { version = "=0.20.0", optional = true, default-features = false, features = [
    +    "std", "default_app", "ui_bevy_render", "bevy_ui_widgets", "ui_picking",
    +    "bevy_winit", "default_font", "webgl2", "x11",
    +] }
    +
    +[target.'cfg(target_arch = "wasm32")'.dependencies]
    +web-sys = { version = "0.3", optional = true, features = ["Window", "Document", "Element"] }
    +
    +[[bin]]
    +name = "tic-tac-toe"
    +path = "src/main.rs"
    +required-features = ["game"]
    +
     [profile.release]
     opt-level = "s"
     lto = "thin"
    +strip = "debuginfo"
    [package]
    name = "magistr-tic-tac-toe"
    version = "0.1.0"
    edition = "2024"
    rust-version = "1.97.1"
    publish = false
    
    # This playable website example builds independently of Magistr.
    [workspace]
    
    [features]
    default = []
    game = ["dep:bevy", "dep:web-sys"]
    
    [dependencies]
    bevy = { version = "=0.20.0", optional = true, default-features = false, features = [
        "std", "default_app", "ui_bevy_render", "bevy_ui_widgets", "ui_picking",
        "bevy_winit", "default_font", "webgl2", "x11",
    ] }
    
    [target.'cfg(target_arch = "wasm32")'.dependencies]
    web-sys = { version = "0.3", optional = true, features = ["Window", "Document", "Element"] }
    
    [[bin]]
    name = "tic-tac-toe"
    path = "src/main.rs"
    required-features = ["game"]
    
    [profile.release]
    opt-level = "s"
    lto = "thin"
    strip = "debuginfo"
    
  42. Draw the board and connect input

    The agent writes src/main.rs: the Bevy window, nine squares, labels, and controls.

    Shared behavior
    Clicks and number keys send moves to the same rules code.

    src/main.rs · +127 −0

    @@ -0,0 +1,127 @@
    +use bevy::{prelude::*, ui_widgets::{Activate, Button}};
    +use magistr_tic_tac_toe::{Board, Mark, Outcome};
    +
    +#[derive(Resource, Default)]
    +struct Game(Board);
    +#[derive(Component)]
    +struct CellLabel(usize);
    +#[derive(Component)]
    +struct StatusLabel;
    +#[derive(Component)]
    +struct Cell(usize);
    +
    +const INK: Color = Color::srgb(0.12, 0.15, 0.19);
    +const PAPER: Color = Color::srgb(0.96, 0.95, 0.91);
    +const CELL: Color = Color::srgb(1.0, 0.99, 0.96);
    +const X_COLOR: Color = Color::srgb(0.12, 0.34, 0.52);
    +const O_COLOR: Color = Color::srgb(0.65, 0.24, 0.12);
    +
    +fn main() {
    +    App::new()
    +        .add_plugins(DefaultPlugins.set(WindowPlugin {
    +            primary_window: Some(Window {
    +                title: "Tic-tac-toe".into(),
    +                resolution: (640, 620).into(),
    +                canvas: Some("#game".into()),
    +                fit_canvas_to_parent: true,
    +                prevent_default_event_handling: false,
    +                ..default()
    +            }),
    +            ..default()
    +        }))
    +        .insert_resource(ClearColor(PAPER))
    +        .init_resource::<Game>()
    +        .add_systems(Startup, setup)
    +        .add_systems(Update, (keyboard, draw).chain())
    +        .run();
    +}
    +
    +fn label(text: impl Into<String>, size: f32) -> impl Bundle {
    +    (Text::new(text), TextFont { font_size: FontSize::Px(size), ..default() }, TextColor(INK))
    +}
    +
    +fn setup(mut commands: Commands) {
    +    commands.spawn(Camera2d);
    +    commands.spawn(Node {
    +        width: percent(100), height: percent(100),
    +        flex_direction: FlexDirection::Column,
    +        align_items: AlignItems::Center, justify_content: JustifyContent::Center,
    +        row_gap: px(18), padding: UiRect::all(px(12)),
    +        ..default()
    +    }).with_children(|root| {
    +        root.spawn(label("Tic-tac-toe", 32.0));
    +        root.spawn((label("X to move", 18.0), StatusLabel));
    +        root.spawn(Node {
    +            display: Display::Grid, width: percent(100), max_width: px(330),
    +            aspect_ratio: Some(1.0), column_gap: px(6), row_gap: px(6),
    +            grid_template_columns: RepeatedGridTrack::flex(3, 1.0),
    +            grid_template_rows: RepeatedGridTrack::flex(3, 1.0), ..default()
    +        }).with_children(|grid| {
    +            for cell in 0..9 {
    +                grid.spawn((
    +                    Button, Cell(cell),
    +                    Node {
    +                        width: percent(100), height: percent(100),
    +                        align_items: AlignItems::Center, justify_content: JustifyContent::Center,
    +                        border: UiRect::all(px(1)), ..default()
    +                    },
    +                    BackgroundColor(CELL), BorderColor::all(Color::srgb(0.57, 0.59, 0.55)),
    +                )).with_child((label((cell + 1).to_string(), 40.0), CellLabel(cell)))
    +                  .observe(move |_: On<Activate>, mut game: ResMut<Game>| { let _ = game.0.play(cell); });
    +            }
    +        });
    +        root.spawn(label("Click a square or press 1–9", 15.0));
    +    });
    +}
    +
    +fn keyboard(keys: Res<ButtonInput<KeyCode>>, mut game: ResMut<Game>) {
    +    let digits = [KeyCode::Digit1, KeyCode::Digit2, KeyCode::Digit3, KeyCode::Digit4,
    +        KeyCode::Digit5, KeyCode::Digit6, KeyCode::Digit7, KeyCode::Digit8, KeyCode::Digit9];
    +    for (cell, key) in digits.into_iter().enumerate() {
    +        if keys.just_pressed(key) { let _ = game.0.play(cell); }
    +    }
    +}
    +
    +fn status(board: &Board) -> String {
    +    match board.outcome() {
    +        Outcome::Playing => format!("{} to move", board.turn().label()),
    +        Outcome::Won(mark, _) => format!("{} wins", mark.label()),
    +        Outcome::Draw => "A draw".into(),
    +    }
    +}
    +
    +fn draw(
    +    game: Res<Game>,
    +    mut labels: Query<(&mut Text, &mut TextColor, Option<&CellLabel>, Has<StatusLabel>)>,
    +    mut cells: Query<(&Cell, &mut BackgroundColor)>,
    +) {
    +    if !game.is_changed() { return; }
    +    let message = status(&game.0);
    +    for (mut text, mut color, cell, is_status) in &mut labels {
    +        if let Some(cell) = cell {
    +            let mark = game.0.cells()[cell.0];
    +            **text = mark.map_or_else(|| (cell.0 + 1).to_string(), |m| m.label().into());
    +            color.0 = match mark { Some(Mark::X) => X_COLOR, Some(Mark::O) => O_COLOR, None => Color::srgb(0.56, 0.58, 0.54) };
    +        } else if is_status { **text = message.clone(); }
    +    }
    +    for (cell, mut background) in &mut cells {
    +        let winning = matches!(game.0.outcome(), Outcome::Won(_, line) if line.contains(&cell.0));
    +        background.0 = if winning { Color::srgb(0.79, 0.87, 0.77) } else { CELL };
    +    }
    +    announce(&game.0, &message);
    +}
    +
    +// A read-only description of the Rust state for screen readers and browser checks.
    +fn announce(board: &Board, message: &str) {
    +    #[cfg(target_arch = "wasm32")]
    +    if let Some(document) = web_sys::window().and_then(|window| window.document()) {
    +        if let Some(element) = document.get_element_by_id("game-status") {
    +            element.set_text_content(Some(message));
    +            let cells: String = board.cells().iter().map(|m| m.map_or('.', |m| if m == Mark::X { 'X' } else { 'O' })).collect();
    +            let _ = element.set_attribute("data-cells", &cells);
    +            let _ = element.set_attribute("data-turn", board.turn().label());
    +        }
    +    }
    +    #[cfg(not(target_arch = "wasm32"))]
    +    let _ = (board, message);
    +}
    use bevy::{prelude::*, ui_widgets::{Activate, Button}};
    use magistr_tic_tac_toe::{Board, Mark, Outcome};
    
    #[derive(Resource, Default)]
    struct Game(Board);
    #[derive(Component)]
    struct CellLabel(usize);
    #[derive(Component)]
    struct StatusLabel;
    #[derive(Component)]
    struct Cell(usize);
    
    const INK: Color = Color::srgb(0.12, 0.15, 0.19);
    const PAPER: Color = Color::srgb(0.96, 0.95, 0.91);
    const CELL: Color = Color::srgb(1.0, 0.99, 0.96);
    const X_COLOR: Color = Color::srgb(0.12, 0.34, 0.52);
    const O_COLOR: Color = Color::srgb(0.65, 0.24, 0.12);
    
    fn main() {
        App::new()
            .add_plugins(DefaultPlugins.set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Tic-tac-toe".into(),
                    resolution: (640, 620).into(),
                    canvas: Some("#game".into()),
                    fit_canvas_to_parent: true,
                    prevent_default_event_handling: false,
                    ..default()
                }),
                ..default()
            }))
            .insert_resource(ClearColor(PAPER))
            .init_resource::<Game>()
            .add_systems(Startup, setup)
            .add_systems(Update, (keyboard, draw).chain())
            .run();
    }
    
    fn label(text: impl Into<String>, size: f32) -> impl Bundle {
        (Text::new(text), TextFont { font_size: FontSize::Px(size), ..default() }, TextColor(INK))
    }
    
    fn setup(mut commands: Commands) {
        commands.spawn(Camera2d);
        commands.spawn(Node {
            width: percent(100), height: percent(100),
            flex_direction: FlexDirection::Column,
            align_items: AlignItems::Center, justify_content: JustifyContent::Center,
            row_gap: px(18), padding: UiRect::all(px(12)),
            ..default()
        }).with_children(|root| {
            root.spawn(label("Tic-tac-toe", 32.0));
            root.spawn((label("X to move", 18.0), StatusLabel));
            root.spawn(Node {
                display: Display::Grid, width: percent(100), max_width: px(330),
                aspect_ratio: Some(1.0), column_gap: px(6), row_gap: px(6),
                grid_template_columns: RepeatedGridTrack::flex(3, 1.0),
                grid_template_rows: RepeatedGridTrack::flex(3, 1.0), ..default()
            }).with_children(|grid| {
                for cell in 0..9 {
                    grid.spawn((
                        Button, Cell(cell),
                        Node {
                            width: percent(100), height: percent(100),
                            align_items: AlignItems::Center, justify_content: JustifyContent::Center,
                            border: UiRect::all(px(1)), ..default()
                        },
                        BackgroundColor(CELL), BorderColor::all(Color::srgb(0.57, 0.59, 0.55)),
                    )).with_child((label((cell + 1).to_string(), 40.0), CellLabel(cell)))
                      .observe(move |_: On<Activate>, mut game: ResMut<Game>| { let _ = game.0.play(cell); });
                }
            });
            root.spawn(label("Click a square or press 1–9", 15.0));
        });
    }
    
    fn keyboard(keys: Res<ButtonInput<KeyCode>>, mut game: ResMut<Game>) {
        let digits = [KeyCode::Digit1, KeyCode::Digit2, KeyCode::Digit3, KeyCode::Digit4,
            KeyCode::Digit5, KeyCode::Digit6, KeyCode::Digit7, KeyCode::Digit8, KeyCode::Digit9];
        for (cell, key) in digits.into_iter().enumerate() {
            if keys.just_pressed(key) { let _ = game.0.play(cell); }
        }
    }
    
    fn status(board: &Board) -> String {
        match board.outcome() {
            Outcome::Playing => format!("{} to move", board.turn().label()),
            Outcome::Won(mark, _) => format!("{} wins", mark.label()),
            Outcome::Draw => "A draw".into(),
        }
    }
    
    fn draw(
        game: Res<Game>,
        mut labels: Query<(&mut Text, &mut TextColor, Option<&CellLabel>, Has<StatusLabel>)>,
        mut cells: Query<(&Cell, &mut BackgroundColor)>,
    ) {
        if !game.is_changed() { return; }
        let message = status(&game.0);
        for (mut text, mut color, cell, is_status) in &mut labels {
            if let Some(cell) = cell {
                let mark = game.0.cells()[cell.0];
                **text = mark.map_or_else(|| (cell.0 + 1).to_string(), |m| m.label().into());
                color.0 = match mark { Some(Mark::X) => X_COLOR, Some(Mark::O) => O_COLOR, None => Color::srgb(0.56, 0.58, 0.54) };
            } else if is_status { **text = message.clone(); }
        }
        for (cell, mut background) in &mut cells {
            let winning = matches!(game.0.outcome(), Outcome::Won(_, line) if line.contains(&cell.0));
            background.0 = if winning { Color::srgb(0.79, 0.87, 0.77) } else { CELL };
        }
        announce(&game.0, &message);
    }
    
    // A read-only description of the Rust state for screen readers and browser checks.
    fn announce(board: &Board, message: &str) {
        #[cfg(target_arch = "wasm32")]
        if let Some(document) = web_sys::window().and_then(|window| window.document()) {
            if let Some(element) = document.get_element_by_id("game-status") {
                element.set_text_content(Some(message));
                let cells: String = board.cells().iter().map(|m| m.map_or('.', |m| if m == Mark::X { 'X' } else { 'O' })).collect();
                let _ = element.set_attribute("data-cells", &cells);
                let _ = element.set_attribute("data-turn", board.turn().label());
            }
        }
        #[cfg(not(target_arch = "wasm32"))]
        let _ = (board, message);
    }
    
  43. Report the first working browser build

    The game compiles for the browser, and the opponent has survived every legal human reply.

    Bevy 0.20.0 now compiles successfully for the browser. The opponent checks also found no losses across every legal human reply, whether the computer moves first or second. I’m turning that build into one walkthrough, with the terminal session as the main view and the actual roadmap and decisions available to open.
  44. The board doesn't all fit yet

    The first Bevy screen cuts off the bottom row and the keyboard instructions.

    What needs fixing
    The layout hasn't reserved enough height for the square board.
    Next
    Give the board an explicit size, then check the screen again.
    Replace the board's implicit height with matching bounded width and height.
    
    The board owns its square geometry. Fixing that measurement lets the parent center all the controls without offsets or hiding the clipped content.
  45. Check off the rules requirements

    The roadmap's checklist now records the tested behavior: valid turns, wins and draws, and no moves after the game ends.

    Reject illegal moves and recognize every game ending
    • X and O alternate; occupied and out-of-range cells cannot change the board.
    • All eight winning lines and a full-board draw are recognized.
    • A finished game accepts no further moves.
  46. Check off the opponent requirements

    The roadmap records that the opponent plays either side, survives every legal human reply, and has a saved explanation for its approach.

    Make an opponent that never loses
    • The computer plays legal moves for either mark.
    • An exhaustive search of human replies finds no computer losses.
    • Record the comparison between a simple tactical policy and full search.
  47. Explain a visual problem

    Playing the game works, but its instructions are clipped. The agent reports the problem before fixing it.

    The docs now follow the game’s actual plan and opponent decision. The browser playthrough passes, but visual review caught clipped instructions on the wider game view. I’m fixing that before recording the rematch flow; the review and test results will remain distinct in the walkthrough.
  48. The board passes a browser playthroughPassed

    The game accepts keyboard moves and stops accepting moves when the round ends.

    Sizes checked
    Both the desktop and narrow board pass.
    $ cd apps/website && node tests/game-browser.mjs
    [exit 0; queue 0ms; execution 3925ms]
    Bevy 0.20.0: desktop and compact boards, keyboard play and finished-game protection passed.
  49. Clicking an occupied square changes nothingPassed

    The pointer test places X in a square, clicks that square again, and confirms the board stays the same.

    $ cd apps/website && node --input-type=module - <<'JS'
    import assert from 'node:assert/strict';import {webkit} from 'playwright';const b=await webkit.launch();try{const p=await b.newPage({viewport:{width:720,height:700}});await p.goto('http://127.0.0.1:4322');await p.locator('#game-status[data-cells]').waitFor({state:'attached',timeout:45000});await p.mouse.click(262,243);await p.waitForFunction(()=>document.getElementById('game-status').getAttribute('data-cells')==='X........');await p.mouse.click(262,243);assert.equal(await p.locator('#game-status').getAttribute('data-cells'),'X........');console.log('Pointer move accepted; occupied square unchanged.');}finally{await b.close();}
    JS
    [exit 0; queue 0ms; execution 1997ms]
    Pointer move accepted; occupied square unchanged.
  50. Review the screen as well as the rulesPassed

    The recorded visual review finds the full board, result, winning row, and keyboard instructions readable at both sizes.

    Checked separately
    A passing rules test alone would not show whether the screen was clipped.
    Opened all four full-resolution frames in docs/evidence/desktop-iterations/2026-10-21-bevy-board-05. Board, turn/result, winning row and supported-character keyboard instructions fit desktop and compact frames. No overlap or clipping. See REVIEW.md and browser-checks.json. Headless WebKit only.
  51. The whole board now fits

    The agent keeps a fixed board size after reviewing the corrected desktop and narrow screens.

    What was fixed
    The bottom row and instructions fit, and the keyboard label uses characters the font can display.
    Keep the definite-size board and proceed to scores and rematches.
    
    The fix is verified in the actual Bevy browser renderer, separately from the rules tests. The earlier visual failures remain part of the record.
  52. Keep the explanation for the layout fix

    The board-size investigation is closed and linked to the saved conclusion.

    For the next session
    The record explains why this board uses a definite size.
    Investigation concluded at step 1754.
  53. Check off the playable board

    The board's checklist now records working input, correct game text, and a browser build.

    Play a complete game through the Bevy board
    • Nine visible cells accept pointer and keyboard input.
    • Turn and game-result text reflect the shared rules.
    • The same Rust game builds for a browser with Bevy.
  54. The playable board is finished

    The roadmap marks the Bevy board complete. It has passed input checks and a separate visual review.

    Play a complete game through the Bevy board
    • Nine visible cells accept pointer and keyboard input.
    • Turn and game-result text reflect the shared rules.
    • The same Rust game builds for a browser with Bevy.
  55. Finish the board task

    The agent saves the completed board work with its browser checks and visual review.

    Render and play the board in Bevy 0.20.0
    Status: completed
  56. Make it a game you can play again

    The agent starts the final roadmap item: connect the computer, keep score, and begin another round.

    Ready to build on
    Both the opponent and the playable board are complete.
    Play again, keep the score and alternate who starts
    • The computer responds through the same game rules.
    • Finished rounds add one score; further input cannot add another.
    • Play again clears the board, preserves the score and alternates the opening player.
  57. Plan the full round of play

    The task covers computer replies, scoring a finished round once, and letting the other player start next time.

    Connect the opponent and finish the match flow
    Status: planned
  58. Start connecting the full game

    The agent begins adding scores and rematches to the board and opponent.

    Connect the opponent and finish the match flow
    Status: active
  59. Keep track of the match

    The rules file now tracks the human's side and the score across rounds.

    Prevent mistakes
    A finished round adds its score once, and a new round alternates who starts.

    src/lib.rs · +136 −9

    @@ -40,6 +40,7 @@
     
     #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     pub enum MoveError {
    +    NotYourTurn,
         OutsideBoard,
         Occupied,
         Finished,
    @@ -161,14 +162,19 @@
     
     /// Choose an optimal legal move. Stable ordering makes recordings reproducible.
     pub fn best_move(board: &Board) -> Option<usize> {
    -    if board.outcome() != Outcome::Playing { return None; }
    +    if board.outcome() != Outcome::Playing {
    +        return None;
    +    }
         let player = board.turn();
         let mut best = None;
         let mut best_score = i16::MIN;
         for cell in [4, 0, 2, 6, 8, 1, 3, 5, 7] {
    -        if board.cells[cell].is_some() { continue; }
    +        if board.cells[cell].is_some() {
    +            continue;
    +        }
             let mut next = board.clone();
    -        next.play(cell).expect("an empty cell on a live board is legal");
    +        next.play(cell)
    +            .expect("an empty cell on a live board is legal");
             let score = minimax(&next, player, 1);
             if score > best_score {
                 best_score = score;
    @@ -180,7 +186,13 @@
     
     fn minimax(board: &Board, player: Mark, depth: i16) -> i16 {
         match board.outcome() {
    -        Outcome::Won(mark, _) => return if mark == player { 10 - depth } else { depth - 10 },
    +        Outcome::Won(mark, _) => {
    +            return if mark == player {
    +                10 - depth
    +            } else {
    +                depth - 10
    +            };
    +        }
             Outcome::Draw => return 0,
             Outcome::Playing => {}
         }
    @@ -189,7 +201,11 @@
             next.play(cell).unwrap();
             minimax(&next, player, depth + 1)
         });
    -    if board.turn() == player { scores.max().unwrap() } else { scores.min().unwrap() }
    +    if board.turn() == player {
    +        scores.max().unwrap()
    +    } else {
    +        scores.min().unwrap()
    +    }
     }
     
     #[cfg(test)]
    @@ -207,7 +223,9 @@
             }
             let moves: Vec<_> = if board.turn() == computer {
                 vec![best_move(&board).expect("live board has a move")]
    -        } else { board.legal_moves().collect() };
    +        } else {
    +            board.legal_moves().collect()
    +        };
             let mut result = (0, 0);
             for cell in moves {
                 let mut next = board.clone();
    @@ -223,7 +241,10 @@
         fn computer_never_loses_against_any_legal_human_reply() {
             for computer in [Mark::X, Mark::O] {
                 let (wins, draws) = count_games(Board::default(), computer);
    -            println!("Computer {}: {wins} wins, {draws} draws, 0 losses.", computer.label());
    +            println!(
    +                "Computer {}: {wins} wins, {draws} draws, 0 losses.",
    +                computer.label()
    +            );
                 assert!(wins > 0 && draws > 0);
             }
         }
    @@ -231,7 +252,9 @@
         #[test]
         fn takes_a_win_before_blocking_and_stops_after_game_over() {
             let mut board = Board::default();
    -        for cell in [0, 3, 1, 4] { board.play(cell).unwrap(); }
    +        for cell in [0, 3, 1, 4] {
    +            board.play(cell).unwrap();
    +        }
             assert_eq!(best_move(&board), Some(2));
             board.play(2).unwrap();
             assert_eq!(best_move(&board), None);
    @@ -240,7 +263,111 @@
         #[test]
         fn answers_opposite_corners_with_an_edge() {
             let mut board = Board::default();
    -        for cell in [0, 4, 8] { board.play(cell).unwrap(); }
    +        for cell in [0, 4, 8] {
    +            board.play(cell).unwrap();
    +        }
             assert!([1, 3, 5, 7].contains(&best_move(&board).unwrap()));
         }
     }
    +
    +#[derive(Clone, Debug)]
    +pub struct Match {
    +    pub board: Board,
    +    pub human: Mark,
    +    /// Human wins, computer wins, draws.
    +    pub score: [u32; 3],
    +}
    +
    +impl Default for Match {
    +    fn default() -> Self {
    +        Self { board: Board::default(), human: Mark::X, score: [0; 3] }
    +    }
    +}
    +
    +impl Match {
    +    pub fn human_move(&mut self, cell: usize) -> Result<(), MoveError> {
    +        if self.board.turn() != self.human { return Err(MoveError::NotYourTurn); }
    +        self.board.play(cell)?;
    +        self.record_ending();
    +        Ok(())
    +    }
    +
    +    pub fn computer_move(&mut self) -> bool {
    +        if self.board.turn() == self.human { return false; }
    +        let Some(cell) = best_move(&self.board) else { return false; };
    +        self.board.play(cell).expect("minimax returns a legal move");
    +        self.record_ending();
    +        true
    +    }
    +
    +    fn record_ending(&mut self) {
    +        // Called only after an accepted move. A finished board rejects every later move.
    +        match self.board.outcome() {
    +            Outcome::Won(mark, _) => self.score[usize::from(mark != self.human)] += 1,
    +            Outcome::Draw => self.score[2] += 1,
    +            Outcome::Playing => {}
    +        }
    +    }
    +
    +    pub fn next_round(&mut self) -> bool {
    +        if self.board.outcome() == Outcome::Playing { return false; }
    +        self.board = Board::default();
    +        self.human = self.human.other();
    +        true
    +    }
    +
    +    pub fn status(&self) -> String {
    +        match self.board.outcome() {
    +            Outcome::Playing if self.board.turn() == self.human => "Your turn".into(),
    +            Outcome::Playing => "Computer's turn".into(),
    +            Outcome::Won(mark, _) if mark == self.human => "You win".into(),
    +            Outcome::Won(_, _) => "Computer wins".into(),
    +            Outcome::Draw => "A draw".into(),
    +        }
    +    }
    +}
    +
    +#[cfg(test)]
    +mod match_tests {
    +    use super::*;
    +    fn finish(game: &mut Match) {
    +        while game.board.outcome() == Outcome::Playing {
    +            if game.board.turn() == game.human {
    +                game.human_move(best_move(&game.board).unwrap()).unwrap();
    +            } else { assert!(game.computer_move()); }
    +        }
    +    }
    +
    +    #[test]
    +    fn completed_round_scores_once_and_rematch_alternates_the_starter() {
    +        let mut game = Match::default();
    +        finish(&mut game);
    +        assert_eq!(game.score, [0, 0, 1]);
    +        let before = game.board.clone();
    +        assert!(game.human_move(0).is_err());
    +        assert!(!game.computer_move());
    +        assert_eq!(game.board, before);
    +        assert_eq!(game.score, [0, 0, 1]);
    +        assert!(game.next_round());
    +        assert_eq!(game.human, Mark::O);
    +        assert_eq!(game.board, Board::default());
    +        assert_eq!(game.score, [0, 0, 1]);
    +        assert_eq!(game.human_move(0), Err(MoveError::NotYourTurn));
    +        assert!(game.computer_move());
    +        assert_eq!(game.board.turn(), Mark::O);
    +        finish(&mut game);
    +        assert_eq!(game.score, [0, 0, 2]);
    +        assert!(game.next_round());
    +        assert_eq!(game.human, Mark::X);
    +    }
    +
    +    #[test]
    +    fn cannot_restart_an_unfinished_round_or_play_twice() {
    +        let mut game = Match::default();
    +        assert!(!game.next_round());
    +        game.human_move(0).unwrap();
    +        assert_eq!(game.human_move(1), Err(MoveError::NotYourTurn));
    +        assert_eq!(game.score, [0; 3]);
    +        assert_eq!(game.board.cells().iter().flatten().count(), 1);
    +    }
    +}
    //! Game rules shared by the Bevy interface and exhaustive opponent checks.
    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    pub enum Mark {
        X,
        O,
    }
    
    impl Mark {
        pub fn other(self) -> Self {
            match self {
                Self::X => Self::O,
                Self::O => Self::X,
            }
        }
        pub fn label(self) -> &'static str {
            match self {
                Self::X => "X",
                Self::O => "O",
            }
        }
    }
    
    pub const LINES: [[usize; 3]; 8] = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6],
    ];
    
    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    pub enum Outcome {
        Playing,
        Won(Mark, [usize; 3]),
        Draw,
    }
    
    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    pub enum MoveError {
        NotYourTurn,
        OutsideBoard,
        Occupied,
        Finished,
    }
    
    #[derive(Clone, Debug, Eq, PartialEq)]
    pub struct Board {
        cells: [Option<Mark>; 9],
        turn: Mark,
    }
    
    impl Default for Board {
        fn default() -> Self {
            Self {
                cells: [None; 9],
                turn: Mark::X,
            }
        }
    }
    
    impl Board {
        pub fn cells(&self) -> &[Option<Mark>; 9] {
            &self.cells
        }
        pub fn turn(&self) -> Mark {
            self.turn
        }
        pub fn outcome(&self) -> Outcome {
            for line in LINES {
                if let Some(mark) = self.cells[line[0]] {
                    if line.iter().all(|&cell| self.cells[cell] == Some(mark)) {
                        return Outcome::Won(mark, line);
                    }
                }
            }
            if self.cells.iter().all(Option::is_some) {
                Outcome::Draw
            } else {
                Outcome::Playing
            }
        }
        pub fn play(&mut self, cell: usize) -> Result<(), MoveError> {
            if cell >= 9 {
                return Err(MoveError::OutsideBoard);
            }
            if self.outcome() != Outcome::Playing {
                return Err(MoveError::Finished);
            }
            if self.cells[cell].is_some() {
                return Err(MoveError::Occupied);
            }
            self.cells[cell] = Some(self.turn);
            self.turn = self.turn.other();
            Ok(())
        }
        pub fn legal_moves(&self) -> impl Iterator<Item = usize> + '_ {
            (0..9).filter(|&i| self.outcome() == Outcome::Playing && self.cells[i].is_none())
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        fn played(moves: &[usize]) -> Board {
            let mut board = Board::default();
            for &cell in moves {
                board.play(cell).unwrap();
            }
            board
        }
    
        #[test]
        fn illegal_moves_leave_both_board_and_turn_unchanged() {
            let mut board = played(&[4]);
            let before = board.clone();
            assert_eq!(board.play(4), Err(MoveError::Occupied));
            assert_eq!(board.play(9), Err(MoveError::OutsideBoard));
            assert_eq!(board, before);
            assert_eq!(board.turn(), Mark::O);
        }
    
        #[test]
        fn recognizes_all_eight_winning_lines_for_both_players() {
            for line in LINES {
                for mark in [Mark::X, Mark::O] {
                    let mut board = Board::default();
                    for cell in line {
                        board.cells[cell] = Some(mark);
                    }
                    assert_eq!(board.outcome(), Outcome::Won(mark, line));
                }
            }
        }
    
        #[test]
        fn completed_games_reject_more_moves() {
            let mut board = played(&[0, 3, 1, 4, 2]);
            assert_eq!(board.outcome(), Outcome::Won(Mark::X, [0, 1, 2]));
            let before = board.clone();
            assert_eq!(board.play(8), Err(MoveError::Finished));
            assert_eq!(board, before);
            assert_eq!(board.legal_moves().count(), 0);
        }
    
        #[test]
        fn a_full_board_without_a_line_is_a_draw() {
            let board = played(&[0, 4, 8, 1, 7, 6, 2, 5, 3]);
            assert_eq!(board.outcome(), Outcome::Draw);
            assert_eq!(board.legal_moves().count(), 0);
        }
    
        #[test]
        fn a_win_on_the_ninth_move_is_not_a_draw() {
            let board = played(&[0, 1, 2, 3, 4, 5, 7, 6, 8]);
            assert_eq!(board.outcome(), Outcome::Won(Mark::X, [0, 4, 8]));
        }
    }
    
    /// Choose an optimal legal move. Stable ordering makes recordings reproducible.
    pub fn best_move(board: &Board) -> Option<usize> {
        if board.outcome() != Outcome::Playing {
            return None;
        }
        let player = board.turn();
        let mut best = None;
        let mut best_score = i16::MIN;
        for cell in [4, 0, 2, 6, 8, 1, 3, 5, 7] {
            if board.cells[cell].is_some() {
                continue;
            }
            let mut next = board.clone();
            next.play(cell)
                .expect("an empty cell on a live board is legal");
            let score = minimax(&next, player, 1);
            if score > best_score {
                best_score = score;
                best = Some(cell);
            }
        }
        best
    }
    
    fn minimax(board: &Board, player: Mark, depth: i16) -> i16 {
        match board.outcome() {
            Outcome::Won(mark, _) => {
                return if mark == player {
                    10 - depth
                } else {
                    depth - 10
                };
            }
            Outcome::Draw => return 0,
            Outcome::Playing => {}
        }
        let scores = board.legal_moves().map(|cell| {
            let mut next = board.clone();
            next.play(cell).unwrap();
            minimax(&next, player, depth + 1)
        });
        if board.turn() == player {
            scores.max().unwrap()
        } else {
            scores.min().unwrap()
        }
    }
    
    #[cfg(test)]
    mod opponent_tests {
        use super::*;
    
        fn count_games(board: Board, computer: Mark) -> (usize, usize) {
            match board.outcome() {
                Outcome::Won(mark, _) => {
                    assert_eq!(mark, computer, "computer lost: {:?}", board.cells());
                    return (1, 0);
                }
                Outcome::Draw => return (0, 1),
                Outcome::Playing => {}
            }
            let moves: Vec<_> = if board.turn() == computer {
                vec![best_move(&board).expect("live board has a move")]
            } else {
                board.legal_moves().collect()
            };
            let mut result = (0, 0);
            for cell in moves {
                let mut next = board.clone();
                next.play(cell).expect("opponent only returns legal moves");
                let (wins, draws) = count_games(next, computer);
                result.0 += wins;
                result.1 += draws;
            }
            result
        }
    
        #[test]
        fn computer_never_loses_against_any_legal_human_reply() {
            for computer in [Mark::X, Mark::O] {
                let (wins, draws) = count_games(Board::default(), computer);
                println!(
                    "Computer {}: {wins} wins, {draws} draws, 0 losses.",
                    computer.label()
                );
                assert!(wins > 0 && draws > 0);
            }
        }
    
        #[test]
        fn takes_a_win_before_blocking_and_stops_after_game_over() {
            let mut board = Board::default();
            for cell in [0, 3, 1, 4] {
                board.play(cell).unwrap();
            }
            assert_eq!(best_move(&board), Some(2));
            board.play(2).unwrap();
            assert_eq!(best_move(&board), None);
        }
    
        #[test]
        fn answers_opposite_corners_with_an_edge() {
            let mut board = Board::default();
            for cell in [0, 4, 8] {
                board.play(cell).unwrap();
            }
            assert!([1, 3, 5, 7].contains(&best_move(&board).unwrap()));
        }
    }
    
    #[derive(Clone, Debug)]
    pub struct Match {
        pub board: Board,
        pub human: Mark,
        /// Human wins, computer wins, draws.
        pub score: [u32; 3],
    }
    
    impl Default for Match {
        fn default() -> Self {
            Self { board: Board::default(), human: Mark::X, score: [0; 3] }
        }
    }
    
    impl Match {
        pub fn human_move(&mut self, cell: usize) -> Result<(), MoveError> {
            if self.board.turn() != self.human { return Err(MoveError::NotYourTurn); }
            self.board.play(cell)?;
            self.record_ending();
            Ok(())
        }
    
        pub fn computer_move(&mut self) -> bool {
            if self.board.turn() == self.human { return false; }
            let Some(cell) = best_move(&self.board) else { return false; };
            self.board.play(cell).expect("minimax returns a legal move");
            self.record_ending();
            true
        }
    
        fn record_ending(&mut self) {
            // Called only after an accepted move. A finished board rejects every later move.
            match self.board.outcome() {
                Outcome::Won(mark, _) => self.score[usize::from(mark != self.human)] += 1,
                Outcome::Draw => self.score[2] += 1,
                Outcome::Playing => {}
            }
        }
    
        pub fn next_round(&mut self) -> bool {
            if self.board.outcome() == Outcome::Playing { return false; }
            self.board = Board::default();
            self.human = self.human.other();
            true
        }
    
        pub fn status(&self) -> String {
            match self.board.outcome() {
                Outcome::Playing if self.board.turn() == self.human => "Your turn".into(),
                Outcome::Playing => "Computer's turn".into(),
                Outcome::Won(mark, _) if mark == self.human => "You win".into(),
                Outcome::Won(_, _) => "Computer wins".into(),
                Outcome::Draw => "A draw".into(),
            }
        }
    }
    
    #[cfg(test)]
    mod match_tests {
        use super::*;
        fn finish(game: &mut Match) {
            while game.board.outcome() == Outcome::Playing {
                if game.board.turn() == game.human {
                    game.human_move(best_move(&game.board).unwrap()).unwrap();
                } else { assert!(game.computer_move()); }
            }
        }
    
        #[test]
        fn completed_round_scores_once_and_rematch_alternates_the_starter() {
            let mut game = Match::default();
            finish(&mut game);
            assert_eq!(game.score, [0, 0, 1]);
            let before = game.board.clone();
            assert!(game.human_move(0).is_err());
            assert!(!game.computer_move());
            assert_eq!(game.board, before);
            assert_eq!(game.score, [0, 0, 1]);
            assert!(game.next_round());
            assert_eq!(game.human, Mark::O);
            assert_eq!(game.board, Board::default());
            assert_eq!(game.score, [0, 0, 1]);
            assert_eq!(game.human_move(0), Err(MoveError::NotYourTurn));
            assert!(game.computer_move());
            assert_eq!(game.board.turn(), Mark::O);
            finish(&mut game);
            assert_eq!(game.score, [0, 0, 2]);
            assert!(game.next_round());
            assert_eq!(game.human, Mark::X);
        }
    
        #[test]
        fn cannot_restart_an_unfinished_round_or_play_twice() {
            let mut game = Match::default();
            assert!(!game.next_round());
            game.human_move(0).unwrap();
            assert_eq!(game.human_move(1), Err(MoveError::NotYourTurn));
            assert_eq!(game.score, [0; 3]);
            assert_eq!(game.board.cells().iter().flatten().count(), 1);
        }
    }
    
  60. Add scores and a Play again button

    The Bevy screen now shows who you are, lets the computer reply, and offers another round when the game ends.

    Same game underneath
    The screen uses the match logic in src/lib.rs.

    src/main.rs · +69 −49

    @@ -1,14 +1,20 @@
    -use bevy::{prelude::*, ui_widgets::{Activate, Button}};
    -use magistr_tic_tac_toe::{Board, Mark, Outcome};
    +use bevy::{prelude::*, ui::InteractionDisabled, ui_widgets::{Activate, Button}};
    +use magistr_tic_tac_toe::{Mark, Match, Outcome};
     
     #[derive(Resource, Default)]
    -struct Game(Board);
    +struct Game(Match);
     #[derive(Component)]
     struct CellLabel(usize);
     #[derive(Component)]
     struct StatusLabel;
     #[derive(Component)]
    +struct ScoreLabel;
    +#[derive(Component)]
    +struct RoleLabel;
    +#[derive(Component)]
     struct Cell(usize);
    +#[derive(Component)]
    +struct Rematch;
     
     const INK: Color = Color::srgb(0.12, 0.15, 0.19);
     const PAPER: Color = Color::srgb(0.96, 0.95, 0.91);
    @@ -20,19 +26,15 @@
         App::new()
             .add_plugins(DefaultPlugins.set(WindowPlugin {
                 primary_window: Some(Window {
    -                title: "Tic-tac-toe".into(),
    -                resolution: (640, 620).into(),
    -                canvas: Some("#game".into()),
    -                fit_canvas_to_parent: true,
    -                prevent_default_event_handling: false,
    -                ..default()
    -            }),
    -            ..default()
    +                title: "Tic-tac-toe".into(), resolution: (640, 620).into(),
    +                canvas: Some("#game".into()), fit_canvas_to_parent: true,
    +                prevent_default_event_handling: false, ..default()
    +            }), ..default()
             }))
             .insert_resource(ClearColor(PAPER))
             .init_resource::<Game>()
             .add_systems(Startup, setup)
    -        .add_systems(Update, (keyboard, draw).chain())
    +        .add_systems(Update, (keyboard, computer, draw).chain())
             .run();
     }
     
    @@ -43,85 +45,103 @@
     fn setup(mut commands: Commands) {
         commands.spawn(Camera2d);
         commands.spawn(Node {
    -        width: percent(100), height: percent(100),
    -        flex_direction: FlexDirection::Column,
    +        width: percent(100), height: percent(100), flex_direction: FlexDirection::Column,
             align_items: AlignItems::Center, justify_content: JustifyContent::Center,
    -        row_gap: px(18), padding: UiRect::all(px(12)),
    -        ..default()
    +        row_gap: px(10), padding: UiRect::all(px(12)), ..default()
         }).with_children(|root| {
    -        root.spawn(label("Tic-tac-toe", 32.0));
    -        root.spawn((label("X to move", 18.0), StatusLabel));
    +        root.spawn(label("Tic-tac-toe", 30.0));
    +        root.spawn((label("You are X", 15.0), RoleLabel));
    +        root.spawn((label("Your turn", 18.0), StatusLabel));
             root.spawn(Node {
    -            display: Display::Grid, width: percent(100), max_width: px(330),
    -            aspect_ratio: Some(1.0), column_gap: px(6), row_gap: px(6),
    +            display: Display::Grid, width: px(264), height: px(264), flex_shrink: 0.0,
    +            column_gap: px(6), row_gap: px(6),
                 grid_template_columns: RepeatedGridTrack::flex(3, 1.0),
                 grid_template_rows: RepeatedGridTrack::flex(3, 1.0), ..default()
             }).with_children(|grid| {
                 for cell in 0..9 {
                     grid.spawn((
                         Button, Cell(cell),
    -                    Node {
    -                        width: percent(100), height: percent(100),
    -                        align_items: AlignItems::Center, justify_content: JustifyContent::Center,
    -                        border: UiRect::all(px(1)), ..default()
    -                    },
    +                    Node { width: percent(100), height: percent(100), align_items: AlignItems::Center,
    +                        justify_content: JustifyContent::Center, border: UiRect::all(px(1)), ..default() },
                         BackgroundColor(CELL), BorderColor::all(Color::srgb(0.57, 0.59, 0.55)),
    -                )).with_child((label((cell + 1).to_string(), 40.0), CellLabel(cell)))
    -                  .observe(move |_: On<Activate>, mut game: ResMut<Game>| { let _ = game.0.play(cell); });
    +                )).with_child((label((cell + 1).to_string(), 38.0), CellLabel(cell)))
    +                  .observe(move |_: On<Activate>, mut game: ResMut<Game>| { let _ = game.0.human_move(cell); });
                 }
             });
    -        root.spawn(label("Click a square or press 1–9", 15.0));
    +        root.spawn((label("You 0   Computer 0   Draws 0", 14.0), ScoreLabel));
    +        root.spawn((
    +            Button, Rematch, InteractionDisabled,
    +            Node { width: px(190), height: px(42), flex_shrink: 0.0, align_items: AlignItems::Center,
    +                justify_content: JustifyContent::Center, border: UiRect::all(px(1)), ..default() },
    +            BackgroundColor(PAPER), BorderColor::all(Color::srgb(0.57,0.59,0.55)),
    +        )).with_child(label("Play again [R]", 16.0))
    +          .observe(|_: On<Activate>, mut game: ResMut<Game>| { game.0.next_round(); });
    +        root.spawn(label("Choose a square: click or 1-9", 13.0));
         });
     }
     
     fn keyboard(keys: Res<ButtonInput<KeyCode>>, mut game: ResMut<Game>) {
    +    if keys.just_pressed(KeyCode::KeyR) { game.0.next_round(); return; }
         let digits = [KeyCode::Digit1, KeyCode::Digit2, KeyCode::Digit3, KeyCode::Digit4,
             KeyCode::Digit5, KeyCode::Digit6, KeyCode::Digit7, KeyCode::Digit8, KeyCode::Digit9];
         for (cell, key) in digits.into_iter().enumerate() {
    -        if keys.just_pressed(key) { let _ = game.0.play(cell); }
    +        if keys.just_pressed(key) { let _ = game.0.human_move(cell); break; }
         }
     }
     
    -fn status(board: &Board) -> String {
    -    match board.outcome() {
    -        Outcome::Playing => format!("{} to move", board.turn().label()),
    -        Outcome::Won(mark, _) => format!("{} wins", mark.label()),
    -        Outcome::Draw => "A draw".into(),
    +fn computer(mut game: ResMut<Game>) {
    +    if game.0.board.turn() != game.0.human && game.0.board.outcome() == Outcome::Playing {
    +        game.0.computer_move();
         }
     }
     
     fn draw(
    -    game: Res<Game>,
    -    mut labels: Query<(&mut Text, &mut TextColor, Option<&CellLabel>, Has<StatusLabel>)>,
    -    mut cells: Query<(&Cell, &mut BackgroundColor)>,
    +    game: Res<Game>, mut commands: Commands,
    +    mut labels: Query<(&mut Text, &mut TextColor, Option<&CellLabel>, Has<StatusLabel>, Has<ScoreLabel>, Has<RoleLabel>)>,
    +    mut surfaces: Query<(Entity, Option<&Cell>, Has<Rematch>, &mut BackgroundColor)>,
     ) {
         if !game.is_changed() { return; }
    -    let message = status(&game.0);
    -    for (mut text, mut color, cell, is_status) in &mut labels {
    +    let message = game.0.status();
    +    let [you, computer, draws] = game.0.score;
    +    for (mut text, mut color, cell, is_status, is_score, is_role) in &mut labels {
             if let Some(cell) = cell {
    -            let mark = game.0.cells()[cell.0];
    +            let mark = game.0.board.cells()[cell.0];
                 **text = mark.map_or_else(|| (cell.0 + 1).to_string(), |m| m.label().into());
    -            color.0 = match mark { Some(Mark::X) => X_COLOR, Some(Mark::O) => O_COLOR, None => Color::srgb(0.56, 0.58, 0.54) };
    +            color.0 = match mark { Some(Mark::X) => X_COLOR, Some(Mark::O) => O_COLOR, None => Color::srgb(0.49,0.51,0.47) };
             } else if is_status { **text = message.clone(); }
    +        else if is_score { **text = format!("You {you}   Computer {computer}   Draws {draws}"); }
    +        else if is_role { **text = format!("You are {}   Computer is {}", game.0.human.label(), game.0.human.other().label()); }
         }
    -    for (cell, mut background) in &mut cells {
    -        let winning = matches!(game.0.outcome(), Outcome::Won(_, line) if line.contains(&cell.0));
    -        background.0 = if winning { Color::srgb(0.79, 0.87, 0.77) } else { CELL };
    +    for (entity, cell, rematch, mut background) in &mut surfaces {
    +        if let Some(cell) = cell {
    +            let winning = matches!(game.0.board.outcome(), Outcome::Won(_, line) if line.contains(&cell.0));
    +            background.0 = if winning { Color::srgb(0.79,0.87,0.77) } else { CELL };
    +        } else if rematch {
    +            if game.0.board.outcome() == Outcome::Playing {
    +                commands.entity(entity).insert(InteractionDisabled);
    +                background.0 = PAPER;
    +            } else {
    +                commands.entity(entity).remove::<InteractionDisabled>();
    +                background.0 = Color::srgb(0.79,0.87,0.77);
    +            }
    +        }
         }
         announce(&game.0, &message);
     }
     
    -// A read-only description of the Rust state for screen readers and browser checks.
    -fn announce(board: &Board, message: &str) {
    +// Read-only Rust state for screen readers and browser verification.
    +fn announce(game: &Match, message: &str) {
         #[cfg(target_arch = "wasm32")]
         if let Some(document) = web_sys::window().and_then(|window| window.document()) {
             if let Some(element) = document.get_element_by_id("game-status") {
                 element.set_text_content(Some(message));
    -            let cells: String = board.cells().iter().map(|m| m.map_or('.', |m| if m == Mark::X { 'X' } else { 'O' })).collect();
    +            let cells: String = game.board.cells().iter().map(|m| m.map_or('.', |m| if m == Mark::X { 'X' } else { 'O' })).collect();
                 let _ = element.set_attribute("data-cells", &cells);
    -            let _ = element.set_attribute("data-turn", board.turn().label());
    +            let _ = element.set_attribute("data-turn", game.board.turn().label());
    +            let _ = element.set_attribute("data-human", game.human.label());
    +            let _ = element.set_attribute("data-score", &format!("{},{},{}",game.score[0],game.score[1],game.score[2]));
             }
         }
         #[cfg(not(target_arch = "wasm32"))]
    -    let _ = (board, message);
    +    let _ = (game, message);
     }
    use bevy::{prelude::*, ui::InteractionDisabled, ui_widgets::{Activate, Button}};
    use magistr_tic_tac_toe::{Mark, Match, Outcome};
    
    #[derive(Resource, Default)]
    struct Game(Match);
    #[derive(Component)]
    struct CellLabel(usize);
    #[derive(Component)]
    struct StatusLabel;
    #[derive(Component)]
    struct ScoreLabel;
    #[derive(Component)]
    struct RoleLabel;
    #[derive(Component)]
    struct Cell(usize);
    #[derive(Component)]
    struct Rematch;
    
    const INK: Color = Color::srgb(0.12, 0.15, 0.19);
    const PAPER: Color = Color::srgb(0.96, 0.95, 0.91);
    const CELL: Color = Color::srgb(1.0, 0.99, 0.96);
    const X_COLOR: Color = Color::srgb(0.12, 0.34, 0.52);
    const O_COLOR: Color = Color::srgb(0.65, 0.24, 0.12);
    
    fn main() {
        App::new()
            .add_plugins(DefaultPlugins.set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Tic-tac-toe".into(), resolution: (640, 620).into(),
                    canvas: Some("#game".into()), fit_canvas_to_parent: true,
                    prevent_default_event_handling: false, ..default()
                }), ..default()
            }))
            .insert_resource(ClearColor(PAPER))
            .init_resource::<Game>()
            .add_systems(Startup, setup)
            .add_systems(Update, (keyboard, computer, draw).chain())
            .run();
    }
    
    fn label(text: impl Into<String>, size: f32) -> impl Bundle {
        (Text::new(text), TextFont { font_size: FontSize::Px(size), ..default() }, TextColor(INK))
    }
    
    fn setup(mut commands: Commands) {
        commands.spawn(Camera2d);
        commands.spawn(Node {
            width: percent(100), height: percent(100), flex_direction: FlexDirection::Column,
            align_items: AlignItems::Center, justify_content: JustifyContent::Center,
            row_gap: px(10), padding: UiRect::all(px(12)), ..default()
        }).with_children(|root| {
            root.spawn(label("Tic-tac-toe", 30.0));
            root.spawn((label("You are X", 15.0), RoleLabel));
            root.spawn((label("Your turn", 18.0), StatusLabel));
            root.spawn(Node {
                display: Display::Grid, width: px(264), height: px(264), flex_shrink: 0.0,
                column_gap: px(6), row_gap: px(6),
                grid_template_columns: RepeatedGridTrack::flex(3, 1.0),
                grid_template_rows: RepeatedGridTrack::flex(3, 1.0), ..default()
            }).with_children(|grid| {
                for cell in 0..9 {
                    grid.spawn((
                        Button, Cell(cell),
                        Node { width: percent(100), height: percent(100), align_items: AlignItems::Center,
                            justify_content: JustifyContent::Center, border: UiRect::all(px(1)), ..default() },
                        BackgroundColor(CELL), BorderColor::all(Color::srgb(0.57, 0.59, 0.55)),
                    )).with_child((label((cell + 1).to_string(), 38.0), CellLabel(cell)))
                      .observe(move |_: On<Activate>, mut game: ResMut<Game>| { let _ = game.0.human_move(cell); });
                }
            });
            root.spawn((label("You 0   Computer 0   Draws 0", 14.0), ScoreLabel));
            root.spawn((
                Button, Rematch, InteractionDisabled,
                Node { width: px(190), height: px(42), flex_shrink: 0.0, align_items: AlignItems::Center,
                    justify_content: JustifyContent::Center, border: UiRect::all(px(1)), ..default() },
                BackgroundColor(PAPER), BorderColor::all(Color::srgb(0.57,0.59,0.55)),
            )).with_child(label("Play again [R]", 16.0))
              .observe(|_: On<Activate>, mut game: ResMut<Game>| { game.0.next_round(); });
            root.spawn(label("Choose a square: click or 1-9", 13.0));
        });
    }
    
    fn keyboard(keys: Res<ButtonInput<KeyCode>>, mut game: ResMut<Game>) {
        if keys.just_pressed(KeyCode::KeyR) { game.0.next_round(); return; }
        let digits = [KeyCode::Digit1, KeyCode::Digit2, KeyCode::Digit3, KeyCode::Digit4,
            KeyCode::Digit5, KeyCode::Digit6, KeyCode::Digit7, KeyCode::Digit8, KeyCode::Digit9];
        for (cell, key) in digits.into_iter().enumerate() {
            if keys.just_pressed(key) { let _ = game.0.human_move(cell); break; }
        }
    }
    
    fn computer(mut game: ResMut<Game>) {
        if game.0.board.turn() != game.0.human && game.0.board.outcome() == Outcome::Playing {
            game.0.computer_move();
        }
    }
    
    fn draw(
        game: Res<Game>, mut commands: Commands,
        mut labels: Query<(&mut Text, &mut TextColor, Option<&CellLabel>, Has<StatusLabel>, Has<ScoreLabel>, Has<RoleLabel>)>,
        mut surfaces: Query<(Entity, Option<&Cell>, Has<Rematch>, &mut BackgroundColor)>,
    ) {
        if !game.is_changed() { return; }
        let message = game.0.status();
        let [you, computer, draws] = game.0.score;
        for (mut text, mut color, cell, is_status, is_score, is_role) in &mut labels {
            if let Some(cell) = cell {
                let mark = game.0.board.cells()[cell.0];
                **text = mark.map_or_else(|| (cell.0 + 1).to_string(), |m| m.label().into());
                color.0 = match mark { Some(Mark::X) => X_COLOR, Some(Mark::O) => O_COLOR, None => Color::srgb(0.49,0.51,0.47) };
            } else if is_status { **text = message.clone(); }
            else if is_score { **text = format!("You {you}   Computer {computer}   Draws {draws}"); }
            else if is_role { **text = format!("You are {}   Computer is {}", game.0.human.label(), game.0.human.other().label()); }
        }
        for (entity, cell, rematch, mut background) in &mut surfaces {
            if let Some(cell) = cell {
                let winning = matches!(game.0.board.outcome(), Outcome::Won(_, line) if line.contains(&cell.0));
                background.0 = if winning { Color::srgb(0.79,0.87,0.77) } else { CELL };
            } else if rematch {
                if game.0.board.outcome() == Outcome::Playing {
                    commands.entity(entity).insert(InteractionDisabled);
                    background.0 = PAPER;
                } else {
                    commands.entity(entity).remove::<InteractionDisabled>();
                    background.0 = Color::srgb(0.79,0.87,0.77);
                }
            }
        }
        announce(&game.0, &message);
    }
    
    // Read-only Rust state for screen readers and browser verification.
    fn announce(game: &Match, message: &str) {
        #[cfg(target_arch = "wasm32")]
        if let Some(document) = web_sys::window().and_then(|window| window.document()) {
            if let Some(element) = document.get_element_by_id("game-status") {
                element.set_text_content(Some(message));
                let cells: String = game.board.cells().iter().map(|m| m.map_or('.', |m| if m == Mark::X { 'X' } else { 'O' })).collect();
                let _ = element.set_attribute("data-cells", &cells);
                let _ = element.set_attribute("data-turn", game.board.turn().label());
                let _ = element.set_attribute("data-human", game.human.label());
                let _ = element.set_attribute("data-score", &format!("{},{},{}",game.score[0],game.score[1],game.score[2]));
            }
        }
        #[cfg(not(target_arch = "wasm32"))]
        let _ = (game, message);
    }
    
  61. The full game passes ten testsPassed

    Rules, opponent moves, scoring, and rematches pass their checks.

    Still no losses
    The opponent also passes every legal human reply as X and O.
    $ cargo test --locked --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --lib -- --nocapture
    [exit 0; queue 0ms; execution 3230ms]
    
    running 10 tests
    test match_tests::cannot_restart_an_unfinished_round_or_play_twice ... ok
    test tests::illegal_moves_leave_both_board_and_turn_unchanged ... ok
    test tests::recognizes_all_eight_winning_lines_for_both_players ... ok
    test tests::completed_games_reject_more_moves ... ok
    test tests::a_win_on_the_ninth_move_is_not_a_draw ... ok
    test tests::a_full_board_without_a_line_is_a_draw ... ok
    test opponent_tests::takes_a_win_before_blocking_and_stops_after_game_over ... ok
    test opponent_tests::answers_opposite_corners_with_an_edge ... ok
    Computer X: 82 wins, 10 draws, 0 losses.
    test match_tests::completed_round_scores_once_and_rematch_alternates_the_starter ... ok
    Computer O: 368 wins, 153 draws, 0 losses.
    test opponent_tests::computer_never_loses_against_any_legal_human_reply ... ok
    
    test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.67s
    
       Compiling magistr-tic-tac-toe v0.1.0 (./examples/bevy-tic-tac-toe)
        Finished `test` profile [unoptimized + debuginfo] target(s) in 0.52s
         Running unittests src/lib.rs (examples/bevy-tic-tac-toe/target/debug/deps/magistr_tic_tac_toe-56e483b27c34fd56)
  62. Report the finished game features

    Scores and rematches are working. The agent is reviewing the walkthrough and its published data.

    The game now has scores and alternating rematches, and all ten rules, opponent, and match tests pass. The landing page has one recording with views for the session, roadmap, reasoning, work, and checks. I’m checking the browser interactions and publication data before the final visual review and push.
  63. The browser playthrough stops waitingFailed

    The test waits 30 seconds for the expected game state and times out.

    What we know
    This run did not finish. A timeout alone doesn't tell us whether the game or the test is wrong.
    Next
    Check which move or screen update the test was waiting for, then try again.
    $ cd apps/website && node tests/game-browser.mjs
    [exit 1; queue 0ms; execution 31910ms]
    node:internal/modules/run_main:107
        triggerUncaughtException(
        ^
    
    page.waitForFunction: Timeout 30000ms exceeded.
        at ./apps/website/tests/game-browser.mjs:31:15 {
      log: [],
      name: 'TimeoutError'
    }
    
    Node.js v24.21.0
  64. The playthrough now finishesPassed

    The next browser run passes with computer replies, scoring, and alternating rematches.

    Also checked
    A finished game ignores further moves. The checks pass at 720px and 320px wide.
    $ cd apps/website && node tests/game-browser.mjs
    [exit 0; queue 0ms; execution 4347ms]
    Bevy 0.20.0: real computer replies, terminal input protection, one score per round and alternating rematches passed at 720px and 320px.
  65. The game builds for the browserPassed

    Rust successfully builds the Bevy game for the browser. The build uses the saved dependency versions.

    $ cargo build --locked --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --features game --target wasm32-unknown-unknown --release
    [exit 0; queue 0ms; execution 340ms]
        Finished `release` profile [optimized] target(s) in 0.29s
  66. Rust flags a small code cleanupFailed

    Clippy, Rust's code checker, finds two nested if statements that can be combined.

    Why this check fails
    This project requires those warnings to be fixed before completion.
    Next
    Combine the conditions and rerun the code checks.
    $ cargo fmt --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --check && cargo clippy --locked --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --lib -- -D warnings
    [exit 101; queue 0ms; execution 411ms]
        Checking magistr-tic-tac-toe v0.1.0 (./examples/bevy-tic-tac-toe)
    error: this `if` statement can be collapsed
      --> src/lib.rs:73:13
       |
    73 | /             if let Some(mark) = self.cells[line[0]] {
    74 | |                 if line.iter().all(|&cell| self.cells[cell] == Some(mark)) {
    75 | |                     return Outcome::Won(mark, line);
    76 | |                 }
    77 | |             }
       | |_____________^
       |
       = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#collapsible_if
       = note: `-D clippy::collapsible-if` implied by `-D warnings`
       = help: to override `-D warnings` add `#[allow(clippy::collapsible_if)]`
    help: collapse nested if block
       |
    73 ~             if let Some(mark) = self.cells[line[0]]
    74 ~                 && line.iter().all(|&cell| self.cells[cell] == Some(mark)) {
    75 |                     return Outcome::Won(mark, line);
    76 ~                 }
       |
    
    error: could not compile `magistr-tic-tac-toe` (lib) due to 1 previous error
  67. Prepare the final review

    The agent checks that the recording includes the decisions, failures, fixes, and completed work.

    The example uses Bevy 0.20.0. I’m finishing the browser review and checking that the published recording preserves the actual decisions, failed checks, fixes, and completed work. Then I’ll push the website and docs directly to `main`.
  68. The game still passes after cleanupPassed

    All ten rules, opponent, scoring, and rematch tests pass again.

    $ cargo test --locked --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --lib
    [exit 0; queue 0ms; execution 2499ms]
    
    running 10 tests
    test match_tests::cannot_restart_an_unfinished_round_or_play_twice ... ok
    test tests::a_full_board_without_a_line_is_a_draw ... ok
    test tests::a_win_on_the_ninth_move_is_not_a_draw ... ok
    test tests::completed_games_reject_more_moves ... ok
    test tests::illegal_moves_leave_both_board_and_turn_unchanged ... ok
    test tests::recognizes_all_eight_winning_lines_for_both_players ... ok
    test opponent_tests::takes_a_win_before_blocking_and_stops_after_game_over ... ok
    test opponent_tests::answers_opposite_corners_with_an_edge ... ok
    test match_tests::completed_round_scores_once_and_rematch_alternates_the_starter ... ok
    test opponent_tests::computer_never_loses_against_any_legal_human_reply ... ok
    
    test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.07s
    
       Compiling magistr-tic-tac-toe v0.1.0 (./examples/bevy-tic-tac-toe)
        Finished `test` profile [unoptimized + debuginfo] target(s) in 0.38s
         Running unittests src/lib.rs (examples/bevy-tic-tac-toe/target/debug/deps/magistr_tic_tac_toe-56e483b27c34fd56)
  69. The cleanup passesPassed

    The formatter and Rust code checker now report no problems.

    $ cargo fmt --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --check && cargo clippy --locked --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --lib -- -D warnings
    [exit 0; queue 0ms; execution 243ms]
        Checking magistr-tic-tac-toe v0.1.0 (./examples/bevy-tic-tac-toe)
        Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
  70. Rebuild the checked gamePassed

    The browser build passes again after the code cleanup.

    $ cargo build --locked --manifest-path examples/bevy-tic-tac-toe/Cargo.toml --features game --target wasm32-unknown-unknown --release
    [exit 0; queue 0ms; execution 343ms]
        Finished `release` profile [optimized] target(s) in 0.30s
  71. Play through the checked build againPassed

    The final browser run passes at desktop and narrow sizes, including computer replies, one score per round, and alternating starts.

    $ cd apps/website && node tests/game-browser.mjs
    [exit 0; queue 0ms; execution 4342ms]
    Bevy 0.20.0: real computer replies, terminal input protection, one score per round and alternating rematches passed at 720px and 320px.
  72. Review the final screensPassed

    The recorded review covers the start, a computer win, and a rematch at desktop and narrow sizes.

    What was checked
    The board, scores, player labels, result, and controls are readable and fit on screen.
    Opened and inspected all six original frames in docs/evidence/desktop-iterations/2026-10-21-bevy-rematch-02. Desktop 720x700 and compact 320x700: start, computer win and alternating rematch. Board, scores, role, result and controls remain readable and contained. Fixed paper appearance; no graph connections. Full observations and limitations in REVIEW.md.
  73. Check off the last requirements

    The final checklist records computer replies, scoring once, and a new round that keeps the score and alternates who starts.

    Play again, keep the score and alternate who starts
    • The computer responds through the same game rules.
    • Finished rounds add one score; further input cannot add another.
    • Play again clears the board, preserves the score and alternates the opening player.
  74. All four pieces are finished

    The roadmap marks the last item complete. Rules, opponent, board, and rematches now form a checked, playable game.

    Play again, keep the score and alternate who starts
    • The computer responds through the same game rules.
    • Finished rounds add one score; further input cannot add another.
    • Play again clears the board, preserves the score and alternates the opening player.
  75. Leave the finished work ready to revisit

    The final task is complete. The project keeps its plan, decisions, changes, failed attempts, and passing checks.

    What another session can use
    The next agent can read what was built and why before making a change.
    Connect the opponent and finish the match flow
    Status: completed
  76. After the finished game, the same session published this page. These replies are not part of the walkthrough above.
  77. Check the publication

    The game’s four pieces are complete. The agent checks the public files for private material.

    The game checks pass, including exhaustive testing of the opponent. The recording now contains all four completed chunks and the failed attempts that led to fixes. I’ve also checked the publication bundle for private paths and prompts; the landing page and all seven guides use the same game example.
  78. Fix a keyboard issue

    Browser testing catches a menu that does not close with Escape. Codex fixes it and reports what changed.

    The browser review caught a mobile navigation bug: Escape didn’t close the menu in WebKit when clicking hadn’t moved keyboard focus. That’s fixed. I also tightened the mobile record views and updated CI’s file mapping so future edits to this standalone example run the website and game checks without selecting Magistr’s entire Rust workspace.
  79. Finish the browser checks

    Playback, details, search, and recovery pass at desktop and phone sizes in both themes.

    The browser checks now pass in light and dark mode at desktop and phone sizes, including playback, record inspection, search, and loading-failure recovery. I’m completing the screenshot review and the commit.
  80. Explain the opponent decision

    The agent sums up the losing fork and the tested replacement while reviewing the phone layout.

    The demo now shows a concrete decision: a simple opponent loses to a fork, the agent records that finding, and the replacement is checked against every legal human reply. The docs follow that same project through rules, the Bevy board, and rematches. I’m finishing the phone-layout review before pushing.
  81. Catch a narrow-screen overflow

    One tab extends beyond the player at 320 pixels. The agent fixes it before shipping.

    The visual review caught one more issue at 320 pixels: the last tab extends past the session border. I’m tightening that row and checking both themes. The game, publication/privacy checks, and CI path-selection tests are passing.
  82. Prepare the commit

    Visual fixes are complete. The same commit now needs to pass the repository’s required checks.

    The visual fixes are complete. I also corrected the return button when you open a check from a work item. I’m recording the final checks and committing on `main`; GitHub requires checks on that exact commit before it will accept the push.
  83. Report the commit

    The changes are committed. Codex explains which checks must finish before pushing to main.

    Committed as `723b7794`. The CI routing change makes future Bevy-example edits run only the relevant checks; because this commit changes the selector itself, GitHub will run the broader checks once. I’m using the required verification branch, then pushing this same commit directly to `main`. No PR.
  84. Report the first CI results

    Security and screenshot checks pass. The remaining build jobs are still running.

    GitHub’s dependency/secret checks and visual-evidence gate have passed. The website, Rust, and packaging jobs are still running. The working tree is clean and the deployment is ready.
  85. Confirm the website checks passed

    The website passes its CI checks; the wider repository checks are still running.

    The website job has passed on GitHub, including the recorded-data checks and browser interactions. The remaining jobs are the broader Rust build and desktop/package checks triggered by the CI selector change.
  86. Keep you updated while CI runs

    Rust and desktop builds are still in progress. The agent reports that no failure has appeared.

    CI is still compiling the Rust targets and desktop package; no failures have been reported. After it finishes, the remaining steps are the direct `main` push, deployment, and a check of the live site.
  87. Share the local preview

    You can inspect the walkthrough while the build checks continue.

    The local preview is available at [localhost:4323](http://localhost:4323) while CI runs. The walkthrough contains 65 recorded actions, with inspectable roadmap items, reasoning, source changes, and check output.
  88. Report successful builds

    Rust compilation and macOS packaging pass. Tests and desktop verification continue.

    The Rust build and macOS package verification have passed. The compiled tests are now running across four workers; desktop verification is also still progressing.
  89. Report test progress

    The compiled tests are running. The agent is waiting for the results before pushing.

    GitHub is now running the compiled test partitions. No failures so far; the direct push remains pending those results and the desktop job.
  90. Narrow down what remains

    Three test workers have passed. The final worker is saving its cache.

    Three test workers have passed; the fourth has finished testing and is saving its cache. Desktop verification is the last substantial job still running.
  91. Finish the Rust tests

    All four Rust test groups pass. Desktop compilation is the remaining prerequisite.

    All four Rust test partitions have passed. Desktop test compilation is still running; it is the only remaining prerequisite for the final required gate.
  92. Build the installer

    Desktop checks move on to packaging the app and installer.

    Desktop verification has moved on to building the app and installer. The test jobs have passed; I’m waiting for packaging and the final gate.
  93. Wait for the installer

    The installer is still building; the completed checks have passed.

    The installer build is still running. Every completed CI job is green.
  94. Check the packaged app

    The installer build passes. GitHub checks the packaged targets next.

    The installer build has passed. GitHub is checking the packaged desktop targets now, then it can issue the final required status.
  95. Run the last package tests

    The packaged targets pass their checks. The final job tests the app and its process ownership.

    The packaged desktop targets passed their checks and linting. The last job is now testing the packaged app and process ownership.
  96. Wait for GitHub to finish

    The tests and packaging pass. GitHub is saving the final cache.

    All test and packaging steps have passed. GitHub is saving the final cache and closing the run; I’ll push `main` as soon as its required status turns green.
  97. Push and publish

    The required checks pass. The agent pushes directly to main and publishes the site.

    GitHub’s required checks passed. I’m pushing the Bevy 0.20.0 overhaul to `main`, then publishing and checking the live site.
  98. Check the live site

    The published walkthrough, guides, search, controls, and mobile layouts pass their browser checks.

    The overhaul is live at [magistr.dev](https://magistr.dev) and pushed to `main`. The live playback, all seven guides, search, keyboard controls, and mobile layouts pass the browser checks; the deployed files also match the reviewed build.
  99. Hand over the finished work

    Codex provides the live site, commit, downloadable game, and verification results.

    Live at [magistr.dev](https://magistr.dev), pushed directly to `main` in [`723b7794`](https://github.com/AlrikOlson/magistr/commit/723b7794).
    
    - Real tic-tac-toe project using **Bevy exactly 0.20.0**, with downloadable source.
    - Scrubbable Codex walkthrough showing recorded roadmap, reasoning, work, failures, and checks.
    - Rewritten landing page and all seven guides around that concrete example.
    
    CI, game tests, and live browser checks pass. [Reviewed screenshots](https://github.com/AlrikOlson/magistr/blob/723b7794/docs/evidence/desktop-iterations/2026-10-21-game-walkthrough-07/REVIEW.md) are committed.

The next session has somewhere to start.

The agent can read the plan, find the opponent experiment, and see which checks passed. You can spend the next conversation on what you want to build next.

Plan the bigger project
Keep deliverables, dependencies and acceptance criteria together. Change the plan as you learn more.
Remember why
Save an investigation and the evidence behind its conclusion. Revisit it when the assumptions change.
Finish a piece
Give the agent one chunk or a bounded run. Keep the task, changes and checks attached to the work.
Save feedback
Want an easier opponent? Keep that idea, investigate the options, then decide whether it belongs on the roadmap.
Work in the code
Find and read source, check that a file is still current before editing it, and undo changes recorded in the journal.

Keep the agent you like.

Codex and Claude Code have setup and project workflows ready to use. Other agents can connect through MCP. Your model account and permissions stay with your agent.

Connect your agent ↗

Install the source preview

v1.0.0-beta.1 builds from the public source repository (opens in a new tab). Registry packages are not published yet.

You'll need Apple Silicon macOS 15 or later, Rust 1.95 or later, Git, and the macOS command-line build tools. Bring your own Codex or Claude Code account.

Full installation instructions
In your terminal
git clone https://github.com/AlrikOlson/magistr.git
cd magistr
cargo install --locked --path crates/magistr-cli
magistr --version

Run magistr init . in the project you want to build. Setup connects your agent and helps you choose the project’s checks. Setup does not start implementation. Open a session, talk through the project, and use magistr next when you’re ready for the first chunk.