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:

Buy Me A Coffee

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.