In this second part of my multipart article on converting my Murdered by Midnight Board Game to a Mobile Game App using Swift and SwiftUI, I’ll cover adding Game Center support to SwiftUI.
TL;DR Takeaway: Doing a quick search on the internet revealed the GameKitUI Library by Sascha Muellner. Sascha had already done all of the hard work of mapping the Game Kit UIs into SwiftUI. This was both a life and a time saver!
Hopefully, other indie developers can find this information useful.
Index:
- Part 1 – Deals with converting a mockup to SwiftUI, handling different device sizes & screen ratios and displays screens as needed.
- Part 2 – Covers adding Game Center support to a SwiftUI app.
- Part 3 – Covers handling tvOS quirks in a SwiftUI app.
- Part 4 – Covers handling macOS Catalyst quirks in a SwiftUI app.
- Bonus Round – Covers handling Music, Sound Effects & Voice in a SwiftUI app.
Challenge 3 – Can I Support Game Center in SwiftUI?
When designing the mobile version of Murdered by Midnight I knew I definitely needed to support Game Center, for the multiplayer ability if nothing else. While the game is fun to play by yourself, it really takes on a new level when you’re racing against other players to find the murderer first.
Additionally, I wanted the game to have leader boards and achievements that the players could win, so again, Game Center support was critical.
Even though I could make the game UI/UX easily with SwiftUI, that meant nothing if I couldn’t get it to work with the Game Center UIs/APIs. This was the second place where I expected I’d have to drop back to Storyboards and UIKit.
Before attempting to recreate the wheel, I did a quick search for any Swift Package ManagerĀ libraries that might do the trick and discovered Sascha Muellner’s most excellent GameKitUI Library. Sascha has done a wonderful job on this library and must be commended!
Challenge 4 – Allowing a User to Sign Into Game Center
On my startup screen I wanted the player to be able to log into Game Center (if they weren’t already) and the display the Game Center Access Point and allow for Multiplayer Games & access to Game Center Leader Boards and Achievements (if successful logged in).
Signing into Game Center:
The Start Screen when the user is signed in:
Game Center when the player taps the Access Point:
The player’s Achievements in the game:
All this is achieved by displaying GKAuthenticationView
from the GameKitUI library and GKAccessPoint
from the standard GameKit library.
The First Gotcha!
Before I show any of the code I used, I’m going to tell you about my first Gotcha and how I solved it, then I’ll show the completed code.
Displaying the Game Center login and getting the status of the player on Game Center was easy enough using GKAuthenticationView
and GKAccessPoint
. However, the signing screen kept getting “stuck” in the displaying/waiting for access mode. Major bummer!
The solution I found was to use a conditional and only show the display until login was either successful or failed:
import SwiftUI
import SwiftletUtilities
import GameKitUI
import GameKit
struct StartGameView: View {
@ObservedObject var dataStore = MasterDataStore.sharedDataStore
@AppStorage("savedSinglePlayerGame") var savedSinglePlayerGame = ""
@State private var allowMultiplayer = false
@State private var enableGameCenter = true
...
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .topLeading) {
...
if enableGameCenter {
GKAuthenticationView(failed: {error in
print("Failed: \(error.localizedDescription)")
DispatchQueue.main.async {
enableGameCenter = false
}
}, authenticated: {player in
print("Hello \(player.displayName)")
GKAccessPoint.shared.location = .topTrailing
GKAccessPoint.shared.isActive = GKLocalPlayer.local.isAuthenticated
// Has a listener been registered
if dataStore.currentGameManager == nil && GKLocalPlayer.local.isAuthenticated {
dataStore.currentGameManager = MultiplayerGameManager()
// GKLocalPlayer.local.unregisterAllListeners()
GKLocalPlayer.local.register(dataStore.currentGameManager!)
print("Game Manager registered")
}
DispatchQueue.main.async {
allowMultiplayer = (GKLocalPlayer.local.isAuthenticated && !GKLocalPlayer.local.isMultiplayerGamingRestricted)
enableGameCenter = false
}
})
}
}
.ignoresSafeArea()
}
.ignoresSafeArea()
}
...
}
I use the enableGameCenter
state to say in the login should be displayed. This state is set to false
after login has been attempted. If the login is successful, I set the allowMultiplayer
state to true
allowing the user to access the Multiplayer games by enabling the button.
I also display the Game Center Access Point and assign it a location in my UI. For all platforms accept tvOS, this allow the player access the Game Center Dashboard by tapping on it:
GKAccessPoint.shared.location = .topTrailing
GKAccessPoint.shared.isActive = GKLocalPlayer.local.isAuthenticated
Additionally, I create a new instance of my MultiplayerGameManager
class (more on this later) and assign it to the player’s Game Center Player so they app can respond to in game events, like it becoming this player’s turn during a match:
dataStore.currentGameManager = MultiplayerGameManager()
GKLocalPlayer.local.register(dataStore.currentGameManager!)
The Second Gotcha!
This all worked great. The player could log into their Game Center account, see the status of their current Leader Boards & Achievements and start Multiplayer Games. On every platform that is except tvOS.
I have to say the solution for this one was very difficult to find on the internet for SwiftUI and I almost gave up. The solution is to display your own button and open the Game Center Dashboard programmatically:
if allowMultiplayer {
ScaledImageButton(imageName: "ButtonGameCenter", width: 369, height: 84, scale: buttonScale) {
SoundManager.shared.playSoundEffect(sound: "ShpiraJazzKick.wav")
GKAccessPoint.shared.trigger(handler: {})
}
}
This little bugger here GKAccessPoint.shared.trigger(handler: {})
was the secret sauce to displaying the dashboard and was a PITA to find!
Challenge 5 – Handling Game Center Events
I’ll admit, this one stumped me for awhile too. I’d written Game Center apps before (back in the dinosaur ages) and responding to Game Center Events required listening in on messages sent to the AppDelegate
. But how to do this in SwiftUI?
Long story short, turns out you don’t need to anymore. Remember that instance of my MultiplayerGameManager
class I added to the GKLocalPlayer
above? It takes care of everything now.
While this doesn’t specifically have anything to do with the SwiftUI (other than changing UI views based on match states). I’m going to show you my version of this class in its entirety. WARNING! This class is looong! I’ll cover some specific bits in detail at the end.
import Foundation
import GameKit
class MultiplayerGameManager:NSObject, GKLocalPlayerListener {
typealias LoadGameDataCompletionHandler = (Bool) -> Void
// MARK: - Static Properties
static var isCurrentPlayer:Bool {
guard let match = MasterDataStore.sharedDataStore.currentMatch else {
return false
}
return (GKLocalPlayer.local.teamPlayerID == match.currentParticipant?.player?.teamPlayerID)
}
// MARK: - Static Functions
static func sendStatusUpdate() {
let dataStore = MasterDataStore.sharedDataStore
// Ensure a match currently open.
guard let match = dataStore.currentMatch else {
return
}
// Convert game to data
let data: Data? = dataStore.currentCase.encode()
// Send new game to other players
if let data = data {
match.saveCurrentTurn(withMatch: data, completionHandler: {error in
if let error = error {
print("Saving match data error: \(error)")
}
})
}
}
static func getNextPlayerList() -> [GKTurnBasedParticipant] {
var list:[GKTurnBasedParticipant] = []
var before:[GKTurnBasedParticipant] = []
var curent:GKTurnBasedParticipant? = nil
var after:[GKTurnBasedParticipant] = []
let teamPlayerId = GKLocalPlayer.local.teamPlayerID
// Ensure a match currently open.
guard let match = MasterDataStore.sharedDataStore.currentMatch else {
return list
}
for participant in match.participants {
switch(participant.status) {
case .active, .matching, .invited:
if curent == nil && participant.player?.teamPlayerID != teamPlayerId {
before.append(participant)
} else if participant.player?.teamPlayerID == teamPlayerId {
curent = participant
} else {
after.append(participant)
}
default:
break
}
}
// Assemble new list
if after.count > 0 {
list.append(contentsOf: after)
}
if before.count > 0 {
list.append(contentsOf: before)
}
if let curent = curent {
list.append(curent)
}
// Return resulting list
return list
}
static func endTurn() {
// Ensure a match currently open.
guard let match = MasterDataStore.sharedDataStore.currentMatch else {
return
}
// Update current player's status
if let detective = MasterDataStore.sharedDataStore.currentCase.findDetectiveForPlayer(teamPlayerId: GKLocalPlayer.local.teamPlayerID) {
detective.status = .waitingForNextTurn
detective.isCurrentPlayer = false
}
// Get players that are next to play
let players = getNextPlayerList()
// Convert game to data
let data: Data? = MasterDataStore.sharedDataStore.currentCase.encode()
// Send new game to other players
if let data = data {
match.endTurn(withNextParticipants: players, turnTimeout: GKExchangeTimeoutDefault, match: data, completionHandler: {error in
if let error = error {
print("Saving match data error: \(error)")
}
})
}
}
static func quitInTurn(outcome:GKTurnBasedMatch.Outcome, teamPlayerId:String = GKLocalPlayer.local.teamPlayerID) {
// Ensure a match currently open.
guard let match = MasterDataStore.sharedDataStore.currentMatch else {
return
}
// Update current player's status
if let detective = MasterDataStore.sharedDataStore.currentCase.findDetectiveForPlayer(teamPlayerId: teamPlayerId) {
switch(outcome) {
case .won:
detective.status = .won
case .lost:
detective.status = .lost
default:
detective.status = .resigned
}
detective.isCurrentPlayer = false
}
markCurrentPlayerQuit(teamPlayerId)
// Get players that are next to play
let players = getNextPlayerList()
// Convert game to data
let data: Data? = MasterDataStore.sharedDataStore.currentCase.encode()
// Send new game to other players
if let data = data {
match.participantQuitInTurn(with: outcome, nextParticipants: players, turnTimeout: GKExchangeTimeoutDefault, match: data, completionHandler: {error in
if let error = error {
print("Saving match data error: \(error)")
}
})
}
// End the match if there are no more players
endMatchIfNoMorePlayers()
}
static func quitOutOfTurn(teamPlayerId:String = GKLocalPlayer.local.teamPlayerID) {
// Ensure a match currently open.
guard let match = MasterDataStore.sharedDataStore.currentMatch else {
return
}
// Update current player's status
if let detective = MasterDataStore.sharedDataStore.currentCase.findDetectiveForPlayer(teamPlayerId: teamPlayerId) {
detective.status = .resigned
detective.isCurrentPlayer = false
}
markCurrentPlayerQuit(teamPlayerId)
// Send new game to other players
match.participantQuitOutOfTurn(with: .quit, withCompletionHandler: {error in
if let error = error {
print("Saving match data error: \(error)")
}
})
}
static func markCurrentPlayerQuit(_ teamPlayerId:String = GKLocalPlayer.local.teamPlayerID) {
// Ensure a match currently open.
guard let match = MasterDataStore.sharedDataStore.currentMatch else {
return
}
// Let match know the player won
for participant in match.participants {
switch(participant.status) {
case .active, .matching, .invited:
if participant.player?.teamPlayerID == teamPlayerId {
participant.matchOutcome = .quit
}
default:
break
}
}
}
static func wonGame() {
let teamPlayerId = GKLocalPlayer.local.teamPlayerID
// Ensure a match currently open.
guard let match = MasterDataStore.sharedDataStore.currentMatch else {
return
}
// Update current player's status
guard let detective = MasterDataStore.sharedDataStore.currentCase.findDetectiveForPlayer(teamPlayerId: GKLocalPlayer.local.teamPlayerID) else {
return
}
detective.status = .won
// Send score to leaderboard
GKLeaderboard.submitScore(1, context: 1, player: GKLocalPlayer.local, leaderboardIDs: ["MurdersSolved"], completionHandler: {error in
if let error = error {
print("Saving score error: \(error)")
} else {
print("Score saved successfully.")
}
})
// Let match know the player won
for participant in match.participants {
switch(participant.status) {
case .active, .matching, .invited:
if participant.player?.teamPlayerID == teamPlayerId {
participant.matchOutcome = .won
} else {
participant.matchOutcome = .lost
}
default:
break
}
}
// Send status update to other players that the game is over
MasterDataStore.sharedDataStore.currentCase.isGameOver = true
// Convert game to data
let data: Data? = MasterDataStore.sharedDataStore.currentCase.encode()
// Send new game to other players
if let data = data {
match.endMatchInTurn(withMatch: data, completionHandler: {error in
if let error = error {
print("Saving match data error: \(error)")
}
})
}
// Update player achievements
updateAchievementsForGameWon()
updateAchievementsForGameLost()
}
static func lostGame() {
let teamPlayerId = GKLocalPlayer.local.teamPlayerID
// Ensure a match currently open.
guard let match = MasterDataStore.sharedDataStore.currentMatch else {
return
}
// Update current player's status
if let detective = MasterDataStore.sharedDataStore.currentCase.findDetectiveForPlayer(teamPlayerId: teamPlayerId) {
detective.status = .lost
}
// Let match know the player lost
for participant in match.participants {
if participant.player?.teamPlayerID == teamPlayerId {
participant.matchOutcome = .lost
}
}
// Get players that are next to play
let players = getNextPlayerList()
// Convert game to data
let data: Data? = MasterDataStore.sharedDataStore.currentCase.encode()
// Send new game to other players
if let data = data {
match.participantQuitInTurn(with: .lost, nextParticipants: players, turnTimeout: GKExchangeTimeoutDefault, match: data, completionHandler: {error in
if let error = error {
print("Saving match data error: \(error)")
}
})
}
// End the match if there are no more players
endMatchIfNoMorePlayers()
// Update player achievements
updateAchievementsForGameLost()
}
static func updateAchievementsForGameWon() {
let teamPlayerId = GKLocalPlayer.local.teamPlayerID
// Ensure a match currently open.
guard let match = MasterDataStore.sharedDataStore.currentMatch else {
return
}
// Get detective account
guard let detective = MasterDataStore.sharedDataStore.currentCase.findDetectiveForPlayer(teamPlayerId: teamPlayerId) else {
return
}
// Load started and completed achievements for local player
GKAchievement.loadAchievements(completionHandler: {results, error in
if let error = error {
print("Error loading achievements: \(error)")
return
}
var achievements:[GKAchievement] = []
if let results = results {
achievements = results
}
// 01 - Super Super Sleuth
if MasterDataStore.sharedDataStore.currentCase.remainingRooms.count > 0 {
updateAchievement(id: "Achievement01", in: achievements, byAmount: 100.0)
}
...
})
}
static func updateAchievementsForGameLost() {
let teamPlayerId = GKLocalPlayer.local.teamPlayerID
// Get detective account
guard let detective = MasterDataStore.sharedDataStore.currentCase.findDetectiveForPlayer(teamPlayerId: teamPlayerId) else {
return
}
// Load started and completed achievements for local player
GKAchievement.loadAchievements(completionHandler: {results, error in
if let error = error {
print("Error loading achievements: \(error)")
return
}
var achievements:[GKAchievement] = []
if let results = results {
achievements = results
}
// 16 - Note Meister
if detective.allLogPagesUsed {
updateAchievement(id: "Achievement16", in: achievements, byAmount: 100.0)
}
...
})
}
private static func getAchievement(for id:String, in achievements:[GKAchievement]) -> GKAchievement {
// Scan all existing achievements
for achievement in achievements {
if achievement.identifier == id {
return achievement
}
}
// Else create new achievement and return
return GKAchievement(identifier: id)
}
private static func updateAchievement(id:String, in achievements:[GKAchievement], byAmount:Double) {
let achievement = getAchievement(for: id, in: achievements)
// Has the user already finished this achievement?
if achievement.isCompleted {
return
}
// Update achievement by the given amount
let amount = achievement.percentComplete + byAmount
if amount > 100.0 {
achievement.percentComplete = 100.0
} else {
achievement.percentComplete = amount
}
// Send results to Game Center
GKAchievement.report([achievement], withCompletionHandler: {error in
if let error = error {
print("Error saving achievement: \(error)")
}
})
}
static func endMatchIfNoMorePlayers() {
// Ensure a match currently open.
guard let match = MasterDataStore.sharedDataStore.currentMatch else {
return
}
// Get the number of currectly active players
var active = 0
for participant in match.participants {
switch(participant.status) {
case .active, .matching, .invited:
if participant.matchOutcome == .none {
active += 1
}
default:
break
}
}
// Out of players?
if active == 0 {
// Send status update to other players that the game is over
MasterDataStore.sharedDataStore.currentCase.isGameOver = true
// Convert game to data
let data: Data? = MasterDataStore.sharedDataStore.currentCase.encode()
// Send new game to other players
if let data = data {
match.endMatchInTurn(withMatch: data, completionHandler: {error in
if let error = error {
print("Saving match data error: \(error)")
}
})
}
}
}
static func startNewGame(completed:LoadGameDataCompletionHandler? = nil) {
let dataStore = MasterDataStore.sharedDataStore
// Ensure a match currently open.
guard let match = dataStore.currentMatch else {
if let completed = completed {
completed(false)
}
return
}
// Create new game
var avatars:[String] = ["PlayerF01", "PlayerF02", "PlayerF03", "PlayerF04", "PlayerF05", "PlayerF06", "PlayerM01", "PlayerM02", "PlayerM03", "PlayerM04", "PlayerM05", "PlayerM06"]
avatars.shuffle()
dataStore.currentCase = MurderCase.BuildMurder(numberOfPlayers: match.participants.count, avatars: avatars, isMultiplayer: true)
// Update result of turn
dataStore.currentCase.resultOfLastTurn = Conversation(characterName: "GameStarted", text: "\(GKLocalPlayer.local.displayName) has started the game.")
// Convert game to data
let data: Data? = dataStore.currentCase.encode()
// Send new game to other players
if let data = data {
match.saveCurrentTurn(withMatch: data, completionHandler: {error in
if let error = error {
print("Saving match data error: \(error)")
if let completed = completed {
completed(false)
}
} else {
if let completed = completed {
completed(true)
}
}
})
}
}
static func loadMatch(canStartNewGame:Bool = false, completed:LoadGameDataCompletionHandler? = nil) {
let dataStore = MasterDataStore.sharedDataStore
// Ensure a match is currentlu open
guard let match = dataStore.currentMatch else {
if let completed = completed {
completed(false)
}
return
}
match.loadMatchData(completionHandler: {data, error in
if let error = error {
print("Loading match data error: \(error)")
if let completed = completed {
completed(false)
}
return
}
if let data = data {
let currentCase = MurderCase.decode(data: data)
if let currentCase = currentCase {
dataStore.currentCase = currentCase
if let completed = completed {
completed(true)
}
} else {
if MultiplayerGameManager.isCurrentPlayer && canStartNewGame {
MultiplayerGameManager.startNewGame(completed: completed)
} else {
if let completed = completed {
completed(false)
}
}
}
} else {
print("No data returned from match.")
if let completed = completed {
completed(false)
}
}
})
}
static func switchView() {
// Ensure that we have an open match
guard let match = MasterDataStore.sharedDataStore.currentMatch else {
return
}
// Execute on main thread
DispatchQueue.main.async {
// Ensure the current player is selected
MasterDataStore.sharedDataStore.currentCase.setCurrentPlayer()
// Take action based on the state ofthe match
switch(match.status) {
case .ended:
// This game has ended, send player to lobby for results
MasterDataStore.sharedDataStore.changeView(view: .multiplayerLobby)
default:
break
}
if let detective = MasterDataStore.sharedDataStore.currentCase.findDetectiveForPlayer(teamPlayerId: GKLocalPlayer.local.teamPlayerID) {
if MultiplayerGameManager.isCurrentPlayer {
if detective.status == .unknown {
// Send player to case view
MasterDataStore.sharedDataStore.changeView(view: .caseView)
} else {
// Send player to investigation view
MasterDataStore.sharedDataStore.changeView(view: .multiplayerLobby)
}
} else {
if detective.status == .unknown {
// Send player to case view
MasterDataStore.sharedDataStore.changeView(view: .caseView)
} else {
// Send player to lobby
MasterDataStore.sharedDataStore.changeView(view: .multiplayerLobby)
}
}
} else {
// Send player to lobby
MasterDataStore.sharedDataStore.changeView(view: .multiplayerLobby)
}
}
}
static func setCurrentPlayerStatus(status:Detective.playerStatus) {
// Ensure we are in a multiplayer game
guard MasterDataStore.sharedDataStore.currentCase.isMultiplayer else {
return
}
if let detective = MasterDataStore.sharedDataStore.currentCase.findDetectiveForPlayer(teamPlayerId: GKLocalPlayer.local.teamPlayerID) {
detective.status = status
}
}
// MARK: - Functions
func player(_ player: GKPlayer, didAccept invite: GKInvite) {
print("Player accepted invite.")
}
func player(_ player: GKPlayer, didReceive challenge: GKChallenge) {
print("Player received challenge.")
}
func player(_ player: GKPlayer, wantsToPlay challenge: GKChallenge) {
print("Player wants to play.")
}
func player(_ player: GKPlayer, matchEnded match: GKTurnBasedMatch) {
print("Player ended match.")
// Save as current match
MasterDataStore.sharedDataStore.currentMatch = match
// Load game data and send player to lobby
MultiplayerGameManager.loadMatch(canStartNewGame: false, completed: {_ in
// Update achievements
MultiplayerGameManager.updateAchievementsForGameLost()
// Send player to lobby
DispatchQueue.main.async {
MasterDataStore.sharedDataStore.changeView(view: .multiplayerLobby)
}
})
}
func player(_ player: GKPlayer, wantsToQuitMatch match: GKTurnBasedMatch) {
print("Player wants to quit match.")
// Save as current match
MasterDataStore.sharedDataStore.currentMatch = match
// Load game data and remove given player from the match
MultiplayerGameManager.loadMatch(canStartNewGame: false, completed: {successful in
if successful {
if player.displayName == match.currentParticipant?.player?.displayName {
MultiplayerGameManager.quitInTurn(outcome: .quit, teamPlayerId: player.teamPlayerID)
} else {
MultiplayerGameManager.quitOutOfTurn(teamPlayerId: player.teamPlayerID)
}
}
})
}
func player(_ player: GKPlayer, receivedTurnEventFor match: GKTurnBasedMatch, didBecomeActive: Bool) {
print("Player received turn based event: \(didBecomeActive)")
// Save as current match
MasterDataStore.sharedDataStore.currentMatch = match
// Load game data and send player to the correct screen based on state
MultiplayerGameManager.loadMatch(canStartNewGame: false, completed: {successful in
if successful {
// Ensure player is attached to a detective
MasterDataStore.sharedDataStore.currentCase.assignPlayerToDetective(teamPlayerId: GKLocalPlayer.local.teamPlayerID, playerName: GKLocalPlayer.local.displayName)
// Is this the current player?
if MultiplayerGameManager.isCurrentPlayer {
if let detective = MasterDataStore.sharedDataStore.currentCase.findDetectiveForPlayer(teamPlayerId: GKLocalPlayer.local.teamPlayerID) {
detective.isCurrentPlayer = true
switch(detective.status) {
case .resigned, .lost, .won, .unknown, .hasMoved:
break
default:
detective.status = .readyToMove
}
}
}
// Is the game just starting?
if MasterDataStore.sharedDataStore.currentCase.resultOfLastTurn.characterName == "GameStarted" {
// Send player to case view
DispatchQueue.main.async {
MasterDataStore.sharedDataStore.changeView(view: .caseView)
}
} else {
// Send player to the correct view based on their status
MultiplayerGameManager.switchView()
}
} else {
// Game not started, start new game
MultiplayerGameManager.startNewGame(completed: {saved in
// Ensure player is attached to a detective
MasterDataStore.sharedDataStore.currentCase.assignPlayerToDetective(teamPlayerId: GKLocalPlayer.local.teamPlayerID, playerName: GKLocalPlayer.local.displayName)
// Jump to case view
MasterDataStore.sharedDataStore.changeView(view: .caseView)
})
}
})
}
func player(_ player: GKPlayer, didComplete challenge: GKChallenge, issuedByFriend friendPlayer: GKPlayer) {
print("Player did complete challenge.")
}
func player(_ player: GKPlayer, issuedChallengeWasCompleted challenge: GKChallenge, byFriend friendPlayer: GKPlayer) {
print("Player issued challenge was completed.")
}
func player(_ player: GKPlayer, receivedExchangeRequest exchange: GKTurnBasedExchange, for match: GKTurnBasedMatch) {
print("Player received exchange request.")
}
func player(_ player: GKPlayer, receivedExchangeCancellation exchange: GKTurnBasedExchange, for match: GKTurnBasedMatch) {
print("Player received exchange cancellation.")
}
func player(_ player: GKPlayer, receivedExchangeReplies replies: [GKTurnBasedExchangeReply], forCompletedExchange exchange: GKTurnBasedExchange, for match: GKTurnBasedMatch) {
print("Player received exchange replies.")
}
}
Here are the specific bits to look at:
isCurrentPlayer
– Tests to see if the player on this device is the current player in a match.sendStatusUpdate
– Sends match update details to all players in the match.getNextPlayerList
– Generates a list of the next players in order from the current to the last. It handles the list wrapping around to the top and removes any player that has quit or lost the match. NOTE: You are responsible for setting the player order and picking the player next up in a match.endTurn
– Ends the current player’s turn.quitInTurn
– If it’s current the player’s and they quit the match. This handles passing the turn on to the next player as well or quitting the match if there are no more players.quitOutOfTurn
– Same as above, only if it isn’t currently this player’s turn.markCurrentPlayerQuit
– Lets the player know who quit the match.wonGame
– Handles the current player winning the game.lostGame
– Handles any player losing the match, for any reason.updateAchievementsForGameWon
– If the player won the match, adjust their achievements.updateAchievementsForGameLost
– If the player lost the match, adjust their achievements.getAchievement
– Gets all possible achievements from from Game Center.updateAchievement
– Updates the status of a specific achievement.endMatchIfNoMorePlayers
– Ends the match if we run out of players.startNewGame
– Handles starting a new game.loadMatch
– Loads a match that either the player selected or we received an event on.switchView
– Changes views based on match events, like it becoming the player’s turn.setCurrentPlayerStatus
– Sets the current player’s status in the match.func player(_ player: GKPlayer, matchEnded match: GKTurnBasedMatch)
– Handles the current match ending.player(_ player: GKPlayer, wantsToQuitMatch match: GKTurnBasedMatch)
– Handles a player quitting the match.player(_ player: GKPlayer, receivedTurnEventFor match: GKTurnBasedMatch, didBecomeActive: Bool)
– Handles an event coming in for a given match.
Challenge 6 – Allowing the User to Start or Continue Multiplayer Games
So we have the player signed into Game Center and they can access the Dashboard to see the Leader Boards and Achievements. Now they need to be able to start, join or continue Multiplayer Games.
The Third Gotcha!
Again, before I show any code, I’m going to tell you about an issue I found and show the code with the solution. Basically, you are supposed to set the Minimum and Maximum number of players for a match and a users starting a new game can adjust the number of player from inside the Game Center match Maker.
When I was allow the user to do this, the instant they added or removed a player, the app was blowing up… inside of GameKit
(according to Xcode 13)!
My workaround was to allow the user to pick the number of players in my UI and pass that number to the Game Center match Maker as both the Minimum and Maximum number. See:
And here’s the code:
import SwiftUI
import SwiftletUtilities
import SwiftletData
import GameKit
import GameKitUI
struct MultiplayerMatchmaking: View {
@ObservedObject var dataStore = MasterDataStore.sharedDataStore
@State private var showMatchmaker = false
@State private var numberOfPlayers = 2
...
@ViewBuilder
private func mainBody() -> some View {
GeometryReader { geometry in
ZStack(alignment: .topLeading) {
...
if showMatchmaker {
GKTurnBasedMatchmakerView(minPlayers: numberOfPlayers, maxPlayers: numberOfPlayers, inviteMessage: "Let's play Murdered by Midnight!", canceled: {
print("Player canceled matchmaking")
showMatchmaker = false
}, failed: {error in
print("Matchmaking ended in error: \(error.localizedDescription)")
showMatchmaker = false
}, started: {match in
print("Match started \(match.participants.count)")
// Save and configure current match
dataStore.currentMatch = match
})
}
}
.ignoresSafeArea()
}
.ignoresSafeArea()
}
...
}
When matchmaking is successful, I’m just saving an instance of the match in my global datastore. The MultiplayerGameManager
that we attached earlier when handle the rest, so there is nothing else to do here.
Support
If you find this useful, please consider making a small donation:
It’s through the support of contributors like yourself, I can continue to create useful articles like this one and continue build, release and maintain high-quality, well documented Swift Packages for free.
In Part 3
Whew! I admit that was a lot to go through. In the next piece, I’ll cover supporting tvOS and the final bit will cover macOS so stay tuned. Read Part 3 here.