TicTacToe
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
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:
- Check if move is legal (i.e. button is empty) 1a. Make move 1b. Update labels 1c. Change turn
- 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.
- 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.
- 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
- After a period of time, the database clears any games which don’t have a second player
- 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.