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.
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.0You ask for a game. Codex agrees on the scope, then saves a four-piece plan before writing any code.
Scroll to read earlier output. ← → step through the build.

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
Ask for a Bevy game
You change course: a small Bevy game instead of the earlier plan. This is where the project starts.
Agree on what to build
Codex proposes a playable board, a computer opponent, and rematches. The walkthrough it mentions is this page.
Make the work concrete
The agent identifies the game behavior it will build and check.
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.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.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.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.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.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.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: plannedBegin 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: activeCreate 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"Put the game rules in place
The new
src/lib.rshandles 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])); } }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)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]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.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: completedPlan 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: plannedStart 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: activeWould 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.The rules pass with the saved versionsPassed
The agent reruns the five tests with
Cargo.lockin 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)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.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.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<_>>()); } }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 backtraceReport what the experiment found
The first opponent looks promising when it starts. Playing second still needs checking.
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`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.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())); } }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)Specify the Bevy version
You add a requirement while the agent is working: use Bevy 0.20.0.
Apply your follow-up
Codex acknowledges the version and moves on to the interface.
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.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.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`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.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: completedMove 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.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: plannedStart 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: activeAdd 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"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); }Report the first working browser build
The game compiles for the browser, and the opponent has survived every legal human reply.
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.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.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.Explain a visual problem
Playing the game works, but its instructions are clipped. The agent reports the problem before fixing it.
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.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.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.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.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.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.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.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: completedMake 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.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: plannedStart 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: activeKeep 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); } }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); }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)Report the finished game features
Scores and rematches are working. The agent is reviewing the walkthrough and its published data.
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.0The 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.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.29sRust flags a small code cleanupFailed
Clippy, Rust's code checker, finds two nested
ifstatements 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 errorPrepare the final review
The agent checks that the recording includes the decisions, failures, fixes, and completed work.
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)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.13sRebuild 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.30sPlay 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.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.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.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.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- After the finished game, the same session published this page. These replies are not part of the walkthrough above.
Check the publication
The game’s four pieces are complete. The agent checks the public files for private material.
Fix a keyboard issue
Browser testing catches a menu that does not close with Escape. Codex fixes it and reports what changed.
Finish the browser checks
Playback, details, search, and recovery pass at desktop and phone sizes in both themes.
Explain the opponent decision
The agent sums up the losing fork and the tested replacement while reviewing the phone layout.
Catch a narrow-screen overflow
One tab extends beyond the player at 320 pixels. The agent fixes it before shipping.
Prepare the commit
Visual fixes are complete. The same commit now needs to pass the repository’s required checks.
Report the commit
The changes are committed. Codex explains which checks must finish before pushing to main.
Report the first CI results
Security and screenshot checks pass. The remaining build jobs are still running.
Confirm the website checks passed
The website passes its CI checks; the wider repository checks are still running.
Keep you updated while CI runs
Rust and desktop builds are still in progress. The agent reports that no failure has appeared.
Share the local preview
You can inspect the walkthrough while the build checks continue.
Report successful builds
Rust compilation and macOS packaging pass. Tests and desktop verification continue.
Report test progress
The compiled tests are running. The agent is waiting for the results before pushing.
Narrow down what remains
Three test workers have passed. The final worker is saving its cache.
Finish the Rust tests
All four Rust test groups pass. Desktop compilation is the remaining prerequisite.
Build the installer
Desktop checks move on to packaging the app and installer.
Wait for the installer
The installer is still building; the completed checks have passed.
Check the packaged app
The installer build passes. GitHub checks the packaged targets next.
Run the last package tests
The packaged targets pass their checks. The final job tests the app and its process ownership.
Wait for GitHub to finish
The tests and packaging pass. GitHub is saving the final cache.
Push and publish
The required checks pass. The agent pushes directly to main and publishes the site.
Check the live site
The published walkthrough, guides, search, controls, and mobile layouts pass their browser checks.
Hand over the finished work
Codex provides the live site, commit, downloadable game, and verification results.
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 instructionsgit clone https://github.com/AlrikOlson/magistr.git
cd magistr
cargo install --locked --path crates/magistr-cli
magistr --versionRun 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.