Fixing a bug in Tic Tac Toe with TDD

I discovered a bug in the game I had written on Tic Tac Toe. When the board is in a certain state, the system did not take the obvious winning move. It still won the game, but with an extra move required. This article details how to write a failing unit test to reproduce the issue and how to update the minimax algorithm to address the issue.

Related articles on TDD in SwiftUI:

  1. Test Driven Development in SwiftUI - Part 1
  2. Test Driven Development in SwiftUI - Part 2
  3. Understanding Minimax Algorithm with Tic Tac Toe
  4. Test Driven Development in SwiftUI - Part 3
  5. Fixing a bug in Tic Tac Toe with TDD


Game play to reproduce the issue

I developed a Tic Tac Toe game using TDD in Test Driven Development in SwiftUI - Part 3. The default for a one-player game is that the player has the X token and makes the first move.

Steps to reproduce the issue:
1. Place the "X-Token" in the top left corner.
2. The system places the O-Token in the center cell.
3. Place the "X-Token" in the top right corner.
4. The system places the "O-Token" in the top center cell to block "X".
5. Place the "X-Token" in the bottom left corner.


Expected Result:  
The system places the `O-Token` in the bottom center cell.

Actual Result:  
The system places the `O-Token` in the left center cell.

There is a winning move available to O-Player in the bottom center cell, however the system places the O-Token in the center cell in the right column. This does not lose the game, as the O-Player now has two winning options available and cannot be stopped by the X-Player. The result is that the O-Player wins the game, but it could have won the game with one less move.

System selects the first cell in the second row in Tic Tac Toe

System selects the first cell in the second row in Tic Tac Toe



Add a Unit Test for the scenario

Now that we know the steps to reproduce the issue, we can write a unit test against the model to test this scenario. Essentially, we set the board up with the tokens as outlined in the image above and assert that the system will place the O-Token in the bottom center cell (cell at index 7). A second assert is added that O-Player is the winning player when the token is placed in this cell. When we run the unit tests, all the existing tests pass, but this new unit test fails. Now we have a test to validate this scenario, we can investigate and fix the issue.

Failing Test added to TicGameModelTests

 1    func test_oToWinInOneMove_ThreeBlankCells() {
 2        // Arrange
 3        var ticModel = TicGameModel()
 4        let fullGrid: [Cell] = [.x, .o, .x,
 5                                .b, .o, .b,
 6                                .x, .b, .b]
 7        for (i,c) in fullGrid.enumerated() {
 8            ticModel.setCell(n: i, c: c)
 9        }
10
11        // Act
12        let nextCell = ticModel.nextMove(player: .o)
13        ticModel.setCell(n: nextCell, c: .o)
14
15        // Assert
16        XCTAssertEqual(nextCell, 7)
17        XCTAssertEqual(ticModel.winner, .o)
18    }

Here is the TicGameModel for reference.

TicGameModel

  1import Foundation
  2
  3enum Winner {
  4    case o, x, none
  5}
  6
  7struct TicGameModel {
  8    private var _board: TicBoardModel
  9    private var _winner: Winner
 10    private var _playerXTurn: Bool
 11    private var _winningLines: [Bool]
 12    private var _boardDisabled: Bool
 13    private var _twoPlayer: Bool
 14
 15    init(twoPlayer: Bool = true) {
 16        _board = TicBoardModel(cells: [.b, .b, .b,
 17                                       .b, .b, .b,
 18                                       .b, .b, .b])
 19        _winningLines = []
 20        for _ in 0..<8 {
 21            _winningLines.append(false)
 22        }
 23        _winner = .none
 24        _playerXTurn = true
 25        _boardDisabled = false
 26        _twoPlayer = twoPlayer
 27    }
 28
 29    var grid: [Cell] {
 30        get { _board.grid }
 31    }
 32
 33    var winner: Winner {
 34        get { _winner }
 35    }
 36
 37    var isGridFull: Bool {
 38        get { _board.availableCells.count == 0 }
 39    }
 40
 41    var isXTurn: Bool {
 42        get { _playerXTurn }
 43    }
 44    
 45    var isTwoPlayer: Bool {
 46        get { _twoPlayer }
 47    }
 48    
 49    var isBoardDisabled: Bool {
 50        get { _boardDisabled }
 51    }
 52    
 53    var winningLines: [Bool] {
 54        get { _winningLines }
 55    }
 56    
 57    func nextMove(player: Cell) -> Int {
 58        // Select a random cell if the board is empty
 59        if _board.availableCells.count == 9 {
 60            return Int.random(in: 0..<9)
 61        }
 62        
 63        var bestMove = -1
 64        // Maximising
 65        if player == .x {
 66            var maxScore = Int.min
 67            for cell in _board.availableCells {
 68                let score = minimax(board: _board,
 69                                    cellNum: cell,
 70                                    isMaximising: true)
 71                if score > maxScore {
 72                    maxScore = score
 73                    bestMove = cell
 74                }
 75            }
 76        } else { // player == .o - Minimising
 77            var minScore = Int.max
 78            for cell in _board.availableCells {
 79                let score = minimax(board: _board,
 80                                    cellNum: cell,
 81                                    isMaximising: false)
 82                if score < minScore {
 83                    minScore = score
 84                    bestMove = cell
 85                }
 86            }
 87        }
 88        return bestMove
 89    }
 90    
 91    mutating func setCell(n:Int, c: Cell) {
 92        guard _board.grid.indices.contains(n) else {
 93            return
 94        }
 95        guard _board.grid[n] == .b else {
 96            return
 97        }
 98        _board = TicBoardModel(cells: _board.grid, n: n, c: c)
 99        _winner = winningPlayer(board: _board)
100        _winningLines = winningLines(board: _board)
101        _playerXTurn.toggle()
102        
103        // Disable the board if it is a one player game
104        if !_twoPlayer {
105            _boardDisabled = true
106        }
107    }
108    
109    mutating func takeSystemTurn() {
110        guard !_twoPlayer else {
111            return
112        }
113        let cellIndex = nextMove(player: _playerXTurn ? .x : .o)
114        setCell(n: cellIndex, c: _playerXTurn ? .x : .o)
115        
116        // Enable the board after the system has taken a turn
117        _boardDisabled = false
118    }
119    
120    private func winningLines(board b: TicBoardModel) -> [Bool] {
121        var result = [false, false, false, false, false, false, false, false]
122        let winOptions: [Set<Int>] = [
123            [0,1,2], [3,4,5], [6,7,8],
124            [0,3,6], [1,4,7], [2,5,8],
125            [0,4,8], [2,4,6]]
126
127        let oCells: Set<Int> = Set(b.grid.indices.map { b.grid[$0] == Cell.o ? $0 : -1 })
128        let xCells: Set<Int> = Set(b.grid.indices.map { b.grid[$0] == Cell.x ? $0 : -1 })
129
130        for (i, win) in winOptions.enumerated() {
131            if win.intersection(xCells) == win {
132                result[i] = true
133            }
134            if win.intersection(oCells) == win {
135                result[i] = true
136            }
137        }
138        return result
139    }
140
141    private func winningPlayer(board b: TicBoardModel) -> Winner {
142        // There are 8 possible winning options in Tic Tac Toe
143        // The order of these options needs to match _winningLines
144        let winOptions: [Set<Int>] = [
145            [0,1,2], [3,4,5], [6,7,8],
146            [0,3,6], [1,4,7], [2,5,8],
147            [0,4,8], [2,4,6]]
148
149        let oCells: Set<Int> = Set(b.grid.indices.map { b.grid[$0] == Cell.o ? $0 : -1 })
150        let xCells: Set<Int> = Set(b.grid.indices.map { b.grid[$0] == Cell.x ? $0 : -1 })
151
152        for win in winOptions {
153            if win.intersection(xCells) == win {
154                return .x
155            }
156            if win.intersection(oCells) == win {
157                return .o
158            }
159        }
160        return .none
161    }
162
163    private func minimax(board: TicBoardModel, cellNum: Int, isMaximising: Bool) -> Int {
164        let b = TicBoardModel(cells: board.grid,
165                              n: cellNum,
166                              c: isMaximising ? .x : .o)
167
168        // Base case
169        if winningPlayer(board: b) == .x {
170            return 1
171        } else if winningPlayer(board: b) == .o {
172            return -1
173        } else if b.availableCells.count == 0 {
174            return 0
175        }
176
177        // Maximising
178        if isMaximising {
179            var minVal = Int.max
180            for nextMove in b.availableCells {
181                let score = minimax(board: b,
182                                    cellNum: nextMove,
183                                    isMaximising: false)
184                minVal = min(minVal, score)
185            }
186            return minVal
187        } else { // Minimising
188            var maxVal = Int.min
189            for nextMove in b.availableCells {
190                let score = minimax(board: b,
191                                    cellNum: nextMove,
192                                    isMaximising: true)
193                maxVal = max(maxVal, score)
194            }
195            return maxVal
196        }
197    }
198        
199}

Add a unit test to test the expected behavior - this test fails
Add a unit test to test the expected behavior - this test fails



Explanation of why

There is an explanation for why the system selects the cell in the right center rather than the bottom center. We need to go back to look at the Minimax algorithm as explained in Understanding Minimax Algorithm with Tic Tac Toe. O-Player is set as the minimising player and X-Player the maximising. The diagram shows that there are 4 available cells where O-Player can place its token from the given starting state.

The diagram shows the options for alternate moves between O-Player and X-Player and the scores roll up to the original moves available to O-Player. It can be seen, from the image, that 2 of the 4 moves available to O-Player result in a score of -1, which is a win for O-Player. As there is no distinction between either of the winning plays, the system selects the first one, which places the token in the left center cell.

Minimax path when O-Player is minimising player
Minimax path when O-Player is minimising player



Change to Algorithm

The solution is to add some factor that gives some increased score for a win in less moves. One way to do this is to multiply the score of -1 or +1 by the number of remaining cells. In the TicGameModel the score is calculated for each available cell by calling the minimax method.

The scores on the same initial state with O-Player as the minimising player are outlined below. There are two winning moves for O-Player from the starting state, but these now have scores of -2 and -4, rather than both having a score of -1. The move with the lower score (-4) will be selected resulting in an immediate win for O-Player.

Minimax path with adjusted scores when O-Player is minimising player
Minimax path with adjusted scores when O-Player is minimising player



Unit test passing

All unit tests on the model and viewmodel now pass, including the newly added tests. Inside the minimax method the available cells number needs to be adjusted by one, as the token has already been added to the cell to calculate the score. I missed this initially, and then added a unit test to test this scenario, before fixing the model code as outlined below.

Change to TicGameModel

 1    private func minimax(board: TicBoardModel, cellNum: Int, isMaximising: Bool) -> Int {
 2        let b = TicBoardModel(cells: board.grid,
 3                              n: cellNum,
 4                              c: isMaximising ? .x : .o)
 5
 6        let scoreFactor = b.availableCells.count + 1
 7        
 8        // Base case
 9        if winningPlayer(board: b) == .x {
10            return 1 * scoreFactor
11        } else if winningPlayer(board: b) == .o {
12            return -1 * scoreFactor
13        } else if b.availableCells.count == 0 {
14            return 0
15        }
16
17        // Maximising
18        if isMaximising {
19            var minVal = Int.max
20            for nextMove in b.availableCells {
21                let score = minimax(board: b,
22                                    cellNum: nextMove,
23                                    isMaximising: false)
24                minVal = min(minVal, score)
25            }
26            return minVal
27        } else { // Minimising
28            var maxVal = Int.min
29            for nextMove in b.availableCells {
30                let score = minimax(board: b,
31                                    cellNum: nextMove,
32                                    isMaximising: true)
33                maxVal = max(maxVal, score)
34            }
35            return maxVal
36        }
37    }

New Unit Test added to TicGameModelTests - passing

 1    func test_oToWinInOneMove_ThreeBlankCells() {
 2        // Arrange
 3        var ticModel = TicGameModel()
 4        let fullGrid: [Cell] = [.x, .o, .x,
 5                                .b, .o, .b,
 6                                .x, .b, .b]
 7        for (i,c) in fullGrid.enumerated() {
 8            ticModel.setCell(n: i, c: c)
 9        }
10
11        // Act
12        let nextCell = ticModel.nextMove(player: .o)
13        ticModel.setCell(n: nextCell, c: .o)
14
15        // Assert
16        XCTAssertEqual(nextCell, 7)
17        XCTAssertEqual(ticModel.winner, .o)
18    }
19
20    func test_oToDraw_TwoBlankCells() {
21        // Arrange
22        var ticModel = TicGameModel()
23        let fullGrid: [Cell] = [.x, .o, .x,
24                                .o, .o, .x,
25                                .b, .x, .b]
26        for (i,c) in fullGrid.enumerated() {
27            ticModel.setCell(n: i, c: c)
28        }
29
30        // Act
31        let nextCell = ticModel.nextMove(player: .o)
32
33        // Assert
34        XCTAssertEqual(nextCell, 8)
35    }

All unit tests passing, including newly added tests
All unit tests passing, including newly added tests



Corrected game play

Here is the original game play behaving as expected with the system winning the game in as few moves as possible.


System selecting to win immediately in Tic Tac Toe

System selecting to win immediately in Tic Tac Toe




Conclusion

It is interesting that despite using TDD and having unit tests on Model and ViewModel, there was still a bug in the logic of the Tic Tac Toe game. The system still selected a winning move, it just seemed wrong to miss an immediate winning move. It turns out the minimax algorithm, as it was originally implemented, treated all wins equally, so the first cell that would lead to a winning move was selected. The change in the algorithm here was to add a factor to score a win in less moves higher than a win in more moves. All of the existing unit tests assure us that we have not broken anything else.