Cómo registrar logros desde un backend en C#
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-csharp-example-app, un pequeño servicio de ASP.NET Core sobre .NET 8 que recibe un desbloqueo desde el juego y lo reenvía a Game Stats AI usando solo HttpClient y System.Text.Json de la biblioteca de clases base. 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
appsettings.json solo guarda el host de la API:
"GameStats": {
"BaseUrl": "https://gamestats.ai"
}
El token es un secreto, y guardar el id de la cuenta de la misma manera mantiene ambos fuera del repositorio. En desarrollo, usa user secrets:
dotnet user-secrets set "GameStats:AccountId" "your_account_id"
dotnet user-secrets set "GameStats:Token" "server_your_token_here"
En producción, defínelos como las variables de entorno GameStats__AccountId y GameStats__Token. Todas las fuentes se enlazan a la misma sección, así que el resto del código nunca necesita saber de dónde salieron los valores.
Una clase de opciones guarda los tres valores:
namespace GameStatsExample;
public sealed class GameStatsOptions
{
public string BaseUrl { get; set; } = "https://gamestats.ai";
public string AccountId { get; set; } = "";
public string Token { get; set; } = "";
}
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 atributos JsonPropertyName mapea los nombres de C# a los nombres en snake_case que la API espera:
using System.Text.Json.Serialization;
namespace GameStatsExample;
public sealed record AchievementEvent(
[property: JsonPropertyName("version_name")] string VersionName,
[property: JsonPropertyName("player_username")] string PlayerUsername,
[property: JsonPropertyName("achievement_name")] string AchievementName,
[property: JsonPropertyName("occurred_at")] DateTime OccurredAt);
En .NET 8 puedes quitar los atributos y definir PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower en JsonSerializerOptions; los atributos mantienen el formato de la API visible en un solo lugar.
occurred_at es una marca de tiempo ISO 8601. System.Text.Json serializa un DateTime en UTC en ese formato con una Z al final, así que DateTime.UtcNow 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: registra un HttpClient tipado
Registra un cliente tipado en Program.cs. IHttpClientFactory administra la vida de las conexiones, y el callback de la fábrica lee las opciones para definir la dirección base y el token bearer una sola vez:
builder.Services.Configure<GameStatsOptions>(builder.Configuration.GetSection("GameStats"));
builder.Services.AddHttpClient<GameStatsClient>((services, client) =>
{
var options = services.GetRequiredService<IOptions<GameStatsOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.Token);
});
Si tu aplicación no usa inyección de dependencias, un único HttpClient de larga vida configurado de la misma manera también funciona.
Paso 5: envía el evento y maneja la respuesta
El cliente envía el record por POST e inspecciona el resultado:
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace GameStatsExample;
public sealed class GameStatsClient(HttpClient http, IOptions<GameStatsOptions> options)
{
private readonly string _accountId = options.Value.AccountId;
public async Task SendAchievementAsync(AchievementEvent achievementEvent, CancellationToken cancellationToken = default)
{
var response = await http.PostAsJsonAsync(
$"api/v1/accounts/{_accountId}/achievement_events", achievementEvent, cancellationToken);
if (response.StatusCode == HttpStatusCode.UnprocessableEntity)
{
var body = await response.Content.ReadFromJsonAsync<ValidationErrors>(cancellationToken);
throw new InvalidOperationException(string.Join("; ", body?.Errors ?? []));
}
response.EnsureSuccessStatusCode();
}
}
public sealed record ValidationErrors(
[property: JsonPropertyName("errors")] 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 EnsureSuccessStatusCode convierte en una HttpRequestException.
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 endpoint de minimal API que el cliente del juego llama:
app.MapPost("/players/{username}/achievements", async (string username, UnlockRequest request, GameStatsClient gameStats, CancellationToken cancellationToken) =>
{
await gameStats.SendAchievementAsync(new AchievementEvent(
VersionName: request.VersionName,
PlayerUsername: username,
AchievementName: request.AchievementName,
OccurredAt: DateTime.UtcNow), cancellationToken);
return Results.Accepted();
});
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.
Esperar el envío con await en línea es el camino mínimo. De manera opcional, agrega el manejador de resiliencia estándar de Microsoft.Extensions.Http.Resilience para reintentos automáticos, que es seguro porque reenviar un desbloqueo no tiene efecto, o mueve los envíos a una cola en segundo plano con Channel<T> y un hosted service si no quieres que estén en la ruta de la petición.
El proyecto completo está en game-stats-ai-csharp-example-app.