Turner Eison

Deep Dive into CollabCounter

Recently, I wanted to learn how GameCenter, and its underlying framework GameKit, work. What better way than to build an app using GameKit?

I also decided to throw in a widget to quickly launch into the counter. I'll go over the process I went through for that after the GameKit section.

Before we begin, CollabCounter is currently available on TestFlight for your enjoyment 😄

Enjoy the article!

The Idea

I didn't want to build a fully fledged game just to learn the framework, even if that's what its intended use is. So, I decided to go with something simple: A multiplayer counter app.

I mocked up some UI in SwiftUI. Nothing crazy, just some buttons and a label for the counter.

GameKit

The GameKit docs could be a little better, and there aren't a ton of tutorials for using GameKit, so I mostly had to figure it out myself. I started out by fleshing out a basic ObservableObject:

class GameCenterManager: NSObject, ObservableObject {
    @Published var isAuthenticated = false
    var viewController: ViewRepresentable?
    
    @Published var shouldShowViewController = false

    override init() {
        super.init()
        GKLocalPlayer.local.authenticateHandler = { [self] gamecenterAuthController, error in
            if GKLocalPlayer.local.isAuthenticated {
                logger.info("Player is authenticated!")
                self.isAuthenticated = true
            } else if let vc = gamecenterAuthController {
                logger.info("Presenting auth controller")
                viewController = ViewRepresentable(viewController: vc)
                shouldShowViewController = true
            } else {
                logger.error("Error authenticating to GameCenter: \(error?.localizedDescription ?? "none", privacy: .sensitive)")
            }
        }
    }
}

Here, I have a published boolean, isAuthenticated, which will notify my view of authentication changes.

Then, I have an optional reference to a ViewRepresentable. This is just a simple wrapper around UIViewControllerRepresentable that lets me pass in any arbitrary UIViewController.

struct ViewRepresentable: UIViewControllerRepresentable {
    var viewController: UIViewController
    
    func makeUIViewController(context: Context) -> some UIViewController {
        viewController
    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { }
}

In my init, I assign a closure to the local player's authentication handler. In that closure, I'm checking for if we're authenticated. If so, nothing to do other than set isAuthenticated to true.

If we aren't authenticated and we have a view controller passed to us, we assign that view controller to our ViewRepresentable property, and set shouldShowViewController to true. That view controller is GameCenter's authentication view, where the user can sign into GameCenter. Setting that property to true activates a sheet on top of our SwiftUI view, and lets the user sign into GameCenter.

.sheet(isPresented: $gameCenterManager.shouldShowViewController) {
    ZStack {
        ProgressView() // Show a spinner until the View Controller finishes loading its views
        gameCenterManager.viewController?.ignoresSafeArea()
    }
}

Now that we have a way to present GameCenter's UI in our SwiftUI views, let's start working on the matchmaking.

extension GameCenterManager: GKMatchmakerViewControllerDelegate {
    func startOnlineCounter() {
        guard isAuthenticated else { return }
        
        let matchRequest = GKMatchRequest()
        matchRequest.defaultNumberOfPlayers = 2
        matchRequest.minPlayers = 2
        matchRequest.maxPlayers = .max
        matchRequest.inviteMessage = "Join my counter!"
        
        guard let vc = GKMatchmakerViewController(matchRequest: matchRequest) else { return }
        vc.matchmakerDelegate = self
        viewController = ViewRepresentable(viewController: vc)
        shouldShowViewController = true
    }
}

When the user wants to start an online counter, we call startOnlineCounter(). That first ensures we're authenticated, then creates a GKMatchRequest for us to use. We set its properties, setting a minimum player count of 2 and a maximum of Int.max, and an invite message of "Join my counter!" We then create a GKMatchmakerViewController with our request, and set ourselves as the delegate. Then we present it the same way we presented the authentication view from earlier!

We also need to implement the various delegate methods, where we mostly just log errors. The fun one is matchmakerViewController(viewController:didFind:) where we're given our match after the users have all connected. We dismiss the matchmaker view, set our reference to the match, and show the counter view.

var match: GKMatch? 

func matchmakerViewController(_ viewController: GKMatchmakerViewController, didFind match: GKMatch) {
    DispatchQueue.main.async {
        self.shouldShowViewController = false
        self.viewController = nil
        self.match = match
        self.match?.delegate = self
        self.shouldShowCounter = true
    }
}

func matchmakerViewControllerWasCancelled(_ viewController: GKMatchmakerViewController) {
    logger.log("User cancelled matchmaking")
    DispatchQueue.main.async {
        self.shouldShowViewController = false
    }
}

func matchmakerViewController(_ viewController: GKMatchmakerViewController, didFailWithError error: Error) {
    logger.error("Matchmaking failed!")
}

Now, we just need to implement the logic for sending and receiving data. GameKit makes this really easy.

extension GameCenterManager: GKMatchDelegate {
    func increment() {
        counter += 1
        hapticGenerator.impactOccurred()
        send()
    }
    
    func decrement() {
        counter -= 1
        hapticGenerator.impactOccurred()
        send()
    }
    
    private func send() {
        let data = withUnsafeBytes(of: counter.bigEndian) { Data($0) }
        do {
            try match?.sendData(toAllPlayers: data, with: .reliable)
        } catch {
            logger.error("Unable to send data, \(error.localizedDescription, privacy: .sensitive)")
        }
    }
    
    func match(_ match: GKMatch, didReceive data: Data, fromRemotePlayer player: GKPlayer) {
        let value = data.withUnsafeBytes {
            $0.load(as: Int.self).bigEndian
        }
        
        DispatchQueue.main.async {
            self.counter = value
        }
    }
}

Whenever we change the value of our counter, we call send(), which gets the bigEndian version of our counter's value, and shoves it into some data. Then, we tell our match to send that data to all players. Similarily, when receiving, we load that data into an Int as a bigEndian value. That's all the work we need to do to send and receive data with GameKit!

To learn more about bigEndian, visit this Wikipedia page on Endianness.

We need to implement two more things: handling leaving the game, and handling when everyone leaves the game:

// This is called when a player changes state, like by leaving the match
func match(_ match: GKMatch, player: GKPlayer, didChange state: GKPlayerConnectionState) {
    if match.players.isEmpty { // if we're the only ones left
        DispatchQueue.main.async {
            self.endMatch()
        }
    }
}

func endMatch() {
    match?.disconnect()
    match?.delegate = nil
    match = nil
    shouldShowCounter = false
}

If everyone leaves, we leave too. We leave the match by:

  • Disconnecting
  • Removing its delegate
  • Releasing the reference to the match
  • Dismissing the counter

Now we have a functioning CollabCounter! We do need to do one more thing, though. Currently, if you receive an invitation and the app isn't open, tapping the invitation doesn't do anything but open the app to its home screen. That's, uh, not what we want. To fix this, we need to write just a little more.

Inside our init from before, we need to add this line after the authentication handler:

GKLocalPlayer.local.register(self)

We also need to implement one more function:

extension GameCenterManager: GKLocalPlayerListener {
    func player(_ player: GKPlayer, didAccept invite: GKInvite) {
        DispatchQueue.main.async {
            guard let vc = GKMatchmakerViewController(invite: invite) else { return }
            vc.matchmakerDelegate = self
            self.viewController = ViewRepresentable(viewController: vc)
            self.shouldShowViewController = true
        }
    }
}

The line we added to the init registers our instance with the GKLocalPlayer as a listener, so we can listen for invite events. Additionally, that method is the callback we get when we hear an invite was accepted. We're given the invite, so we make a GKMatchmakerViewController from it, and present it.

WidgetKit

I thought it'd be a good idea to make a widget for this little app, partly for practice with WidgetKit, and partly because I wanted to have some fun 😝

After I first added the widget extension to the project, I made a super simple view that looks like the home screen of the app.

struct CollabCounterWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            HStack {
                Text("CollabCounter")
                Spacer()
                Image(systemName: "chevron.up.chevron.down")
            }
            .font(.subheadline)
            .padding(.horizontal)
            Link(destination: URL(string: "startLocalCounter")!) {
                Capsule()
                    .foregroundColor(.blue)
                    .overlay(Text("Local Counter")
                                .font(.title))
            }
            Link(destination: URL(string: "startOnlineCounter")!) {
                Capsule()
                    .foregroundColor(.blue)
                    .overlay(Text("Online Counter")
                                .font(.title))
            }
        }
        .padding(8)
    }
}

Every widget has a timeline that provides data, through entries, to our SwiftUI view. However, since we're just displaying static content, we can just set our timeline to refresh never and not worry about it.

The fun part here is the Link objects. These create tappable areas in the widget that will deep link into our app. We can handle these deep links in any of our views, by adding a modifier on.

.onOpenURL { url in

}

In order to handle the startLocalCounter link, we dismiss any visible views, and present the counter as-is. To handle the startOnlineCounter link, we dismiss everything again. Then, if we're authenticated, call startOnlineCounter() from our GameCenterManager. If we're not authenticated, we wait for a short time to allow time for GameCenter to authenticate, then make that call.

.onOpenURL { url in
    // Dismiss all active screens before handling the URL
    gameCenterManager.shouldShowCounter = false
    gameCenterManager.shouldShowViewController = false
    showingSettingsSheet = false
    
    if url.absoluteString == "startLocalCounter" {
        gameCenterManager.shouldShowCounter = true
    }
    
    if url.absoluteString == "startOnlineCounter" {
        if !GKLocalPlayer.local.isAuthenticated {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
                gameCenterManager.startOnlineCounter()
            }
        } else {
            gameCenterManager.startOnlineCounter()
        }
    }
}

That's all we need to do! Now, when we tap on the widget, the appropriate action will occur.

Viola! We're done! 🎉

Thanks for taking the time to read this! I spent a fair amount of time building this app, and figured "why not write about it?"

You can follow me on Twitter, where I post... occasionally.

CollabCounter is currently available on TestFlight for your enjoyment 😄

Tagged with: