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:
- Test Driven Development in SwiftUI - Part 1
- Test Driven Development in SwiftUI - Part 2
- Understanding Minimax Algorithm with Tic Tac Toe
- Test Driven Development in SwiftUI - Part 3
- 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
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
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
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
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
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
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 one-player game resulting in a draw
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.