Cómo registrar logros desde un backend en Go
Por Mike Dalton ·
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.


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.