Eerlijke leaderboards zonder servercode: gevalideerde runs, sus-pakketten en spelersprofielen
Kort samengevat
Bouw door de server gevalideerde leaderboards zonder servercode: eenmalige runtickets, afspeelbare sus-pakketten, servervaluta en spelersprofielen.
De eerste week van een nieuw leaderboard eindigt meestal op dezelfde manier: ergens tussen de eerlijke top tien en de rest van het bord staat een speler met 2.147.483.647 punten, de grootste waarde die een signed 32-bit integer kan bevatten. Niemand heeft die run gespeeld. Een memory editor of een onderscheppende Proxy heeft hem geschreven, omdat bij de meeste leaderboards de gameclient de enige getuige is en tegelijk de rechter.
Deze release pakt die zwakke plek van twee kanten aan. Validated Actions verplaatst de beslissing over wat telt naar de server, en de nieuwe sus-runpakketten bewaren elke verdachte run als een compleet, opnieuw af te spelen dossier. Tegelijk worden leaderboards persoonlijker: elke entry heeft nu een spelersprofiel met avatar, frame en maximaal drie badges, en cosmetics zijn te ontgrendelen via cadeaucodes. Hieronder lees je hoe elk onderdeel werkt, welke cijfers erachter zitten, werkende code voor Unity en Godot, en de grenzen die je moet kennen voordat je erop vertrouwt.
Waarom je scores die de client meldt niet kunt vertrouwen
Een klassieke score-submit is één request: speler-ID, score, klaar. Alles wat de server over die run weet, komt van het apparaat dat het meeste belang heeft bij liegen. De gebruikelijke tegenmaatregelen hebben allemaal dezelfde zwakte:
- Het request in de client ondertekenen. De signing key zit gewoon in je build. Hem uit een IL2CPP-binary of een GDScript-export halen kost een middag, en vanaf dat moment zien vervalste requests er volkomen geldig uit.
- De score in het geheugen obfusceren. Dat vertraagt simpele memory editors, maar een Proxy die de HTTP-body herschrijft, komt nooit aan je geheugenindeling.
- Plausibiliteitschecks in de client. Alles wat op het apparaat draait, kan op het apparaat worden weggepatcht.
Het robuuste antwoord is dat de server bepaalt wat telt. De schoolboekversie daarvan is een autoritatieve simulatie: je gamelogica draait op hardware die jij beheert en de client stuurt alleen inputs. Voor een snelle actiegame met duizenden gelijktijdige runs betekent dat weken Netcode-werk plus een blijvende hostingrekening. De meeste indieteams hebben de volledige simulatie niet nodig. Ze hebben drie goedkopere garanties nodig: de server weet wanneer een run begon, welke regels ervoor gelden en welk bewijs er achteraf is. Precies dat gat vult Validated Actions.
Zo werkt een gevalideerde run
Een gevalideerde run heeft vier stappen, en de server beheert de twee die ertoe doen:
- Start van de run. De game roept
StartRunaan. De server geeft een ondertekend ticket voor eenmalig gebruik uit, gekoppeld aan de speler, de API-key en optioneel één leaderboard. Het ticket bevat een door de server gekozen Seed (0 tot 2.147.483.646) en een vervaltijd: standaard 2 uur, instelbaar van 60 seconden tot 6 uur. - Spelen. De game initialiseert zijn randomness met de server-Seed en legt de inputs van de speler vast in een compacte bytelog.
- Einde van de run. De game roept
SubmitValidatedaan met de score, een optionele stage, optionele verdiende waarden en de inputlog. De SDK stuurt de SHA-256-hash van de log, dus de log zelf blijft voorlopig op het apparaat. - Servercheck. De server verifieert het ticket, meet de duur zelf (van uitgifte van het ticket tot de submit) en past jouw regels toe. Pas daarna schrijft hij iets weg.
Een ticket telt precies één keer, over alle regio's heen. Een afgewezen run verbrandt zijn ticket ook, dus niemand kan je drempels aftasten door hetzelfde ticket opnieuw te proberen met iets lagere scores. Omdat de server de Seed kiest en het aantal tickets per speler per uur beperkt (standaard 60), wordt farmen naar een gelukkige Seed traag en zichtbaar.
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}");
Dezelfde flow bestaat in Godot (Horizon.validatedActions.startRun en submitValidated) en Unreal (Horizon->ValidatedActions), elk met een compleet voorbeeld in de SDK.
Regels die alleen de server kent
Elke API-key krijgt één regelset, die je in het Dashboard bewerkt als formulier of als JSON. De regels verschijnen nooit in app-responses of foutmeldingen: de client krijgt alleen een machineleesbare code te zien. Hier is een realistische regelset voor een arcadegame met waves:
{
"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 }
}
}
Loop een paar submits erdoorheen op het bord weekly:
| Ingediende run | Antwoord van de server |
|---|---|
| 9.999.999 punten | 422 SCORE_ABOVE_MAX |
| 21.000 punten, 3 seconden na het ticket | 422 DURATION_TOO_SHORT |
| 150.000 punten in 120 seconden (1.250 per seconde) | 422 SCORE_RATE_TOO_HIGH |
| 190.000 punten in 240 seconden | geaccepteerd, maar sus (boven het zachte maximum van 180.000) |
| hetzelfde ticket een tweede keer | 422 TICKET_CONSUMED |
De server controleert in een vaste volgorde (stage, score, stageregel, duur, score per seconde, verdiende waarden en daarna de zachte drempels) en de eerste fout beslist. Scoreregels gelden alleen voor runs met een bord; bij een run zonder bord worden de stage- en duurregels nog steeds gecontroleerd.
Zodra de regels kloppen, zet je het bord op "Alleen gevalideerde inzendingen". Vanaf dat moment wordt een gewone SubmitScore naar dat bord geweigerd met 403 VALIDATED_SUBMIT_REQUIRED, terwijl alle andere borden gewone submits blijven accepteren. Zelfs met een lege regelset garandeert een bord met alleen gevalideerde submits servertickets, eenmalig gebruik, koppeling aan de speler, de limieten per uur en een opgeslagen loghash.
Zachte drempels en sus-runs
Harde regels wijzen af. Zachte drempels markeren een run alleen, en daar moet je beginnen: een week met zachte limieten laat zien hoe echt spel eruitziet voordat je de getallen omzet in harde afwijzingen. Een run is sus als hij is geaccepteerd en minstens één zachte drempel heeft overschreden. Afgewezen runs en technische fouten zijn nooit sus. Het submitresultaat bevat een simpele sus-flag; welke drempel afging, blijft op de server.
Het interessante deel is wat er daarna gebeurt. Met deze update bewaart de server een sus-pakket voor elke sus-run, los van de beste score van de speler, de Top N of latere scorewijzigingen. Een valsspeler die één absurde run post en daarna een normale, kan het bewijs niet meer begraven onder een betere entry.
De startcontext: waarmee de run begon
Een Replay is alleen bruikbaar als je de exacte startcondities kunt reproduceren. Daarom legt de server bij StartRun nu de startcontext vast: de geldende regelversie, de echte bytes van de Cloud Save van de speler (met SHA-256 en revisie), de waarden die de server beheert, de Seed en het starttijdstip. De game kan zijn eigen kant van het verhaal toevoegen:
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);
Serverwaarden en clientwaarden blijven strikt gescheiden, en beide voeden een canonieke tekst waarvan de SHA-256 op het ticket wordt opgeslagen. De submit wordt ook getoetst aan de regelversie van de start, dus een limiet aanscherpen terwijl een run loopt verandert nooit het oordeel over die run. Om te voorkomen dat regelwijzigingen worden misbruikt om af te tasten, zijn wijzigingen beperkt tot 30 per API-key per uur.
Wat een sus-pakket bevat
Elk pakket wordt opgeslagen onder de run-ID en bundelt alles wat een reviewer nodig heeft: de startcontext, de gekoppelde regels, het resultaat, de serverwaarden voor en na de run en de inputlog (de SDK uploadt die automatisch, net als bij Top N-runs). In het tabblad Review van het Dashboard filter je op sus en exporteer je een pakket als ZIP-bestand:
<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
Bij elke export berekent de server alle checksums opnieuw en meldt het resultaat in manifest.integrity en in de header X-Package-Integrity (ok of mismatch). Past één onderdeel niet binnen de groottelimiet, dan wordt alleen de checksum ervan bewaard en wordt het onderdeel gemarkeerd als OMITTED_SIZE_LIMIT, zodat je altijd weet wat er ontbreekt en waarom.
Voer cloud-save.bin, initial-state.bin, de Seed en input-log.bin in je eigen deterministische simulatie in, en je reproduceert de score of niet. Replays, oordelen en sancties blijven bij jou; de server markeert en archiveert, maar draait nooit je gamecode.
Valuta die de client niet kan schrijven
Leaderboards zijn niet het enige waarbij valsspelen loont. Met serverbeheerde waarden definieer je tellers zoals gold of gems in dezelfde regelset. Alleen geaccepteerde gevalideerde runs wijzigen ze: er is geen app-endpoint en geen SDK-methode die een saldo instelt. De regel uit de JSON hierboven leest zo:
maxPerRun: 500: een run die 800 goud claimt, wordt afgewezen metEARNED_ABOVE_MAX.minPerRun: -1000: een run mag tot 1.000 goud uitgeven; meer uitgeven dan het saldo mislukt metINSUFFICIENT_BALANCE.dailyCap: 5000: positieve credits per UTC-dag worden afgekapt, niet afgewezen. Een speler die vandaag al 4.800 heeft verdiend en een run van 500 goud afrondt, krijgt er 200 bijgeschreven.
De response toont requested en credited voor elke geraakte key, zodat de game "daglimiet bereikt" kan tonen in plaats van stilletjes munten kwijt te raken. Voor aankopen is de regel simpel: geef het item pas als de uitgave volledig is verwerkt (IsFullyCredited in Unity en Unreal). Het is dezelfde redenering als achter upgradeshops weghalen bij client-side defaults: het apparaat mag vragen, alleen de server kent toe.
Cloud Save werkt zoals voorheen en wordt een spiegel. Kopieer de serverwaarden na elke geaccepteerde run naar de save voor offline weergave, overschrijf die kopie bij het opstarten met GetState en stuur nooit een waarde uit de save terug als saldo.
Spelersprofielen bij elke leaderboard-entry
De tweede helft van deze update gaat over de mensen op het bord in plaats van de getallen. Elke leaderboard-entry in Top, Around en Rank heeft nu een profiel met avatarId, frameId en maximaal drie badges:
{ "position": 1, "username": "Gravedigger", "score": 15000,
"profile": { "avatarId": "avatar.zombie_07", "frameId": "frame.gold", "badges": ["badge.supporter"] } }
De server leest het profiel samen met de weergavenaam uit zijn username-cache, dus een toplijst kost geen extra lookup per entry. Profielwijzigingen zijn uiterlijk binnen 10 minuten op elke server zichtbaar.
Een catalogus van ID's, geen afbeeldingen
Elk project beheert een cosmeticscatalogus in het Dashboard. Een entry heeft een ID (bijvoorbeeld avatar.zombie_07), een type (avatar, frame of badge) en een locked-flag. Vrije entries kan elke speler kiezen; vergrendelde entries alleen spelers die ze hebben ontgrendeld. De server slaat alleen ID's op, nooit afbeeldingen: je game koppelt elke ID aan zijn eigen sprites, dus de art blijft in je build en een nieuw frame kost één rij in de catalogus.
Een rij van het bord renderen in Godot ziet er zo uit:
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)
Onbekende ID's (bijvoorbeeld nadat je een catalogusentry hebt verwijderd) moeten gewoon als "niet ingesteld" worden weergegeven: spelers houden de verouderde ID tot ze hun profiel wijzigen, en er gaat niets stuk.
Ontgrendelen via cadeaucodes
Unlocks worden alleen door de server geschreven, op dit moment via cadeaucodes met grants of handmatig in het Dashboard. Een speler kan maximaal 25 unlocks hebben. Een code met grants inwisselen geeft de toegekende ID's terug en gooit het gecachete profiel weg, zodat de volgende GetProfile het nieuwe item als beschikbaar toont:
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"])
Een vergrendeld item kiezen zonder unlock mislukt met 403 COSMETIC_LOCKED, meer dan drie badges met 400 INVALID_BADGES. Zo worden eventbeloningen, Discord-giveaways en supportervoordelen elk één cadeaucode, zonder eigen unlock-endpoint. Eén restant van de oude API is definitief verdwenen: de parameter metadata bij score-submits werd nooit opgeslagen en is nu deprecated. Informatie per speler hoort in het profiel.
Scorelimieten tellen nu per API-key
Een kleinere wijziging met echte impact voor studio's die meerdere games of omgevingen in één account draaien: de scorelimiet geldt nu per API-key. Elke key mag zoveel scorerijen bevatten als het abonnement toestaat (één rij is één speler op één bord), opgeteld over alle borden van die key, en elke extra key krijgt zijn eigen volledige tegoed. Bestaande rijen kunnen altijd worden verbeterd; alleen de eerste score van een speler op een bord kan een 403 krijgen als de key vol is.
Best practices voor eerlijke leaderboards
- Begin zacht, maak het daarna hard. Draai een week met alleen
soft-drempels, bekijk de sus-runs in het tabblad Review en zet de getallen om in harde regels met een veiligheidsmarge van 20 tot 30 procent boven de beste legitieme run. - Roep
StartRunaan wanneer de run echt begint. De klok start bij het ticket. Starten in het menu of vóór een laadscherm van 40 seconden maaktminDurationSecondszinloos. - Houd inputlogs klein en deterministisch. Leg vaste inputframes vast met delta-encoding. Bewijs is beperkt tot 32 KB per log, en een run van 10 minuten met 30 inputframes per seconde en 1 byte per frame past daar ruim in.
- Versioneer alles in de startcontext. Zonder
simulationVersionencontentDigestkan een pakket van vorige maand worden afgespeeld tegen de balancing van deze maand en om de verkeerde reden falen. - Behandel de save als spiegel, nooit als bron. Saldi stromen van de server naar Cloud Save, nooit terug.
Wat het niet doet
We zeggen het liever ronduit. Een aangepaste client die plausibele waarden binnen jouw regels meldt, wordt nog steeds geaccepteerd: Validated Actions beperkt hoeveel een valsspeler kan winnen en maakt verdachte runs controleerbaar, het maakt valsspelen niet onmogelijk. De server controleert grenzen, timing en eenmalig gebruik en archiveert bewijs, maar draait of herspeelt je gamecode niet, en er is nog geen automatisch oordeel over bewijs. Validated Actions is alleen beschikbaar in de cloud; op de self-hosted simpleServer melden de SDK's NOT_SUPPORTED. Elke run heeft een ingelogde speler nodig, en runs op een leaderboard hebben ook een weergavenaam nodig.
Capaciteit per abonnement
Elke feature in deze post is beschikbaar op elk abonnement, ook FREE. Alleen de capaciteit groeit:
| FREE | BASIC | PRO | ENTERPRISE | |
|---|---|---|---|---|
| Gevalideerde runs per account en UTC-uur | 300 | 3.000 | 20.000 | 200.000 |
| Serverbeheerde waarden per API-key | 8 | 16 | 32 | 64 |
| Bewijsslots voor Top N-runs | 50 | 500 | 2.500 | 25.000 |
| Opgeslagen sus-pakketten | 10 | 100 | 1.000 | 10.000 |
| Bewaartermijn sus-pakketten | 14 dagen | 30 dagen | 90 dagen | 180 dagen |
| Cosmeticscatalogus per API-key | 50 | 200 | 500 | 1.000 |
| Scorerijen per API-key | 5.000 | 25.000 | 200.000 | 2.500.000 |
Als het quotum voor sus-pakketten vol is, worden nieuwe sus-runs nog steeds geaccepteerd en als sus gemeld, ze worden alleen niet gearchiveerd. Opgeslagen pakketten worden nooit door nieuwe verdrongen.
Aan de slag
Update naar de nieuwste SDK voor Unity, Godot of Unreal, kies één bord en voeg een regelset toe met alleen zachte drempels. Voeg een ValidatedRunContext toe aan je StartRun-aanroep en vul daarna je cosmeticscatalogus met de avatars en frames die je al meelevert. De featurepagina van Validated Actions en de featurepagina van het leaderboard vatten regels en limieten samen, en de quickstart loopt alle drie de engines door. Klaar om je volgende leaderboard eerlijk én persoonlijk te maken? Probeer horizOn gratis of duik in de API-documentatie.