Logo
Aanmelden
← Alle artikelen

Achievements bijhouden vanuit een Go-backend

Mike Dalton Door Mike Dalton ·

Een paneel van een code-editor en het Go-logo naast het Game Stats AI-logo, met een pijl naar een dashboardkaart met een trofee en een staafgrafiek.

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.

Als je game al met een backend praat die jij beheert, is die backend een goede plek om achievement-events vandaan te versturen. Je API-token blijft op je servers in plaats van mee te gaan in de gameclient, en de integratie werkt hetzelfde ongeacht welke engine de client gebruikt.

Om te laten zien hoe dat eruitziet hebben we game-stats-ai-go-example-app gebouwd, een kleine HTTP-service op Go 1.22 die een unlock van de game ontvangt en doorstuurt naar Game Stats AI, met alleen net/http en encoding/json uit de standaardbibliotheek. Deze gids loopt er stap voor stap doorheen.

Stap 1: maak een servertoken aan

Game Stats AI heeft twee soorten API-tokens. Clienttokens kunnen alleen telemetrie versturen en zijn daarom veilig om in een gameclient op te nemen. Servertokens kunnen telemetrie versturen en rapporten lezen, en moeten op een vertrouwde backend blijven.

Omdat deze code op je backend draait, maak je een servertoken aan op gamestats.ai/api_tokens. Dezelfde pagina toont het account-id dat de endpoint-URL nodig heeft.

Het formulier voor een nieuw API-token met de naam ingevuld en het tokentype Server geselecteerd.

Stap 2: bewaar het token en het account-id in configuratie

De app leest de host van de API, het account-id en het token uit omgevingsvariabelen, dus er staan geen geheimen in de repository. Stel het account-id en het token in waar de app ook draait:

export GAMESTATS_ACCOUNT_ID=your_account_id
export GAMESTATS_TOKEN=server_your_token_here

Een kleine struct bevat de drie waarden, ingevuld vanuit de omgeving. GAMESTATS_BASE_URL is optioneel en valt terug op de productiehost:

package main

import "os"

type Config struct {
    BaseURL   string
    AccountID string
    Token     string
}

func LoadConfig() Config {
    baseURL := os.Getenv("GAMESTATS_BASE_URL")
    if baseURL == "" {
        baseURL = "https://gamestats.ai"
    }

    return Config{
        BaseURL:   baseURL,
        AccountID: os.Getenv("GAMESTATS_ACCOUNT_ID"),
        Token:     os.Getenv("GAMESTATS_TOKEN"),
    }
}

Elke waarde uit de omgeving lezen houdt development en productie identiek. Wil je liever een bestand voor lokale development, bewaar de export-regels dan in een door git genegeerd bestand en source het voordat je de app start.

Stap 3: definieer de payload van het event

Game Stats AI accepteert achievement-events op een enkel endpoint:

POST https://gamestats.ai/api/v1/accounts/{account_id}/achievement_events
Authorization: Bearer <token>
Content-Type: application/json

De body bestaat uit vier verplichte velden. Struct-tags koppelen de Go-veldnamen aan de snake_case-namen die de API verwacht:

package main

import "time"

type AchievementEvent struct {
    VersionName     string    `json:"version_name"`
    PlayerUsername  string    `json:"player_username"`
    AchievementName string    `json:"achievement_name"`
    OccurredAt      time.Time `json:"occurred_at"`
}

De standaardbibliotheek heeft geen globale naamgevingsstrategie, dus elk veld geeft zijn naam op de lijn op in zijn tag.

occurred_at is een ISO 8601-tijdstempel. encoding/json serialiseert een time.Time in RFC 3339-formaat, en een UTC-waarde eindigt op een Z, dus time.Now().UTC() heeft geen format string nodig.

Achievements, spelers en versies worden aangemaakt zodra ze voor het eerst in een event voorkomen, dus je hoeft vooraf niets te registreren. Hetzelfde achievement nogmaals versturen voor dezelfde speler heeft geen effect, waardoor retries veilig zijn.

Stap 4: configureer een HTTP-client

Een http.Client uit de standaardbibliotheek is veilig voor gelijktijdig gebruik, dus bouw er één en hergebruik die voor elke request. Een kleine struct bewaart die samen met de basis-URL, het token en het account-id:

func NewGameStatsClient(config Config) *GameStatsClient {
    return &GameStatsClient{
        httpClient: &http.Client{Timeout: 10 * time.Second},
        baseURL:    config.BaseURL,
        token:      config.Token,
        accountID:  config.AccountID,
    }
}

Eén gedeelde client verwerkt elke request, en de time-out begrenst hoe lang een verzending wacht voordat die opgeeft.

Stap 5: verstuur het event en verwerk de respons

De client serialiseert het event, post het en inspecteert het resultaat:

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "strings"
    "time"
)

type GameStatsClient struct {
    httpClient *http.Client
    baseURL    string
    token      string
    accountID  string
}

func NewGameStatsClient(config Config) *GameStatsClient {
    return &GameStatsClient{
        httpClient: &http.Client{Timeout: 10 * time.Second},
        baseURL:    config.BaseURL,
        token:      config.Token,
        accountID:  config.AccountID,
    }
}

type validationErrors struct {
    Errors []string `json:"errors"`
}

func (client *GameStatsClient) SendAchievement(ctx context.Context, event AchievementEvent) error {
    body, err := json.Marshal(event)
    if err != nil {
        return err
    }

    url := fmt.Sprintf("%s/api/v1/accounts/%s/achievement_events", client.baseURL, client.accountID)
    request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
    if err != nil {
        return err
    }
    request.Header.Set("Authorization", "Bearer "+client.token)
    request.Header.Set("Content-Type", "application/json")

    response, err := client.httpClient.Do(request)
    if err != nil {
        return err
    }
    defer response.Body.Close()

    if response.StatusCode == http.StatusUnprocessableEntity {
        var validation validationErrors
        if err := json.NewDecoder(response.Body).Decode(&validation); err != nil {
            return err
        }
        return fmt.Errorf("game stats rejected the event: %s", strings.Join(validation.Errors, "; "))
    }

    if response.StatusCode >= http.StatusMultipleChoices {
        return fmt.Errorf("game stats returned status %d", response.StatusCode)
    }

    return nil
}

Een event dat in de wachtrij is gezet geeft 201 Created terug met een leeg JSON-object. Een payload die de validatie niet haalt geeft 422 Unprocessable Entity terug met de redenen, zoals {"errors":["Achievement name can't be blank"]}, en de client toont ze in de teruggegeven fout. Een verkeerd of ontbrekend token geeft 401 of 403 terug zonder body, wat de client meldt als een onverwachte statuscode.

Stap 6: roep het aan wanneer een speler een achievement unlockt

Overal waar je backend een unlock te weten komt, roep je de client aan en verstuur je het event. De voorbeeldapp doet dat vanuit een HTTP-handler die de gameclient aanroept:

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "time"
)

type unlockRequest struct {
    AchievementName string `json:"achievementName"`
    VersionName     string `json:"versionName"`
}

func main() {
    client := NewGameStatsClient(LoadConfig())

    mux := http.NewServeMux()
    mux.HandleFunc("POST /players/{username}/achievements", func(writer http.ResponseWriter, request *http.Request) {
        var unlock unlockRequest
        if err := json.NewDecoder(request.Body).Decode(&unlock); err != nil {
            http.Error(writer, err.Error(), http.StatusBadRequest)
            return
        }

        event := AchievementEvent{
            VersionName:     unlock.VersionName,
            PlayerUsername:  request.PathValue("username"),
            AchievementName: unlock.AchievementName,
            OccurredAt:      time.Now().UTC(),
        }

        if err := client.SendAchievement(request.Context(), event); err != nil {
            http.Error(writer, err.Error(), http.StatusBadGateway)
            return
        }

        writer.WriteHeader(http.StatusAccepted)
    })

    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

unlockRequest is een struct met twee velden, de naam van het achievement en de versienaam die de gameclient meestuurt. De net/http-router van Go 1.22 matcht de methode en het pad-segment {username}, en request.PathValue leest dat weer uit.

Het event inline versturen is het minimale pad. Probeer optioneel een mislukte verzending opnieuw, wat veilig is omdat het opnieuw versturen van een unlock geen effect heeft, of verplaats de verzending van het request-pad af naar een achtergrond-goroutine of een wachtrij.

Het volledige project staat in game-stats-ai-go-example-app.

© 2026 Rowhome Labs, LLC