TicTacToe

Tic Tac Toe - Modern

Download on the App Store

iOS app which allows multiplayer gameplay using Firebase Firestore Database.

I started this project with the goal of being able to play TicTacToe with a friend on my iPhone. Once finished, I compared my app to those on the App Store and noticed that most had implemented an online feature such that people could play remotely. I thought the feature was cool, but most had implemented the online portion of the game using Game Center or some other way which made their game difficult to play or visually unappealing. With that analysis, my new objective became creating a game which had the following features:

  • can be played online
  • is simple to use
  • is visually appealing

I want to use this space to detail my experience creating this app and help others who wish to do something similar. For reference, the offline portion of this game took 2 hours to complete, while the online features took ~30 hours. I’ll detail the choices I made, the goals I had, and the achievements I made. I can still see problems coming up with this app. Sometimes it haunts me at night. If I were to make this app again, I would likely do it very differently. However, at this point I’m not sinking anymore time into a Tic-Tac-Toe game.

TicTacToePreview_1.mp4

Table of contents

Download on the App Store

Technologies

Project is created with:

  • Swift version: 5.2
  • Firebase Firestore Database
  • Xcode version 13.4.1
  • CocoaPods version 1.11.3

Setup

https://firebase.google.com/docs/firestore/query-data/listen

The Game

The game UI is designed using a 3×3 grid of buttons. Each time the board is tapped, the title of that button is set to the current current string (“X” or “O”).

var board = [UIButton]()

// 1 of the 9 buttons
@IBOutlet weak var a1: UIButton!
	...

func initBoard () {
    board.append(a1)
    ...
}

override func viewDidLoad(){
    super.viewDidLoad()
    initBoard()
    getColorScheme()
}

Local Play

When a player taps on a button on the board, several things need to happen in a certain order:

  1. Check if move is legal (i.e. button is empty) 1a. Make move 1b. Update labels 1c. Change turn
  1. Check if there is a winner

2a. If there is a winner, increment their score, alert the players

func resultAlert(title: String) {
	// UIAlertController must be of style type .alert for iPadOS not to raise problems
    let ac = UIAlertController(title: title, message: nil, preferredStyle: .alert)
    ac.addAction(UIAlertAction(title: "Reset", style: .default, handler:{ (_) in self.resetBoard()}))
    self.present(ac, animated: true)
}

         

Multiplayer

For the most intricate part of this game I want to detail a few things:

  • How to use Firebase (for those interested in developing)
  • Design choices I made
    • Info to store in database
    • Potential problems
      • Rematch
      • Snapshot listener
      • Player switching
    • Finding previous games

Firebase

Firebase has a multitude of features that can be used to help develop apps. The feature I used is called a Cloud Firestore Database. Firebase also offers a Realtime database that you can use for other types of applications. You can read about which suits your needs here.

When a player chooses to create a game on the app. Several things must happen in order. Since this is now an online multiplayer game, the source of truth is no longer on any local device, it is now on the database. It is important that no read/write conflicts occur, since that could cause the game to malfunction. Redundancies must be made. To simplify querying the database, I used a function to streamline the program. The following function states that we will be writing to our Game collection in the database, the only collection I stored. If the collection doesn’t exist (which at one point it didn’t), a new collection will be made.

enum FCollectionReference: String {
    case Game
}

func FirebaseReference(_ collectionReference: FCollectionReference) -> CollectionReference {
    return Firestore.firestore().collection(collectionReference.rawValue)
}

Now it is possible to query this collection. Adding, deleting, and modifying documents at will.

  1. First, a local game needs to be instantiated and then set online.

func createNewGame(with userId: String) {
    let anId = (UUID().uuidString).prefix(8)
    self.game = Game(id: String(anId), player1Name: self.currentUser.name, player2Name: "", player1Id: userId, player2Id: "", player1Score: 0, player2Score: 0, blockMoveForPlayerId: userId, waitForResetPlayer1: false, waitForResetPlayer2: false, move: -1, prevGameId: "")
    self.createOnlineGame()
}

func createOnlineGame() {
    do {
	    // the line below is of great significance
        try FirebaseReference(.Game).document(self.game.id).setData(from: self.game)
        self.waitingLabel.setTitle(" Game is online ", for: .normal)
        self.listenForGameChanges()
    } catch {
        print("Error creating online game", error.localizedDescription)
    }
}

I’ll yet again reiterate the importance of how the database is queried. In a line above, I highlight the following line:

try FirebaseReference(.Game).document(self.game.id).setData(from: self.game)

Several things of importance going on here. First, notice how I was able to set the data on the document. The game objects is a subclass of codable. Cloud Firestore converts the objects to supported data types.

The variable I use to name and parse my game documents is a variable named id, it is a truncated uuidString. If the document with the given id does not exist, which it shouldn’t it will be created. If the document does exist, its contents will be overwritten with the newly provided data, unless you specify that the data should be merged into the existing document. You can read more about adding data here.

So there is now have a joinable game in the database, what now? There needs to be an organized way to join games, whether the player would like to join a friend or auto-matchmake.

If a gameId is provided when a player presses ‘Join’, the following line will run:

FirebaseReference(.Game).whereField("player2Id", isEqualTo: "").whereField("id", isEqualTo:  gameId).getDocuments

This line determines if the gameId provided is valid and if the game is joinable (there isn’t a player2 in the game already).

If no results are found because the gameId was incorrect, a second player already joined, or a gameId wasn’t provided, then the database needs to be queried to find an open game; an open game being a game where there isn’t a player 2, and I am not player 1.

FirebaseReference(.Game).whereField("player2Id", isEqualTo: "").whereField("player1Id", isNotEqualTo: userId).getDocuments

At this step you should be wondering how it’s possible that a user can be player 1 in an empty game while searching for a game to join. If the user creates a game, then force quits the application, there will temporarily be an open game on the database. This open game will remain on the database until one of three things happens.

  1. The user opens the app and attempts to join or create a new game
    • The app recognizes that the user has an open game and deletes the unused game
  2. After a period of time, the database clears any games which don’t have a second player
  3. Another player joins the open game
    • Obviously player 1 isn’t part of the game, but that won’t be entirely clear to player 2
    • The game will be deleted once player 2 closes the view

So let’s say all goes to plan. Player 2 finds a joinable game.

if let gameData = querySnapshot?.documents.first {
   self.game = try? gameData.data(as: Game.self)
   self.game.player2Id = userId
   self.game.player2Name = self.currentUser.name
   self.game.blockMoveForPlayerId = userId
   self.checkForPrevGame(with: userId)
}

We set the variables in our local game to equate the variables in our database. But we don’t alert the other player just yet as to whether we have joined their game. This was a design choice I made in how players would get back into their previous games. Once player 2 joins the game, player 2 checks if they have played with player 1 before.

First player 2 checks if they and the other player played a game before where player 2 was player 2 in the previous game and player 1 was player 1 in the previous game. If they haven’t then player 2 checks if they and the other player played a game before where player 2 was player 1 in the previous game and player 1 was player 2 in the previous game. If this isn’t the case, then they haven’t

func checkForPrevGame(with userId: String, _ deleteCurrGame: Bool = false) {

        // I am now player 2 in a game, have I and this player played a game before?
        self.waitingLabel.setTitle("...Loading Game...", for: .normal)
        FirebaseReference(.Game).whereField("player2Id", isEqualTo: self.currentUser.id).whereField("player1Id", isEqualTo: self.game.player1Id).whereField("id", isNotEqualTo: self.game.id).getDocuments { querySnapshot, error in

            if error != nil {
                return
            }
            if let prevGameData = querySnapshot?.documents.first {
                self.prevGame = try? prevGameData.data(as: Game.self)

                // have to block game for myself, I am "O", and i gotta wait for x
                self.prevGame.blockMoveForPlayerId = userId
                // i was player 2 in our previous game

                self.game.prevGameId = self.prevGame.id
                self.updateGame(self.game)
                self.hasGameStarted = true

                self.game = self.prevGame

                if self.game.player2Name != self.currentUser.name {
                    self.game.player2Name = self.currentUser.name // user has changed names
                }

                self.updatePlayerLabels()

                self.oPlayer.isHighlighted = true
                self.oPlayer.highlightedTextColor = UIColor(red:0.0, green:0.55, blue:0.15, alpha:1.0)

                self.crossesScore = self.game.player1Score
                self.noughtsScore = self.game.player2Score

                self.updateScore()

                self.waitingLabel.setTitle("...Opponents Move...", for: .normal)

                self.listenForGameChanges()

                return

            }

            else {
                // have we played a game before where I was player1 ?
                print("checkprev Line 204")
                FirebaseReference(.Game).whereField("player2Id", isEqualTo: self.game.player1Id).whereField("player1Id", isEqualTo: self.currentUser.id).whereField("id", isNotEqualTo: self.game.id).getDocuments { querySnapshot, error in
                        print("FirebaseService Line 206")

                    if error != nil {
                        print("Error joing game, line 209")
//                        self.createNewGame(with: userId)
                        return
                    }

                    if let prevGameData = querySnapshot?.documents.first {
                        // we have, I was player1 and other was player 2
                        print("We have, line 214")

                        self.prevGame = try? prevGameData.data(as: Game.self)

                        // have to block game for myself, am "X", need to wait for "O"
                        self.prevGame.blockMoveForPlayerId = userId // **



                        // need to check if i was player I or 2 in our previous game
                        // i am player 1 from prev game
                        print("I joined a prev game where i was player 1, line 213")
                        self.xPlayer.isHighlighted = true
                        self.xPlayer.highlightedTextColor = UIColor(red:0.0, green:0.55, blue:0.15, alpha:1.0)


                        self.game.prevGameId = self.prevGame.id

                        self.updateGame(self.game)

                        self.game = self.prevGame

                        if self.game.player1Name != self.currentUser.name {
                            self.game.player1Name = self.currentUser.name // user has changed names
                        }

                        self.updatePlayerLabels()


                        print("I am player 1")
                        self.xPlayer.isHighlighted = true
                        self.xPlayer.highlightedTextColor = UIColor(red:0.0, green:0.55, blue:0.15, alpha:1.0)

                        self.crossesScore = self.game.player1Score
                        self.noughtsScore = self.game.player2Score

                        self.updateScore()

                        self.waitingLabel.setTitle("Game Started", for: .normal)

                        self.listenForGameChanges()

                        return

                    } else {
                    // can now let player 1 know to start game
                        self.updateGame(self.game)
                        self.waitingLabel.setTitle("Game Started", for: .normal)
                        self.listenForGameChanges()

                        self.updatePlayerLabels()

                        print("I am player 2")
                        self.oPlayer.isHighlighted = true
                        self.oPlayer.highlightedTextColor = UIColor(red:0.0, green:0.55, blue:0.15, alpha:1.0)

                    }
                }
            }
        }
        return
    }

https://firebase.google.com/docs/firestore/query-data/get-data

Design Choices

Information stored in database
Potential problems
Rematch
Snapshot listener
Player switching

Friends

This is a sentence

Concluding thoughts

Features in Beta/developement The one thing I plan on adding is notifications.

GitHub

View Github