64

In a tic-tac-toe implementation I guess that the challenging part is to determine the best move to be played by the machine.

What are the algorithms that can pursued? I'm looking into implementations from simple to complex. How would I go about tackling this part of the problem?

Michael Petrotta
  • 59,888
  • 27
  • 145
  • 179
  • 23
    http://imgs.xkcd.com/comics/tic_tac_toe.png – Mooing Duck Jun 21 '12 at 16:17
  • While the Wikipedia answer might be good enough, I added below an algorithm which figures out the best possible move for each given board by checking all possible moves and grading them. – Ben Carp Jan 06 '18 at 14:38
  • I asked myself something similar: http://blog.maxant.co.uk/pebble/2018/04/07/1523086680000.html – Ant Kutschera Apr 11 '18 at 18:53
  • Here's a very [visual answer](https://www.corvil.com/kb/what-algorithm-for-a-tic-tac-toe-game-can-i-use-to-determine-the-best-move-for-the-ai). – rbinnun Nov 02 '18 at 14:31

10 Answers10

56

The strategy from Wikipedia for playing a perfect game (win or tie every time) seems like straightforward pseudo-code:

Quote from Wikipedia (Tic Tac Toe#Strategy)

A player can play a perfect game of Tic-tac-toe (to win or, at least, draw) if they choose the first available move from the following list, each turn, as used in Newell and Simon's 1972 tic-tac-toe program.[6]

  1. Win: If you have two in a row, play the third to get three in a row.

  2. Block: If the opponent has two in a row, play the third to block them.

  3. Fork: Create an opportunity where you can win in two ways.

  4. Block Opponent's Fork:

    Option 1: Create two in a row to force the opponent into defending, as long as it doesn't result in them creating a fork or winning. For example, if "X" has a corner, "O" has the center, and "X" has the opposite corner as well, "O" must not play a corner in order to win. (Playing a corner in this scenario creates a fork for "X" to win.)

    Option 2: If there is a configuration where the opponent can fork, block that fork.

  5. Center: Play the center.

  6. Opposite Corner: If the opponent is in the corner, play the opposite corner.

  7. Empty Corner: Play an empty corner.

  8. Empty Side: Play an empty side.

Recognizing what a "fork" situation looks like could be done in a brute-force manner as suggested.

Note: A "perfect" opponent is a nice exercise but ultimately not worth 'playing' against. You could, however, alter the priorities above to give characteristic weaknesses to opponent personalities.

Breakthrough
  • 2,444
  • 2
  • 23
  • 37
bkane
  • 979
  • 5
  • 5
  • 3
    How would you suggest implementing the forking parts of the strategy then? – Andrew Szeto Apr 07 '09 at 19:17
  • 50
    So what you are saying is: The only winning move is not to play. – tooleb Jun 15 '09 at 19:16
  • Wouldnt a center fork be more valuable than other forks? – Daniel Kobe Sep 23 '15 at 13:41
  • 2
    @Nick "try yourself" is a bit useless here without any information on how to find the counterexample. Is there a configuration reachable by this strategy where following (6) instead of (7) would create a losing game, for instance? Would be helpful to post more information on your counterexample. – djechlin Apr 12 '16 at 17:11
39

What you need (for tic-tac-toe or a far more difficult game like Chess) is the minimax algorithm, or its slightly more complicated variant, alpha-beta pruning. Ordinary naive minimax will do fine for a game with as small a search space as tic-tac-toe, though.

In a nutshell, what you want to do is not to search for the move that has the best possible outcome for you, but rather for the move where the worst possible outcome is as good as possible. If you assume your opponent is playing optimally, you have to assume they will take the move that is worst for you, and therefore you have to take the move that MINimises their MAXimum gain.

Nick Johnson
  • 100,655
  • 16
  • 128
  • 198
  • 1
    Missing here is a vital piece of information: the thing to be maximized is the value of an evaluation function assumed to return a numeric value for any (hypothetical, but especially reachable by placing the next piece) board position. Something cheap like (piece on center field worth 100 points, corners 30, side 5) might do, but will lack any of the high-level information discussed above like existing pair, existing fork, ... So this would not be my first choice. – guidot Jul 06 '12 at 13:54
  • 4
    @guidot Tic-tac-toe's search space is so small, your evaluation function is trivial: +inf if the game is a winning state, -inf if it's a losing state, 0 if it's a tie. – Nick Johnson Jul 08 '12 at 02:04
  • 2
    Minimax or alpha-beta is surely not the first idea to pursuit for such a trival game (this limits the value of the original answer). If you do, however (perhaps with the idea of proceeding to more complex games like go-moku), you need an evaluation function. Such a function is only sensible for the given algorithms, if it produces a result for ANY intermediate position, the suggested (very generic) one, which is restricted to completed games is only helpful to select the final winning message. – guidot Jul 08 '12 at 08:56
  • 2
    On the contrary, minimax or alpha-beta with an all-or-nothing evaluation function are applicable to any game you want to exhaustively search. Alpha-beta reduces the search space substantially over brute-force; minimax is simply a sensible way to do search the game tree and find the best available move. – Nick Johnson Jul 09 '12 at 02:40
  • 1
    I agree starting from sentence 2. Your picture of exhaustive search seems to be, that an analysis until end of game is possible. For many non-trivial games this is a bit optimistic. In that (general) case one needs an evaluation for intermediate positions, since its return value is the comparison value for mini-maxing (see wikipedia, alpha-beta-pruning diagram, the numbers in the nodes). Substantial references (as opposed to general remarks) to disprove this are welcome. – guidot Jul 09 '12 at 07:25
  • I didn't say you could always do exhaustive search, just that when you can - such as in Tic Tac Toe - an all-or-nothing evaluator is all you need. – Nick Johnson Jul 09 '12 at 09:42
  • This begs the question. If the implementation of minimax requires the fact that the search space is small, just brute force the problem in the first place. – djechlin Apr 12 '16 at 17:12
14

The brute force method of generating every single possible board and scoring it based on the boards it later produces further down the tree doesn't require much memory, especially once you recognize that 90 degree board rotations are redundant, as are flips about the vertical, horizontal, and diagonal axis.

Once you get to that point, there's something like less than 1k of data in a tree graph to describe the outcome, and thus the best move for the computer.

-Adam

Adam Davis
  • 91,931
  • 60
  • 264
  • 330
  • 2
    Well, if you want to get *really* complex... ;-) The brute-force approach is probably better than my wimpy ranking idea, though a little harder to implement. – Daniel Spiewak Sep 24 '08 at 05:32
7

A typical algo for tic-tac-toe should look like this:

Board : A nine-element vector representing the board. We store 2 (indicating Blank), 3 (indicating X), or 5 (indicating O). Turn: An integer indicating which move of the game about to be played. The 1st move will be indicated by 1, last by 9.

The Algorithm

The main algorithm uses three functions.

Make2: returns 5 if the center square of the board is blank i.e. if board[5]=2. Otherwise, this function returns any non-corner square (2, 4, 6 or 8).

Posswin(p): Returns 0 if player p can’t win on his next move; otherwise, it returns the number of the square that constitutes a winning move. This function will enable the program both to win and to block opponents win. This function operates by checking each of the rows, columns, and diagonals. By multiplying the values of each square together for an entire row (or column or diagonal), the possibility of a win can be checked. If the product is 18 (3 x 3 x 2), then X can win. If the product is 50 (5 x 5 x 2), then O can win. If a winning row (column or diagonal) is found, the blank square in it can be determined and the number of that square is returned by this function.

Go (n): makes a move in square n. this procedure sets board [n] to 3 if Turn is odd, or 5 if Turn is even. It also increments turn by one.

The algorithm has a built-in strategy for each move. It makes the odd numbered move if it plays X, the even-numbered move if it plays O.

Turn = 1    Go(1)   (upper left corner).
Turn = 2    If Board[5] is blank, Go(5), else Go(1).
Turn = 3    If Board[9] is blank, Go(9), else Go(3).
Turn = 4    If Posswin(X) is not 0, then Go(Posswin(X)) i.e. [ block opponent’s win], else Go(Make2).
Turn = 5    if Posswin(X) is not 0 then Go(Posswin(X)) [i.e. win], else if Posswin(O) is not 0, then Go(Posswin(O)) [i.e. block win], else if Board[7] is blank, then Go(7), else Go(3). [to explore other possibility if there be any ].
Turn = 6    If Posswin(O) is not 0 then Go(Posswin(O)), else if Posswin(X) is not 0, then Go(Posswin(X)), else Go(Make2).
Turn = 7    If Posswin(X) is not 0 then Go(Posswin(X)), else if Posswin(X) is not 0, then Go(Posswin(O)) else go anywhere that is blank.
Turn = 8    if Posswin(O) is not 0 then Go(Posswin(O)), else if Posswin(X) is not 0, then Go(Posswin(X)), else go anywhere that is blank.
Turn = 9    Same as Turn=7.

I have used it. Let me know how you guys feel.

OmG
  • 18,337
  • 10
  • 57
  • 90
Kaushik
  • 71
  • 1
  • 1
6

Since you're only dealing with a 3x3 matrix of possible locations, it'd be pretty easy to just write a search through all possibilities without taxing you computing power. For each open space, compute through all the possible outcomes after that marking that space (recursively, I'd say), then use the move with the most possibilities of winning.

Optimizing this would be a waste of effort, really. Though some easy ones might be:

  • Check first for possible wins for the other team, block the first one you find (if there are 2 the games over anyway).
  • Always take the center if it's open (and the previous rule has no candidates).
  • Take corners ahead of sides (again, if the previous rules are empty)
billjamesdev
  • 14,554
  • 6
  • 53
  • 76
  • 2
    At a human level, starting with the corner as P1 produces wins much more often. Your opponent mistakenly thinks that since you didn't take the center, maybe they shouldn't either, for some reason. – djechlin Apr 12 '16 at 17:14
3

An attempt without using a play field.

  1. to win(your double)
  2. if not, not to lose(opponent's double)
  3. if not, do you already have a fork(have a double double)
  4. if not, if opponent has a fork
    1. search in blocking points for possible double and fork(ultimate win)
    2. if not search forks in blocking points(which gives the opponent the most losing possibilities )
    3. if not only blocking points(not to lose)
  5. if not search for double and fork(ultimate win)
  6. if not search only for forks which gives opponent the most losing possibilities
  7. if not search only for a double
  8. if not dead end, tie, random.
  9. if not(it means your first move)
    1. if it's the first move of the game;
      1. give the opponent the most losing possibility(the algorithm results in only corners which gives 7 losing point possibility to opponent)
      2. or for breaking boredom just random.
    2. if it's second move of the game;
      1. find only the not losing points(gives a little more options)
      2. or find the points in this list which has the best winning chance(it can be boring,cause it results in only all corners or adjacent corners or center)

Note: When you have double and forks, check if your double gives the opponent a double.if it gives, check if that your new mandatory point is included in your fork list.

Synxis
  • 9,236
  • 2
  • 42
  • 64
  • Actually I meant, an attempt without using game tree which is optimal solution for that kind of decision problems. Only to hope to have some more insight. – Mesut Ergul Aug 12 '12 at 11:09
3

You can have the AI play itself in some sample games to learn from. Use a supervised learning algorithm, to help it along.

J.J.
  • 4,856
  • 1
  • 24
  • 29
1

Rank each of the squares with numeric scores. If a square is taken, move on to the next choice (sorted in descending order by rank). You're going to need to choose a strategy (there are two main ones for going first and three (I think) for second). Technically, you could just program all of the strategies and then choose one at random. That would make for a less predictable opponent.

Daniel Spiewak
  • 54,515
  • 14
  • 108
  • 120
  • P1 can start anywhere. There are moves P2 can make in response to P1's first move that create a winning game for P1 if both players subsequently play optimally, for any possible first move. – djechlin Apr 12 '16 at 17:15
0

This answer assumes you understand implementing the perfect algorithm for P1 and discusses how to achieve a win in conditions against ordinary human players, who will make some mistakes more commonly than others.

The game of course should end in a draw if both players play optimally. At a human level, P1 playing in a corner produces wins far more often. For whatever psychological reason, P2 is baited into thinking that playing in the center is not that important, which is unfortunate for them, since it's the only response that does not create a winning game for P1.

If P2 does correctly block in the center, P1 should play the opposite corner, because again, for whatever psychological reason, P2 will prefer the symmetry of playing a corner, which again produces a losing board for them.

For any move P1 may make for the starting move, there is a move P2 may make that will create a win for P1 if both players play optimally thereafter. In that sense P1 may play wherever. The edge moves are weakest in the sense that the largest fraction of possible responses to this move produce a draw, but there are still responses that will create a win for P1.

Empirically (more precisely, anecdotally) the best P1 starting moves seem to be first corner, second center, and last edge.

The next challenge you can add, in person or via a GUI, is not to display the board. A human can definitely remember all the state but the added challenge leads to a preference for symmetric boards, which take less effort to remember, leading to the mistake I outlined in the first branch.

I'm a lot of fun at parties, I know.

djechlin
  • 59,258
  • 35
  • 162
  • 290
  • I'm fun at parties too - we should get together... And as such, I disagree with your claim that P1 playing in a corner produces wins far more often. Do you have a reference for that? My analysis shows the centre is best, although it does depend on the type of player: http://blog.maxant.co.uk/pebble/2018/04/07/1523086680000.html – Ant Kutschera Apr 07 '18 at 20:46
  • @AntKutschera no reference, just personal experience, but I was feeling confident because the psychology/intuition is so strong that unorthodox openings require unorthodox responses. If for some other reason the player has prior suppositions or is primed otherwise, it wouldn't work I guess. – djechlin Apr 09 '18 at 16:23
0

A Tic-tac-toe adaptation to the min max algorithem

let gameBoard: [
    [null, null, null],
    [null, null, null],
    [null, null, null]
]

const SYMBOLS = {
    X:'X',
    O:'O'
}

const RESULT = {
    INCOMPLETE: "incomplete",
    PLAYER_X_WON: SYMBOLS.x,
    PLAYER_O_WON: SYMBOLS.o,
    tie: "tie"
}

We'll need a function that can check for the result. The function will check for a succession of chars. What ever the state of the board is, the result is one of 4 options: either Incomplete, player X won, Player O won or a tie.

function checkSuccession (line){
    if (line === SYMBOLS.X.repeat(3)) return SYMBOLS.X
    if (line === SYMBOLS.O.repeat(3)) return SYMBOLS.O
    return false 
}

function getResult(board){

    let result = RESULT.incomplete
    if (moveCount(board)<5){
        return result
    }

    let lines

    //first we check row, then column, then diagonal
    for (var i = 0 ; i<3 ; i++){
        lines.push(board[i].join(''))
    }

    for (var j=0 ; j<3; j++){
        const column = [board[0][j],board[1][j],board[2][j]]
        lines.push(column.join(''))
    }

    const diag1 = [board[0][0],board[1][1],board[2][2]]
    lines.push(diag1.join(''))
    const diag2 = [board[0][2],board[1][1],board[2][0]]
    lines.push(diag2.join(''))
    
    for (i=0 ; i<lines.length ; i++){
        const succession = checkSuccesion(lines[i])
        if(succession){
            return succession
        }
    }

    //Check for tie
    if (moveCount(board)==9){
        return RESULT.tie
    }

    return result
}

Our getBestMove function will receive the state of the board, and the symbol of the player for which we want to determine the best possible move. Our function will check all possible moves with the getResult function. If it is a win it will give it a score of 1. if it's a loose it will get a score of -1, a tie will get a score of 0. If it is undetermined we will call the getBestMove function with the new state of the board and the opposite symbol. Since the next move is of the oponent, his victory is the lose of the current player, and the score will be negated. At the end possible move receives a score of either 1,0 or -1, we can sort the moves, and return the move with the highest score.

const copyBoard = (board) => board.map( 
    row => row.map( square => square  ) 
)

function getAvailableMoves (board) {
  let availableMoves = []
  for (let row = 0 ; row<3 ; row++){
    for (let column = 0 ; column<3 ; column++){
      if (board[row][column]===null){
        availableMoves.push({row, column})
      }
    }
  }
  return availableMoves
}

function applyMove(board,move, symbol) {
  board[move.row][move.column]= symbol
  return board
}
 
function getBestMove (board, symbol){

    let availableMoves = getAvailableMoves(board)

    let availableMovesAndScores = []

    for (var i=0 ; i<availableMoves.length ; i++){
      let move = availableMoves[i]
      let newBoard = copyBoard(board)
      newBoard = applyMove(newBoard,move, symbol)
      result = getResult(newBoard,symbol).result
      let score
      if (result == RESULT.tie) {score = 0}
      else if (result == symbol) {
        score = 1
      }
      else {
        let otherSymbol = (symbol==SYMBOLS.x)? SYMBOLS.o : SYMBOLS.x
        nextMove = getBestMove(newBoard, otherSymbol)
        score = - (nextMove.score)
      }
      if(score === 1)  // Performance optimization
        return {move, score}
      availableMovesAndScores.push({move, score})
    }

    availableMovesAndScores.sort((moveA, moveB )=>{
        return moveB.score - moveA.score
      })
    return availableMovesAndScores[0]
  }

Algorithm in action, Github, Explaining the process in more details

Ben Carp
  • 24,214
  • 9
  • 60
  • 72