Des classements équitables sans code serveur : parties validées, paquets sus et profils joueur
En bref
Créez des classements validés par le serveur sans code serveur : tickets à usage unique, paquets sus rejouables, monnaie serveur et profils joueur.
La première semaine d'un nouveau classement se termine généralement de la même façon : quelque part entre le top 10 honnête et le reste du classement trône un joueur avec 2 147 483 647 points, la plus grande valeur que peut contenir un entier signé de 32 bits. Personne n'a joué cette partie. Un éditeur de mémoire ou un proxy d'interception l'a écrite, car dans la plupart des classements, le client du jeu est à la fois le seul témoin et le juge.
Cette version s'attaque à ce point faible sur deux fronts. Validated Actions confie au serveur la décision de ce qui compte, et les nouveaux paquets de parties sus conservent chaque partie suspecte sous forme de dossier complet et rejouable. En parallèle, les classements deviennent plus personnels : chaque entrée porte désormais un profil joueur avec avatar, cadre et jusqu'à trois badges, et des cosmétiques peuvent être débloqués via des codes cadeaux. Vous trouverez ci-dessous le fonctionnement de chaque brique, les chiffres qui la sous-tendent, du code fonctionnel pour Unity et Godot, ainsi que les limites à connaître avant de vous y fier.
Pourquoi les scores envoyés par le client ne sont pas fiables
Un envoi de score classique tient en une seule requête : ID du joueur, score, terminé. Tout ce que le serveur sait de cette partie provient de l'appareil qui a le plus intérêt à mentir. Les contre-mesures habituelles partagent toutes le même défaut :
- Signer la requête dans le client. La clé de signature est livrée dans votre build. L'extraire d'un binaire IL2CPP ou d'un export GDScript prend un après-midi, et à partir de là, les requêtes falsifiées paraissent parfaitement valides.
- Obfusquer le score en mémoire. Cela ralentit les éditeurs de mémoire occasionnels, mais un proxy qui réécrit le corps HTTP ne touche jamais à l'agencement de votre mémoire.
- Contrôles de plausibilité dans le client. Tout ce qui tourne sur l'appareil peut être neutralisé par un patch sur l'appareil.
La réponse robuste, c'est que le serveur décide de ce qui compte. La version classique consiste en une simulation autoritaire : votre logique de jeu tourne sur du matériel que vous contrôlez et le client n'envoie que des entrées. Pour un jeu d'action nerveux avec des milliers de parties simultanées, cela représente des semaines de travail de netcode, plus une facture d'hébergement permanente. La plupart des équipes indé n'ont pas besoin de la simulation complète. Elles ont besoin de trois garanties moins coûteuses : le serveur sait quand une partie a commencé, quelles règles s'y appliquent et quelles preuves existent ensuite. C'est exactement le vide que comble Validated Actions.
Comment fonctionne une partie validée
Une partie validée comporte quatre étapes, et le serveur maîtrise les deux qui comptent :
- Début de partie. Le jeu appelle
StartRun. Le serveur émet un ticket à usage unique signé, lié au joueur, à la clé API et éventuellement à un classement. Le ticket contient une seed choisie par le serveur (de 0 à 2 147 483 646) et une date d'expiration : 2 heures par défaut, configurable de 60 secondes à 6 heures. - Jeu. Le jeu initialise son aléatoire avec la seed du serveur et enregistre les entrées du joueur dans un log d'octets compact.
- Fin de partie. Le jeu appelle
SubmitValidatedavec le score, un niveau (stage) facultatif, des valeurs gagnées facultatives et le log d'entrées. Le SDK envoie le hash SHA-256 du log, le log lui-même reste donc sur l'appareil pour l'instant. - Vérification serveur. Le serveur vérifie le ticket, mesure lui-même la durée (de l'émission du ticket à l'envoi) et applique vos règles. Ce n'est qu'ensuite qu'il écrit quoi que ce soit.
Un ticket compte exactement une fois, toutes régions confondues. Une partie rejetée brûle aussi son ticket, si bien que personne ne peut sonder vos seuils en réessayant le même ticket avec des scores légèrement inférieurs. Comme le serveur choisit la seed et limite les tickets par joueur et par heure (60 par défaut), la chasse à la seed chanceuse devient lente et 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}");
Le même flux existe dans Godot (Horizon.validatedActions.startRun et submitValidated) et dans Unreal (Horizon->ValidatedActions), chacun avec un exemple complet dans le SDK.
Des règles que seul le serveur connaît
Chaque clé API dispose d'un jeu de règles, modifiable dans le Dashboard sous forme de formulaire ou de JSON. Les règles n'apparaissent jamais dans les réponses de l'app ni dans les messages d'erreur : le client ne reçoit jamais qu'un code lisible par machine. Voici un jeu de règles réaliste pour un jeu d'arcade à vagues :
{
"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 }
}
}
Faites passer quelques envois sur le classement weekly :
| Partie envoyée | Réponse du serveur |
|---|---|
| 9 999 999 points | 422 SCORE_ABOVE_MAX |
| 21 000 points, 3 secondes après le ticket | 422 DURATION_TOO_SHORT |
| 150 000 points en 120 secondes (1 250 par seconde) | 422 SCORE_RATE_TOO_HIGH |
| 190 000 points en 240 secondes | acceptée, mais sus (au-dessus du maximum soft de 180 000) |
| le même ticket une deuxième fois | 422 TICKET_CONSUMED |
Le serveur vérifie dans un ordre fixe (niveau, score, règle de niveau, durée, score par seconde, valeurs gagnées, puis seuils soft) et le premier échec l'emporte. Les règles de score ne s'appliquent qu'aux parties associées à un classement ; une partie sans classement voit tout de même ses règles de niveau et de durée vérifiées.
Une fois les règles au point, activez sur le classement l'option « Envois validés uniquement ». Dès lors, un simple SubmitScore vers ce classement est refusé avec 403 VALIDATED_SUBMIT_REQUIRED, tandis que tous les autres classements continuent d'accepter les envois normaux. Même avec un jeu de règles vide, un classement en mode validé uniquement garantit des tickets serveur, l'usage unique, le lien au joueur, les limites horaires et un hash de log stocké.
Seuils soft et parties sus
Les règles strictes rejettent. Les seuils soft se contentent de marquer une partie, et c'est par là que vous devriez commencer : une semaine de limites soft vous montre à quoi ressemble le jeu réel avant que vous ne transformiez les chiffres en rejets stricts. Une partie est sus lorsqu'elle a été acceptée et a franchi au moins un seuil soft. Les parties rejetées et les erreurs techniques ne sont jamais sus. Le résultat de l'envoi contient un simple indicateur sus ; le seuil déclenché, lui, reste sur le serveur.
Le plus intéressant, c'est la suite. Avec cette mise à jour, le serveur conserve un paquet sus pour chaque partie sus, indépendamment du meilleur score du joueur, du top N ou de modifications ultérieures du score. Un tricheur qui publie une partie absurde puis une partie normale ne peut plus enterrer les preuves sous une meilleure entrée.
Le contexte de départ : avec quoi la partie a commencé
Un replay n'est utile que si vous pouvez reproduire exactement les conditions de départ. C'est pourquoi, lors de StartRun, le serveur enregistre désormais le contexte de départ : la version des règles en vigueur, les octets réels du Cloud Save du joueur (avec SHA-256 et révision), les valeurs détenues par le serveur, la seed et l'heure de début. Le jeu peut ajouter sa propre version des faits :
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);
Les valeurs serveur et les valeurs client restent strictement séparées, et toutes alimentent un texte canonique dont le SHA-256 est stocké sur le ticket. L'envoi est en outre vérifié par rapport à la version des règles du départ, si bien que durcir une limite pendant qu'une partie est en cours ne change jamais le verdict de cette partie. Pour éviter que les modifications de règles ne servent de sonde, elles sont limitées à 30 par clé API et par heure.
Contenu d'un paquet sus
Chaque paquet est stocké sous l'ID de la partie et regroupe tout ce dont un relecteur a besoin : le contexte de départ, les règles liées, le résultat, les valeurs détenues par le serveur avant et après la partie, ainsi que le log d'entrées (le SDK l'envoie automatiquement, comme pour les parties du top N). Dans l'onglet Review du Dashboard, vous filtrez sur sus et exportez un paquet sous forme de fichier 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
À chaque export, le serveur recalcule toutes les sommes de contrôle et indique le résultat dans manifest.integrity et dans l'en-tête X-Package-Integrity (ok ou mismatch). Si un élément du paquet dépasse la limite de taille, seule sa somme de contrôle est conservée et l'élément est marqué OMITTED_SIZE_LIMIT, pour que vous sachiez toujours ce qui manque et pourquoi.
Injectez cloud-save.bin, initial-state.bin, la seed et input-log.bin dans votre propre simulation déterministe : soit vous reproduisez le score, soit non. Replays, verdicts et sanctions restent entre vos mains ; le serveur marque et archive, il n'exécute jamais le code de votre jeu.
Une monnaie que le client ne peut pas écrire
Les classements ne sont pas la seule cible des tricheurs. Avec les valeurs détenues par le serveur, vous définissez des compteurs comme gold ou gems dans le même jeu de règles. Seules les parties validées et acceptées les modifient : aucun endpoint de l'app ni aucune méthode du SDK ne permet de définir un solde. La règle du JSON ci-dessus se lit ainsi :
maxPerRun: 500: une partie qui réclame 800 pièces d'or est rejetée avecEARNED_ABOVE_MAX.minPerRun: -1000: une partie peut dépenser jusqu'à 1 000 pièces d'or ; dépenser plus que le solde échoue avecINSUFFICIENT_BALANCE.dailyCap: 5000: les crédits positifs par jour UTC sont plafonnés, pas rejetés. Un joueur qui a déjà gagné 4 800 aujourd'hui et termine une partie à 500 pièces d'or se voit créditer 200.
La réponse indique requested et credited pour chaque clé concernée, afin que le jeu puisse afficher « limite quotidienne atteinte » au lieu de perdre des pièces en silence. Pour les achats, la règle est simple : n'accordez l'objet que si la dépense a été entièrement créditée (IsFullyCredited dans Unity et Unreal). C'est le même raisonnement qui pousse à sortir les boutiques d'améliorations des valeurs par défaut côté client : l'appareil peut demander, seul le serveur accorde.
Cloud Save fonctionne comme avant et devient un miroir. Copiez les valeurs serveur dans la sauvegarde après chaque partie acceptée pour l'affichage hors ligne, écrasez cette copie avec GetState au démarrage et ne renvoyez jamais une valeur de la sauvegarde comme solde.
Des profils joueur sur chaque entrée du classement
La seconde moitié de cette mise à jour concerne les personnes du classement plutôt que les chiffres. Chaque entrée de classement dans Top, Around et Rank porte désormais un profil avec avatarId, frameId et jusqu'à trois badges :
{ "position": 1, "username": "Gravedigger", "score": 15000,
"profile": { "avatarId": "avatar.zombie_07", "frameId": "frame.gold", "badges": ["badge.supporter"] } }
Le serveur lit le profil en même temps que le nom affiché depuis son cache de noms d'utilisateur, de sorte qu'une liste top ne coûte aucune requête supplémentaire par entrée. Les modifications de profil apparaissent sur chaque serveur en 10 minutes maximum.
Un catalogue d'ID, pas d'images
Chaque projet gère un catalogue de cosmétiques dans le Dashboard. Une entrée possède un ID (par exemple avatar.zombie_07), un type (avatar, frame ou badge) et un indicateur locked. Les entrées libres peuvent être choisies par n'importe quel joueur ; les entrées verrouillées, uniquement par les joueurs qui les ont débloquées. Le serveur ne stocke que des ID, jamais d'images : votre jeu associe chaque ID à ses propres sprites, les graphismes restent donc dans votre build et un nouveau cadre ne coûte qu'une ligne de catalogue.
Voici à quoi ressemble le rendu d'une ligne de classement dans 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)
Les ID inconnus (par exemple après la suppression d'une entrée du catalogue) doivent simplement s'afficher comme « non défini » : les joueurs conservent l'ID obsolète jusqu'à ce qu'ils modifient leur profil, et rien ne casse.
Déblocages via codes cadeaux
Seul le serveur écrit les déblocages, aujourd'hui via des codes cadeaux avec grants ou manuellement dans le Dashboard. Un joueur peut détenir jusqu'à 25 déblocages. Utiliser un code avec grants renvoie les ID accordés et invalide le profil en cache, si bien que le prochain GetProfile affiche le nouvel objet comme 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"])
Choisir un objet verrouillé sans l'avoir débloqué échoue avec 403 COSMETIC_LOCKED, choisir plus de trois badges avec 400 INVALID_BADGES. Récompenses d'événements, giveaways Discord et avantages pour les supporters deviennent ainsi chacun un simple code cadeau, sans endpoint de déblocage sur mesure. Un vestige de l'ancienne API disparaît définitivement : le paramètre metadata des envois de score n'a jamais été stocké et est désormais obsolète. Les informations propres à chaque joueur ont leur place dans le profil.
Les limites de scores comptent désormais par clé API
Un changement plus modeste, mais à l'impact réel pour les studios qui gèrent plusieurs jeux ou environnements dans un même compte : la limite de scores s'applique désormais par clé API. Chaque clé peut contenir autant de lignes de score que le permet l'offre (une ligne correspond à un joueur sur un classement), cumulées sur tous les classements de cette clé, et chaque clé supplémentaire dispose de son propre quota complet. Les lignes existantes peuvent toujours être améliorées ; seul le premier score d'un joueur sur un classement peut renvoyer 403 lorsque la clé est pleine.
Bonnes pratiques pour des classements équitables
- Commencez en soft, puis durcissez. Faites tourner une semaine avec uniquement des seuils
soft, examinez les parties sus dans l'onglet Review et transformez les chiffres en règles strictes avec une marge de sécurité de 20 à 30 % au-dessus de la meilleure partie légitime. - Appelez
StartRunquand la partie commence vraiment. Le chronomètre démarre avec le ticket. Démarrer dans le menu ou avant un écran de chargement de 40 secondes rendminDurationSecondsinutile. - Gardez des logs d'entrées petits et déterministes. Enregistrez des frames d'entrée fixes avec un encodage delta. Les preuves sont plafonnées à 32 Ko par log, et une partie de 10 minutes à 30 frames d'entrée par seconde avec 1 octet par frame y tient largement.
- Versionnez tout dans le contexte de départ. Sans
simulationVersionnicontentDigest, un paquet du mois dernier risque d'être rejoué avec l'équilibrage de ce mois-ci et d'échouer pour la mauvaise raison. - Traitez la sauvegarde comme un miroir, jamais comme une source. Les soldes vont du serveur vers le Cloud Save, jamais dans l'autre sens.
Ce que cela ne fait pas
Nous préférons le dire clairement. Un client modifié qui envoie des valeurs plausibles dans le cadre de vos règles reste accepté : Validated Actions limite ce qu'un tricheur peut gagner et rend les parties suspectes vérifiables, mais ne rend pas la triche impossible. Le serveur vérifie les bornes, le timing et l'usage unique et archive les preuves, mais il n'exécute ni ne rejoue le code de votre jeu, et il n'existe pas encore de verdict automatique sur les preuves. Validated Actions est disponible uniquement dans le cloud ; sur le simpleServer auto-hébergé, les SDK renvoient NOT_SUPPORTED. Chaque partie nécessite un joueur connecté, et les parties sur un classement nécessitent en plus un nom affiché.
Capacité par offre
Toutes les fonctionnalités de cet article sont disponibles dans toutes les offres, y compris FREE. Seule la capacité augmente :
| FREE | BASIC | PRO | ENTERPRISE | |
|---|---|---|---|---|
| Parties validées par compte et par heure UTC | 300 | 3 000 | 20 000 | 200 000 |
| Valeurs détenues par le serveur par clé API | 8 | 16 | 32 | 64 |
| Emplacements de preuves pour les parties du top N | 50 | 500 | 2 500 | 25 000 |
| Paquets sus stockés | 10 | 100 | 1 000 | 10 000 |
| Conservation des paquets sus | 14 jours | 30 jours | 90 jours | 180 jours |
| Catalogue de cosmétiques par clé API | 50 | 200 | 500 | 1 000 |
| Lignes de score par clé API | 5 000 | 25 000 | 200 000 | 2 500 000 |
Lorsque le quota de paquets sus est plein, les nouvelles parties sus sont toujours acceptées et signalées comme sus ; elles ne sont simplement pas archivées. Les paquets stockés ne sont jamais évincés par de nouveaux.
Pour commencer
Passez au dernier SDK pour Unity, Godot ou Unreal, choisissez un classement et ajoutez un jeu de règles avec uniquement des seuils soft. Ajoutez un ValidatedRunContext à votre appel StartRun, puis remplissez votre catalogue de cosmétiques avec les avatars et cadres que vous livrez déjà. La page de la fonctionnalité Validated Actions et la page de la fonctionnalité classements résument règles et limites, et le quickstart couvre les trois moteurs. Prêt à rendre votre prochain classement à la fois équitable et personnel ? Essayez horizOn gratuitement ou plongez dans la documentation de l'API.