Logo
Crear cuenta
← Todas las publicaciones

Cómo registrar logros desde un backend en Java

Mike Dalton Por Mike Dalton ·

Un panel de editor de código y los logos de Java y Spring 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-java-example-app, un pequeño servicio de Spring Boot sobre Java 21 que recibe un desbloqueo desde el juego y lo reenvía a Game Stats AI usando el RestClient de Spring y Jackson. 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

application.yml contiene el host de la API y lee el id de la cuenta y el token desde variables de entorno:

gamestats:
  base-url: https://gamestats.ai
  account-id: ${GAMESTATS_ACCOUNT_ID:}
  token: ${GAMESTATS_TOKEN:}

El token es un secreto, y leer el id de la cuenta desde el entorno de la misma manera mantiene ambos fuera del repositorio. Define las variables GAMESTATS_ACCOUNT_ID y GAMESTATS_TOKEN dondequiera que se ejecute la aplicación:

export GAMESTATS_ACCOUNT_ID=your_account_id
export GAMESTATS_TOKEN=server_your_token_here

Usar variables de entorno en cada entorno mantiene el desarrollo y la producción idénticos. Si prefieres un archivo para el desarrollo local, pon los valores en un application-local.yml ignorado por git e inicia la aplicación con SPRING_PROFILES_ACTIVE=local.

Un record enlazado al prefijo gamestats guarda los tres valores:

package ai.gamestats.example;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties("gamestats")
public record GameStatsProperties(
    @DefaultValue("https://gamestats.ai") String baseUrl,
    String accountId,
    String token) {
}

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. Un record con anotaciones @JsonProperty mapea los nombres de Java a los nombres en snake_case que la API espera:

package ai.gamestats.example;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;

public record AchievementEvent(
    @JsonProperty("version_name") String versionName,
    @JsonProperty("player_username") String playerUsername,
    @JsonProperty("achievement_name") String achievementName,
    @JsonProperty("occurred_at") Instant occurredAt) {
}

Puedes quitar las anotaciones y definir en su lugar la estrategia de nombres SNAKE_CASE de Jackson; las anotaciones mantienen el formato de la API visible en un solo lugar.

occurred_at es una marca de tiempo ISO 8601. Jackson serializa un Instant en ese formato con una Z al final, así que Instant.now() 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 RestClient

Spring Boot configura automáticamente un RestClient.Builder que ya lleva la configuración de JSON y HTTP del framework. Inyéctalo en el cliente y define la dirección base y el token bearer una sola vez, en el constructor:

public GameStatsClient(RestClient.Builder restClientBuilder, GameStatsProperties properties) {
    this.restClient = restClientBuilder
        .baseUrl(properties.baseUrl())
        .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + properties.token())
        .build();
    this.accountId = properties.accountId();
}

Un RestClient construido es inmutable y thread-safe, así que esta única instancia compartida atiende cada petición. Si tu aplicación no usa inyección de dependencias, llama a RestClient.builder() tú mismo y guarda el resultado en un campo de larga vida.

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

El cliente envía el record por POST e inspecciona el resultado:

package ai.gamestats.example;

import java.util.List;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;

@Component
public class GameStatsClient {

    private final RestClient restClient;
    private final String accountId;

    public GameStatsClient(RestClient.Builder restClientBuilder, GameStatsProperties properties) {
        this.restClient = restClientBuilder
            .baseUrl(properties.baseUrl())
            .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + properties.token())
            .build();
        this.accountId = properties.accountId();
    }

    public void sendAchievement(AchievementEvent achievementEvent) {
        try {
            restClient.post()
                .uri("/api/v1/accounts/{accountId}/achievement_events", accountId)
                .body(achievementEvent)
                .retrieve()
                .toBodilessEntity();
        } catch (HttpClientErrorException.UnprocessableEntity exception) {
            ValidationErrors errors = exception.getResponseBodyAs(ValidationErrors.class);
            throw new IllegalStateException(String.join("; ", errors == null ? List.of() : errors.errors()));
        }
    }

    public record ValidationErrors(List<String> errors) {
    }
}

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 mensaje de la excepción. Un token incorrecto o ausente devuelve 401 o 403 sin cuerpo, que retrieve() convierte en una HttpClientErrorException.

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

Donde sea que tu backend se entere de un desbloqueo, inyecta GameStatsClient y envía el evento. La aplicación de ejemplo lo hace en un controlador que el cliente del juego llama:

package ai.gamestats.example;

import java.time.Instant;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AchievementController {

    private final GameStatsClient gameStatsClient;

    public AchievementController(GameStatsClient gameStatsClient) {
        this.gameStatsClient = gameStatsClient;
    }

    @PostMapping("/players/{username}/achievements")
    @ResponseStatus(HttpStatus.ACCEPTED)
    public void unlock(@PathVariable String username, @RequestBody UnlockRequest request) {
        gameStatsClient.sendAchievement(new AchievementEvent(
            request.versionName(),
            username,
            request.achievementName(),
            Instant.now()));
    }

    public record UnlockRequest(String achievementName, String versionName) {
    }
}

UnlockRequest es un record de dos campos con el nombre del logro y el nombre de la versión que envía el cliente del juego.

Enviar el evento en línea es el camino mínimo. De manera opcional, agrega @Retryable de Spring Retry para reintentos automáticos, que es seguro porque reenviar un desbloqueo no tiene efecto, o mueve los envíos fuera de la ruta de la petición con @Async o una cola en segundo plano.

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

© 2026 Rowhome Labs, LLC