Volver al Blog

Clasificaciones justas sin código de servidor: partidas validadas, paquetes sus y perfiles de jugador

Publicado el 3 de octubre de 2026
Clasificaciones justas sin código de servidor: partidas validadas, paquetes sus y perfiles de jugador Generado con ayuda de IA

En resumen

Crea tablas de clasificación validadas por el servidor sin código propio: tickets de un solo uso, paquetes sus reproducibles y perfiles de jugador.

La primera semana de una clasificación nueva suele terminar igual: en algún punto entre el top ten honesto y el resto de la tabla aparece un jugador con 2.147.483.647 puntos, el valor más alto que cabe en un entero de 32 bits con signo. Nadie jugó esa partida. La escribió un editor de memoria o un proxy que intercepta el tráfico, porque en la mayoría de las clasificaciones el cliente del juego es el único testigo y, a la vez, el juez.

Esta versión ataca ese punto débil desde dos frentes. Validated Actions traslada al servidor la decisión de qué cuenta, y los nuevos paquetes de partidas sus conservan cada partida sospechosa como un expediente completo y reproducible. Al mismo tiempo, las clasificaciones se vuelven más personales: cada entrada lleva ahora un perfil de jugador con avatar, marco y hasta tres insignias, y los cosméticos se pueden desbloquear con códigos de regalo. A continuación verás cómo funciona cada pieza, las cifras que hay detrás, código funcional para Unity y Godot y los límites que queremos que conozcas antes de confiar en ello.

Por qué no puedes fiarte de las puntuaciones que envía el cliente

Un envío de puntuación clásico es una sola petición: ID del jugador, puntuación y listo. Todo lo que el servidor sabe de esa partida viene del dispositivo que más interés tiene en mentir sobre ella. Las contramedidas habituales comparten el mismo defecto:

  • Firmar la petición en el cliente. La clave de firma viaja dentro de tu build. Extraerla de un binario IL2CPP o de una exportación de GDScript lleva una tarde, y a partir de ahí las peticiones falsificadas parecen totalmente válidas.
  • Ofuscar la puntuación en memoria. Esto frena a los editores de memoria ocasionales, pero un proxy que reescribe el cuerpo HTTP nunca toca la disposición de tu memoria.
  • Comprobaciones de plausibilidad en el cliente. Todo lo que se ejecuta en el dispositivo se puede eliminar con un parche en el dispositivo.

La respuesta robusta es que el servidor decida qué cuenta. La versión de manual es una simulación autoritativa: tu lógica de juego corre en hardware que controlas y el cliente solo envía entradas. Para un juego de acción rápido con miles de partidas simultáneas, eso supone semanas de trabajo de netcode más una factura de hosting permanente. La mayoría de los equipos indie no necesitan la simulación completa. Necesitan tres garantías más baratas: el servidor sabe cuándo empezó una partida, qué reglas se le aplican y qué pruebas existen después. Ese es exactamente el hueco que cubre Validated Actions.

Cómo funciona una partida validada

Una partida validada tiene cuatro pasos, y el servidor controla los dos que importan:

  1. Inicio de la partida. El juego llama a StartRun. El servidor emite un ticket de un solo uso firmado y vinculado al jugador, a la API key y, opcionalmente, a una tabla. El ticket incluye una seed elegida por el servidor (de 0 a 2.147.483.646) y una caducidad: 2 horas por defecto, configurable entre 60 segundos y 6 horas.
  2. Juego. El juego inicializa su aleatoriedad con la seed del servidor y registra las entradas del jugador en un log compacto de bytes.
  3. Fin de la partida. El juego llama a SubmitValidated con la puntuación, una fase opcional, valores ganados opcionales y el log de entradas. El SDK envía el hash SHA-256 del log, así que el log en sí se queda de momento en el dispositivo.
  4. Comprobación del servidor. El servidor verifica el ticket, mide la duración por su cuenta (desde la emisión del ticket hasta el envío) y aplica tus reglas. Solo entonces escribe algo.

Un ticket cuenta exactamente una vez, en todas las regiones. Una partida rechazada también quema su ticket, así que nadie puede sondear tus umbrales reintentando el mismo ticket con puntuaciones algo más bajas. Como el servidor elige la seed y limita los tickets por jugador y hora (60 por defecto), buscar una seed afortunada se vuelve lento y visible.

var va = ValidatedActionsManager.Instance;

// Run start: single-use ticket plus server seed
ValidatedRun run = await va.StartRun("weekly");
if (run == null) { Debug.Log(va.LastErrorCode); return; } // e.g. RUN_RATE_LIMITED
var random = new System.Random(run.seed);

// ... play, record the inputs into inputLog (byte[]) ...

// Run end: the SDK hashes the log and sends the SHA-256 with the ticket
ValidatedSubmitResult result = await va.SubmitValidated(18250, inputLog);
if (result == null)
{
    // e.g. DURATION_TOO_SHORT; HasActiveRun tells you if the ticket survived
    Debug.Log($"{va.LastErrorCode}, run kept: {va.HasActiveRun}");
    return;
}
Debug.Log($"Rank {result.rank}, {result.durationSeconds}s measured by the server, sus: {result.sus}");

El mismo flujo existe en Godot (Horizon.validatedActions.startRun y submitValidated) y en Unreal (Horizon->ValidatedActions), cada uno con un ejemplo completo en el SDK.

Reglas que solo conoce el servidor

Cada API key tiene un conjunto de reglas, que editas en el Dashboard como formulario o como JSON. Las reglas nunca aparecen en las respuestas de la app ni en los mensajes de error: el cliente solo recibe un código legible por máquina. Este es un conjunto de reglas realista para un arcade por oleadas:

{
  "formatVersion": 1,
  "ticketLifetimeSeconds": 3600,
  "maxRunsPerPlayerPerHour": 30,
  "defaults": {
    "maxScore": 250000,
    "minDurationSeconds": 45,
    "maxScorePerSecond": 900.0,
    "stages": {
      "wave_10": { "maxScore": 40000, "minDurationSeconds": 60 }
    },
    "soft": { "maxScore": 180000, "maxScorePerSecond": 600.0 }
  },
  "leaderboards": {
    "speedrun": { "minScore": 95000, "minDurationSeconds": 95 }
  },
  "values": {
    "gold": { "maxPerRun": 500, "minPerRun": -1000, "dailyCap": 5000 }
  }
}

Prueba unos cuantos envíos contra la tabla weekly:

Partida enviada Respuesta del servidor
9.999.999 puntos 422 SCORE_ABOVE_MAX
21.000 puntos, 3 segundos después del ticket 422 DURATION_TOO_SHORT
150.000 puntos en 120 segundos (1.250 por segundo) 422 SCORE_RATE_TOO_HIGH
190.000 puntos en 240 segundos aceptada, pero sus (por encima del máximo soft de 180.000)
el mismo ticket por segunda vez 422 TICKET_CONSUMED

El servidor comprueba en un orden fijo (fase, puntuación, regla de fase, duración, puntuación por segundo, valores ganados y, por último, umbrales soft) y gana el primer fallo. Las reglas de puntuación solo se aplican a partidas con tabla; a una partida sin tabla se le siguen comprobando las reglas de fase y de duración.

Cuando las reglas encajen, activa en la tabla "Solo envíos validados". A partir de ese momento, un SubmitScore normal a esa tabla se rechaza con 403 VALIDATED_SUBMIT_REQUIRED, mientras que el resto de tablas siguen aceptando envíos normales. Incluso con un conjunto de reglas vacío, una tabla solo validada garantiza tickets del servidor, un solo uso, vinculación al jugador, los límites por hora y un hash del log almacenado.

Umbrales soft y partidas sus

Las reglas duras rechazan. Los umbrales soft solo marcan una partida, y por ahí deberías empezar: una semana con límites soft te enseña cómo es el juego real antes de convertir las cifras en rechazos duros. Una partida es sus cuando se aceptó y superó al menos un umbral soft. Las partidas rechazadas y los errores técnicos nunca son sus. El resultado del envío incluye un simple flag sus; qué umbral saltó se queda en el servidor.

Lo interesante es lo que pasa después. Con esta actualización, el servidor guarda un paquete sus por cada partida sus, independientemente de la mejor puntuación del jugador, del top N o de cambios posteriores de puntuación. Un tramposo que publica una partida absurda y después una normal ya no puede enterrar las pruebas bajo una entrada mejor.

El contexto de inicio: con qué empezó la partida

Un replay solo sirve si puedes reproducir exactamente las condiciones iniciales. Por eso, en StartRun el servidor registra ahora el contexto de inicio: la versión de reglas vigente, los bytes reales del Cloud Save del jugador (con SHA-256 y revisión), los valores propiedad del servidor, la seed y la hora de inicio. El juego puede añadir su parte de la historia:

var context = new ValidatedRunContext(
    gameVersion: Application.version,          // e.g. "1.4.2"
    contentVersion: "levels-2026-10",
    simulationVersion: "sim-7",
    replayFormatVersion: "input-v3",
    contentDigest: ValidatedActionsManager.ComputeInputLogHash(levelBytes),
    initialState: serializedLevelState);       // bytes the simulation starts from

ValidatedRun run = await ValidatedActionsManager.Instance.StartRun("weekly", context);

Los valores del servidor y los del cliente se mantienen estrictamente separados, y ambos alimentan un texto canónico cuyo SHA-256 se guarda en el ticket. Además, el envío se comprueba contra la versión de reglas del inicio, así que endurecer un límite mientras una partida está en curso nunca cambia el veredicto de esa partida. Para evitar que se abuse de las ediciones de reglas como sonda, los cambios están limitados a 30 por API key y hora.

Qué contiene un paquete sus

Cada paquete se guarda bajo el ID de la partida y reúne todo lo que necesita quien revisa: el contexto de inicio, las reglas vinculadas, el resultado, los valores propiedad del servidor antes y después de la partida y el log de entradas (el SDK lo sube automáticamente, igual que en las partidas del top N). En la pestaña Review del Dashboard filtras por sus y exportas un paquete como archivo ZIP:

<runId>.hzn-va-package.zip
├── manifest.json        format horizon.validated-actions.package v1
├── start-context.txt    canonical start context, hashed on the ticket
├── rules.json           the rule version the run was checked against
├── cloud-save.bin       the player's save at run start
├── initial-state.bin    the game's initial simulation state
├── input-log.bin        the recorded inputs
└── SHA256SUMS

En cada exportación, el servidor recalcula todas las sumas de verificación e informa del resultado en manifest.integrity y en la cabecera X-Package-Integrity (ok o mismatch). Si una parte concreta no cabe en el límite de tamaño, solo se conserva su suma de verificación y la parte se marca como OMITTED_SIZE_LIMIT, para que siempre sepas qué falta y por qué.

Pasa cloud-save.bin, initial-state.bin, la seed y input-log.bin a tu propia simulación determinista y sabrás si la puntuación se reproduce o no. Los replays, veredictos y sanciones quedan en tus manos; el servidor marca y archiva, nunca ejecuta el código de tu juego.

Moneda que el cliente no puede escribir

Las clasificaciones no son lo único en lo que merece la pena hacer trampas. Con los valores propiedad del servidor defines contadores como gold o gems en el mismo conjunto de reglas. Solo las partidas validadas aceptadas los modifican: no hay ningún endpoint de la app ni ningún método del SDK que fije un saldo. La regla del JSON anterior se lee así:

  • maxPerRun: 500: una partida que reclama 800 de oro se rechaza con EARNED_ABOVE_MAX.
  • minPerRun: -1000: una partida puede gastar hasta 1.000 de oro; gastar más que el saldo falla con INSUFFICIENT_BALANCE.
  • dailyCap: 5000: los abonos positivos por día UTC se recortan, no se rechazan. Un jugador que ya ha ganado 4.800 hoy y termina una partida de 500 de oro recibe 200.

La respuesta muestra requested y credited para cada clave afectada, de modo que el juego puede mostrar "límite diario alcanzado" en lugar de perder monedas en silencio. Para las compras la regla es sencilla: entrega el objeto solo cuando el gasto se haya abonado por completo (IsFullyCredited en Unity y Unreal). Es el mismo razonamiento que hay detrás de sacar las tiendas de mejoras de los valores por defecto del cliente: el dispositivo puede pedir, solo el servidor concede.

Cloud Save sigue funcionando como antes y se convierte en un espejo. Copia los valores del servidor en el save después de cada partida aceptada para mostrarlos sin conexión, sobrescribe esa copia con GetState al arrancar y nunca devuelvas un valor del save como saldo.

Perfiles de jugador en cada entrada de la clasificación

La segunda mitad de esta actualización trata de las personas de la tabla más que de las cifras. Cada entrada de la clasificación en Top, Around y Rank lleva ahora un perfil con avatarId, frameId y hasta tres badges:

{ "position": 1, "username": "Gravedigger", "score": 15000,
  "profile": { "avatarId": "avatar.zombie_07", "frameId": "frame.gold", "badges": ["badge.supporter"] } }

El servidor lee el perfil junto con el nombre visible desde su caché de nombres de usuario, así que una lista top no cuesta ninguna consulta extra por entrada. Los cambios de perfil aparecen en todos los servidores en un máximo de 10 minutos.

Un catálogo de IDs, no de imágenes

Cada proyecto mantiene un catálogo de cosméticos en el Dashboard. Una entrada tiene un ID (por ejemplo avatar.zombie_07), un tipo (avatar, frame o badge) y un flag locked. Cualquier jugador puede elegir las entradas libres; las bloqueadas, solo quienes las hayan desbloqueado. El servidor solo guarda IDs, nunca imágenes: tu juego asigna a cada ID sus propios sprites, así que el arte se queda en tu build y un marco nuevo cuesta una fila del catálogo.

Así se renderiza una fila de la tabla en Godot:

var entries := await Horizon.leaderboard.getTop(10, true, "weekly")
for entry in entries:
	var row := RowScene.instantiate()
	row.set_rank(entry.position, entry.username, entry.score)
	if entry.profile.hasAvatar():
		row.set_avatar(AVATARS.get(entry.profile.avatarId, DEFAULT_AVATAR))
	if entry.profile.hasFrame():
		row.set_frame(FRAMES.get(entry.profile.frameId))
	for badge_id in entry.profile.badges:
		row.add_badge(BADGES.get(badge_id))
	$Board.add_child(row)

Los IDs desconocidos (por ejemplo, después de borrar una entrada del catálogo) deberían mostrarse simplemente como "sin definir": los jugadores conservan el ID obsoleto hasta que cambian su perfil, y nada se rompe.

Desbloqueos mediante códigos de regalo

Los desbloqueos solo los escribe el servidor, hoy mediante códigos de regalo con grants o a mano en el Dashboard. Un jugador puede tener hasta 25 desbloqueos. Canjear un código con grants devuelve los IDs concedidos y descarta el perfil en caché, de modo que el siguiente GetProfile muestra el nuevo objeto como disponible:

var result := await Horizon.giftCodes.redeem("SPOOKY-FRAME-2026")
if not result.is_empty() and not result["grantedUnlocks"].is_empty():
	await Horizon.playerProfile.getProfile() # reloads catalog and unlocks
	if Horizon.playerProfile.isAvailable("frame.spooky"):
		await Horizon.playerProfile.setProfile("avatar.zombie_07", "frame.spooky", ["badge.supporter"])

Elegir un objeto bloqueado sin haberlo desbloqueado falla con 403 COSMETIC_LOCKED, y elegir más de tres insignias, con 400 INVALID_BADGES. Así, las recompensas de eventos, los sorteos en Discord y las ventajas para supporters se convierten en un código de regalo cada uno, sin un endpoint de desbloqueo propio. Un resto de la API antigua desaparece para siempre: el parámetro metadata en los envíos de puntuación nunca se almacenó y ahora está obsoleto. La información por jugador pertenece al perfil.

Los límites de puntuación ahora cuentan por API key

Un cambio más pequeño, pero con impacto real para estudios que gestionan varios juegos o entornos en una misma cuenta: el límite de puntuaciones se aplica ahora por API key. Cada clave puede contener tantas filas de puntuación como permita el plan (una fila es un jugador en una tabla), sumadas en todas las tablas de esa clave, y cada clave adicional recibe su propia cuota completa. Las filas existentes siempre se pueden mejorar; solo la primera puntuación de un jugador en una tabla puede recibir un 403 cuando la clave está llena.

Buenas prácticas para clasificaciones justas

  1. Empieza en soft y luego endurece. Mantén una semana solo umbrales soft, revisa las partidas sus en la pestaña Review y convierte las cifras en reglas duras con un margen de seguridad del 20 al 30 por ciento por encima de la mejor partida legítima.
  2. Llama a StartRun cuando la partida empiece de verdad. El reloj arranca con el ticket. Si empiezas en el menú o antes de una pantalla de carga de 40 segundos, minDurationSeconds deja de tener sentido.
  3. Mantén los logs de entradas pequeños y deterministas. Registra frames de entrada fijos con codificación delta. Las pruebas están limitadas a 32 KB por log, y una partida de 10 minutos a 30 frames de entrada por segundo con 1 byte por frame cabe de sobra.
  4. Versiona todo en el contexto de inicio. Sin simulationVersion y contentDigest, un paquete del mes pasado podría reproducirse con el balance de este mes y fallar por el motivo equivocado.
  5. Trata el save como un espejo, nunca como una fuente. Los saldos fluyen del servidor al Cloud Save, nunca al revés.

Lo que no hace

Preferimos decirlo claramente. Un cliente modificado que envía valores plausibles dentro de tus reglas se sigue aceptando: Validated Actions limita cuánto puede ganar un tramposo y hace revisables las partidas sospechosas, pero no hace imposible hacer trampas. El servidor comprueba límites, tiempos y uso único y archiva pruebas, pero no ejecuta ni reproduce el código de tu juego, y todavía no hay un veredicto automático sobre las pruebas. Validated Actions solo está disponible en la nube; en el simpleServer autoalojado, los SDK devuelven NOT_SUPPORTED. Cada partida necesita un jugador con sesión iniciada, y las partidas en una tabla necesitan además un nombre visible.

Capacidad por plan

Todas las funciones de este artículo están disponibles en todos los planes, incluido FREE. Solo crece la capacidad:

FREE BASIC PRO ENTERPRISE
Partidas validadas por cuenta y hora UTC 300 3.000 20.000 200.000
Valores propiedad del servidor por API key 8 16 32 64
Espacios de pruebas para partidas del top N 50 500 2.500 25.000
Paquetes sus almacenados 10 100 1.000 10.000
Retención de paquetes sus 14 días 30 días 90 días 180 días
Catálogo de cosméticos por API key 50 200 500 1.000
Filas de puntuación por API key 5.000 25.000 200.000 2.500.000

Cuando la cuota de paquetes sus está llena, las nuevas partidas sus se siguen aceptando y marcando como sus; simplemente no se archivan. Los paquetes nuevos nunca desplazan a los ya almacenados.

Primeros pasos

Actualiza al SDK más reciente para Unity, Godot o Unreal, elige una tabla y añade un conjunto de reglas solo con umbrales soft. Añade un ValidatedRunContext a tu llamada a StartRun y luego llena tu catálogo de cosméticos con los avatares y marcos que ya incluyes en el juego. La página de la función Validated Actions y la página de la función de clasificaciones resumen reglas y límites, y el quickstart recorre los tres motores. ¿Listo para que tu próxima clasificación sea justa y personal a la vez? Prueba horizOn gratis o sumérgete en la documentación de la API.