An Exhaustive Explanation of Minimax, a Staple AI Algorithm

10 January 2012

Overview

The minimax algorithm is used to determine which moves a computer
player makes in games like tic-tac-toe, checkers, othello, and
chess. These kinds of games are called games of perfect information
because it is possible to see all possible moves. A game like scrabble
is not a game of perfect information because there's no way to predict
your opponent's moves because you can't see his hand.

You can think of the algorithm as similar to the human thought process
of saying, "OK, if I make this move, then my opponent can only make
two moves, and each of those would let me win. So this is the right
move to make."

The rest of the article explains how to represent possible moves and
evaluate them. An example command-line tic-tac-toe game using Ruby is
given.

Representing Moves with the Game Tree Data Structure

Here's an example of a Game Tree for tic-tac-toe:

Note that this isn't a full game tree. A full game tree has hundreds
of thousands of game states, so that's uh... not really possible to
include in an image. Some of the discrepancies between the example
above and a fully-drawn game tree include:

The first Game State would show nine moves descending from it, one
for each of the empty spaces on its board

Similarly, the next level of Game States would show eight moves
descending from them, and so on for each Game State

Representing the game as a game tree allows the computer to evaluate
each of its current possible moves by determining whether it will
ultimately result in a win or a loss. We'll get into how the computer
determines this in the next section, Ranking. Before that, though, we
need to clearly define the central concepts defining a Game Tree:

The board state. In this case, where the X's and O's are.

The current player - the player who will be making the next move.

The next available moves. For humans, a move involves placing a game token. For the computer, it's a matter of selecting the next game state. As humans, we never say, "I've selected the next game state", but it's useful to think of it that way in order to understand the minimax algorithm.

The game state - the grouping of the three previous concepts.

So, a Game Tree is a structure for organizing all possible (legal)
game states by the moves which allow you to transition from one game
state to the next. This structure is ideal for allowing the computer
to evaluate which moves to make because, by traversing the game tree,
a computer can easily "foresee" the outcome of a move and thus
"decide" whether to take it.

Next we'll go into detail about how to determine whether a move is
good or bad.

Ranking Game States

The basic approach is to assign a numerical value to a move based on
whether it will result in a win, draw, or loss. We'll begin
illustrating this concept by showing how it applies to final game
states, then show how to apply it to intermediate game states.

Final Game States

Have a look at this Game Tree:

It's X's turn, and X has three possible moves, one of which (the
middle one) will lead immediately to victory. It's obvious that an AI
should select the winning move. The way we ensure this is to give each
move a numerical value based on its board state. Let's use the
following rankings:

Win: 1

Draw: 0

Lose: -1

These rankings are arbitrary. What's important is that winning
corresponds to the highest ranking, losing to the lowest, and a draw's
between the two.

Since the lowest-ranked moves correspond with the worst outcomes and
highest-ranked moves correspond with the best outcomes, we should
choose the move with the highest value. This is the "max" part of
"minimax". Below are some more examples of final game states and their
numerical values:

You might be wondering whether or not we should apply different
rankings based on the player whose turn it is. For now, let's ignore
the question entirely and only view things from X's perspective.

Of course, only the most boring game in the world would start out by
presenting you with the options of "win immediately" and "don't win
immediately." And an algorithm would be useless if it only worked in
such a situation. But guess what! Minimax isn't a useless
algorithm. Below I'll describe how to determine the ranks of
intermediate Game States.

Intermediate Game States

Have another look at this game tree:

As you can see, it's X's turn in the top Game State. There are 3
possible moves, including a winning move. Since this Game State allows
X to win, X should try to get it if possible. This Game State is as
good as winning, so its rank should be 1. In general, we can say that
the rank of an intermediate Game State where X is the current player
should be set to the maximum rank of the available moves.

Now have a look at this game tree:

It's O's turn, and there are 5 possible moves, three of which are
shown. One of the moves results in an immediate win for O. From X's
perspective this Game State is equivalent to a loss, since it allows O
to select a move that will cause X to lose. Therefore, its rank should
be -1. In general, we can say that the rank of an intermediate Game
State where O is the current player should be set to the minimum
rank of the available moves. That's what the "mini" in "minimax"
refers to.

By the way - the above game tree probably looks ridiculous to you. You
might say, "Well of course you shouldn't make such a dumb move. Why
would anyone give up a win and allow O to win?" Minimax is our way of
giving the computer the ability to "know" that it's a dumb move, too.

To sum up:

Final Game States are ranked according to whether they're a win,
draw, or loss.

Intermediate Game States are ranked according to whose turn it is and the available moves.

If it's X's turn, set the rank to that of the maximum move available. In other words, if a move will result in a win, X should take it.

If it's O's turn, set the rank to that of the minimum move available. In other words, If a move will result in a loss, X should avoid it.

And that's the minimax algorithm!

Ruby Example

Below is a Ruby implementation of Tic-Tac-Toe. It has a few
limitations - for example, the computer always plays X. This is
beacuse it's meant to illustrate the concepts described here.

To run it, copy it to something like "tictactoe.rb" and run ruby
tictactoe.rb.

You can
view the repo on github. In
the future, I might add Common Lisp and C versions of Tic-Tac-Toe, and
I'd welcome versions in other languages as well.

#!/usr/bin/env ruby# This is a pretty naive implementation of Tic-Tac-Toe meant to# demonstrate the minimax algorithm. Note that it takes awhile to# start up - 30 seconds on a 2010 mac book air. Also note that the# Ruby isn't all that great as Ruby, but I don't care as long as it# illustrates the algorithm well.## You can see that there's a hard-coded assumption that the AI is the# X player, means that no human will ever be able to win. I'm positive# that is only a small taste of what is to come, once robots take# over the world.classGameStateattr_accessor:current_player,:board,:moves,:rankdefinitialize(current_player,board)self.current_player=current_playerself.board=boardself.moves=[]enddefrank@rank||=final_state_rank||intermediate_state_rankend# this is only ever called when it's the AI's (the X player) turndefnext_movemoves.max{|a,b|a.rank<=>b.rank}enddeffinal_state_rankiffinal_state?return0ifdraw?winner=="X"?1:-1endenddeffinal_state?winner||draw?enddefdraw?board.compact.size==9&&winner.nil?enddefintermediate_state_rank# recursion, babyranks=moves.collect{|game_state|game_state.rank}ifcurrent_player=='X'ranks.maxelseranks.minendenddefwinner@winner||=[# horizontal wins[0,1,2],[3,4,5],[6,7,8],# vertical wins[0,3,6],[1,4,7],[2,5,8],# diagonal wins[0,4,8],[6,4,2]].collect{|positions|(board[positions[0]]==board[positions[1]]&&board[positions[1]]==board[positions[2]]&&board[positions[0]])||nil}.compact.firstendendclassGameTreedefgenerateinitial_game_state=GameState.new('X',Array.new(9))generate_moves(initial_game_state)initial_game_stateenddefgenerate_moves(game_state)next_player=(game_state.current_player=='X'?'O':'X')game_state.board.each_with_indexdo|player_at_position,position|unlessplayer_at_positionnext_board=game_state.board.dupnext_board[position]=game_state.current_playernext_game_state=GameState.new(next_player,next_board)game_state.moves<<next_game_stategenerate_moves(next_game_state)endendendendclassGamedefinitialize@game_state=@initial_game_state=GameTree.new.generateenddefturnif@game_state.final_state?describe_final_game_stateputs"Play again? y/n"answer=getsifanswer.downcase.strip=='y'@game_state=@initial_game_stateturnelseexitendendif@game_state.current_player=='X'puts"\n==============="@game_state=@game_state.next_moveputs"X's move:"render_boardturnelseget_human_moveputs"The result of your move:"render_boardputs""turnendenddefrender_boardoutput=""0.upto(8)do|position|output<<" #{@game_state.board[position]||position} "caseposition%3when0,1thenoutput<<"|"when2thenoutput<<"\n-----------\n"unlessposition==8endendputsoutputenddefget_human_moveputs"Enter square # to place your 'O' in:"position=getsmove=@game_state.moves.find{|game_state|game_state.board[position.to_i]=='O'}ifmove@game_state=moveelseputs"That's not a valid move"get_human_moveendenddefdescribe_final_game_stateif@game_state.draw?puts"It was a draw!"elsif@game_state.winner=='X'puts"X won!"elseputs"O won!"endendendGame.new.turn

The End... or Is It???

I hope that explains the algorithm! If anything's unclear, please let
me know in the comments below. I'd like for this post to explain the
algorithm completely, and I'll use any feedback to revise it.

Also, you may have noticed that the algorithm, as written, requires a
lot of memory. Tic-Tac-Toe, one of the simplest, most boring games in
existence, requires hundreds of thousands of game states in the above,
naive Ruby implementation. In the future, I may cover some
optimization techniques, like alpha-beta programming. Look for it!