Tic-tac-toe strategy: Implementing the Minimax Algorithm

This is an update to Ire Aderinokun’s post about a game of Tic-tac-toe she built. I made the Computer player unbeatable using the Minimax Algorithm and she encouraged me to write about it. I will focus on the Computer player’s strategy so check out her initial blog post to see how she built the game. Play against my AI player and checkout the code.

Tic-tac-toe strategy

A perfect Tic-tac-toe player will always win or draw and if the opponent is also a perfect player the game will always end in a draw. Normally as human players we think ahead and try to make moves based on the current state of the game, and what we think the opponent will do next. Some basic strategy include:

To create an intelligent player we need to make the computer simulate the same forward thinking and enable it to make decisions just like we would. To accomplish this the Minimax algorithm is used to generate every possible move based on the current state of the game and choose the move that would most likely lead to a loss for the opponent.

Minimax Algorithm

The Minimax Algorithm is used to determine the next move in games where players take turns, by minimizing the opponent’s (referred to as the minimizing player) chances of wining. Thereby maximizing the player’s (referred to as the maximizing player) chances of wining.

The Minimax Algorithm generates every possible move by means of recursion and assigns a score to each move. The move with the highest score is then selected and returned. The score for a move is calculated by a function that evaluates the current state of the game and determines how good that move is for the current player. The higher the score, the better.

Implementation

Let’s walk through the implementation. We define Minimax as a function that receives two arguments: dept and player. We use the dept parameter to determine how many moves ahead the computer should simulate, and the player, represented as "X" or "O" to keep track of the current player.

function miniMax(dept, player) {
  var bestMove = -1;
  var bestScore = (player === "O") ? -Infinity : Infinity;
  ...
}

To begin, a bestScore variable is initialized to negative Infinity if the current player is the maximizing player, and positive Infinity if the current player is the minimizing player i.e the opponent.

The Minimax algorithm is a recursive algorithm so we need a base case that returns an actual value to prevent further calls to itself.

function miniMax(dept, player) {
  var bestMove = -1;
  var bestScore = (player === "O") ? -Infinity : Infinity;

  if (over() || dept === 0) {
    bestScore = scoreBoard();
    return [bestScore, bestMove];
  }
  ...
}

The function returns the bestScore and bestMove when the game is over, or when the dept parameter becomes zero. over() is a predefined function that checks if the game is won or the game board if full. So we need to stop generating moves when this condition is met and return a score for that move which is calculated by another function scoreBoard().

Next we call a function that returns an array of possible/un-played moves, than we use a for loop to iterate over each move and place the current player’s piece on the board.

function miniMax(dept, player) {
  ...
  var validMoves = possibleMoves();

  // Iterate over the possible moves and try each.
  for (var i = 0; i < validMoves.length; i++) {
    board[validMoves[i] - 1] = player; // Make the move. (account for array index)
  ...
}

At this point, we check to see who the current player is and call the miniMax function with the opposing player’s piece. We also decrease the dept variable by one.

function miniMax(dept, player) {
  ...
  // Iterate over the possible moves and try each.
  for (var i = 0; i < validMoves.length; i++) {
    board[validMoves[i] - 1] = player; // Make the move. (account for array index)

    if (player === "O") { // maximizing player's turn
      var score = miniMax(dept-1, "X")[0];
      ...
    } else { // opponent's/minimizing player's turn
      var score = miniMax(dept-1, "O")[0]
      ...
    }
  ...
}

The score result for the recursive call is then compared with the current bestScore. Keeping with the idea of maximizing the player’s chances and minimizing the opponent’s chances, If it’s the maximizing player’s turn, we set bestScore to the score result, and bestMove to the current move, if bestScore is less than the score result. And if it’s the minimizing player’s turn and the bestScore is greater than the score result, we set bestScore to the score result, and bestMove to the current move.

function miniMax(dept, player) {
  ...
    if (player === "O") { // maximizing player's turn
      var score = miniMax(dept-1, "X")[0];

      if (bestScore < score) {
        bestScore = score;
        bestMove = validMOves[i];
      }
    } else { // opponent's/minimizing player's turn
      var score = miniMax(dept-1, "O")[0]

      if (bestScore > score) {
        bestScore = score;
        bestMove = validMOves[i];
      }
    }
  ...
}

Finally we undo the current move so we can try other moves then return bestScore and bestMove.

function miniMax(dept, player) {
  ...
  // Iterate over the possible moves and try each.
  for (var i = 0; i < validMoves.length; i++) {
    board[validMoves[i] - 1] = player; // Make the move. (account for array index)
    ...
    board[validMoves[i] - 1] = "" // Undo move
  }

  return [bestScore, bestMove]
}

Putting it all together:

/**
 * Determine the best move for Computer player.
 * @param {Number} dept - Dept of game tree
 * @param {string} player - Current player's piece, X or O
 * @returns {Array} Array containing bestMove and bestScore
 */
function miniMax(dept, player) {
  var bestMove = -1;
  var bestScore = (player === "O") ? -Infinity : Infinity;

  if (over() || dept === 0) {
    bestScore = scoreBoard();
    return [bestScore, bestMove];
  }

  var validMoves = possibleMoves();

  // Iterate over the possible moves and try each.
  for (var i = 0; i < validMoves.length; i++) {
    board[validMoves[i] - 1] = player; // Make the move. (account for array index)

    if (player === "O") { // maximizing player's turn
      var score = miniMax(dept-1, "X")[0];

      if (bestScore < score) {
        bestScore = score;
        bestMove = validMoves[i];
      }

    } else { // opponent's/minimizing player's turn
      var score = miniMax(dept-1, "O")[0]

      if (bestScore > score) {
        bestScore = score;
        bestMove = validMoves[i];
      }
    }

    board[validMoves[i] - 1] = ""; // Undo move
  }

  return [bestScore, bestMove]
}

Scoring Game state. scoreBoard()

The scoreBoard() function iterate through an array of predefined win combinations after a move is made and returns a score for the current state of the game. This can be done differently but my implementation returns 100 for an immediate win and -100 for an immediate loss. 10 for two in a row and -10 for two in a row for the opponent. The function also returns 20 if there is a chance for a fork, so a fork would be preferred to a move that would yield just two in a row. See full implementation.

Improving Efficiency

Despite Tic-tac-toe’s simplicity, it has about 362,880 (9! Factorial) possible moves, imagine that number for a game like Chess. To improve the Minimax Algorithm’s efficiency, Alpha-beta pruning is used. Alpha-beta pruning is a search algorithm that reduces the amount of moves the Minimax Algorithm generates, by ignoring moves that will not influence the final decision. See the implementation.

I hope this has been helpful, please leave comments and suggestions below. Don’t forget to play the game and checkout the code on Github.

Share this article.

Share Tweet Share
blog comments powered by Disqus