Mark
Prijzen Aanmelden
← Alle artikelen

Achievements bijhouden in een Unity-game

Mike Dalton Door Mike Dalton ·

Een desktopgamevenster met een blauw vierkant en een gouden bol, en een pijl naar een dashboardkaart met een trofee en een staafgrafiek.

Achievement-tracking legt de mijlpalen vast die spelers in je game bereiken, zoals een hoge score, een uitgespeeld level of een gevonden geheim. In een Unity-game verstuurt een script op de computer van de speler elke mijlpaal naar een API terwijl de game blijft draaien.

Om te laten zien hoe dat eruitziet, hebben we Orb Rush gebouwd, een kleine Unity-game waarin de speler 30 seconden heeft om zoveel mogelijk bollen te verzamelen, en hebben we de API van Game Stats AI erin ingebouwd. Het afgeronde project staat in onze voorbeeldrepository. Het gebruikt Unity 6000.5 en C#, en is bedoeld voor desktopbuilds. Overweeg je ook de eigen dienst van Unity, lees dan Unity Analytics in 2026: prijzen, limieten en alternatieven.

Stap 1: bepaal wat telt als achievement

Koppel achievements aan momenten die al in je game bestaan. In Orb Rush stijgt de score met één voor elke bol die de speler verzamelt, dus de achievements zijn het verzamelen van 10, 20 en 30 bollen in één ronde.

Het gamescript verwerkt al een verzamelde bol, dus daar komt de aanroep die de achievement ontgrendelt:

static readonly Dictionary<int, string> OrbAchievements = new()
{
    { 10, "Collected 10 Orbs" },
    { 20, "Collected 20 Orbs" },
    { 30, "Collected 30 Orbs" },
};

void CollectOrb()
{
    orbsCollected += 1;
    if (OrbAchievements.TryGetValue(orbsCollected, out var achievementName))
    {
        GameStats.Instance.Unlock(achievementName);
    }
    MoveOrb();
}

GameStats is de component 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.

Het scherm voor een nieuw API-token, met een naamveld en een keuze voor tokentype Client of Server

Sla het account-id en het token op in Assets/StreamingAssets/game_stats.json:

{
  "account_id": "your_account_id",
  "api_token": "client_your_token"
}

Voeg het bestand en het bijbehorende .meta-bestand toe aan .gitignore om het token buiten je repository te houden. Stap 7 legt uit waarom spelers het toch kunnen lezen.

Stap 3: voeg een GameStats-component toe die blijft bestaan

Unity roept een statische methode met [RuntimeInitializeOnLoadMethod] aan wanneer de game start. Met BeforeSceneLoad draait die voordat de eerste scène laadt, dus GameStats maakt zijn eigen GameObject aan en geen enkele scène hoeft het te bevatten. DontDestroyOnLoad houdt dat GameObject in leven als je van scène wisselt, zodat elk script GameStats.Instance kan aanroepen:

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void CreateInstance()
{
    var gameObject = new GameObject("GameStats");
    DontDestroyOnLoad(gameObject);
    Instance = gameObject.AddComponent<GameStats>();
}

void Awake()
{
    var configPath = Path.Combine(Application.streamingAssetsPath, ConfigFileName);
    if (!File.Exists(configPath))
    {
        Debug.LogWarning($"Game Stats AI is disabled: {configPath} is missing.");
        enabled = false;
        return;
    }

    var config = JsonUtility.FromJson<GameStatsConfig>(File.ReadAllText(configPath));
    endpointUrl = $"{config.base_url}/api/v1/accounts/{config.account_id}/achievement_events";
    apiToken = config.api_token;

    statePath = Path.Combine(Application.persistentDataPath, StateFileName);
    LoadState();
    StartCoroutine(SendPendingEvents());
}

In Awake leest de component het configuratiebestand en bouwt de URL één keer op. Als het bestand ontbreekt, schakelt de component zichzelf uit en draait de game gewoon door.

JsonUtility vult de velden van een [Serializable]-klasse met de JSON-sleutels die dezelfde naam hebben:

[Serializable]
class GameStatsConfig
{
    public string base_url = "https://gamestats.ai";
    public string account_id;
    public string api_token;
}

Velden die niet in het bestand staan, houden hun standaardwaarde, dus base_url wijst naar Game Stats AI tenzij je een base_url-sleutel toevoegt.

Stap 4: bouw de payload

De body bestaat uit vier verplichte velden:

public void Unlock(string achievementName)
{
    if (!enabled || state.unlockedAchievements.Contains(achievementName))
    {
        return;
    }

    state.unlockedAchievements.Add(achievementName);
    state.pendingEvents.Add(new AchievementEvent
    {
        version_name = Application.version,
        player_username = state.playerUsername,
        achievement_name = achievementName,
        occurred_at = DateTime.UtcNow.ToString("o"),
    });
    SaveState();

    if (!sending)
    {
        StartCoroutine(SendPendingEvents());
    }
}
[Serializable]
class AchievementEvent
{
    public string version_name;
    public string player_username;
    public string achievement_name;
    public string occurred_at;
}

De veldnamen komen overeen met de JSON-sleutels die de API verwacht, omdat JsonUtility veldnamen als sleutels gebruikt. Application.version komt uit Edit > Project Settings > Player > Version. Werk die bij voordat je elke build uitbrengt, want Game Stats AI groepeert unlocks per versie. DateTime.UtcNow.ToString("o") geeft de huidige UTC-tijd terug in ISO 8601-formaat, bijvoorbeeld 2026-10-12T21:47:13.0283120Z. 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 UnityWebRequest in een coroutine

Een coroutine is een methode die Unity kan pauzeren en over meerdere frames kan hervatten. Wanneer een coroutine SendWebRequest() met yield teruggeeft, pauzeert Unity de coroutine tot het antwoord binnenkomt, en ondertussen blijft de game draaien.

De coroutine verstuurt het eerste wachtende event en wacht op het antwoord voordat hij het volgende verstuurt:

IEnumerator SendPendingEvents()
{
    sending = true;

    while (state.pendingEvents.Count > 0)
    {
        var body = JsonUtility.ToJson(state.pendingEvents[0]);
        using var request = UnityWebRequest.Post(endpointUrl, body, "application/json");
        request.SetRequestHeader("Authorization", $"Bearer {apiToken}");
        request.timeout = 10;
        yield return request.SendWebRequest();

        if (request.responseCode == 422)
        {
            Debug.LogWarning($"Game Stats AI rejected an event: {request.downloadHandler.text}");
        }

        if (request.responseCode == 201 || request.responseCode == 422)
        {
            state.pendingEvents.RemoveAt(0);
            SaveState();
        }
        else
        {
            yield return new WaitForSeconds(RetryDelaySeconds);
        }
    }

    sending = false;
}

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. De vlag sending zorgt ervoor dat er maar één exemplaar van de coroutine tegelijk draait, dus Unlock voegt toe aan de wachtrij terwijl er een verzoek loopt.

Stap 6: bewaar unlocks tussen sessies

De component bewaart drie dingen in een JSON-bestand in Application.persistentDataPath, een map per gebruiker die Unity tussen sessies bewaart: een gegenereerde spelersnaam, de achievements die al ontgrendeld zijn en de events die nog niet verstuurd zijn.

void LoadState()
{
    state = File.Exists(statePath)
        ? JsonUtility.FromJson<GameStatsState>(File.ReadAllText(statePath))
        : new GameStatsState();

    if (string.IsNullOrEmpty(state.playerUsername))
    {
        state.playerUsername = "player_" + Guid.NewGuid().ToString("N").Substring(0, 12);
    }
    SaveState();
}

void SaveState()
{
    File.WriteAllText(statePath, JsonUtility.ToJson(state));
}
[Serializable]
class GameStatsState
{
    public string playerUsername;
    public List<string> unlockedAchievements = new();
    public List<AchievementEvent> pendingEvents = new();
}

Door de lijst met ontgrendelde achievements wordt elke achievement één keer per speler verstuurd, niet één keer per ronde. Door de lijst met wachtende events gaat een unlock die offline is behaald niet verloren. Om dat te testen, hebben we de server gestopt, een ronde Orb Rush gespeeld en de game opnieuw gestart toen de server weer draaide: de drie unlocks werden bij het opstarten verstuurd met hun oorspronkelijke tijdstempels. 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 je builds

Unity kopieert alles in Assets/StreamingAssets ongewijzigd naar elke build, dus het configuratiebestand heeft geen extra buildstap nodig. In een macOS-build komt het terecht in OrbRush.app/Contents/Resources/Data/StreamingAssets/game_stats.json.

Iedereen die je game downloadt, kan dit bestand lezen. Daarom moet er een client-token in staan en nooit een server-token.

Stap 8: bekijk de unlocks in een rapport

Om achievements te vergelijken, open je Rapporten, kies je Nieuw rapport, selecteer je Prestatierapport en vink je de achievements aan die je wilt opnemen.

Het rapport hieronder komt uit onze eigen testrondes van Orb Rush, aangevuld met gesimuleerde spelers die naar hetzelfde endpoint zijn verstuurd. Alle 213 spelers verzamelden 10 bollen, 116 (54%) verzamelden er 20 en 36 (17%) verzamelden er 30:

Een prestatierapport voor Orb Rush met een staafgrafiek en een tabel met hoeveel spelers elke bol-achievement hebben ontgrendeld

De volledige integratie staat in de voorbeeldrepository.

Lees verder

© 2026 Rowhome Labs, LLC