Achievements bijhouden vanuit een Java-backend
Door Mike Dalton ·
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-java-example-app gebouwd, een kleine Spring Boot-service op Java 21 die een unlock van de game ontvangt en doorstuurt naar Game Stats AI, met Spring's RestClient en Jackson. 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.


Stap 2: bewaar het token en het account-id in configuratie
application.yml bevat de host van de API en leest het account-id en het token uit omgevingsvariabelen:
gamestats:
base-url: https://gamestats.ai
account-id: ${GAMESTATS_ACCOUNT_ID:}
token: ${GAMESTATS_TOKEN:}
Het token is een geheim, en het account-id op dezelfde manier uit de omgeving lezen houdt beide buiten de repository. Stel de variabelen GAMESTATS_ACCOUNT_ID en GAMESTATS_TOKEN in waar de app ook draait:
export GAMESTATS_ACCOUNT_ID=your_account_id
export GAMESTATS_TOKEN=server_your_token_here
Overal omgevingsvariabelen gebruiken houdt development en productie identiek. Wil je liever een bestand voor lokale development, zet de waarden dan in een application-local.yml die door git wordt genegeerd en start de app met SPRING_PROFILES_ACTIVE=local.
Een record dat aan het gamestats-prefix is gebonden bevat de drie waarden:
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) {
}
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. Een record met @JsonProperty-annotaties koppelt de Java-namen aan de snake_case-namen die de API verwacht:
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) {
}
Je kunt de annotaties weglaten en in plaats daarvan Jacksons SNAKE_CASE-naamgevingsstrategie instellen; de annotaties houden het formaat van de API op een plek zichtbaar.
occurred_at is een ISO 8601-tijdstempel. Jackson serialiseert een Instant in dat formaat met een Z aan het einde, dus Instant.now() 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 RestClient
Spring Boot configureert automatisch een RestClient.Builder die de JSON- en HTTP-configuratie van het framework al meebrengt. Injecteer die in de client en stel het basisadres en het bearer-token één keer in, in de 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();
}
Een gebouwde RestClient is immutable en thread-safe, dus deze ene gedeelde instance verwerkt elke request. Gebruikt je app geen dependency injection, roep dan zelf RestClient.builder() aan en bewaar het resultaat in een langlevend veld.
Stap 5: verstuur het event en verwerk de respons
De client post het record en inspecteert het resultaat:
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) {
}
}
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 het bericht van de exception. Een verkeerd of ontbrekend token geeft 401 of 403 terug zonder body, wat retrieve() omzet in een HttpClientErrorException.
Stap 6: roep het aan wanneer een speler een achievement unlockt
Overal waar je backend een unlock te weten komt, injecteer je GameStatsClient en verstuur je het event. De voorbeeldapp doet dat in een controller die de gameclient aanroept:
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 is een record met twee velden, de naam van het achievement en de versienaam die de gameclient meestuurt.
Het event inline versturen is het minimale pad. Voeg optioneel @Retryable van Spring Retry toe voor automatische retries, wat veilig is omdat het opnieuw versturen van een unlock geen effect heeft, of verplaats het versturen van het request-pad af met @Async of een achtergrondwachtrij.
Het volledige project staat in game-stats-ai-java-example-app.