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:

  1. Win if possible on the next move.
  2. Block the other player from winning on their next move.
  3. 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:

  1. Can the machine win when possible?
  2. Does the machine block your winning move?
  3. 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!