Using Apple’s GameKit APIs with Godot

This is a quick guide on using the APIs in this Godot addon to access Apple’s GameKit APIs. For an overview of what you can do with GameKit, check [Apple’s GameKit Documentation](https://developer.apple.com/documentation/gamekit/)

One of the design choices in this binding has been to surface the same class names that Apple uses for their own data types to simplify looking things up and finding resources online. The method names on the other hand reflect the Godot naming scheme.

So instead of calling loadPhoto on GKPlayer, you would use the load_photo method. And instead of the property gamePlayerID, you would access game_player_id.

Table of Contents

Installation

Installing in your project

Make sure that you have added the directory containing the “GodotApplePlugins” to your project, it should contain both a godot_apple_plugins.gdextension and a bin directory with the native libraries that you will use.

The APIs have been exposed to both MacOS and iOS, so you can iterate quickly on your projects.

Entitlements

For your software to be able to use the GameKit APIs, you will need your Godot engine to have the com.apple.developer.game-center entitlements. The easiest way to do this is to use Xcode to add the entitlement to your iOS project.

See the file Entitlements for additional directions * without this, calling the APIs won’t do much.

Authentication

Create an instance of GameCenterManager, and then you can connect to the authentication_error and authentication_result signals to track the authentication state.

Then call the authenticate() method to trigger the authentication:

var game_center: GameCenterManager

func _ready() -> void:
    game_center = GameCenterManager.new()

    game_center.authentication_error.connect(func(error: String) -> void:
            print("Received error %s" % error)
    )
    game_center.authentication_result.connect(func(status: bool) -> void:
            print("Authentication updated, status: %s" % status
    )

    game_center.authenticate()

Players

Fetch the Local Player

From the GameCenterManager instance, you can access local_player, which is a GKLocalPlayer. GKLocalPlayer is a subclass of GKPlayer and represents the player using your game, with properties that track the local player state.

var local: GKLocalPlayer

func _ready() -> void:
    gameCenter = GameCenterManager.new()
    local = gameCenter.local_player
    print("ONREADY: local, is auth: %s" % local.is_authenticated)
    print("ONREADY: local, player ID: %s" % local.game_player_id)

There are a number of interesting properties in local_player that you might want to use in your game like is_authenticated, is_underage, is_multiplayer_gaming_restricted and so on.

GKPlayer

This is the base class for a player, either the local one or friends and contains properties and methods that are common to both

Apple Documentation:

Loading a Player Photo

# Here, we put the image inside an existing TextureRect, named $texture_rect:
local_player.load_photo(true, func(image: Image, error: Variant)->void:
    if error == null:
            $texture_rect.texture = ImageTexture.create_from_image(image)
)

Friends

# Loads the local player's friends list if the local player and their friends grant access.
local_player.load_friends(func(friends: Array[GKPlayer], error: Variant)->void:
    if error:
            print(error)
    else:
            for friend in friends:
                    print(friend.display_name)
)

# Loads players to whom the local player can issue a challenge.
local_player.local.load_challengeable_friends(func(friends: Array[GKPlayer], error: Variant)->void:
    if error:
        print(error)
    else:
        for friend in friends:
            print(friend.display_name)
)

# Loads players from the friends list or players that recently participated in a game with the local player.
local_player.load_recent_friends(func(friends: Array[GKPlayer], error: Variant)->void:
    if error:
        print(error)
    else:
        for friend in friends:
            print(friend.display_name)
)

FetchItemsForIdentityVerificationSignature

local_player.fetch_items_for_identity_verification_signature(func(values: Dictionary, error: Variant)->void:
    if error:
        print(error)
    else:
        print("Identity dictionary")
        print(values)
)

Saved Games

Handling Conflicts

When multiple devices save games with the same name, conflicts can occur. You can handle these conflicts by registering a listener and implementing the resolution logic.

var game_center: GameCenterManager
var local: GKLocalPlayer

func _ready() -> void:
    game_center = GameCenterManager.new()
    local = game_center.local_player

    # Register the listener to receive conflict signals
    local.register_listener()

    local.conflicting_saved_games.connect(_on_conflicting_saved_games)

func _on_conflicting_saved_games(player: GKPlayer, conflicts: Array) -> void:
    print("Received conflict for player: %s" % player.alias)

    # Logic to determine which data to keep (e.g., newest, highest score, or user choice)
    # For this example, we assume we want to keep the data from the first conflicting save.

    var chosen_save = conflicts[0] as GKSavedGame

    chosen_save.load_data(func(data: PackedByteArray, error: Variant) -> void:
        if error:
            print("Error loading data: %s" % error)
            return

        # Resolve the conflict using the chosen data
        local.resolve_conflicting_saved_games(conflicts, data, func(saved_games: Array[GKSavedGame], error: Variant) -> void:
            if error:
                print("Error resolving conflict: %s" % error)
            else:
                print("Conflict resolved!")
        )
    )

Saved Game Modifications

You can also listen for modifications to saved games (e.g., from other devices).

func _ready() -> void:
    # ... setup local player ...
    local.saved_game_modified.connect(_on_saved_game_modified)

func _on_saved_game_modified(player: GKPlayer, saved_game: GKSavedGame) -> void:
    print("Saved game modified: %s" % saved_game.name)
    # Reload data or update UI

Achievements

List all achievements

Note: This only returns achievements with progress that the player has reported. Use GKAchievementDescription for a list of all available achievements.

GKAchievement.load_achievements(func(achievements: Array[GKAchievement], error: Variant)->void:
    if error:
        print("Load achievement error %s" % error)
    else:
        for achievement in achievements:
            print("Achievement: %s" % achievement.identifier)
)

List Descriptions

GKAchievementDescription.load_achievement_descriptions(func(adescs: Array[GKAchievementDescription], error: Variant)->void:
    if error:
        print("Load achievement description error %s" % error)
    else:
        for adesc in adescs:
            print("Achievement Description ID: %s" % adesc.identifier)
            print("    Unachieved: %s" % adesc.unachieved_description)
            print("    Achieved: %s" % adesc.achieved_description)
)

Load Achievement Description Image

adesc.load_image(func(image: Image, error: Variant)->void:
    if error == null:
        $texture_rect.texture = ImageTexture.create_from_image(image)
    else:
        print("Error loading achievement image %s" % error)

Report Progress

Reporting Achievement First Time

var id = "a001"
var percentage = 100

GKAchievementDescription.load_achievement_descriptions(func(descriptions: Array[GKAchievementDescription], error: Variant)->void:
    if error:
        print("Load achievement descriptions error %s" % error)
    else:
        for desc in descriptions:
            if desc.identifier == id:
                var new_achievement := GKAchievement.new()
                new_achievement.identifier = desc.identifier
                new_achievement.percent_complete = percentage

                GKAchievement.report_achievement([new_achievement], func(error: Variant)->void:
                    if error:
                        print("Error submitting achievement")
                    else:
                        print("Success!")
                )
)

Updating Already Reported Achievement

var id = "a001"
var percentage = 100

GKAchievement.load_achievements(func(achievements: Array[GKAchievement], error: Variant)->void:
    if error:
        print("Load achievement error %s" % error)
    else:
        for achievement in achievements:
            if achievement.identifier == id:
                if not achievement.is_completed:
                    achievement.percent_complete = percentage
                    achievement.show_completion_banner = true
                GKAchievement.report_achievement([achievement], func(error: Variant)->void:
                    if error:
                        print("Error submitting achievement")
                    else:
                        print("Success!")
                )
)

Reset All Achievements

GKAchievement.reset_achievements(func(error: Variant)->void:
    if error:
        print("Error resetting" % error)
    else:
        print("Success")
)

Realtime Matchmaking

Events

You can use the convenience request_match method after configuring your request, and on your callback setup the game_match to track the various states of the match, like this:

var req = GKMatchRequest.new()
req.max_players = 2
req.min_players = 1
req.invite_message = "Join me in a quest to fun"
GKMatchmakerViewController.request_match(req, func(game_match: GKMatch, error: Variant)->void:
    if error:
        print("Could not request a match %s" % error)
    else:
        print("Got a match!")

        game_match.data_received.connect(func (data: PackedByteArray, from_player: GKPlayer)->void:
            print("Received data from Player")
        )
        game_match.data_received_for_recipient_from_player.connect(func(data: PackedByteArray, for_recipient: GKPlayer, from_remote_player: GKPlayer)->void:
            print("Received data from a player to another player")
        )
        gameMatch.did_fail_with_error.connect(func(error: String)->void:
            print("Match failed with %s" % error)
        )
        gameMatch.should_reinvite_disconnected_player = (func(player: GKPlayer)->bool:
            # We always reinvite
            return true
        )
        gameMatch.player_changed.connect(func(player: GKPlayer, connected: bool)->void:
            print("Status of player changed to %s" % connected)
        )
)

Disconnect

game_match.disconnect()

Send to all

var data = "How do you do fellow kids".to_utf8_buffer()
game_match.send_data_to_all_players(data, GKMatch.SendDataMode.reliable)

Send to Players

game_match.send(array, [first_player, second_player], GKMatch.SendDataMode.reliable)

Rule-Based Matchmaking Properties (iOS/macOS 17.2+)

You can attach properties to GKMatchRequest to let Game Center use rule-based matching data, and inspect those values from GKMatch once a match is created.

var request := GKMatchRequest.new()
request.min_players = 2
request.max_players = 2
request.queue_name = "ranked_duo"
request.properties = {
    "region": "us-east",
    "skill_bucket": 12,
    "mode": "ranked"
}

# Keys can be a player's game_player_id (or GKPlayer object).
request.recipient_properties = {
    friend_player.game_player_id: {"role": "support"}
}

On the resulting GKMatch:

print(game_match.properties)
print(game_match.player_properties) # Dictionary keyed by game_player_id

Invite Acceptance Event

GKLocalPlayer now emits an invite_accepted signal when an invite is accepted. Use it to immediately resolve to a GKMatch.

var local := game_center.local_player
var matchmaker := GKMatchmaker.new()

func _ready() -> void:
    local.register_listener()
    local.invite_accepted.connect(func(player: GKPlayer, invite: GKInvite) -> void:
        matchmaker.match_for_invite(invite, func(match: GKMatch, error: Variant) -> void:
            if error:
                print("Invite match error: %s" % error)
            else:
                print("Joined invited match")
        )
    )

Turn-Based Matchmaking

Use GKTurnBasedMatch for asynchronous turn-based sessions, and register a GKLocalPlayer listener to receive turn-based events.

var local := game_center.local_player

func _ready() -> void:
    local.register_listener()

    local.turn_event_received.connect(func(player: GKPlayer, match: GKTurnBasedMatch, did_become_active: bool) -> void:
        print("Turn event for match %s (active=%s)" % [match.match_id, did_become_active])
    )

    local.match_requested_with_other_players.connect(func(player: GKPlayer, recipients: Array) -> void:
        print("Turn-based request with %d recipients" % recipients.size())
    )

    local.turn_based_match_ended.connect(func(player: GKPlayer, match: GKTurnBasedMatch) -> void:
        print("Turn-based match ended: %s" % match.match_id)
    )

Create/find and advance a turn:

var request := GKMatchRequest.new()
request.min_players = 2
request.max_players = 2

GKTurnBasedMatch.find(request, func(match: GKTurnBasedMatch, error: Variant) -> void:
    if error:
        print("Find turn-based match failed: %s" % error)
        return

    match.load_match_data(func(data: PackedByteArray, load_error: Variant) -> void:
        if load_error:
            print("Load match data error: %s" % load_error)
            return

        var updated_data := "next turn payload".to_utf8_buffer()
        var next_participants: Array = match.participants
        match.end_turn(
            next_participants,
            604800.0, # one week default timeout
            updated_data,
            func(end_error: Variant) -> void:
                if end_error:
                    print("End turn error: %s" % end_error)
        )
    )
)

Exchange-related signals: * exchange_received(player, exchange, match) * exchange_canceled(player, exchange, match) * exchange_completed(player, replies, match) * player_wants_to_quit_match(player, match)

Turn-Based Matchmaker UI

You can present Apple’s built-in turn-based matchmaking UI and handle its delegate callbacks through signals.

var request := GKMatchRequest.new()
request.min_players = 2
request.max_players = 2

var controller := GKTurnBasedMatchmakerViewController.create_controller(request)
controller.did_find_match.connect(func(match: GKTurnBasedMatch) -> void:
    print("Found turn-based match %s" % match.match_id)
)
controller.cancelled.connect(func(_: String) -> void:
    print("Turn-based matchmaking cancelled")
)
controller.failed_with_error.connect(func(message: String) -> void:
    print("Turn-based matchmaking failed: %s" % message)
)
controller.present()

Challenges

GKLocalPlayer now emits challenge-related signals once you call register_listener().

local.challenge_received.connect(func(player: GKPlayer, challenge: GKChallenge) -> void:
    print("Challenge from: %s" % (challenge.issuing_player.display_name if challenge.issuing_player else "unknown"))
    print("Message: %s" % challenge.message)
)

local.challenge_completed.connect(func(player: GKPlayer, challenge: GKChallenge, friend_player: GKPlayer) -> void:
    print("Challenge completed by %s" % friend_player.display_name)
)

Load currently received challenges:

GKChallenge.load_received_challenges(func(challenges: Array, error: Variant) -> void:
    if error:
        print("Load challenges error: %s" % error)
    else:
        for challenge in challenges:
            print("Challenge type: %s state: %s" % [challenge.challenge_type, challenge.state])
)

Leaderboards

Report Score

GKLeaderboard.load_leaderboards(PackedStringArray(["MyLeaderboard"]), func(leaderboards: Array [GKLeaderboard], error: Variant)->void:
    var score = 100
    var context = 0

    if error:
        print("Error loading leaderboard %s" % error)
    else:
        leaderboards[0].submit_score(score, context, local, func(error: Variant)->void:
            if error:
                print("Error submitting leadeboard %s" % error)
    )
)

Load Leaderboards

# Loads all leaderboards
GKLeaderboard.load_leaderboards(PackedStringArray(), func(leaderboards: Array [GKLeaderboard], error: Variant)->void:
    if error:
        print("Error loading leaderboards %s" % error)
    else:
        print("Got %s" % leaderboards)
)

# Load specific ones
GKLeaderboard.load_leaderboards(PackedStringArray(["My leaderboard"]), func(leaderboards: Array [GKLeaderboard], error: Variant)->void:
    if error:
        print("Error loading leaderboard %s" % error)
    else:
        print("Got %s" % leaderboards)
)

Load Scores