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 😄