Achievements bijhouden in een Godot-game
Door Mike Dalton ·
Achievement-tracking legt de mijlpalen vast die spelers in je game bereiken: een level uitspelen, een verborgen item vinden, een eindbaas verslaan. Die gegevens laten zien hoe ver spelers komen, waar ze afhaken en welke content ze nooit vinden.
Om te laten zien hoe dat eruitziet in Godot hebben we de API van Game Stats AI ingebouwd in Dodge the Creeps, de game die je bouwt in de Godot-tutorial Your first 2D game. Het afgeronde project staat in onze voorbeeldrepository. Het gebruikt Godot 4.7 en GDScript.
Stap 1: bepaal wat telt als achievement
Koppel achievements aan momenten die al in je game bestaan. Dodge the Creeps heeft geen levels: de score stijgt met één voor elke seconde dat de speler overleeft. De achievements zijn dus overlevingsmijlpalen na 10, 30 en 60 seconden.
De score wordt al bijgewerkt in main.gd, dus daar komt de aanroep die de achievement ontgrendelt:
const SURVIVAL_ACHIEVEMENTS = {
10: "Survived 10 Seconds",
30: "Survived 30 Seconds",
60: "Survived 60 Seconds",
}
func _on_ScoreTimer_timeout():
score += 1
$HUD.update_score(score)
if score in SURVIVAL_ACHIEVEMENTS:
GameStats.unlock(SURVIVAL_ACHIEVEMENTS[score])
GameStats is de autoload die de rest van deze gids bouwt.
Stap 2: maak een client-token aan
Game Stats AI ontvangt achievement-events op één endpoint:
POST https://gamestats.ai/api/v1/accounts/{account_id}/achievement_events
Authorization: Bearer <token>
Content-Type: application/json
Dit token zit in je game, die spelers downloaden en uitvoeren, dus behandel het als openbaar. Maak een client-token aan, geen server-token. Kies bij Instellingen > API-tokens voor Client
wanneer je het token aanmaakt. Een client-token kan alleen telemetrie versturen, dus een gelekt token kan de rapporten van je account niet lezen.


Sla het account-id en het token op in een bestand game_stats.cfg in de hoofdmap van het project:
[game_stats]
account_id="your_account_id"
api_token="client_your_token"
Door het bestand aan .gitignore toe te voegen blijft het token buiten je repository, maar het blijft zichtbaar voor spelers: het bestand zit in elke geëxporteerde build.
Stap 3: voeg een autoload toe
Een autoload is een node die Godot aanmaakt wanneer de game start en die blijft bestaan als je van scène wisselt. Daardoor is het een goede plek voor code die elke scène kan aanroepen. Maak game_stats.gd aan en registreer het als GameStats via Project > Project Settings > Globals > Autoload. Dat voegt dit toe aan project.godot:
[autoload]
GameStats="*res://game_stats.gd"
In _ready leest de autoload het configuratiebestand en bouwt de URL en headers één keer op. Als het bestand ontbreekt, schakelt de autoload zichzelf uit en draait de game gewoon door:
func _ready():
var config = ConfigFile.new()
if config.load(CONFIG_PATH) != OK:
push_warning("Game Stats AI is disabled: %s is missing." % CONFIG_PATH)
return
var base_url = config.get_value("game_stats", "base_url", "https://gamestats.ai")
var account_id = config.get_value("game_stats", "account_id")
var api_token = config.get_value("game_stats", "api_token")
endpoint_url = "%s/api/v1/accounts/%s/achievement_events" % [base_url, account_id]
headers = PackedStringArray([
"Authorization: Bearer %s" % api_token,
"Content-Type: application/json",
])
version_name = ProjectSettings.get_setting("application/config/version")
load_state()
http_request = HTTPRequest.new()
http_request.timeout = 10.0
http_request.request_completed.connect(_on_request_completed)
add_child(http_request)
enabled = true
send_next_event()
De versie komt uit Project Settings > Application > Config > Version. Werk die bij voordat je elke build uitbrengt, want Game Stats AI groepeert unlocks per versie.
Stap 4: bouw de payload
De body bestaat uit vier verplichte velden:
func unlock(achievement_name):
if not enabled or achievement_name in unlocked_achievements:
return
unlocked_achievements.append(achievement_name)
pending_events.append({
"version_name": version_name,
"player_username": player_username,
"achievement_name": achievement_name,
"occurred_at": Time.get_datetime_string_from_system(true) + "Z",
})
save_state()
send_next_event()
Time.get_datetime_string_from_system(true) geeft de huidige UTC-tijd terug zonder tijdzone-achtervoegsel, dus de Z markeert die als UTC. Achievements, spelers en versies worden aangemaakt bij het eerste gebruik, dus je hoeft niets vooraf te registreren.
Het event krijgt zijn occurred_at één keer, op het moment dat de speler de achievement ontgrendelt, en wordt met dat tijdstempel opgeslagen. Game Stats AI negeert een unlock die al ontvangen is alleen als speler, achievement, versie en occurred_at allemaal overeenkomen. Een nieuwe poging die de payload opnieuw opbouwt met een nieuw tijdstempel zou een tweede unlock vastleggen.
Stap 5: verstuur events met een HTTPRequest-node
HTTPRequest is een node die HTTP- en HTTPS-verzoeken doet zonder de game te blokkeren. Je roept request aan, de game blijft draaien en de node zendt request_completed uit wanneer het antwoord binnenkomt.
Elke HTTPRequest-node verwerkt één verzoek tegelijk, dus de autoload verstuurt het eerste wachtende event en wacht op het antwoord voordat hij het volgende verstuurt:
func send_next_event():
if request_in_flight or pending_events.is_empty():
return
var body = JSON.stringify(pending_events[0])
if http_request.request(endpoint_url, headers, HTTPClient.METHOD_POST, body) == OK:
request_in_flight = true
else:
retry_later()
func _on_request_completed(result, response_code, _headers, body):
request_in_flight = false
if result == HTTPRequest.RESULT_SUCCESS and response_code == 201:
pending_events.pop_front()
elif result == HTTPRequest.RESULT_SUCCESS and response_code == 422:
push_warning("Game Stats AI rejected an event: %s" % body.get_string_from_utf8())
pending_events.pop_front()
else:
retry_later()
return
save_state()
send_next_event()
func retry_later():
get_tree().create_timer(RETRY_DELAY_SECONDS).timeout.connect(send_next_event)
Een 201 betekent dat het event is geaccepteerd. Een 422 betekent dat de payload zelf ongeldig is, bijvoorbeeld {"errors":["Version name can't be blank"]}, dus opnieuw versturen helpt niet: log het en ga door met het volgende. Elk ander resultaat, ook helemaal geen verbinding, laat het event vooraan in de wachtrij staan en probeert het na 30 seconden opnieuw.
Stap 6: bewaar unlocks tussen sessies
De autoload bewaart drie dingen in user://game_stats_state.cfg: een gegenereerde spelersnaam, de achievements die al ontgrendeld zijn en de events die nog niet verstuurd zijn.
func load_state():
var state = ConfigFile.new()
state.load(STATE_PATH)
player_username = state.get_value("player", "username", "")
if player_username.is_empty():
player_username = "player_" + Crypto.new().generate_random_bytes(6).hex_encode()
unlocked_achievements = state.get_value("achievements", "unlocked", [])
pending_events = state.get_value("achievements", "pending", [])
save_state()
func save_state():
var state = ConfigFile.new()
state.set_value("player", "username", player_username)
state.set_value("achievements", "unlocked", unlocked_achievements)
state.set_value("achievements", "pending", pending_events)
state.save(STATE_PATH)
Door de lijst met ontgrendelde achievements wordt elke achievement één keer per speler verstuurd, niet één keer per potje. Door de lijst met wachtende events gaat een unlock die offline is behaald niet verloren: die wordt met het oorspronkelijke tijdstempel verstuurd zodra de game weer start. Heeft je game al spelersaccounts, gebruik dan dat id voor player_username in plaats van een gegenereerde naam.
Stap 7: neem het configuratiebestand op in exports
Godot exporteert standaard alleen resourcebestanden, en game_stats.cfg is geen resource. Zonder extra stap vindt de geëxporteerde game het bestand niet en schakelt de integratie zichzelf uit.
Open in Project > Export het tabblad Resources van elke exportpreset en vul game_stats.cfg in bij Filters to export non-resource files/folders.
Iedereen die je game downloadt, kan dit bestand lezen. Daarom moet er een client-token in staan en nooit een server-token.
De volledige integratie staat in de voorbeeldrepository.