Fair Leaderboards Without Server Code: Validated Runs, Sus Packages and Player Profiles
In a nutshell
Build server-validated leaderboards without server code: single-use run tickets, replayable sus packages, server-owned currency and player profiles.
Week one of a new leaderboard usually ends the same way: somewhere between the honest top ten and the rest of the board sits a player with 2,147,483,647 points, the largest value a signed 32-bit integer can hold. Nobody played that run. A memory editor or an intercepting proxy wrote it, because on most leaderboards the game client is the only witness and also the judge.
This release tackles that weak spot from two sides. Validated Actions moves the decision about what counts to the server, and the new sus run packages keep every suspicious run as a complete, replayable case file. At the same time, leaderboards get more personal: every entry now carries a player profile with avatar, frame and up to three badges, and cosmetics can be unlocked through gift codes. Below you will find how each piece works, the numbers behind it, working code for Unity and Godot, and the limits we want you to know before you rely on it.
Why Client-Reported Scores Cannot Be Trusted
A classic score submit is a single request: player ID, score, done. Everything the server knows about that run comes from the device that has the strongest interest in lying about it. The usual counter measures all share the same flaw:
- Signing the request in the client. The signing key ships inside your build. Extracting it from an IL2CPP binary or a GDScript export takes an afternoon, and from then on forged requests look perfectly valid.
- Obfuscating the score in memory. This slows down casual memory editors, but a proxy that rewrites the HTTP body never touches your memory layout.
- Plausibility checks in the client. Whatever runs on the device can be patched out on the device.
The robust answer is that the server decides what counts. The textbook version of that is an authoritative simulation: your game logic runs on hardware you control and the client only sends inputs. For a fast action game with thousands of concurrent runs, that is weeks of netcode work plus a permanent hosting bill. Most indie teams do not need the full simulation. They need three cheaper guarantees: the server knows when a run started, which rules apply to it, and what evidence exists afterwards. That is exactly the gap Validated Actions fills.
How a Validated Run Works
A validated run has four steps, and the server owns the two that matter:
- Run start. The game calls
StartRun. The server issues a signed, single-use ticket bound to the player, the API key and optionally one leaderboard. The ticket carries a server-chosen seed (0 to 2,147,483,646) and an expiry: 2 hours by default, configurable from 60 seconds to 6 hours. - Play. The game seeds its randomness with the server seed and records the player's inputs into a compact byte log.
- Run end. The game calls
SubmitValidatedwith the score, an optional stage, optional earned values and the input log. The SDK sends the SHA-256 hash of the log, so the log itself stays on the device for now. - Server check. The server verifies the ticket, measures the duration itself (from ticket issue to submit) and applies your rules. Only then does it write anything.
A ticket counts exactly once, across all regions. A rejected run burns its ticket too, so nobody can probe your thresholds by retrying the same ticket with slightly lower scores. Because the server picks the seed and limits tickets per player per hour (60 by default), farming for a lucky seed becomes slow and 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}");
The same flow exists in Godot (Horizon.validatedActions.startRun and submitValidated) and Unreal (Horizon->ValidatedActions), each with a complete example in the SDK.
Rules Only the Server Knows
Each API key gets one rule set, edited in the Dashboard as a form or as JSON. The rules never appear in app responses or error messages: the client only ever learns a machine readable code. Here is a realistic rule set for a wave based arcade game:
{
"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 }
}
}
Walk a few submits through it on the weekly board:
| Submitted run | Server answer |
|---|---|
| 9,999,999 points | 422 SCORE_ABOVE_MAX |
| 21,000 points, 3 seconds after the ticket | 422 DURATION_TOO_SHORT |
| 150,000 points in 120 seconds (1,250 per second) | 422 SCORE_RATE_TOO_HIGH |
| 190,000 points in 240 seconds | accepted, but sus (above the soft maximum of 180,000) |
| the same ticket a second time | 422 TICKET_CONSUMED |
The server checks in a fixed order (stage, score, stage rule, duration, score per second, earned values, then soft thresholds) and the first failure wins. Score rules apply only to runs with a board; a run without a board still gets its stage and duration rules checked.
Once the rules fit, switch the board to "Validated submissions only". From then on a plain SubmitScore to that board is refused with 403 VALIDATED_SUBMIT_REQUIRED, while every other board keeps accepting normal submits. Even with an empty rule set, a validated only board guarantees server tickets, single use, binding to the player, the hourly limits and a stored log hash.
Soft Thresholds and Sus Runs
Hard rules reject. Soft thresholds only mark a run, and that is where you should start: a week of soft limits tells you what real play looks like before you turn the numbers into hard rejections. A run is sus when it was accepted and crossed at least one soft threshold. Rejected runs and technical errors are never sus. The submit result carries a plain sus flag; which threshold fired stays on the server.
The interesting part is what happens next. With this update the server keeps a sus package for every sus run, independent of the player's best score, the top N or later score changes. A cheater who posts one absurd run and then a normal one can no longer bury the evidence under a better entry.
The start context: what the run began with
A replay is only useful if you can reproduce the exact starting conditions. So at StartRun the server now records the start context: the rule version in force, the real bytes of the player's Cloud Save (with SHA-256 and revision), the server-owned values, the seed and the start time. The game can add its own side of the story:
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);
Server values and client values stay strictly separated, and both feed a canonical text whose SHA-256 is stored on the ticket. The submit is also checked against the rule version from the start, so tightening a limit while a run is in flight never changes that run's verdict. To keep rule edits from being abused as a probe, changes are limited to 30 per API key and hour.
What a sus package contains
Each package is stored under the run ID and bundles everything a reviewer needs: the start context, the bound rules, the result, the server-owned values before and after the run, and the input log (the SDK uploads it automatically, just like for top N runs). In the Review tab of the Dashboard you filter by sus and export a package as a ZIP file:
<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
On every export the server recomputes all checksums and reports the result in manifest.integrity and the X-Package-Integrity header (ok or mismatch). If a single part does not fit the size limit, only its checksum is kept and the part is marked OMITTED_SIZE_LIMIT, so you always know what is missing and why.
Feed cloud-save.bin, initial-state.bin, the seed and input-log.bin into your own deterministic simulation, and you either reproduce the score or you do not. Replays, verdicts and sanctions stay with you; the server marks and archives, it never runs your game code.
Currency the Client Cannot Write
Leaderboards are not the only thing worth cheating on. With server-owned values you define counters such as gold or gems in the same rule set. Only accepted validated runs change them: there is no app endpoint and no SDK method that sets a balance. The rule from the JSON above reads like this:
maxPerRun: 500: a run that claims 800 gold is rejected withEARNED_ABOVE_MAX.minPerRun: -1000: a run may spend up to 1,000 gold; spending more than the balance fails withINSUFFICIENT_BALANCE.dailyCap: 5000: positive credits per UTC day are clamped, not rejected. A player who already earned 4,800 today and finishes a 500 gold run gets 200 credited.
The response shows requested and credited for every touched key, so the game can show "daily limit reached" instead of silently losing coins. For purchases the rule is simple: grant the item only when the spend was fully credited (IsFullyCredited in Unity and Unreal). This is the same reasoning behind moving upgrade shops off client-side defaults: the device can request, only the server grants.
Cloud Save keeps working as before and becomes a mirror. Copy the server values into the save after each accepted run for offline display, overwrite that copy with GetState on startup, and never send a value from the save back as a balance.
Player Profiles on Every Leaderboard Entry
The second half of this update is about the people on the board rather than the numbers. Every leaderboard entry in Top, Around and Rank now carries a profile with avatarId, frameId and up to three badges:
{ "position": 1, "username": "Gravedigger", "score": 15000,
"profile": { "avatarId": "avatar.zombie_07", "frameId": "frame.gold", "badges": ["badge.supporter"] } }
The server reads the profile together with the display name from its username cache, so a top list costs no extra lookup per entry. Profile changes show up on every server within 10 minutes at the latest.
A catalog of IDs, not images
Each project maintains a cosmetics catalog in the Dashboard. An entry has an ID (for example avatar.zombie_07), a type (avatar, frame or badge) and a locked flag. Free entries can be picked by any player; locked entries only by players who unlocked them. The server stores IDs only, never images: your game maps each ID to its own sprites, so art stays in your build and a new frame costs one catalog row.
Rendering a board row in Godot looks like this:
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)
Unknown IDs (for example after you delete a catalog entry) should simply render as "not set": players keep the stale ID until they change their profile, and nothing breaks.
Unlocks through gift codes
Unlocks are written only by the server, today through gift codes with grants or by hand in the Dashboard. A player can hold up to 25 unlocks. Redeeming a code with grants returns the granted IDs and drops the cached profile, so the next GetProfile shows the new item as available:
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"])
Picking a locked item without the unlock fails with 403 COSMETIC_LOCKED, more than three badges with 400 INVALID_BADGES. That turns event rewards, Discord giveaways and supporter perks into one gift code each, without a custom unlock endpoint. One leftover from the old API is gone for good: the metadata parameter on score submits was never stored and is now deprecated. Per player information belongs in the profile.
Score Limits Now Count per API Key
A smaller change with real impact for studios that run several games or environments in one account: the score limit now applies per API key. Each key may hold as many score rows as the tier allows (one row is one player on one board), summed over all boards of that key, and every additional key gets its own full allowance. Existing rows can always be improved; only a player's first score on a board can hit 403 when the key is full.
Best Practices for Fair Leaderboards
- Start soft, then harden. Run a week with only
softthresholds, look at the sus runs in the Review tab, and turn the numbers into hard rules with a safety margin of 20 to 30 percent above the best legitimate run. - Call
StartRunwhen the run really begins. The clock starts at the ticket. Starting in the menu or before a 40 second loading screen makesminDurationSecondsmeaningless. - Keep input logs small and deterministic. Record fixed input frames with delta encoding. Evidence is capped at 32 KB per log, and a 10 minute run at 30 input frames per second with 1 byte per frame fits comfortably.
- Version everything in the start context. Without
simulationVersionandcontentDigest, a package from last month may replay against this month's balancing and fail for the wrong reason. - Treat the save as a mirror, never as a source. Balances flow from the server into Cloud Save, never back.
What It Does Not Do
We prefer to say this plainly. A modified client that reports plausible values within your rules is still accepted: Validated Actions limits how much a cheater can gain and makes suspicious runs reviewable, it does not make cheating impossible. The server checks bounds, timing and single use and archives evidence, but it does not run or replay your game code, and there is no automatic verdict on evidence yet. Validated Actions is cloud only; on the self-hosted simpleServer the SDKs report NOT_SUPPORTED. Every run needs a signed in player, and runs on a leaderboard also need a display name.
Capacity per Tier
Every feature in this post is available on every plan, including FREE. Only capacity grows:
| FREE | BASIC | PRO | ENTERPRISE | |
|---|---|---|---|---|
| Validated runs per account and UTC hour | 300 | 3,000 | 20,000 | 200,000 |
| Server-owned values per API key | 8 | 16 | 32 | 64 |
| Evidence slots for top N runs | 50 | 500 | 2,500 | 25,000 |
| Stored sus packages | 10 | 100 | 1,000 | 10,000 |
| Sus package retention | 14 days | 30 days | 90 days | 180 days |
| Cosmetics catalog per API key | 50 | 200 | 500 | 1,000 |
| Score rows per API key | 5,000 | 25,000 | 200,000 | 2,500,000 |
When the sus package quota is full, new sus runs are still accepted and reported as sus, they are just not archived. Stored packages are never pushed out by new ones.
Get Started
Update to the latest SDK for Unity, Godot or Unreal, pick one board and add a rule set with soft thresholds only. Add a ValidatedRunContext to your StartRun call, then fill your cosmetics catalog with the avatars and frames you already ship. The Validated Actions feature page and the leaderboard feature page sum up rules and limits, and the quickstart walks through all three engines. Ready to make your next leaderboard both fair and personal? Try horizOn for free or dive into the API docs.