Test Driven Development in SwiftUI - Part 3

This is the fourth part in this trilogy! - with a brief interlude last week for an introduction to the Minimax algorithm. This article builds on the App from part 1 and 2, which allows two players to play Tic Tac Toe. In this article we will implement the minimax algorithm to determin the best move for the computer to make and allow a player to play the system. This will be implemented in a Test Driven Development pattern TDD.

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


Add a unit test for Player X to win the game

It is easier to start with a game board that is almost complete, where the next move should be a move by Player X to win. In this setup, it is easier to see what the next move should be as opposed to starting with an empty board.

Tic tac toe board setup with X to play

Tic tac toe board setup with X to play

There are three available cells for Player X to place the token. These are the results of selecting each of these options with the minimising player (Player O) making their best move afterwards. The scores are based on player X being the maximising player and player O being he minimising player.

Options for next move for Player-X, only one option results in a win

Options for next move for Player-X, only one option results in a win

The following unit test is added to TicModelTests. This fails to compile with the error "Value of type 'TicModel' has no member 'nextMove'"


TicModelTests

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

TicModel

There are some proponents of TDD that recommend implementing the minimum amount of code to get the test to pass. This would be implementing a method in TicModel for nextMove that simply returns the number 6. Now the coce compiles and the unit test passes.

1    func nextMove(player: Cell) -> Int {
2        return 6
3    }

I'm not a fan of this approach and prefer to implement real code that will make an attempt to determine the next move and return the cell position.

Here is a bit more code added to TicModel to implement Minimax and determine the cell index for the next move for player X. A readonly property is added to get the indices of all available cells in the grid. A recursive function is added for the minimax algorithm that takes a copy of the TicModel, the sell index and whether this is the maximising or minimising player. This works and correctly calculates that a winning move is place token X at index 6.

 1struct TicModel {
 2    ...
 3    var availableCells: [Int] {
 4        get { grid.indices.filter { grid[$0] == Cell.b } }
 5    }
 6    ...
 7    func nextMove(player: Cell) -> Int {
 8        for nextMove in availableCells {
 9            if player == .x {
10                if minimax(board: self, cellNum: nextMove, isMaximising: true) == 1 {
11                    return nextMove
12                }
13            }
14        }
15        return -99
16    }
17    ...
18    private func minimax(board: TicModel, cellNum: Int, isMaximising: Bool) -> Int {
19        var b = board
20        b.setCell(n: cellNum, c: isMaximising ? .x : .o)
21        _ = b.updateGameStatus()
22        
23        // Base case
24        if b.winner == .x {
25            return 1
26        } else if b.winner == .o {
27            return -1
28        } else if b.isGridFull {
29            return 0
30        }
31        
32        // Maximising
33        if isMaximising {
34            var bestVal = Int.min
35            for nextMove in b.availableCells {
36            let score = minimax(board: b, cellNum: nextMove, isMaximising: false)
37                bestVal = max(bestVal, score)
38            }
39            return bestVal
40        } else { // Minimising
41            var bestVal = Int.max
42            for nextMove in b.availableCells {
43            let score = minimax(board: b, cellNum: nextMove, isMaximising: true)
44                bestVal = min(bestVal, score)
45            }
46            return bestVal
47        }
48    }
49    ...


Add a unit test for Player O to win the game

The same tic tac toe board setup can be used in a test to determine the next move Player O would make. It happens to be the same cell index, as it is the first one of two cells that result in a win for player O. Of the three available cells, two result in an immediate win for player O and the other will result in a win for player X. We will assume that when there are two winning options that the first one will be chosen (that is the cell with the lower index).

Options for next move for Player-O, two options result in a win

Options for next move for Player-O, two options result in a win


TicModelTests

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

TicModel

 1struct TicModel {
 2    ...
 3    func nextMove(player: Cell) -> Int {
 4        for nextMove in availableCells {
 5            if player == .x {
 6                if minimax(board: self, cellNum: nextMove, isMaximising: true) == 1 {
 7                    return nextMove
 8                }
 9            }
10            else if player == .o {
11                if minimax(board: self, cellNum: nextMove, isMaximising: false) == -1 {
12                    return nextMove
13                }
14            }
15        }
16        return -99
17    }
18    ...


Refactor

This works for the two unit tests and could be built upon for other test cases, but I don't like the code. Let's review the minimax function. The way the minimax algorithm works is that it literally plays the best alternate turns for each of the available options and then rewinds back to the next best option. Our initial TicModel was set up to use a mutating function setCell to place a token in a position, if we use this mutating function on the main model, then there is no easy way to rewind the moves. We get around this by creating a copy of the model in each call to minimax - this feels wrong.

 1    private func minimax(board: TicModel, cellNum: Int, isMaximising: Bool) -> Int {
 2        var b = board
 3        b.setCell(n: cellNum, c: isMaximising ? .x : .o)
 4        _ = b.updateGameStatus()
 5        
 6        // Base case
 7        if b.winner == .x {
 8            return 1
 9        } else if b.winner == .o {
10            return -1
11        } else if b.isGridFull {
12            return 0
13        }
14        
15        // Maximising
16        if isMaximising {
17            var bestVal = Int.min
18            for nextMove in b.availableCells {
19            let score = minimax(board: b, cellNum: nextMove, isMaximising: false)
20                bestVal = max(bestVal, score)
21            }
22            return bestVal
23        } else { // Minimising
24            var bestVal = Int.max
25            for nextMove in b.availableCells {
26            let score = minimax(board: b, cellNum: nextMove, isMaximising: true)
27                bestVal = min(bestVal, score)
28            }
29            return bestVal
30        }
31    }

A better approach is to separate the TicModel into two models; one representing the tit tac toe board and one representing the tic tac toe game. I'll spare the blow by blow unit test creation and list the unit tests for the ticBoard here.


TicBoardModelTests

 1class TicBoardModelTests: XCTestCase {
 2    
 3    func test_initialValue_nineBlankCells() {
 4        // Arrange
 5        let ticBoardModel = TicBoardModel(cells: [.b, .b, .b,
 6                                                  .b, .b, .b,
 7                                                  .b, .b, .b])
 8
 9        // Act
10
11        // Assert
12        XCTAssertEqual(ticBoardModel.grid.count, 9)
13        XCTAssertEqual((ticBoardModel.grid.filter { $0 == Cell.b }.count), 9)
14    }
15
16    func test_availableCells_sixSevenEight() {
17        // Arrange
18        let ticBoardModel = TicBoardModel(cells: [.x, .o, .x,
19                                                  .o, .x, .o,
20                                                  .b, .b, .b])
21
22        // Act
23
24        // Assert
25        XCTAssertEqual(ticBoardModel.availableCells, [6,7,8])
26    }
27
28    func test_availableCells_zeroFourSeven() {
29        // Arrange
30        let ticBoardModel = TicBoardModel(cells: [.b, .o, .x,
31                                                  .o, .b, .o,
32                                                  .x, .b, .x])
33
34        // Act
35
36        // Assert
37        XCTAssertEqual(ticBoardModel.availableCells, [0,4,7])
38    }
39    
40    func test_availableCellsAfterSetting_zeroFourSeven() {
41        // Arrange
42        let ticBoardModel = TicBoardModel(cells: [.b, .o, .x,
43                                                  .o, .b, .o,
44                                                  .x, .b, .x],
45                                          n: 7,
46                                          c: .x)
47
48        // Act
49
50        // Assert
51        XCTAssertEqual(ticBoardModel.availableCells, [0,4])
52    }
53}

TicBoardModel

The purpose of the TicBoardModel is to hold a structure for the board and know the available cells.

 1struct TicBoardModel {
 2    var grid: [Cell]
 3    
 4    init(cells: [Cell]) {
 5        grid = cells
 6    }
 7    
 8    init(cells: [Cell], n: Int, c: Cell) {
 9        grid = cells
10        grid[n] = c
11    }
12    
13    var availableCells: [Int] {
14        get { grid.indices.filter { grid[$0] == .b } }
15    }
16}

TicGameModel

The ticModel is renamed to TicGameModel and updated to use the TicBoardModel to maintain the board state. When a token is placed on the board, a new board is created with the new token in place. The main change to TicGameModel is in the setCell function to create a new board with the move, update the winner and winning lines and toggle the current player. The updateGameStatus function is removed and everything is updated in the setCell function. The unit tests are updated, mostly to remove any calls to updateGameStatus. There is only one minor change required in TicViewModel - to remove a to updateGameStatus.

 1    ...
 2    mutating func setCell(n:Int, c: Cell) {
 3        guard _board.grid.indices.contains(n) else {
 4            return
 5        }
 6        guard _board.grid[n] == .b else {
 7            return
 8        }
 9        _board = TicBoardModel(cells: _board.grid, n: n, c: c)
10        _winner = winningPlayer(board: _board)
11        _winningLines = winningLines(board: _board)
12        _playerXTurn.toggle()
13    }
14    ...
15    private func winningLines(board b: TicBoardModel) -> [Bool] {
16        var result = [false, false, false, false, false, false, false, false]
17        let winOptions: [Set<Int>] = [
18            [0,1,2], [3,4,5], [6,7,8],
19            [0,3,6], [1,4,7], [2,5,8],
20            [0,4,8], [2,4,6]]
21
22        let oCells: Set<Int> = Set(b.grid.indices.map { b.grid[$0] == Cell.o ? $0 : -1 })
23        let xCells: Set<Int> = Set(b.grid.indices.map { b.grid[$0] == Cell.x ? $0 : -1 })
24
25        for (i, win) in winOptions.enumerated() {
26            if win.intersection(xCells) == win {
27                result[i] = true
28            }
29            if win.intersection(oCells) == win {
30                result[i] = true
31            }
32        }
33        return result
34    }
35
36    private func winningPlayer(board b: TicBoardModel) -> Winner {
37        // There are 8 possible winning options in Tic Tac Toe
38        // The order of these options needs to match _winningLines
39        let winOptions: [Set<Int>] = [
40            [0,1,2], [3,4,5], [6,7,8],
41            [0,3,6], [1,4,7], [2,5,8],
42            [0,4,8], [2,4,6]]
43
44        let oCells: Set<Int> = Set(b.grid.indices.map { b.grid[$0] == Cell.o ? $0 : -1 })
45        let xCells: Set<Int> = Set(b.grid.indices.map { b.grid[$0] == Cell.x ? $0 : -1 })
46
47        for win in winOptions {
48            if win.intersection(xCells) == win {
49                return .x
50            }
51            if win.intersection(oCells) == win {
52                return .o
53            }
54        }
55        return .none
56    }

Minimax

The nextMove and minimax functions are updated to use the TicBoardModel.

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

All unit tests are run to ensure everything is still working as expected.

All Unit Tests passing after refactoring for ticBoardModel
All Unit Tests passing after refactoring for ticBoardModel



Next Best Move

We need to add more unit tests for different scenarios where the next move is not as obvious and where the next move results in a draw. Using these unit tests we will implement any required changes to nextMove and minimax functions.


TicGameModelTests

Add a test for player X to place their token in the last remaining cell, resulting in a draw. This unit test fails as the nextMove function currently returns -99 if there is no winning move.

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

TicGameModel

The nextMove function is rewritten to a format similar to the minimax function. It calls the minimax function for each of the available cells and returns the cell index of the best score from minimax. A higher score is better for player X, while a lower score is better for player O.

 1    func nextMove(player: Cell) -> Int {
 2        var bestMove = -1
 3        // Maximising
 4        if player == .x {
 5            var maxScore = Int.min
 6            for cell in _board.availableCells {
 7                let score = minimax(board: _board,
 8                                    cellNum: cell,
 9                                    isMaximising: true)
10                if score > maxScore {
11                    maxScore = score
12                    bestMove = cell
13                }
14            }
15        } else { // player == .o - Minimising
16            var minScore = Int.max
17            for cell in _board.availableCells {
18                let score = minimax(board: _board,
19                                    cellNum: cell,
20                                    isMaximising: false)
21                if score < minScore {
22                    minScore = score
23                    bestMove = cell
24                }
25            }
26        }
27        return bestMove
28    }

TicGameModelTests

Add a test where player X needs to block player O from winning in their next move.

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

TicGameModel

This unit test exposed a flaw in the minimax function where the maximising player was calling the minimising player, but looking for the max value instead of the minimim value. This did not show when there was a win option available in one move.

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

TicGameModelTests

Two more unit tests are added on to ensure the next best move is made when there is no obvious move to win. A final unit test to play alternate best moves from each player, which results in the game being a draw.

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

TicGameModel

A final change was added to the nextMove function to return a random cell if the board is blank. The existing code will evaluate all 9 cells and return cell 0, and this could be hard-coded to return zero or return a random cell so that game varies a little.

 1    func nextMove(player: Cell) -> Int {
 2        // Select a random cell if the board is empty
 3        if _board.availableCells.count == 9 {
 4            return Int.random(in: 0..<9)
 5        }
 6        
 7        var bestMove = -1
 8        // Maximising
 9        if player == .x {
10            var maxScore = Int.min
11            for cell in _board.availableCells {
12                let score = minimax(board: _board,
13                                    cellNum: cell,
14                                    isMaximising: true)
15                if score > maxScore {
16                    maxScore = score
17                    bestMove = cell
18                }
19            }
20        } else { // player == .o - Minimising
21            var minScore = Int.max
22            for cell in _board.availableCells {
23                let score = minimax(board: _board,
24                                    cellNum: cell,
25                                    isMaximising: false)
26                if score < minScore {
27                    minScore = score
28                    bestMove = cell
29                }
30            }
31        }
32        return bestMove
33    }

All Unit Tests passing with changes for Minimax
All Unit Tests passing with changes for Minimax



One Player Game

To play against the system, we need to add an option to select a One player or two player game. Start with adding a property of whether the game is for two players or not. Add unit tests and code as detailed below in the usual back and forth manner. We will start with the assumption that this property cannot be changed in the middle of a game, so the value will be set in the initialiser.


TicGameModelTests

 1    func test_initialValue_twoPlayers() {
 2        // Arrange
 3        let ticModel = TicGameModel()
 4
 5        // Act
 6
 7        // Assert
 8        XCTAssertTrue(ticModel.isTwoPlayer)
 9    }
10
11    func test_setOnePlayer_onePlayer() {
12        // Arrange
13        let ticModel = TicGameModel(twoPlayer: false)
14
15        // Act
16
17        // Assert
18        XCTAssertFalse(ticModel.isTwoPlayer)
19    }
20
21    func test_setTwoPlayers_twoPlayers() {
22        // Arrange
23        let ticModel = TicGameModel(twoPlayer: true)
24
25        // Act
26
27        // Assert
28        XCTAssertTrue(ticModel.isTwoPlayer)
29    }
30    
31    func test_xAndoSystemTurns_draw() {
32        // Arrange
33        var ticModel = TicGameModel(twoPlayer: false)
34
35        // Act
36        let fullGrid: [Cell] = [.x, .b, .b,
37                                .b, .b, .b,
38                                .b, .b, .b]
39        for (i,c) in fullGrid.enumerated() {
40            ticModel.setCell(n: i, c: c)
41        }
42        ticModel.takeSystemTurn()
43        ticModel.takeSystemTurn()
44        ticModel.takeSystemTurn()
45        ticModel.takeSystemTurn()
46        ticModel.takeSystemTurn()
47        ticModel.takeSystemTurn()
48        ticModel.takeSystemTurn()
49        ticModel.takeSystemTurn()
50        ticModel.takeSystemTurn()
51        ticModel.takeSystemTurn()
52        ticModel.takeSystemTurn()
53
54        // Assert
55        XCTAssertTrue(ticModel.isGridFull)
56        XCTAssertEqual(Winner.none, ticModel.winner)
57    }

TicGameModel

The twoPlayer value is defaulted to true, so the existing game continues to behave as it is and all unit tests pass. A read-only property is added to expose the two player status. A function is added to take a turn for the system player.

 1struct TicGameModel {
 2    ...
 3    private var _twoPlayer: Bool
 4    ...
 5    init(twoPlayer: Bool = true) {
 6        _board = TicBoardModel(cells: [.b, .b, .b,
 7                                       .b, .b, .b,
 8                                       .b, .b, .b])
 9        _winningLines = []
10        for _ in 0..<8 {
11            _winningLines.append(false)
12        }
13        _winner = .none
14        _playerXTurn = true
15        _twoPlayer = twoPlayer
16    }
17    ...
18    var isTwoPlayer: Bool {
19        get { _twoPlayer }
20    }
21    ...    
22    mutating func takeSystemTurn() {
23        guard !_twoPlayer else {
24            return
25        }
26        let cellIndex = nextMove(player: _playerXTurn ? .x : .o)
27        setCell(n: cellIndex, c: _playerXTurn ? .x : .o)
28    }
29    ...

Update the ViewModel

Building on the premise that we will not change the number of players in the middle of a game, we only need to change the reset function in TicViewModel. A function is also added to take the turn for the system in the game.


TicViewModelTests

 1    func test_reset_twoPlayers() {
 2        // Arrange
 3        let ticViewModel = TicViewModel()
 4
 5        // Act
 6        ticViewModel.reset(twoPlayer: true)
 7
 8        // Assert
 9        XCTAssertTrue(ticViewModel.isTwoPlayer)
10    }
11    
12    func test_reset_onePlayer() {
13        // Arrange
14        let ticViewModel = TicViewModel()
15
16        // Act
17        ticViewModel.reset(twoPlayer: false)
18
19        // Assert
20        XCTAssertFalse(ticViewModel.isTwoPlayer)
21    }

TicViewModel

1    func reset(twoPlayer: Bool) {
2        // initialize a new model
3        ticModel = TicGameModel(twoPlayer: twoPlayer)
4    }


Disable User Input

Before we update the view, we will need a way to disable the user from making a selection while the system makes a move. We add a boolean property to the model to hold the disabled state. The expected behavior is that if this is a one player game, then the board is disabled after the user takes his turn and then the board is enabled after the system takes its turn. Unit tests are written to test this behavior and then the model is updated to implement this.


TicGameModelTests

 1    func test_initialBoardDisabled_false() {
 2        // Arrange
 3        let ticModel = TicGameModel()
 4
 5        // Act
 6
 7        // Assert
 8        XCTAssertFalse(ticModel.isBoardDisabled)
 9    }
10    
11    func test_OnePlayer_boardDisabled() {
12        // Arrange
13        var ticModel = TicGameModel(twoPlayer: false)
14
15        // Act
16        ticModel.setCell(n: 0, c: .x)
17
18        // Assert
19        XCTAssertTrue(ticModel.isBoardDisabled)
20    }
21    
22    func test_OnePlayerPlay_boardNotDisabled() {
23        // Arrange
24        var ticModel = TicGameModel(twoPlayer: false)
25
26        // Act
27        ticModel.setCell(n: 0, c: .x)
28        ticModel.takeSystemTurn()
29
30        // Assert
31        XCTAssertFalse(ticModel.isBoardDisabled)
32    }
33    
34    func test_TwoPlayer_boardNotDisabled() {
35        // Arrange
36        var ticModel = TicGameModel(twoPlayer: true)
37
38        // Act
39        ticModel.setCell(n: 0, c: .x)
40
41        // Assert
42        XCTAssertFalse(ticModel.isBoardDisabled)
43    }

TicGameModel

 1struct TicGameModel {
 2    ...
 3    private var _boardDisabled: Bool
 4    ...
 5    init(twoPlayer: Bool = true) {
 6        _board = TicBoardModel(cells: [.b, .b, .b,
 7                                       .b, .b, .b,
 8                                       .b, .b, .b])
 9        _winningLines = []
10        for _ in 0..<8 {
11            _winningLines.append(false)
12        }
13        _winner = .none
14        _playerXTurn = true
15        _boardDisabled = false
16        _twoPlayer = twoPlayer
17    }
18    ...
19    var isBoardDisabled: Bool {
20        get { _boardDisabled }
21    }
22    ...
23    mutating func setCell(n:Int, c: Cell) {
24        guard _board.grid.indices.contains(n) else {
25            return
26        }
27        guard _board.grid[n] == .b else {
28            return
29        }
30        _board = TicBoardModel(cells: _board.grid, n: n, c: c)
31        _winner = winningPlayer(board: _board)
32        _winningLines = winningLines(board: _board)
33        _playerXTurn.toggle()
34        
35        // Disable the board if it is a one player game
36        if !_twoPlayer {
37            _boardDisabled = true
38        }
39    }
40    ...
41    mutating func takeSystemTurn() {
42        guard !_twoPlayer else {
43            return
44        }
45        let cellIndex = nextMove(player: _playerXTurn ? .x : .o)
46        setCell(n: cellIndex, c: _playerXTurn ? .x : .o)
47        
48        // Enable the board after the system has taken a turn
49        _boardDisabled = false
50    }
51    ...    

TicViewModelTests

 1    func test_initialValue_boardNotDisabled() {
 2        // Arrange
 3        let ticViewModel = TicViewModel()
 4        
 5        // Act
 6        
 7        // Assert
 8        XCTAssertFalse(ticViewModel.isBoardDisabled)
 9    }
10
11    func test_onpPlayerGame_boardDisabled() {
12        // Arrange
13        let ticViewModel = TicViewModel()
14        
15        // Act
16        ticViewModel.reset(twoPlayer: false)
17        ticViewModel.setCell(index: 0, cellValue: .x)
18        
19        // Assert
20        XCTAssertTrue(ticViewModel.isBoardDisabled)
21    }

TicViewModel

 1    var isBoardDisabled: Bool {
 2        get { ticModel.isBoardDisabled }
 3    }
 4    
 5    func setCell(index: Int, cellValue: Cell)  {
 6        ticModel.setCell(n: index, c: cellValue)
 7        
 8        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [self] in
 9            ticModel.takeSystemTurn()
10        }
11    }

All Unit Tests passing after changes to ViewModel

All Unit Tests passing after changes to ViewModel



Update the View

There are not many changes to the views as all the logic is taken care of in the Model. I did add the async call to the ViewModel after first implementing it synchronously because of the delay with the system calculating the first response and UI not updating until both moves were made. There are different ways of implementing a User Interface for deciding on a one or two player game. The simplist is to have two buttons to start either a one-player or two-player game.


TicTacToeView

 1    ...
 2    Button("New Two Player Game") {
 3        ticVm.reset(twoPlayer: true)
 4    }
 5    .buttonStyle(ActionButtonStyle())
 6
 7    Button("New One Player Game") {
 8        ticVm.reset(twoPlayer: false)
 9    }
10    .buttonStyle(ActionButtonStyle())
11    ...

GridView

The only change to the GridView is to set the cell buttons to disabled when the board is disabled. The setCell function in the ViewModel could possibly be renamed to process move or something, as it is now making the players move and also the system move if it is a one-player game.

 1    ...
 2    Button(action: {
 3        // set the cell to X or O
 4        ticVm.setCell(index: index,
 5                      cellValue: ticVm.isXTurn ? .x : .o)
 6    }) {
 7        ZStack {
 8            BackGroundCardView()
 9                .padding(2)
10
11            Group {
12                if cellContent == .b {
13                    // leave cell blank
14                } else if cellContent == .x {
15                    XShape()
16                        .fill(Color(red: 150/255, green: 20/255, blue: 20/255))
17                } else {
18                    OShape()
19                        .fill(Color(red: 100/255, green: 20/255, blue: 140/255))
20                }
21            }
22            .padding(12)
23        }
24        .frame(width: 80, height: 80)
25    }
26    .disabled(ticVm.isGameOver || cellContent != .b || ticVm.isBoardDisabled) 
27    ...

Tic tac toe two-player games works as before

Tic tac toe two-player games works as before

Tic tac toe one-player game resulting in a draw

Tic tac toe one-player game resulting in a draw

Tic tac toe one-player game resulting in a win

Tic tac toe one-player game resulting in a win




Conclusion

This article walked through the implementation of the Minimax algorithm in Swift to determine the next move in a game of Tic Tac Toe. This is implemented in a Test Driven Development process with the unit tests written for the Model and ViewModel before the implementation code. A more complicated scoring mechanism could be developed that gave some points for two cells in a row of the same token or a higher score if the game could be won with more of the remaining cells blank. The setting of a one-player or two-plater could be moved to a configuration setting, with just one button to start a new game. A further configuration of who should go first could also be added. An additional level could be set for playing the system where the next move is chosen at random to give a player a chance of winning.