Uczciwe tablice wyników bez kodu serwera: walidowane rozgrywki, pakiety sus i profile graczy
W skrócie
Zbuduj tablice wyników walidowane przez serwer bez kodu serwera: jednorazowe bilety, odtwarzalne pakiety sus, waluta serwera i profile graczy.
Pierwszy tydzień nowej tablicy wyników zwykle kończy się tak samo: gdzieś między uczciwą pierwszą dziesiątką a resztą tabeli siedzi gracz z wynikiem 2 147 483 647 punktów, czyli największą wartością, jaką może przechować 32-bitowa liczba całkowita ze znakiem. Tej rozgrywki nikt nie zagrał. Wpisał ją edytor pamięci albo przechwytujący Proxy, bo w większości tablic wyników klient gry jest jedynym świadkiem i jednocześnie sędzią.
To wydanie atakuje ten słaby punkt z dwóch stron. Validated Actions przenosi decyzję o tym, co się liczy, na serwer, a nowe pakiety rozgrywek sus zachowują każdą podejrzaną rozgrywkę jako kompletne akta sprawy, które można odtworzyć. Jednocześnie tablice wyników stają się bardziej osobiste: każdy wpis ma teraz profil gracza z awatarem, ramką i maksymalnie trzema odznakami, a kosmetyki można odblokować kodami podarunkowymi. Poniżej znajdziesz opis działania każdego elementu, liczby, które za nim stoją, działający kod dla Unity i Godot oraz ograniczenia, które warto znać, zanim zaczniesz na tym polegać.
Dlaczego nie można ufać wynikom zgłaszanym przez klienta
Klasyczne wysłanie wyniku to jedno żądanie: ID gracza, wynik, gotowe. Wszystko, co serwer wie o tej rozgrywce, pochodzi z urządzenia, które ma największy interes w tym, żeby kłamać. Typowe środki zaradcze mają tę samą wadę:
- Podpisywanie żądania w kliencie. Klucz do podpisu trafia razem z grą do twojego buildu. Wyciągnięcie go z binarki IL2CPP albo eksportu GDScript zajmuje jedno popołudnie, a od tej chwili sfałszowane żądania wyglądają na całkowicie poprawne.
- Zaciemnianie wyniku w pamięci. To spowalnia amatorskie edytory pamięci, ale Proxy, który przepisuje treść żądania HTTP, w ogóle nie dotyka układu twojej pamięci.
- Sprawdzanie wiarygodności w kliencie. Wszystko, co działa na urządzeniu, można na tym urządzeniu wyciąć łatką.
Solidna odpowiedź brzmi: to serwer decyduje, co się liczy. Podręcznikowa wersja tego podejścia to autorytatywna symulacja: logika gry działa na sprzęcie, który kontrolujesz, a klient wysyła tylko inputy. W szybkiej grze akcji z tysiącami równoległych rozgrywek oznacza to tygodnie pracy nad Netcode i stały rachunek za hosting. Większość zespołów indie nie potrzebuje pełnej symulacji. Potrzebuje trzech tańszych gwarancji: serwer wie, kiedy rozgrywka się zaczęła, jakie zasady jej dotyczą i jakie dowody istnieją po jej zakończeniu. Dokładnie tę lukę wypełnia Validated Actions.
Jak działa walidowana rozgrywka
Walidowana rozgrywka ma cztery kroki, a serwer kontroluje te dwa, które mają znaczenie:
- Start rozgrywki. Gra wywołuje
StartRun. Serwer wystawia podpisany bilet jednorazowy powiązany z graczem, kluczem API i opcjonalnie jedną tablicą wyników. Bilet zawiera Seed wybrany przez serwer (od 0 do 2 147 483 646) oraz termin ważności: domyślnie 2 godziny, konfigurowalny od 60 sekund do 6 godzin. - Gra. Gra inicjalizuje swoją losowość Seedem z serwera i zapisuje inputy gracza w kompaktowym logu bajtowym.
- Koniec rozgrywki. Gra wywołuje
SubmitValidatedz wynikiem, opcjonalnym etapem, opcjonalnymi zdobytymi wartościami i logiem inputów. SDK wysyła hash SHA-256 logu, więc sam log na razie zostaje na urządzeniu. - Kontrola na serwerze. Serwer weryfikuje bilet, sam mierzy czas trwania (od wystawienia biletu do wysłania wyniku) i stosuje twoje zasady. Dopiero wtedy cokolwiek zapisuje.
Bilet liczy się dokładnie raz, we wszystkich regionach. Odrzucona rozgrywka również zużywa swój bilet, więc nikt nie wybada twoich progów, ponawiając ten sam bilet z nieco niższymi wynikami. Ponieważ to serwer wybiera Seed i ogranicza liczbę biletów na gracza na godzinę (domyślnie 60), farmienie szczęśliwego Seeda staje się powolne i widoczne.
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}");
Ten sam przepływ istnieje w Godot (Horizon.validatedActions.startRun i submitValidated) oraz w Unreal (Horizon->ValidatedActions), w obu przypadkach z kompletnym przykładem w SDK.
Zasady, które zna tylko serwer
Każdy klucz API dostaje jeden zestaw zasad, edytowany w Dashboardzie jako formularz albo jako JSON. Zasady nigdy nie pojawiają się w odpowiedziach dla aplikacji ani w komunikatach błędów: klient dowiaduje się wyłącznie kodu czytelnego dla maszyny. Oto realistyczny zestaw zasad dla gry arcade opartej na falach:
{
"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 }
}
}
Przepuść przez niego kilka zgłoszeń na tablicy weekly:
| Zgłoszona rozgrywka | Odpowiedź serwera |
|---|---|
| 9 999 999 punktów | 422 SCORE_ABOVE_MAX |
| 21 000 punktów, 3 sekundy po wystawieniu biletu | 422 DURATION_TOO_SHORT |
| 150 000 punktów w ciągu 120 sekund (1250 na sekundę) | 422 SCORE_RATE_TOO_HIGH |
| 190 000 punktów w ciągu 240 sekund | zaakceptowana, ale sus (powyżej miękkiego maksimum 180 000) |
| ten sam bilet po raz drugi | 422 TICKET_CONSUMED |
Serwer sprawdza w stałej kolejności (etap, wynik, zasada etapu, czas trwania, wynik na sekundę, zdobyte wartości, a na końcu progi miękkie), a decyduje pierwszy błąd. Zasady dotyczące wyniku obowiązują tylko rozgrywki przypisane do tablicy; rozgrywka bez tablicy nadal ma sprawdzane zasady etapu i czasu trwania.
Gdy zasady są już dopasowane, przełącz tablicę na „Tylko zweryfikowane zgłoszenia”. Od tej chwili zwykłe SubmitScore do tej tablicy jest odrzucane z 403 VALIDATED_SUBMIT_REQUIRED, a wszystkie pozostałe tablice nadal przyjmują normalne zgłoszenia. Nawet przy pustym zestawie zasad tablica tylko dla walidowanych zgłoszeń gwarantuje bilety z serwera, jednorazowe użycie, powiązanie z graczem, limity godzinowe i zapisany hash logu.
Progi miękkie i rozgrywki sus
Twarde zasady odrzucają. Progi miękkie tylko oznaczają rozgrywkę i od nich warto zacząć: tydzień z miękkimi limitami pokazuje, jak wygląda prawdziwa gra, zanim zamienisz liczby w twarde odrzucenia. Rozgrywka jest sus, gdy została zaakceptowana i przekroczyła co najmniej jeden próg miękki. Odrzucone rozgrywki i błędy techniczne nigdy nie są sus. Wynik zgłoszenia zawiera prostą flagę sus; to, który próg zadziałał, zostaje na serwerze.
Najciekawsze jest to, co dzieje się potem. Od tej aktualizacji serwer przechowuje pakiet sus dla każdej rozgrywki sus, niezależnie od najlepszego wyniku gracza, Top N czy późniejszych zmian wyniku. Oszust, który wrzuci jedną absurdalną rozgrywkę, a potem normalną, nie zakopie już dowodów pod lepszym wpisem.
Kontekst startowy: z czym rozgrywka się zaczęła
Replay jest użyteczny tylko wtedy, gdy da się odtworzyć dokładne warunki początkowe. Dlatego przy StartRun serwer zapisuje teraz kontekst startowy: obowiązującą wersję zasad, rzeczywiste bajty Cloud Save gracza (z SHA-256 i numerem rewizji), wartości należące do serwera, Seed i czas startu. Gra może dodać własną wersję wydarzeń:
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);
Wartości serwera i klienta pozostają ściśle rozdzielone, a obie zasilają kanoniczny tekst, którego SHA-256 jest zapisywany na bilecie. Zgłoszenie jest też sprawdzane względem wersji zasad z chwili startu, więc zaostrzenie limitu w trakcie trwającej rozgrywki nigdy nie zmienia werdyktu dla tej rozgrywki. Aby edycji zasad nie dało się nadużyć do sondowania, zmiany są ograniczone do 30 na klucz API na godzinę.
Co zawiera pakiet sus
Każdy pakiet jest zapisywany pod ID rozgrywki i zawiera wszystko, czego potrzebuje osoba weryfikująca: kontekst startowy, powiązane zasady, wynik, wartości należące do serwera przed rozgrywką i po niej oraz log inputów (SDK przesyła go automatycznie, tak samo jak w przypadku rozgrywek z Top N). W zakładce Review w Dashboardzie filtrujesz po sus i eksportujesz pakiet jako plik 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
Przy każdym eksporcie serwer ponownie oblicza wszystkie sumy kontrolne i raportuje wynik w manifest.integrity oraz w nagłówku X-Package-Integrity (ok lub mismatch). Jeśli pojedyncza część nie mieści się w limicie rozmiaru, zachowywana jest tylko jej suma kontrolna, a część zostaje oznaczona jako OMITTED_SIZE_LIMIT, więc zawsze wiesz, czego brakuje i dlaczego.
Wrzuć cloud-save.bin, initial-state.bin, Seed i input-log.bin do własnej deterministycznej symulacji, a wynik albo się odtworzy, albo nie. Replaye, werdykty i sankcje pozostają po twojej stronie; serwer oznacza i archiwizuje, nigdy nie uruchamia kodu twojej gry.
Waluta, której klient nie może zapisać
Tablice wyników to nie jedyne, na czym opłaca się oszukiwać. Dzięki wartościom należącym do serwera definiujesz liczniki takie jak gold czy gems w tym samym zestawie zasad. Zmieniają je wyłącznie zaakceptowane walidowane rozgrywki: nie ma endpointu aplikacji ani metody SDK, która ustawia saldo. Zasada z powyższego JSON-a działa tak:
maxPerRun: 500: rozgrywka, która deklaruje 800 złota, zostaje odrzucona zEARNED_ABOVE_MAX.minPerRun: -1000: rozgrywka może wydać do 1000 złota; wydanie więcej, niż wynosi saldo, kończy się błędemINSUFFICIENT_BALANCE.dailyCap: 5000: dodatnie wpływy w ciągu doby UTC są przycinane, a nie odrzucane. Gracz, który dziś zarobił już 4800 i kończy rozgrywkę za 500 złota, dostaje na konto 200.
Odpowiedź pokazuje requested i credited dla każdego dotkniętego klucza, więc gra może wyświetlić „osiągnięto dzienny limit”, zamiast po cichu gubić monety. Przy zakupach zasada jest prosta: przyznaj przedmiot dopiero wtedy, gdy wydatek został w pełni zaksięgowany (IsFullyCredited w Unity i Unreal). To ta sama logika, która stoi za wyprowadzeniem sklepów z ulepszeniami z domyślnych wartości po stronie klienta: urządzenie może prosić, przyznaje tylko serwer.
Cloud Save działa jak dotąd i staje się lustrem. Po każdej zaakceptowanej rozgrywce kopiuj wartości serwera do zapisu na potrzeby wyświetlania offline, przy starcie nadpisuj tę kopię przez GetState i nigdy nie odsyłaj wartości z zapisu jako salda.
Profile graczy przy każdym wpisie w tablicy wyników
Druga połowa tej aktualizacji dotyczy ludzi na tablicy, a nie liczb. Każdy wpis w tablicy wyników w Top, Around i Rank zawiera teraz profil z avatarId, frameId i maksymalnie trzema badges:
{ "position": 1, "username": "Gravedigger", "score": 15000,
"profile": { "avatarId": "avatar.zombie_07", "frameId": "frame.gold", "badges": ["badge.supporter"] } }
Serwer odczytuje profil razem z nazwą wyświetlaną ze swojego cache nazw użytkowników, więc lista najlepszych nie wymaga żadnego dodatkowego zapytania na wpis. Zmiany profilu pojawiają się na każdym serwerze najpóźniej po 10 minutach.
Katalog ID, a nie obrazków
Każdy projekt prowadzi w Dashboardzie katalog kosmetyków. Wpis ma ID (na przykład avatar.zombie_07), typ (avatar, frame lub badge) oraz flagę locked. Darmowe wpisy może wybrać każdy gracz; zablokowane tylko gracze, którzy je odblokowali. Serwer przechowuje wyłącznie ID, nigdy obrazki: twoja gra mapuje każde ID na własne sprite'y, więc grafika zostaje w twoim buildzie, a nowa ramka kosztuje jeden wiersz w katalogu.
Renderowanie wiersza tablicy w Godot wygląda tak:
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)
Nieznane ID (na przykład po usunięciu wpisu z katalogu) powinny po prostu renderować się jako „nie ustawiono”: gracze zachowują nieaktualne ID, dopóki nie zmienią profilu, i nic się nie psuje.
Odblokowania przez kody podarunkowe
Odblokowania zapisuje wyłącznie serwer, obecnie przez kody podarunkowe z grants albo ręcznie w Dashboardzie. Gracz może mieć do 25 odblokowań. Zrealizowanie kodu z grants zwraca przyznane ID i usuwa profil z cache, więc kolejne GetProfile pokazuje nowy przedmiot jako dostępny:
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"])
Wybór zablokowanego przedmiotu bez odblokowania kończy się błędem 403 COSMETIC_LOCKED, a więcej niż trzy odznaki błędem 400 INVALID_BADGES. Dzięki temu nagrody za eventy, giveawaye na Discordzie i bonusy dla wspierających to po jednym kodzie podarunkowym, bez własnego endpointu do odblokowań. Jedna pozostałość starego API zniknęła na dobre: parametr metadata przy wysyłaniu wyników nigdy nie był zapisywany i jest teraz przestarzały. Informacje o graczu należą do profilu.
Limity wyników liczone teraz na klucz API
Mniejsza zmiana, ale z realnym wpływem dla studiów, które prowadzą kilka gier lub środowisk na jednym koncie: limit wyników obowiązuje teraz na klucz API. Każdy klucz może przechowywać tyle wierszy wyników, ile pozwala plan (jeden wiersz to jeden gracz na jednej tablicy), sumowanych po wszystkich tablicach tego klucza, a każdy dodatkowy klucz dostaje własną pełną pulę. Istniejące wiersze zawsze można poprawić; tylko pierwszy wynik gracza na danej tablicy może dostać 403, gdy klucz jest pełny.
Dobre praktyki dla uczciwych tablic wyników
- Zacznij miękko, potem zaostrzaj. Przez tydzień używaj tylko progów
soft, przejrzyj rozgrywki sus w zakładce Review i zamień liczby w twarde zasady z marginesem bezpieczeństwa od 20 do 30 procent powyżej najlepszej uczciwej rozgrywki. - Wywołuj
StartRun, gdy rozgrywka naprawdę się zaczyna. Zegar rusza w chwili wystawienia biletu. Start w menu albo przed 40-sekundowym ekranem ładowania sprawia, żeminDurationSecondstraci sens. - Utrzymuj logi inputów małe i deterministyczne. Zapisuj stałe klatki inputu z kodowaniem delta. Dowody są ograniczone do 32 KB na log, a 10-minutowa rozgrywka przy 30 klatkach inputu na sekundę i 1 bajcie na klatkę mieści się z zapasem.
- Wersjonuj wszystko w kontekście startowym. Bez
simulationVersionicontentDigestpakiet z zeszłego miesiąca może zostać odtworzony z balansem z tego miesiąca i nie przejść z niewłaściwego powodu. - Traktuj zapis jako lustro, nigdy jako źródło. Salda płyną z serwera do Cloud Save, nigdy w drugą stronę.
Czego to nie robi
Wolimy powiedzieć to wprost. Zmodyfikowany klient, który zgłasza wiarygodne wartości mieszczące się w twoich zasadach, nadal zostanie zaakceptowany: Validated Actions ogranicza, ile oszust może zyskać, i pozwala przejrzeć podejrzane rozgrywki, ale nie czyni oszukiwania niemożliwym. Serwer sprawdza granice, czas i jednorazowe użycie oraz archiwizuje dowody, ale nie uruchamia ani nie odtwarza kodu twojej gry i na razie nie wydaje automatycznych werdyktów na podstawie dowodów. Validated Actions działa tylko w chmurze; na self-hostowanym simpleServer SDK zwracają NOT_SUPPORTED. Każda rozgrywka wymaga zalogowanego gracza, a rozgrywki na tablicy wyników także nazwy wyświetlanej.
Pojemność w poszczególnych planach
Każda funkcja z tego wpisu jest dostępna w każdym planie, także w FREE. Rośnie tylko pojemność:
| FREE | BASIC | PRO | ENTERPRISE | |
|---|---|---|---|---|
| Walidowane rozgrywki na konto i godzinę UTC | 300 | 3000 | 20 000 | 200 000 |
| Wartości należące do serwera na klucz API | 8 | 16 | 32 | 64 |
| Sloty na dowody dla rozgrywek z Top N | 50 | 500 | 2500 | 25 000 |
| Przechowywane pakiety sus | 10 | 100 | 1000 | 10 000 |
| Okres przechowywania pakietów sus | 14 dni | 30 dni | 90 dni | 180 dni |
| Katalog kosmetyków na klucz API | 50 | 200 | 500 | 1000 |
| Wiersze wyników na klucz API | 5000 | 25 000 | 200 000 | 2 500 000 |
Gdy limit pakietów sus jest wyczerpany, nowe rozgrywki sus nadal są akceptowane i zgłaszane jako sus, po prostu nie są archiwizowane. Zapisane pakiety nigdy nie są wypierane przez nowe.
Pierwsze kroki
Zaktualizuj SDK dla Unity, Godot lub Unreal do najnowszej wersji, wybierz jedną tablicę i dodaj zestaw zasad wyłącznie z progami miękkimi. Dodaj ValidatedRunContext do wywołania StartRun, a potem wypełnij katalog kosmetyków awatarami i ramkami, które już dostarczasz w grze. Strona funkcji Validated Actions i strona funkcji tablicy wyników podsumowują zasady i limity, a quickstart przeprowadza przez wszystkie trzy silniki. Chcesz, żeby twoja następna tablica wyników była jednocześnie uczciwa i osobista? Wypróbuj horizOn za darmo albo zagłęb się w dokumentację API.