Achievements bijhouden vanuit een C++-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-cpp-example-app gebouwd, een kleine HTTP-service op Drogon die een unlock van de game ontvangt en doorstuurt naar Game Stats AI. Drogon is een C++17-framework dat een HTTP-server, een HTTP-client en JSON bundelt, dus de applicatiecode heeft één framework om tegenaan te programmeren. 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: stel de build in met CMake en FetchContent
C++ heeft geen webserver in de standaardbibliotheek, dus de app is afhankelijk van Drogon. Met FetchContent van CMake wordt Drogon zelf gedownload en gebouwd tijdens het configureren, dus je installeert of bouwt het framework nooit met de hand.
cmake_minimum_required(VERSION 3.16)
project(game-stats-ai-cpp-example-app CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(FetchContent)
FetchContent_Declare(
drogon
GIT_REPOSITORY https://github.com/drogonframework/drogon
GIT_TAG v1.9.13
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(drogon)
add_executable(game-stats-ai-cpp-example-app
main.cc
GameStatsClient.cc
)
target_link_libraries(game-stats-ai-cpp-example-app PRIVATE drogon)
Linken met drogon zet ook zijn JSON-bibliotheek op het include-pad, dus de rest van de app kan een request-body opbouwen zonder extra werk. Drogon leunt op drie alomtegenwoordige C-bibliotheken, jsoncpp, OpenSSL en zlib, die je één keer installeert met je package manager (brew install jsoncpp openssl op macOS, apt-get install libjsoncpp-dev libssl-dev zlib1g-dev op Debian).
Stap 3: 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:
#pragma once
#include <cstdlib>
#include <string>
struct Config {
std::string baseUrl;
std::string accountId;
std::string token;
};
inline std::string envOrDefault(const char *name, const std::string &fallback) {
const char *value = std::getenv(name);
return value ? std::string(value) : fallback;
}
inline Config loadConfig() {
return Config{
envOrDefault("GAMESTATS_BASE_URL", "https://gamestats.ai"),
envOrDefault("GAMESTATS_ACCOUNT_ID", ""),
envOrDefault("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 4: 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 struct bevat ze, en een toJson-methode bouwt het snake_case-object dat de API verwacht:
#pragma once
#include <chrono>
#include <ctime>
#include <json/json.h>
#include <string>
struct AchievementEvent {
std::string versionName;
std::string playerUsername;
std::string achievementName;
std::string occurredAt;
Json::Value toJson() const {
Json::Value body;
body["version_name"] = versionName;
body["player_username"] = playerUsername;
body["achievement_name"] = achievementName;
body["occurred_at"] = occurredAt;
return body;
}
};
inline std::string currentTimestamp() {
const auto now = std::chrono::system_clock::now();
const std::time_t seconds = std::chrono::system_clock::to_time_t(now);
std::tm utc{};
gmtime_r(&seconds, &utc);
char formatted[sizeof("2026-08-10T12:00:00Z")];
std::strftime(formatted, sizeof(formatted), "%Y-%m-%dT%H:%M:%SZ", &utc);
return formatted;
}
occurred_at is een ISO 8601-tijdstempel. De uitvoer van gmtime_r opmaken met een afsluitende Z geeft de UTC-waarde die de API verwacht.
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 5: configureer de Drogon HTTP-client
De HttpClient van Drogon wordt één keer opgebouwd vanuit de basis-URL en hergebruikt voor elke request. Een kleine class bewaart die samen met het token en het account-id:
#pragma once
#include "AchievementEvent.h"
#include "Config.h"
#include <drogon/HttpClient.h>
#include <functional>
#include <optional>
#include <string>
class GameStatsClient {
public:
explicit GameStatsClient(const Config &config);
using ResultCallback = std::function<void(std::optional<std::string> error)>;
void sendAchievement(const AchievementEvent &event, ResultCallback callback);
private:
drogon::HttpClientPtr httpClient_;
std::string token_;
std::string accountId_;
};
De client leest het schema en de host uit de basis-URL, dus een https://-URL gebruikt TLS op poort 443 zonder extra instellingen. De callback geeft niets terug bij succes, of een bericht dat beschrijft wat er misging.
Stap 6: verstuur het event en verwerk de respons
De client bouwt een JSON-request, voegt de authorization-header toe en verstuurt die. De sendRequest van Drogon is asynchroon, dus die blokkeert nooit de thread die hem aanroept:
#include "GameStatsClient.h"
#include <drogon/HttpRequest.h>
#include <drogon/HttpTypes.h>
using namespace drogon;
namespace {
std::string joinValidationErrors(const HttpResponsePtr &response) {
const auto body = response->getJsonObject();
if (!body || !(*body)["errors"].isArray()) {
return "unknown validation error";
}
std::string joined;
for (const auto &reason : (*body)["errors"]) {
if (!joined.empty()) {
joined += "; ";
}
joined += reason.asString();
}
return joined;
}
} // namespace
GameStatsClient::GameStatsClient(const Config &config)
: httpClient_(HttpClient::newHttpClient(config.baseUrl)),
token_(config.token),
accountId_(config.accountId) {}
void GameStatsClient::sendAchievement(const AchievementEvent &event,
ResultCallback callback) {
auto request = HttpRequest::newHttpJsonRequest(event.toJson());
request->setMethod(Post);
request->setPath("/api/v1/accounts/" + accountId_ + "/achievement_events");
request->addHeader("Authorization", "Bearer " + token_);
httpClient_->sendRequest(
request, [callback = std::move(callback)](
ReqResult result, const HttpResponsePtr &response) {
if (result != ReqResult::Ok) {
callback("could not reach game stats");
return;
}
const int status = static_cast<int>(response->getStatusCode());
if (status == k422UnprocessableEntity) {
callback("game stats rejected the event: " +
joinValidationErrors(response));
return;
}
if (status >= k300MultipleChoices) {
callback("game stats returned status " + std::to_string(status));
return;
}
callback(std::nullopt);
});
}
newHttpJsonRequest serialiseert de struct en zet de Content-Type-header, dus alleen de authorization-header hoeft nog toegevoegd te worden. Een event in de wachtrij 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 voegt ze samen in de gerapporteerde fout. Een verkeerd of ontbrekend token geeft 401 of 403 terug zonder body, wat de client meldt als een onverwachte statuscode.
Stap 7: ontvang de unlock en start de server
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:
#include "AchievementEvent.h"
#include "Config.h"
#include "GameStatsClient.h"
#include <drogon/drogon.h>
#include <functional>
#include <memory>
#include <optional>
#include <string>
using namespace drogon;
int main() {
auto client = std::make_shared<GameStatsClient>(loadConfig());
app().registerHandler(
"/players/{username}/achievements",
[client](const HttpRequestPtr &request,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &username) {
const auto unlock = request->getJsonObject();
if (!unlock) {
auto response = HttpResponse::newHttpResponse();
response->setStatusCode(k400BadRequest);
callback(response);
return;
}
AchievementEvent event;
event.versionName = (*unlock)["versionName"].asString();
event.playerUsername = username;
event.achievementName = (*unlock)["achievementName"].asString();
event.occurredAt = currentTimestamp();
client->sendAchievement(
event, [callback](std::optional<std::string> error) {
auto response = HttpResponse::newHttpResponse();
response->setStatusCode(error ? k502BadGateway : k202Accepted);
callback(response);
});
},
{Post});
LOG_INFO << "listening on :8080";
app().addListener("0.0.0.0", 8080).run();
}
De inkomende body bevat de naam van het achievement en de versienaam die de gameclient meestuurt, en Drogon geeft het pad-segment {username} door als het laatste argument van de handler. De handler vult occurred_at aan de serverkant in, geeft het event vervolgens door aan de client en antwoordt 202 Accepted zodra de verzending slaagt.
Het event inline versturen is het minimale pad. Omdat de client van Drogon asynchroon is, antwoordt de handler al zonder een thread te blokkeren, en het opnieuw versturen van een unlock heeft geen effect, dus een mislukte verzending opnieuw proberen is veilig toe te voegen.
Het volledige project staat in game-stats-ai-cpp-example-app.