A TicTacToe game in SwiftUI

Jun 18, 2019 · 14 mins read · Costantino Pistagna · @valv0

A TicTacToe game in SwiftUI

Today we will try to build a TicTacToe game in less than 160 lines with SwiftU Apple’s new declarative framework that promises to supplant its brother UIKit in the near future. Let’s open Xcode.

In these hours the beta2 came out together with macOS catalina beta2. I strongly advise you to install both of them, since we have noticed an overall remarkable improvement in performance as well as an improved Xcode responsiveness in all respects.

Once opened Xcode, we will create a new project, let’s name it TicTacToe and make sure that the tick in SwiftUI is enabled. Press next and choose a folder to store your new project.

Fig.1

Once the initialization phase is complete, Xcode shows the skeleton for the pre-generated project. Move to ContentView.swift and let’s start immediately shaping the logic of our game.

In the TicTacToe game, the two opponents challenge each other, each taking an X or O mark, in a 3x3 board. The winner is the one who is the first to line up three identical signs horizontally, vertically or diagonally.

The first thing to do, therefore, will be to create a model that reflects what has just been said.

Let’s write an enum that reflects the status of a box. This may be empty, occupied by us or by the computer.

enum SquareStatus {
    case empty
    case visitor
    case home
}

To play we will need a 3x3 chessboard. So, try to define the model for a single square in the board:

class Square: BindableObject {
    let didChange = PassthroughSubject<Void, Never>()
    
    var status: SquareStatus {
        didSet {
            didChange.send(())
        }
    }
    
    init(status: SquareStatus) {
        self.status = status
    }
}

The Square class adopts the BindableObject protocol. Objects that adopt this protocol are objects that notify subscribers in the SwiftUI framework when one or more properties in it change.

To do this it is necessary to adopt the protocol correctly, adding an instance that publishes an event when an object changes. This instance is called didChange:

let didChange = PassthroughSubject <Void, Never> ()

PassthroughtSubject is a class made available by the Combine framework, which allows us to publish a notification to all applicants. The notification has two parameters, the output and a possible error.

For now, all that we want is to notify all applicants that something has changed into the Square object. So, since we are not interested in any of the two parameters, we will pass Void for the first parameter and Never for the second. The first indicates that we do not pass any output. The second indicates that there is no possibility of error.

Next, we define the property status of our box and, inside it, we’ll add a mechanism that sends a notification when we change its value:

var status: SquareStatus {
    didSet {
        didChange.send(())
    }
}

We can move, next, on to the actual Model that define the game and the chessboard. We will call it ModelBoard:

class ModelBoard {
    var squares = [Square]()

    init() {
        for _ in 0...8 {
            squares.append(Square(status: .empty))
        }
    }

We start with the declaration of the array variable that will host the individual pieces of the board. In the init function we are going to add 9 elements of type Square all with status .empty

At the end of the game we will need a method to bring the game back to its initial state:

func resetGame() {
    for i in 0...8 {
        squares[i].status = .empty
    }
}

Now we need to have a way to check if the game is over.

A game ends if there are no more boxes available or if one of the two players align three symbols before the other.

var gameOver: (SquareStatus, Bool) {
    get {
        if thereIsAWinner != .empty {
            return (thereIsAWinner, true)
        } else {
            for i in 0...8 {
                if squares[i].status == .empty {
                    return (.empty, false)
                }
            }
            return (.empty, true)
        }
    }
}

The getter variable thereIsAWinner will simply check all winning occurrences:

private var thereIsAWinner:SquareStatus {
    get {
        if let check = self.checkIndexes([0, 1, 2]) {
            return check
        } else  if let check = self.checkIndexes([3, 4, 5]) {
            return check
        }  else  if let check = self.checkIndexes([6, 7, 8]) {
            return check
        }  else  if let check = self.checkIndexes([0, 3, 6]) {
            return check
        }  else  if let check = self.checkIndexes([1, 4, 7]) {
            return check
        }  else  if let check = self.checkIndexes([2, 5, 8]) {
            return check
        }  else  if let check = self.checkIndexes([0, 4, 8]) {
            return check
        }  else  if let check = self.checkIndexes([2, 4, 6]) {
            return check
        }
        return .empty
    }
}

The checkIndex function takes an array of integers (indexes) as input and returns an optional SquareStatus: .home or .visitor if there’s a winner; nil otherwise:

private func checkIndexes(_ indexes: [Int]) -> SquareStatus? {
    var homeCounter:Int = 0
    var visitorCounter:Int = 0
    for anIndex in indexes {
        let aSquare = squares[anIndex]
        if aSquare.status == .home {
            homeCounter = homeCounter + 1
        } else if aSquare.status == .visitor {
            visitorCounter = visitorCounter + 1
        }
    }
    if homeCounter == 3 {
        return .home
    } else if visitorCounter == 3 {
        return .visitor
    }
    return nil
}

We need a way to make a move. For our human player we will check if the selected square is empty. If yes, then we will change its status to .home

Since, we’re only implementing a Human vs Machine game, we also need to trigger the computer move at the end.

func makeMove(index: Int, player:SquareStatus) -> Bool {
    if squares[index].status == .empty {
        squares[index].status = player
        if player == .home { aiMove() }
        return true
    }
    return false
}

The move of artificial intelligence is really simple: we only collect a random index, check if it is empty and if this’s the case we will fill it with the .visitor flag.

private func aiMove() {
    var anIndex = Int.random(in: 0 ... 8)
    while (makeMove(index: anIndex, player: .visitor) == false && gameOver.1 == false) {
        anIndex = Int.random(in: 0 ... 8)
    }
}

Now that the model is ready, we need to interact with SwiftUI in order to build a View that could respect the Square Model. Let’s call it SquareView:

struct SquareView: View {
    @ObjectBinding var dataSource:Square
    var action: () -> Void
    var body: some View {
        Button(action: {
            self.action()
        }) {
            Text((dataSource.status != .empty) ?
                (dataSource.status != .visitor) ? "X" : "0"
                : " ")
                .frame(minWidth: 60, minHeight: 60)
                .background(Color.gray)
                .padding(EdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4))
        }
    }
}

First of all, we add an @ObjectBinding property wrapper in front of the Square datasource.

This way we’re telling to SwiftUI that the SquareView is interested in changing notifications from the model. When a change will come, SwiftUI will automatically trigger a new body for the SquareView.

Inside the body we’re conditionally check if there’s a status other than .empty. If so, we write X or O accordingly.

.frame, .background and .padding are all cosmetic adjustments related to the Square.

Finally, we need to modify our ContentView in order to house the chessBoard by creating a private var for that purpouse, naming it checker:

struct ContentView : View {
    private var checker = ModelBoard()
    @State private var isGameOver = false

We also define a @State property for checking if the game is finished; we need to define it as a @State property, since we would like to refresh the ContentView body once the game is completed.

Since every SquareView is the same and it’s behaviour change only based on index, we define also a buttonAction method:

func buttonAction(_ index: Int) {
    _ = self.checker.makeMove(index: index, player: .home)
    self.isGameOver = self.checker.gameOver.1
}

This way, every SquareView will invoke the method with its specific index and the relevant code is executed with respect to the specified position.

Finally, we can build the whole body, reflecting the 9x9 SquareView(s):

var body: some View {
    VStack {
        HStack {
            SquareView(dataSource: checker.squares[0]) { self.buttonAction(0) }
            SquareView(dataSource: checker.squares[1]) { self.buttonAction(1) }
            SquareView(dataSource: checker.squares[2]) { self.buttonAction(2) }
        }
        HStack {
            SquareView(dataSource: checker.squares[3]) { self.buttonAction(3) }
            SquareView(dataSource: checker.squares[4]) { self.buttonAction(4) }
            SquareView(dataSource: checker.squares[5]) { self.buttonAction(5) }
        }
        HStack {
            SquareView(dataSource: checker.squares[6]) { self.buttonAction(6) }
            SquareView(dataSource: checker.squares[7]) { self.buttonAction(7) }
            SquareView(dataSource: checker.squares[8]) { self.buttonAction(8) }
        }
    }

One last thing, is regarding a feedback for the user: we want to notify the user with a popup when either the game is end or someone will win.

To make this, we could use the .presentation View modifier. In it’s simple form it takes:

  • A conditional boolean to discern if it should appear or not
  • A message parameter with a Text for the body of the alert
  • A dismissButton: with an Alert.Button
.presentation($isGameOver) { () -> Alert in
    Alert(title: Text("Game Over"),
          message: Text(self.checker.gameOver.0 != .empty ?
            (self.checker.gameOver.0 == .home) ? "You Win!" : "iPhone Wins!"
            : "Parity"), dismissButton: Alert.Button.destructive(Text("Ok"), onTrigger: {
                self.checker.resetGame()
            }) )
}

That’s all folks! As promised, in less than 160 lines of code we have a fully working TicTacToe game. If we Build and Run our code our application will looks like the following:

Fig.1

Costantino Pistagna
Costantino Pistagna · @valv0 Costantino is a software architect, project manager and consultant with more than ten years of experience in the software industry. He developed and managed projects for universities, medium-sized companies, multi-national corporations, and startups. He is among the first teachers for the Apple's iOS Developer Academy, based in Europe. Regularly, he lectures iOS development around the world, giving students the skills to develop their own high quality apps. While not writing apps, Costantino improves his chefs skills, travelling the world with his beautiful family.