GameKit Patterns
Advanced GameKit patterns for player identity verification, legacy voice chat, saved games, custom matchmaking UI, leaderboard images, challenge handling, and rule-based matchmaking.
Contents
- Server-Side Identity Verification
- Voice Chat
- Saved Games
- Custom Matchmaking UI
- Leaderboard Images and Sets
- Challenge Handling
- Rule-Based Matchmaking
- Player Groups and Attributes
- Hosted Matches
- Turn-Based Data Exchanges
- Friend Management
- Nearby Player Discovery
- SharePlay Integration
Server-Side Identity Verification
Verify the local player on a backend server using a cryptographic signature from the async identity-verification API:
enum GameKitIdentityError: Error {
case missingBundleIdentifier
}
func verifyPlayerOnServer() async throws {
let (publicKeyURL, signature, salt, timestamp) =
try await GKLocalPlayer.local.fetchItemsForIdentityVerificationSignature()
// Send these values plus the bundle ID and a scoped player identifier.
// Use teamPlayerID for most games, or gamePlayerID for Apple Arcade games.
let playerID = GKLocalPlayer.local.teamPlayerID
guard let bundleID = Bundle.main.bundleIdentifier else {
throw GameKitIdentityError.missingBundleIdentifier
}
sendToServer(publicKeyURL, signature, salt, timestamp, playerID, bundleID)
}The server fetches the public key from the URL Apple provides, then verifies
that Apple signed it. Verify the signature over this byte sequence:
teamPlayerID (or Apple Arcade gamePlayerID) as UTF-8, bundle ID as UTF-8,
timestamp as big-endian UInt64, then salt. Reject stale timestamps and trust
only fields covered by the signature.
Voice Chat
GKVoiceChat is deprecated. Prefer SharePlay for new voice or social audio
work. Keep this section only for maintaining existing GameKit voice chat.
Each named channel supports independent volume and mute controls.
Prerequisites
Add NSMicrophoneUsageDescription to Info.plist, activate an audio session, and
check GKVoiceChat.isVoIPAllowed() before creating channels.
import AVFoundation
func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setActive(true)
}Creating and Starting a Channel
Create voice chat channels from a GKMatch object:
func startVoiceChat(in match: GKMatch) {
guard GKVoiceChat.isVoIPAllowed() else { return }
guard let voiceChat = match.voiceChat(withName: "teamChat") else { return }
voiceChat.volume = 0.8
voiceChat.start()
voiceChat.isActive = true
voiceChat.playerVoiceChatStateDidChangeHandler = { player, state in
switch state {
case .connected:
print("\(player.displayName) joined voice chat")
case .disconnected:
print("\(player.displayName) left voice chat")
case .speaking:
// Update UI to show speaking indicator
self.showSpeakingIndicator(for: player)
case .silent:
self.hideSpeakingIndicator(for: player)
case .connecting:
break
@unknown default:
break
}
}
}Multiple Channels
Create separate channels for different purposes, such as team chat and global chat. A player can only have their microphone active in one channel at a time:
let teamChat = match.voiceChat(withName: "team")
let allChat = match.voiceChat(withName: "all")
// Start both but activate only one microphone at a time
teamChat?.start()
allChat?.start()
teamChat?.isActive = true
allChat?.isActive = false
// Switch active channel
func switchToAllChat() {
teamChat?.isActive = false
allChat?.isActive = true
}Muting Players
func mutePlayer(_ player: GKPlayer, in voiceChat: GKVoiceChat) {
voiceChat.setPlayer(player, muted: true)
}
func unmutePlayer(_ player: GKPlayer, in voiceChat: GKVoiceChat) {
voiceChat.setPlayer(player, muted: false)
}Stopping Voice Chat
func stopVoiceChat(_ voiceChat: GKVoiceChat) {
voiceChat.isActive = false
voiceChat.stop()
}Saved Games
GameKit stores game data in the player's iCloud account, accessible from devices
using the same Game Center account. The player must have an iCloud account and
iCloud Drive enabled, and the app needs the iCloud capability with an iCloud
container identifier. Saved games are managed through GKLocalPlayer and
represented by GKSavedGame.
Saving Game Data
Encode game state and save with a descriptive name:
func saveGame(state: GameState, name: String) async throws {
let data = try JSONEncoder().encode(state)
try await GKLocalPlayer.local.saveGameData(data, withName: name)
}Saving with an existing filename overwrites that file. Use unique filenames for multiple save slots. Duplicate filenames from multiple devices are conflicts that your game must resolve.
Fetching Saved Games
func fetchSavedGames() async throws -> [GKSavedGame] {
try await GKLocalPlayer.local.fetchSavedGames()
}Loading Saved Game Data
func loadSavedGame(_ savedGame: GKSavedGame) async throws -> GameState {
let data = try await savedGame.loadData()
return try JSONDecoder().decode(GameState.self, from: data)
}GKSavedGame properties: name, modificationDate, deviceName.
Resolving Conflicts
When the same save name exists from multiple devices, GameKit may report conflicts. Resolve them by choosing the authoritative data:
func resolveConflicts(_ conflicts: [GKSavedGame], using data: Data) async throws {
try await GKLocalPlayer.local.resolveConflictingSavedGames(
conflicts, with: data
)
}Listening for Saved Game Events
Implement GKSavedGameListener, or GKLocalPlayerListener if the same object
handles multiple Game Center events, to respond to save events from other
devices:
extension GameManager: GKSavedGameListener {
func player(_ player: GKPlayer, didModifySavedGame savedGame: GKSavedGame) {
// Another device modified a save. Refresh local data.
Task { await refreshSavedGames() }
}
func player(_ player: GKPlayer,
hasConflictingSavedGames savedGames: [GKSavedGame]) {
// Resolve conflicts using game-specific merge logic.
Task { await resolveConflictingGames(savedGames) }
}
}Register the listener:
GKLocalPlayer.local.register(gameManager)Deleting Saved Games
func deleteSavedGame(name: String) async throws {
try await GKLocalPlayer.local.deleteSavedGames(withName: name)
}Custom Matchmaking UI
Build a custom interface for finding players instead of using
GKMatchmakerViewController. Use GKMatchmaker directly.
Finding a Match Programmatically
actor MatchManager {
private var currentMatch: GKMatch?
func findMatch(minPlayers: Int, maxPlayers: Int) async throws -> GKMatch {
let request = GKMatchRequest()
request.minPlayers = minPlayers
request.maxPlayers = maxPlayers
let match = try await GKMatchmaker.shared().findMatch(for: request)
GKMatchmaker.shared().finishMatchmaking(for: match)
currentMatch = match
return match
}
func cancelMatchmaking() {
GKMatchmaker.shared().cancel()
}
}Adding Players to an Existing Match
func addPlayers(to match: GKMatch, request: GKMatchRequest) async throws {
try await GKMatchmaker.shared().addPlayers(
to: match,
matchRequest: request
)
}Querying Matchmaking Activity
Check how many players are currently looking for matches:
func checkActivity() async throws -> Int {
try await GKMatchmaker.shared().queryActivity()
}
func checkGroupActivity(group: Int) async throws -> Int {
try await GKMatchmaker.shared().queryPlayerGroupActivity(group)
}Inviting Specific Players
func invitePlayers(_ players: [GKPlayer]) async throws -> GKMatch {
let request = GKMatchRequest()
request.minPlayers = 2
request.maxPlayers = 4
request.recipients = players
request.inviteMessage = "Play a round?"
request.recipientResponseHandler = { player, response in
switch response {
case .accepted:
print("\(player.displayName) accepted")
case .declined:
print("\(player.displayName) declined")
default:
break
}
}
return try await GKMatchmaker.shared().findMatch(for: request)
}Leaderboard Images and Sets
Loading Leaderboard Images
Leaderboard images configured in App Store Connect are not loaded with the leaderboard data. Fetch them separately:
func loadLeaderboardImage(leaderboardID: String) async throws -> UIImage? {
let leaderboards = try await GKLeaderboard.loadLeaderboards(
IDs: [leaderboardID]
)
guard let leaderboard = leaderboards.first else { return nil }
return try await leaderboard.loadImage()
}Leaderboard Sets
Leaderboard sets group related leaderboards together. Load sets and then load the leaderboards within each set:
func loadLeaderboardSets() async throws -> [GKLeaderboardSet] {
try await GKLeaderboardSet.loadLeaderboardSets()
}
func loadLeaderboards(in set: GKLeaderboardSet) async throws -> [GKLeaderboard] {
try await set.loadLeaderboards()
}Leaderboard Entry Properties
GKLeaderboard.Entry provides these properties for display:
func displayEntry(_ entry: GKLeaderboard.Entry) {
let playerName = entry.player.displayName
let rank = entry.rank
let score = entry.score
let formatted = entry.formattedScore
let context = entry.context // Game-defined value submitted with the score
let date = entry.date
}Submitting Scores with Context
Use context to store additional metadata with a score, such as the level
where the score was achieved:
try await GKLeaderboard.submitScore(
score,
context: levelID,
player: GKLocalPlayer.local,
leaderboardIDs: ["com.mygame.scores"]
)Challenge Handling
Players can challenge friends to beat their scores or complete achievements.
Achievement Challenges
func challengeFriends(
achievementID: String,
message: String,
players: [GKPlayer]
) {
let achievement = GKAchievement(identifier: achievementID)
let vc = achievement.challengeComposeController(
withMessage: message,
players: players
) { composeVC, issued, sentPlayers in
composeVC.dismiss(animated: true)
}
present(vc, animated: true)
}Finding Challengeable Players
func loadChallengeableFriends() async throws -> [GKPlayer] {
try await GKLocalPlayer.local.loadChallengableFriends()
}Selecting Players Who Can Earn an Achievement
Filter players who haven't already completed an achievement:
func findEligiblePlayers(
for achievementID: String,
from players: [GKPlayer]
) async throws -> [GKPlayer] {
let achievement = GKAchievement(identifier: achievementID)
return try await achievement.selectChallengeablePlayers(players)
}Opening the Challenges View
GKAccessPoint.shared.triggerForChallenges { }Rule-Based Matchmaking
Configure matchmaking rules in App Store Connect to refine player matching
based on game-specific criteria. Rules evaluate player properties to determine
compatible matches. queueName, properties, recipientProperties,
GKMatch.properties, and GKMatch.playerProperties require iOS 17.2+ or the
equivalent platform releases.
queueName must be a case-sensitive reverse-DNS-style identifier using only
letters, numbers, hyphens, and periods. properties and recipientProperties
must be JSON-serializable, and the key gc is reserved by GameKit.
Setting Up Rule-Based Matching
func findRuleBasedMatch(skill: Int, region: String) async throws -> GKMatch {
let request = GKMatchRequest()
request.minPlayers = 2
request.maxPlayers = 4
request.queueName = "com.mygame.competitive"
request.properties = [
"skill": skill,
"region": region
]
let match = try await GKMatchmaker.shared().findMatch(for: request)
GKMatchmaker.shared().finishMatchmaking(for: match)
return match
}Accessing Match Properties
After a match is found, read the properties that matchmaking rules evaluated:
func inspectMatchProperties(_ match: GKMatch) {
// Local player's properties (includes rule additions)
let myProps = match.properties
// Other players' properties
for (player, props) in match.playerProperties ?? [:] {
print("\(player.displayName): \(props)")
}
}Rule-Based Matching with Invited Players
Set properties for invited recipients. Every key in recipientProperties must
also be present in recipients.
func inviteWithRules(
players: [GKPlayer],
properties: [String: Any]
) async throws -> GKMatch {
let request = GKMatchRequest()
request.minPlayers = 2
request.maxPlayers = 4
request.queueName = "com.mygame.competitive"
request.recipients = players
request.properties = properties
var recipientProps: [GKPlayer: [String: Any]] = [:]
for player in players {
recipientProps[player] = ["skill": 1000] // Default skill for invitees
}
request.recipientProperties = recipientProps
return try await GKMatchmaker.shared().findMatch(for: request)
}When queueName is set, playerGroup and playerAttributes are ignored.
Player Groups and Attributes
Use player groups and attributes for simple matchmaking without rules.
Player Groups
Restrict matching to players in the same group. Groups are identified by an integer value:
let request = GKMatchRequest()
request.minPlayers = 2
request.maxPlayers = 4
request.playerGroup = 42 // Only matches players in group 42Use groups to separate players by game mode, difficulty, or map.
Player Attributes
Use playerAttributes only for simple non-rule-based matchmaking. If the value
is nonzero, GameKit tries to combine players so the bitwise OR of all
participants' masks equals 0xFFFFFFFF:
let attackerMask: UInt32 = 0x000000FF
let defenderMask: UInt32 = 0x0000FF00
let supportMask: UInt32 = 0xFFFF0000
let request = GKMatchRequest()
request.minPlayers = 3
request.maxPlayers = 3
request.playerAttributes = attackerMaskUse matchmaking rules for modern skill, region, version, party-code, and team
assignment logic. When queueName is set, GameKit ignores playerGroup and
playerAttributes.
Hosted Matches
For server-hosted games, use GKMatchmaker to find players but handle
networking through your own infrastructure.
func findPlayersForHostedMatch() async throws -> [GKPlayer] {
let request = GKMatchRequest()
request.minPlayers = 2
request.maxPlayers = 8
let players = try await GKMatchmaker.shared().findPlayers(
forHostedRequest: request
)
// Connect players through your game server
return players
}Hosted Match with Matchmaking Rules
func findPlayersWithRules() async throws -> GKMatchedPlayers {
let request = GKMatchRequest()
request.minPlayers = 2
request.maxPlayers = 8
request.queueName = "com.mygame.ranked"
request.properties = ["elo": 1500]
let matchedPlayers = try await GKMatchmaker.shared().findMatchedPlayers(
request
)
// matchedPlayers.players - the matched players
// matchedPlayers.properties - the local player's properties
// matchedPlayers.playerProperties - other players' properties
return matchedPlayers
}Turn-Based Data Exchanges
Exchange data between participants in a turn-based match without waiting for turns. Useful for trading items, sending gifts, or requesting actions.
Sending an Exchange
enum TurnExchangeError: Error {
case dataTooLarge
}
func sendExchange(
match: GKTurnBasedMatch,
to recipients: [GKTurnBasedParticipant],
data: Data
) async throws -> GKTurnBasedExchange {
guard data.count <= match.exchangeDataMaximumSize else {
throw TurnExchangeError.dataTooLarge
}
try await match.sendExchange(
to: recipients,
data: data,
localizableMessageKey: "EXCHANGE_REQUEST",
arguments: [],
timeout: GKExchangeTimeoutDefault
)
}Handling Exchange Events
extension GameManager: GKTurnBasedEventListener {
func player(
_ player: GKPlayer,
receivedExchangeRequest exchange: GKTurnBasedExchange,
for match: GKTurnBasedMatch
) {
// Process the exchange request and reply
let responseData = buildResponse(for: exchange)
Task {
try await exchange.reply(
withLocalizableMessageKey: "EXCHANGE_REPLY",
arguments: [],
data: responseData
)
}
}
func player(
_ player: GKPlayer,
receivedExchangeReplies replies: [GKTurnBasedExchangeReply],
forCompletedExchange exchange: GKTurnBasedExchange,
for match: GKTurnBasedMatch
) {
// All recipients replied. Merge exchange data into match state.
Task {
let mergedData = mergeExchangeData(exchange, replies: replies)
try await match.saveMergedMatch(
mergedData,
withResolvedExchanges: [exchange]
)
}
}
}Exchange Limits
exchangeDataMaximumSize: maximum size per exchange payloadexchangeMaxInitiatedExchangesPerPlayer: maximum concurrent outgoing exchanges
Ending a Turn-Based Match with Scores
Submit leaderboard scores and achievements when the match ends:
func endMatchWithScores(
match: GKTurnBasedMatch,
data: Data,
scores: [GKLeaderboardScore],
achievements: [GKAchievement]
) async throws {
for participant in match.participants {
participant.matchOutcome = determineOutcome(for: participant)
}
try await match.endMatchInTurn(
withMatch: data,
leaderboardScores: scores,
achievements: achievements
)
}Friend Management
Loading Friends
Requires the NSGKFriendListUsageDescription key in Info.plist:
func loadFriends() async throws -> [GKPlayer] {
let status = try await GKLocalPlayer.local.loadFriendsAuthorizationStatus()
guard status == .authorized else { return [] }
return try await GKLocalPlayer.local.loadFriends()
}Presenting Friend Request UI
func sendFriendRequest(from viewController: UIViewController) async {
guard !GKLocalPlayer.local.isPresentingFriendRequestViewController else {
return
}
try? await GKLocalPlayer.local.presentFriendRequestCreator(
from: viewController
)
}Loading Recent Players
func loadRecentPlayers() async throws -> [GKPlayer] {
try await GKLocalPlayer.local.loadRecentPlayers()
}Nearby Player Discovery
Find players on the same local network or via Bluetooth for local multiplayer:
func startBrowsingForNearbyPlayers() {
GKMatchmaker.shared().startBrowsingForNearbyPlayers { player, reachable in
if reachable {
self.addNearbyPlayer(player)
} else {
self.removeNearbyPlayer(player)
}
}
}
func stopBrowsing() {
GKMatchmaker.shared().stopBrowsingForNearbyPlayers()
}SharePlay Integration
Use GameKit's SharePlay bridge when a FaceTime or Messages SharePlay session should add players to a GameKit match. Keep full GroupActivities session design outside this GameKit reference.
func startSharePlayMatch() {
GKMatchmaker.shared().startGroupActivity { player in
// A player from the FaceTime call joined.
// Connect them to the game session.
self.addSharePlayPlayer(player)
}
}
func stopSharePlayMatch() {
GKMatchmaker.shared().stopGroupActivity()
}This creates a group activity on behalf of the player. Combine with GroupActivities framework for full SharePlay integration in your game UI.