Logo
Crear cuenta
← Todas las publicaciones

Cómo registrar logros desde un backend en Go

Mike Dalton Por Mike Dalton ·

Un panel de editor de código y el logo de Go junto al logo de Game Stats AI, con una flecha que apunta a una tarjeta de dashboard con un trofeo y un gráfico de barras.

El seguimiento de logros registra los hitos que los jugadores alcanzan en tu juego: superar un nivel, encontrar un objeto oculto, vencer a un jefe. Esos registros muestran hasta dónde llegan los jugadores, dónde se detienen y qué contenido nunca encuentran.

Si tu juego ya se comunica con un backend que tú controlas, ese backend es un buen lugar desde donde enviar los eventos de logros. Tu token de la API se queda en tus servidores en lugar de ir dentro del cliente del juego, y la integración funciona igual sin importar qué motor use el cliente.

Para mostrar cómo se ve eso, construimos game-stats-ai-go-example-app, un pequeño servicio HTTP sobre Go 1.22 que recibe un desbloqueo desde el juego y lo reenvía a Game Stats AI usando solo net/http y encoding/json de la biblioteca estándar. Esta guía lo recorre paso a paso.

Paso 1: crea un token de servidor

Game Stats AI tiene dos tipos de tokens de API. Los tokens de cliente solo pueden enviar telemetría, así que es seguro incluirlos en el cliente del juego. Los tokens de servidor pueden enviar telemetría y leer reportes, y deben quedarse en un backend de confianza.

Como este código corre en tu backend, crea un token de servidor en gamestats.ai/api_tokens. La misma página muestra el id de la cuenta que la URL del endpoint necesita.

El formulario de nuevo token de API con el nombre diligenciado y el tipo de token Server seleccionado.

Paso 2: guarda el token y el id de la cuenta en la configuración

La aplicación lee el host de la API, el id de la cuenta y el token desde variables de entorno, así que ningún secreto vive en el repositorio. Define el id de la cuenta y el token dondequiera que se ejecute la aplicación:

export GAMESTATS_ACCOUNT_ID=your_account_id
export GAMESTATS_TOKEN=server_your_token_here

Un struct pequeño guarda los tres valores, tomados del entorno. GAMESTATS_BASE_URL es opcional y usa por defecto el host de producción:

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"),
    }
}

Leer cada valor desde el entorno mantiene el desarrollo y la producción idénticos. Si prefieres un archivo para el desarrollo local, guarda los export en un archivo ignorado por git y cárgalo con source antes de iniciar la aplicación.

Paso 3: define el payload del evento

Game Stats AI acepta eventos de logros en un único endpoint:

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

El cuerpo son cuatro campos obligatorios. Las etiquetas de struct mapean los nombres de los campos de Go a los nombres en snake_case que la API espera:

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"`
}

La biblioteca estándar no tiene una estrategia de nombres global, así que cada campo declara su nombre en el cable en su etiqueta.

occurred_at es una marca de tiempo ISO 8601. encoding/json serializa un time.Time en formato RFC 3339, y un valor UTC termina con una Z al final, así que time.Now().UTC() no necesita ninguna cadena de formato.

Los logros, los jugadores y las versiones se crean la primera vez que aparecen en un evento, así que no hay nada que registrar por adelantado. Enviar el mismo logro para el mismo jugador otra vez no tiene efecto, lo que hace seguros los reintentos.

Paso 4: configura un cliente HTTP

Un http.Client de la biblioteca estándar es seguro para uso concurrente, así que construye uno y reutilízalo en cada petición. Un struct pequeño lo guarda junto con la URL base, el token y el id de la cuenta:

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

Un único cliente compartido atiende cada petición, y el tiempo de espera limita cuánto espera un envío antes de rendirse.

Paso 5: envía el evento y maneja la respuesta

El cliente serializa el evento, lo envía por POST e inspecciona el resultado:

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
}

Un evento encolado devuelve 201 Created con un objeto JSON vacío. Un payload que falla la validación devuelve 422 Unprocessable Entity con las razones, como {"errors":["Achievement name can't be blank"]}, y el cliente las expone en el error devuelto. Un token incorrecto o ausente devuelve 401 o 403 sin cuerpo, que el cliente reporta como un código de estado inesperado.

Paso 6: llámalo cuando un jugador desbloquee un logro

Dondequiera que tu backend se entere de un desbloqueo, llama al cliente y envía el evento. La aplicación de ejemplo lo hace desde un handler HTTP que el cliente del juego llama:

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 es un struct de dos campos con el nombre del logro y el nombre de la versión que envía el cliente del juego. El router net/http de Go 1.22 hace coincidir el método y el segmento de ruta {username}, y request.PathValue lo vuelve a leer.

Enviar el evento en línea es el camino mínimo. De manera opcional, reintenta un envío fallido, que es seguro porque reenviar un desbloqueo no tiene efecto, o mueve el envío fuera de la ruta de la petición a una goroutine en segundo plano o una cola.

El proyecto completo está en game-stats-ai-go-example-app.

© 2026 Rowhome Labs, LLC