Building Tic-Tac-Toe in Rust - Part 3 - Adding a Machine Player
Building Tic-Tac-Toe in Rust: Part 3 - Adding a Machine Player
In this part of our series, we’ll enhance our Tic-Tac-Toe game by adding a simple machine player. The machine will use the best_move
function to decide its moves based on the following priorities:
- Win if possible on the next move.
- Block the other player from winning on their next move.
- Choose a random move from the available squares.
We’ll also integrate the machine player with the main game loop and write tests to ensure its functionality.
Step 1: The best_move
Function
The best_move
function evaluates the current board and returns the best available move for the specified player ('X'
or 'O'
). Here’s the implementation:
impl GameState {
pub fn best_move(&self, player: char) -> Option<usize> {
let opponent = if player == 'X' { 'O' } else { 'X' };
// Step 1: Check for a winning move
for i in 0..9 {
if self.board[i].is_none() {
let mut simulated_board = self.board.clone();
simulated_board[i] = Some(player);
let simulated_state = GameState {
board: simulated_board,
current_player: player,
};
if simulated_state.check_winner() == Some(player) {
return Some(i);
}
}
}
// Step 2: Check for a blocking move
for i in 0..9 {
if self.board[i].is_none() {
let mut simulated_board = self.board.clone();
simulated_board[i] = Some(opponent);
let simulated_state = GameState {
board: simulated_board,
current_player: opponent,
};
if simulated_state.check_winner() == Some(opponent) {
return Some(i);
}
}
}
// Step 3: Choose the best available square
let priority_squares = [4, 0, 2, 6, 8, 1, 3, 5, 7];
for &i in &priority_squares {
if self.board[i].is_none() {
return Some(i);
}
}
None
}
}
Step 2: Unit Tests for best_move
Here are some unit tests to ensure the machine player behaves as expected:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_best_move_winning() {
let mut game = GameState::new();
game.board = vec![
Some('X'), Some('X'), None,
None, None, None,
None, None, None,
];
assert_eq!(game.best_move('X'), Some(2));
}
#[test]
fn test_best_move_blocking() {
let mut game = GameState::new();
game.board = vec![
Some('O'), Some('O'), None,
None, None, None,
None, None, None,
];
assert_eq!(game.best_move('X'), Some(2));
}
}
Step 3: Integrate with the Main Game Loop
Let’s modify the game loop to let the machine player make a move:
fn main() {
let mut game = GameState::new();
loop {
game.display_board();
if game.current_player == 'X' {
// Human player
game.play_turn();
} else {
// Machine player
if let Some(move_index) = game.best_move('O') {
println!("AI chooses position {}", move_index + 1);
game.board[move_index] = Some('O');
game.current_player = 'X';
}
}
if let Some(winner) = game.check_winner() {
game.display();
println!("Player {} wins!", winner);
break;
}
if game.board.iter().all(|&square| square.is_some()) {
game.display();
println!("It's a draw!");
break;
}
}
}
Step 4: Test the Integration
Run the game and try different scenarios:
- Can the machine win when possible?
- Does the machine block your winning move?
- Does the machine choose an available square when no immediate threats exist?
Wrapping Up
In this post, we’ve:
- Implemented the
best_move
function for our machine player. - Written unit tests to validate the function.
- Integrated the machine player with the main game loop.
The best_move function is a great starting point for a simple AI. In future posts, we’ll explore more advanced techniques like the minimax algorithm to create an unbeatable AI.
As always, you can find the code for this post on GitHub: Tic-Tac-Toe Repository.
Until then, try experimenting with different strategies or tweaking the AI’s priorities. Happy coding!