Zurück zum Blog

Faire Leaderboards ohne Servercode: Validierte Runs, Sus-Pakete und Spielerprofile

Veröffentlicht am 3. Oktober 2026
Faire Leaderboards ohne Servercode: Validierte Runs, Sus-Pakete und Spielerprofile Mit Hilfe von KI generiert

Kurz und knapp

Baue servervalidierte Leaderboards ohne Servercode: Einmal-Tickets pro Run, abspielbare Sus-Pakete, serverseitige Währung und Spielerprofile.

Die erste Woche eines neuen Leaderboards endet meist gleich: Irgendwo zwischen den ehrlichen Top Ten und dem Rest des Boards steht ein Spieler mit 2.147.483.647 Punkten, dem größten Wert, den ein vorzeichenbehafteter 32-Bit-Integer fassen kann. Diesen Run hat niemand gespielt. Ein Memory-Editor oder ein abfangender Proxy hat ihn geschrieben, denn bei den meisten Leaderboards ist der Game-Client der einzige Zeuge und zugleich der Richter.

Dieses Release packt diese Schwachstelle von zwei Seiten an. Validated Actions (im Dashboard: Validierte Aktionen) verlagert die Entscheidung, was zählt, auf den Server, und die neuen Sus-Run-Pakete bewahren jeden verdächtigen Run als vollständige, abspielbare Fallakte auf. Gleichzeitig werden Leaderboards persönlicher: Jeder Eintrag trägt jetzt ein Spielerprofil mit Avatar, Rahmen und bis zu drei Badges, und Cosmetics lassen sich über Gift Codes freischalten. Im Folgenden erfährst du, wie die einzelnen Teile funktionieren und welche Zahlen dahinterstehen, du siehst lauffähigen Code für Unity und Godot und lernst die Grenzen kennen, die du kennen solltest, bevor du dich darauf verlässt.

Warum vom Client gemeldeten Scores nicht zu trauen ist

Ein klassischer Score-Submit ist ein einziger Request: Spieler-ID, Score, fertig. Alles, was der Server über diesen Run weiß, stammt von dem Gerät, das das größte Interesse daran hat, darüber zu lügen. Die üblichen Gegenmaßnahmen haben alle denselben Haken:

  • Den Request im Client signieren. Der Signaturschlüssel wird mit deinem Build ausgeliefert. Ihn aus einem IL2CPP-Binary oder einem GDScript-Export zu extrahieren, dauert einen Nachmittag, und ab dann sehen gefälschte Requests vollkommen gültig aus.
  • Den Score im Speicher verschleiern. Das bremst Gelegenheits-Memory-Editoren aus, aber ein Proxy, der den HTTP-Body umschreibt, berührt dein Speicherlayout nie.
  • Plausibilitätsprüfungen im Client. Was auf dem Gerät läuft, lässt sich auf dem Gerät auch wieder herauspatchen.

Die robuste Antwort lautet: Der Server entscheidet, was zählt. Die Lehrbuchvariante davon ist eine autoritative Simulation: Deine Spiellogik läuft auf Hardware, die du kontrollierst, und der Client sendet nur Eingaben. Für ein schnelles Actionspiel mit Tausenden parallelen Runs bedeutet das Wochen an Netcode-Arbeit plus eine dauerhafte Hosting-Rechnung. Die meisten Indie-Teams brauchen nicht die volle Simulation. Sie brauchen drei günstigere Garantien: Der Server weiß, wann ein Run begonnen hat, welche Regeln für ihn gelten und welche Belege danach existieren. Genau diese Lücke schließt Validated Actions.

So funktioniert ein validierter Run

Ein validierter Run hat vier Schritte, und die zwei entscheidenden gehören dem Server:

  1. Run-Start. Das Spiel ruft StartRun auf. Der Server stellt ein signiertes Einmal-Ticket aus, das an den Spieler, den API-Key und optional ein Leaderboard gebunden ist. Das Ticket enthält einen vom Server gewählten Seed (0 bis 2.147.483.646) und ein Ablaufdatum: standardmäßig 2 Stunden, konfigurierbar von 60 Sekunden bis 6 Stunden.
  2. Spielen. Das Spiel initialisiert seinen Zufall mit dem Server-Seed und zeichnet die Eingaben des Spielers in einem kompakten Byte-Log auf.
  3. Run-Ende. Das Spiel ruft SubmitValidated mit dem Score, einer optionalen Stage, optionalen verdienten Werten und dem Input-Log auf. Das SDK sendet den SHA-256-Hash des Logs, das Log selbst bleibt also vorerst auf dem Gerät.
  4. Serverprüfung. Der Server verifiziert das Ticket, misst die Dauer selbst (von der Ticketausgabe bis zum Submit) und wendet deine Regeln an. Erst dann schreibt er etwas.

Ein Ticket zählt genau einmal, über alle Regionen hinweg. Ein abgelehnter Run verbrennt sein Ticket ebenfalls, so kann niemand deine Schwellenwerte abtasten, indem er dasselbe Ticket mit leicht niedrigeren Scores erneut versucht. Weil der Server den Seed wählt und Tickets pro Spieler und Stunde begrenzt (standardmäßig 60), wird das Farmen nach einem Glücks-Seed langsam und sichtbar.

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}");

Derselbe Ablauf existiert in Godot (Horizon.validatedActions.startRun und submitValidated) und Unreal (Horizon->ValidatedActions), jeweils mit einem vollständigen Beispiel im SDK.

Regeln, die nur der Server kennt

Jeder API-Key bekommt ein Regelset, das du im Dashboard als Formular oder als JSON bearbeitest. Die Regeln tauchen nie in App-Antworten oder Fehlermeldungen auf: Der Client erfährt immer nur einen maschinenlesbaren Code. Hier ein realistisches Regelset für ein wellenbasiertes Arcade-Spiel:

{
  "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 }
  }
}

Spiel ein paar Submits auf dem Board weekly damit durch:

Eingereichter Run Antwort des Servers
9.999.999 Punkte 422 SCORE_ABOVE_MAX
21.000 Punkte, 3 Sekunden nach dem Ticket 422 DURATION_TOO_SHORT
150.000 Punkte in 120 Sekunden (1.250 pro Sekunde) 422 SCORE_RATE_TOO_HIGH
190.000 Punkte in 240 Sekunden akzeptiert, aber sus (über dem Soft-Maximum von 180.000)
dasselbe Ticket ein zweites Mal 422 TICKET_CONSUMED

Der Server prüft in fester Reihenfolge (Stage, Score, Stage-Regel, Dauer, Score pro Sekunde, verdiente Werte, dann Soft-Schwellenwerte), und der erste Fehlschlag gewinnt. Score-Regeln gelten nur für Runs mit Board; bei einem Run ohne Board werden trotzdem Stage- und Dauerregeln geprüft.

Sobald die Regeln passen, stellst du das Board auf „Nur validierte Einreichungen“. Ab dann wird ein einfacher SubmitScore auf dieses Board mit 403 VALIDATED_SUBMIT_REQUIRED abgelehnt, während alle anderen Boards weiterhin normale Submits annehmen. Selbst mit leerem Regelset garantiert ein rein validiertes Board Server-Tickets, Einmalnutzung, Bindung an den Spieler, die Stundenlimits und einen gespeicherten Log-Hash.

Soft-Schwellenwerte und Sus-Runs

Harte Regeln lehnen ab. Soft-Schwellenwerte markieren einen Run nur, und genau dort solltest du anfangen: Eine Woche mit Soft-Limits zeigt dir, wie echtes Spielen aussieht, bevor du die Zahlen in harte Ablehnungen verwandelst. Ein Run ist sus, wenn er akzeptiert wurde und mindestens einen Soft-Schwellenwert überschritten hat. Abgelehnte Runs und technische Fehler sind nie sus. Das Submit-Ergebnis enthält ein schlichtes sus-Flag; welcher Schwellenwert gegriffen hat, bleibt auf dem Server.

Spannend wird es danach. Mit diesem Update bewahrt der Server für jeden Sus-Run ein Sus-Paket auf, unabhängig vom Bestscore des Spielers, den Top N oder späteren Score-Änderungen. Ein Cheater, der einen absurden Run und danach einen normalen postet, kann die Beweise nicht mehr unter einem besseren Eintrag begraben.

Der Startkontext: womit der Run begann

Ein Replay ist nur nützlich, wenn du die exakten Startbedingungen reproduzieren kannst. Deshalb zeichnet der Server bei StartRun jetzt den Startkontext auf: die geltende Regelversion, die echten Bytes des Cloud Saves des Spielers (mit SHA-256 und Revision), die serverseitigen Werte, den Seed und die Startzeit. Das Spiel kann seine eigene Sicht der Dinge ergänzen:

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);

Serverwerte und Clientwerte bleiben strikt getrennt, und beide fließen in einen kanonischen Text, dessen SHA-256 auf dem Ticket gespeichert wird. Der Submit wird außerdem gegen die Regelversion vom Start geprüft, sodass das Verschärfen eines Limits während eines laufenden Runs das Urteil über diesen Run nie ändert. Damit Regeländerungen nicht als Sonde missbraucht werden, sind sie auf 30 pro API-Key und Stunde begrenzt.

Was ein Sus-Paket enthält

Jedes Paket wird unter der Run-ID gespeichert und bündelt alles, was ein Reviewer braucht: den Startkontext, die gebundenen Regeln, das Ergebnis, die serverseitigen Werte vor und nach dem Run sowie das Input-Log (das SDK lädt es automatisch hoch, genau wie bei Top-N-Runs). Im Tab Review des Dashboards filterst du nach sus und exportierst ein Paket als ZIP-Datei:

<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

Bei jedem Export berechnet der Server alle Prüfsummen neu und meldet das Ergebnis in manifest.integrity und im Header X-Package-Integrity (ok oder mismatch). Passt ein einzelner Teil nicht in das Größenlimit, wird nur seine Prüfsumme behalten und der Teil als OMITTED_SIZE_LIMIT markiert, damit du immer weißt, was fehlt und warum.

Füttere cloud-save.bin, initial-state.bin, den Seed und input-log.bin in deine eigene deterministische Simulation, und du reproduzierst den Score oder eben nicht. Replays, Urteile und Sanktionen bleiben bei dir; der Server markiert und archiviert, er führt nie deinen Spielcode aus.

Währung, die der Client nicht schreiben kann

Leaderboards sind nicht das Einzige, bei dem sich Cheaten lohnt. Mit serverseitigen Werten definierst du Zähler wie gold oder gems im selben Regelset. Nur akzeptierte validierte Runs verändern sie: Es gibt keinen App-Endpoint und keine SDK-Methode, die einen Kontostand setzt. Die Regel aus dem JSON oben liest sich so:

  • maxPerRun: 500: Ein Run, der 800 Gold beansprucht, wird mit EARNED_ABOVE_MAX abgelehnt.
  • minPerRun: -1000: Ein Run darf bis zu 1.000 Gold ausgeben; wer mehr als den Kontostand ausgeben will, scheitert mit INSUFFICIENT_BALANCE.
  • dailyCap: 5000: Positive Gutschriften pro UTC-Tag werden gekappt, nicht abgelehnt. Ein Spieler, der heute schon 4.800 verdient hat und einen Run mit 500 Gold abschließt, bekommt 200 gutgeschrieben.

Die Antwort zeigt requested und credited für jeden berührten Key, sodass das Spiel „Tageslimit erreicht“ anzeigen kann, statt stillschweigend Münzen zu verlieren. Für Käufe gilt eine einfache Regel: Vergib das Item nur, wenn die Ausgabe vollständig verbucht wurde (IsFullyCredited in Unity und Unreal). Dieselbe Überlegung steckt dahinter, Upgrade-Shops von clientseitigen Defaults wegzuholen: Das Gerät darf anfragen, nur der Server vergibt.

Cloud Save funktioniert weiter wie bisher und wird zum Spiegel. Kopiere die Serverwerte nach jedem akzeptierten Run für die Offline-Anzeige in den Save, überschreibe diese Kopie beim Start mit GetState und schick niemals einen Wert aus dem Save als Kontostand zurück.

Spielerprofile an jedem Leaderboard-Eintrag

Die zweite Hälfte dieses Updates dreht sich um die Menschen auf dem Board statt um die Zahlen. Jeder Leaderboard-Eintrag in Top, Around und Rank trägt jetzt ein Profil mit avatarId, frameId und bis zu drei badges:

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

Der Server liest das Profil zusammen mit dem Anzeigenamen aus seinem Username-Cache, sodass eine Top-Liste keinen zusätzlichen Lookup pro Eintrag kostet. Profiländerungen sind spätestens nach 10 Minuten auf jedem Server sichtbar.

Ein Katalog aus IDs, nicht aus Bildern

Jedes Projekt pflegt im Dashboard einen Cosmetics-Katalog. Ein Eintrag hat eine ID (zum Beispiel avatar.zombie_07), einen Typ (avatar, frame oder badge) und ein locked-Flag. Freie Einträge kann jeder Spieler wählen, gesperrte nur Spieler, die sie freigeschaltet haben. Der Server speichert nur IDs, niemals Bilder: Dein Spiel ordnet jeder ID eigene Sprites zu, die Grafiken bleiben also in deinem Build, und ein neuer Rahmen kostet genau eine Katalogzeile.

So sieht das Rendern einer Board-Zeile in Godot aus:

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)

Unbekannte IDs (zum Beispiel nachdem du einen Katalogeintrag gelöscht hast) sollten einfach als „nicht gesetzt“ dargestellt werden: Spieler behalten die veraltete ID, bis sie ihr Profil ändern, und nichts geht kaputt.

Freischaltungen über Gift Codes

Freischaltungen schreibt nur der Server, heute über Gift Codes mit grants oder manuell im Dashboard. Ein Spieler kann bis zu 25 Freischaltungen besitzen. Das Einlösen eines Codes mit Grants liefert die gewährten IDs zurück und verwirft das gecachte Profil, sodass der nächste GetProfile das neue Item als verfügbar anzeigt:

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"])

Wer ein gesperrtes Item ohne Freischaltung wählt, bekommt 403 COSMETIC_LOCKED, bei mehr als drei Badges kommt 400 INVALID_BADGES. Damit werden Event-Belohnungen, Discord-Giveaways und Supporter-Perks zu jeweils einem Gift Code, ganz ohne eigenen Unlock-Endpoint. Ein Überbleibsel der alten API ist endgültig weg: Der Parameter metadata bei Score-Submits wurde nie gespeichert und ist jetzt deprecated. Spielerbezogene Informationen gehören ins Profil.

Score-Limits zählen jetzt pro API-Key

Eine kleinere Änderung mit echter Wirkung für Studios, die mehrere Spiele oder Umgebungen in einem Account betreiben: Das Score-Limit gilt jetzt pro API-Key. Jeder Key darf so viele Score-Zeilen halten, wie der Tarif erlaubt (eine Zeile ist ein Spieler auf einem Board), summiert über alle Boards dieses Keys, und jeder weitere Key bekommt sein eigenes volles Kontingent. Bestehende Zeilen können immer verbessert werden; nur der erste Score eines Spielers auf einem Board kann auf ein 403 laufen, wenn der Key voll ist.

Best Practices für faire Leaderboards

  1. Erst soft, dann hart. Lass eine Woche lang nur soft-Schwellenwerte laufen, sieh dir die Sus-Runs im Tab Review an und mach aus den Zahlen harte Regeln mit einer Sicherheitsmarge von 20 bis 30 Prozent über dem besten legitimen Run.
  2. Ruf StartRun auf, wenn der Run wirklich beginnt. Die Uhr startet mit dem Ticket. Startest du schon im Menü oder vor einem 40 Sekunden langen Ladebildschirm, wird minDurationSeconds bedeutungslos.
  3. Halte Input-Logs klein und deterministisch. Zeichne feste Input-Frames mit Delta-Encoding auf. Belege sind auf 32 KB pro Log begrenzt, und ein 10-minütiger Run mit 30 Input-Frames pro Sekunde und 1 Byte pro Frame passt bequem hinein.
  4. Versioniere alles im Startkontext. Ohne simulationVersion und contentDigest wird ein Paket vom letzten Monat womöglich gegen das Balancing dieses Monats abgespielt und scheitert aus dem falschen Grund.
  5. Behandle den Save als Spiegel, nie als Quelle. Kontostände fließen vom Server in den Cloud Save, nie zurück.

Was es nicht leistet

Wir sagen das lieber klar. Ein modifizierter Client, der plausible Werte innerhalb deiner Regeln meldet, wird weiterhin akzeptiert: Validated Actions begrenzt, wie viel ein Cheater gewinnen kann, und macht verdächtige Runs überprüfbar, es macht Cheaten aber nicht unmöglich. Der Server prüft Grenzen, Timing und Einmalnutzung und archiviert Belege, aber er führt deinen Spielcode weder aus noch spielt er ihn nach, und eine automatische Bewertung der Belege gibt es noch nicht. Validated Actions gibt es nur in der Cloud; auf dem selbst gehosteten simpleServer melden die SDKs NOT_SUPPORTED. Jeder Run braucht einen angemeldeten Spieler, und Runs auf einem Leaderboard brauchen zusätzlich einen Anzeigenamen.

Kapazität pro Tarif

Jedes Feature in diesem Beitrag ist in jedem Tarif verfügbar, auch in FREE. Nur die Kapazität wächst:

FREE BASIC PRO ENTERPRISE
Validierte Runs pro Account und UTC-Stunde 300 3.000 20.000 200.000
Serverseitige Werte pro API-Key 8 16 32 64
Beleg-Slots für Top-N-Runs 50 500 2.500 25.000
Gespeicherte Sus-Pakete 10 100 1.000 10.000
Aufbewahrung von Sus-Paketen 14 Tage 30 Tage 90 Tage 180 Tage
Cosmetics-Katalog pro API-Key 50 200 500 1.000
Score-Zeilen pro API-Key 5.000 25.000 200.000 2.500.000

Ist das Kontingent für Sus-Pakete voll, werden neue Sus-Runs weiterhin akzeptiert und als sus gemeldet, sie werden nur nicht archiviert. Gespeicherte Pakete werden nie von neuen verdrängt.

Leg los

Aktualisiere auf das neueste SDK für Unity, Godot oder Unreal, wähle ein Board und füge ein Regelset mit ausschließlich Soft-Schwellenwerten hinzu. Ergänze deinen StartRun-Aufruf um einen ValidatedRunContext und befülle dann deinen Cosmetics-Katalog mit den Avataren und Rahmen, die du bereits auslieferst. Die Feature-Seite zu Validated Actions und die Feature-Seite zum Leaderboard fassen Regeln und Limits zusammen, und der Quickstart führt durch alle drei Engines. Bereit, dein nächstes Leaderboard fair und persönlich zu machen? Teste horizOn kostenlos oder tauch in die API-Dokumentation ein.