Torna al Blog

Classifiche eque senza codice server: partite validate, pacchetti sus e profili giocatore

Pubblicato il 3 ottobre 2026
Classifiche eque senza codice server: partite validate, pacchetti sus e profili giocatore Generata con l'aiuto dell'IA

In breve

Crea classifiche validate dal server senza codice server: ticket monouso, pacchetti sus riproducibili, valuta lato server e profili giocatore.

La prima settimana di una nuova classifica di solito finisce allo stesso modo: da qualche parte tra la top ten onesta e il resto della classifica spunta un giocatore con 2.147.483.647 punti, il valore più alto che un intero con segno a 32 bit può contenere. Nessuno ha giocato quella partita. L'ha scritta un memory editor o un proxy di intercettazione, perché nella maggior parte delle classifiche il client di gioco è l'unico testimone e al tempo stesso il giudice.

Questa release affronta quel punto debole su due fronti. Validated Actions sposta sul server la decisione su cosa conta, e i nuovi pacchetti delle partite sus conservano ogni partita sospetta come un fascicolo completo e riproducibile. Allo stesso tempo le classifiche diventano più personali: ogni voce ora include un profilo giocatore con avatar, cornice e fino a tre badge, e i cosmetici si possono sbloccare tramite codici regalo. Qui sotto trovi come funziona ogni elemento, i numeri che ci stanno dietro, codice funzionante per Unity e Godot e i limiti che vogliamo tu conosca prima di farci affidamento.

Perché i punteggi inviati dal client non sono affidabili

Un classico invio del punteggio è una singola richiesta: ID giocatore, punteggio, fatto. Tutto ciò che il server sa di quella partita arriva dal dispositivo che ha il massimo interesse a mentire. Le contromisure abituali hanno tutte lo stesso difetto:

  • Firmare la richiesta nel client. La chiave di firma viene distribuita dentro la tua build. Estrarla da un binario IL2CPP o da un export GDScript richiede un pomeriggio, e da lì in poi le richieste contraffatte sembrano perfettamente valide.
  • Offuscare il punteggio in memoria. Rallenta i memory editor occasionali, ma un proxy che riscrive il body HTTP non tocca mai il layout della tua memoria.
  • Controlli di plausibilità nel client. Qualunque cosa giri sul dispositivo può essere rimossa con una patch sul dispositivo.

La risposta solida è che sia il server a decidere cosa conta. La versione da manuale è una simulazione autoritativa: la logica di gioco gira su hardware che controlli tu e il client invia solo gli input. Per un action veloce con migliaia di partite in contemporanea, significa settimane di lavoro sul netcode più un conto di hosting permanente. La maggior parte dei team indie non ha bisogno della simulazione completa. Servono tre garanzie più economiche: il server sa quando è iniziata una partita, quali regole si applicano e quali prove esistono dopo. È esattamente il vuoto che colma Validated Actions.

Come funziona una partita validata

Una partita validata ha quattro passaggi, e i due che contano sono in mano al server:

  1. Inizio della partita. Il gioco chiama StartRun. Il server emette un ticket monouso firmato, legato al giocatore, alla API key e, facoltativamente, a una classifica. Il ticket contiene un seed scelto dal server (da 0 a 2.147.483.646) e una scadenza: 2 ore di default, configurabile da 60 secondi a 6 ore.
  2. Gioco. Il gioco inizializza la sua casualità con il seed del server e registra gli input del giocatore in un log di byte compatto.
  3. Fine della partita. Il gioco chiama SubmitValidated con il punteggio, uno stage facoltativo, valori guadagnati facoltativi e il log degli input. L'SDK invia l'hash SHA-256 del log, quindi per ora il log resta sul dispositivo.
  4. Controllo del server. Il server verifica il ticket, misura da sé la durata (dall'emissione del ticket all'invio) e applica le tue regole. Solo allora scrive qualcosa.

Un ticket conta esattamente una volta, in tutte le regioni. Anche una partita rifiutata brucia il suo ticket, così nessuno può sondare le tue soglie riprovando lo stesso ticket con punteggi leggermente più bassi. Poiché il server sceglie il seed e limita i ticket per giocatore all'ora (60 di default), cercare un seed fortunato diventa lento e visibile.

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

Lo stesso flusso esiste in Godot (Horizon.validatedActions.startRun e submitValidated) e in Unreal (Horizon->ValidatedActions), ciascuno con un esempio completo nell'SDK.

Regole che conosce solo il server

Ogni API key ha un set di regole, che modifichi nel Dashboard come modulo o come JSON. Le regole non compaiono mai nelle risposte dell'app o nei messaggi di errore: il client riceve sempre e solo un codice leggibile dalla macchina. Ecco un set di regole realistico per un arcade a ondate:

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

Prova qualche invio sulla classifica weekly:

Partita inviata Risposta del server
9.999.999 punti 422 SCORE_ABOVE_MAX
21.000 punti, 3 secondi dopo il ticket 422 DURATION_TOO_SHORT
150.000 punti in 120 secondi (1.250 al secondo) 422 SCORE_RATE_TOO_HIGH
190.000 punti in 240 secondi accettata, ma sus (sopra il massimo soft di 180.000)
lo stesso ticket una seconda volta 422 TICKET_CONSUMED

Il server controlla in un ordine fisso (stage, punteggio, regola dello stage, durata, punteggio al secondo, valori guadagnati, poi soglie soft) e vince il primo errore. Le regole sul punteggio valgono solo per le partite con una classifica; una partita senza classifica viene comunque controllata sulle regole di stage e di durata.

Quando le regole sono a posto, imposta la classifica su "Solo invii convalidati". Da quel momento un semplice SubmitScore su quella classifica viene rifiutato con 403 VALIDATED_SUBMIT_REQUIRED, mentre tutte le altre classifiche continuano ad accettare invii normali. Anche con un set di regole vuoto, una classifica solo validata garantisce ticket del server, uso singolo, legame al giocatore, limiti orari e un hash del log memorizzato.

Soglie soft e partite sus

Le regole rigide rifiutano. Le soglie soft si limitano a segnalare una partita, ed è da lì che dovresti partire: una settimana di limiti soft ti mostra com'è il gioco reale prima di trasformare i numeri in rifiuti rigidi. Una partita è sus quando è stata accettata e ha superato almeno una soglia soft. Le partite rifiutate e gli errori tecnici non sono mai sus. Il risultato dell'invio contiene un semplice flag sus; quale soglia sia scattata resta sul server.

La parte interessante è ciò che succede dopo. Con questo aggiornamento il server conserva un pacchetto sus per ogni partita sus, indipendentemente dal miglior punteggio del giocatore, dalla top N o da modifiche successive del punteggio. Un cheater che pubblica una partita assurda e poi una normale non può più seppellire le prove sotto una voce migliore.

Il contesto di avvio: con cosa è iniziata la partita

Un replay è utile solo se puoi riprodurre esattamente le condizioni di partenza. Per questo, al momento di StartRun, il server ora registra il contesto di avvio: la versione delle regole in vigore, i byte reali del Cloud Save del giocatore (con SHA-256 e revisione), i valori gestiti dal server, il seed e l'ora di inizio. Il gioco può aggiungere la sua versione dei fatti:

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

I valori del server e quelli del client restano rigorosamente separati, ed entrambi confluiscono in un testo canonico il cui SHA-256 viene salvato sul ticket. Inoltre l'invio viene verificato rispetto alla versione delle regole dell'avvio, quindi stringere un limite mentre una partita è in corso non cambia mai il verdetto su quella partita. Per evitare che le modifiche alle regole vengano usate come sonda, sono limitate a 30 per API key all'ora.

Cosa contiene un pacchetto sus

Ogni pacchetto viene salvato sotto l'ID della partita e raccoglie tutto ciò che serve a chi fa la revisione: il contesto di avvio, le regole vincolate, il risultato, i valori gestiti dal server prima e dopo la partita e il log degli input (l'SDK lo carica automaticamente, come per le partite della top N). Nella scheda Review del Dashboard filtri per sus ed esporti un pacchetto come file 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

A ogni export il server ricalcola tutti i checksum e riporta il risultato in manifest.integrity e nell'header X-Package-Integrity (ok o mismatch). Se una singola parte non rientra nel limite di dimensione, viene conservato solo il suo checksum e la parte viene marcata come OMITTED_SIZE_LIMIT, così sai sempre cosa manca e perché.

Passa cloud-save.bin, initial-state.bin, il seed e input-log.bin alla tua simulazione deterministica: o il punteggio si riproduce, o no. Replay, verdetti e sanzioni restano a te; il server segnala e archivia, non esegue mai il codice del tuo gioco.

Valuta che il client non può scrivere

Le classifiche non sono l'unica cosa su cui valga la pena barare. Con i valori gestiti dal server definisci contatori come gold o gems nello stesso set di regole. Solo le partite validate e accettate li modificano: non esiste alcun endpoint dell'app né alcun metodo dell'SDK che imposti un saldo. La regola del JSON qui sopra si legge così:

  • maxPerRun: 500: una partita che dichiara 800 monete d'oro viene rifiutata con EARNED_ABOVE_MAX.
  • minPerRun: -1000: una partita può spendere fino a 1.000 monete d'oro; spendere più del saldo fallisce con INSUFFICIENT_BALANCE.
  • dailyCap: 5000: gli accrediti positivi per giorno UTC vengono limitati al tetto, non rifiutati. Un giocatore che oggi ha già guadagnato 4.800 e completa una partita da 500 monete d'oro se ne vede accreditare 200.

La risposta mostra requested e credited per ogni chiave coinvolta, così il gioco può mostrare "limite giornaliero raggiunto" invece di perdere monete in silenzio. Per gli acquisti la regola è semplice: assegna l'oggetto solo quando la spesa è stata accreditata per intero (IsFullyCredited in Unity e Unreal). È lo stesso ragionamento alla base della scelta di spostare gli shop di potenziamenti fuori dai valori di default lato client: il dispositivo può chiedere, solo il server concede.

Cloud Save continua a funzionare come prima e diventa uno specchio. Copia i valori del server nel salvataggio dopo ogni partita accettata per la visualizzazione offline, sovrascrivi quella copia con GetState all'avvio e non rimandare mai un valore del salvataggio come saldo.

Profili giocatore su ogni voce della classifica

La seconda metà di questo aggiornamento riguarda le persone in classifica più che i numeri. Ogni voce di classifica in Top, Around e Rank ora include un profilo con avatarId, frameId e fino a tre badges:

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

Il server legge il profilo insieme al nome visualizzato dalla sua cache dei nomi utente, quindi una top list non costa alcuna lookup aggiuntiva per voce. Le modifiche al profilo compaiono su ogni server entro 10 minuti al massimo.

Un catalogo di ID, non di immagini

Ogni progetto gestisce un catalogo dei cosmetici nel Dashboard. Una voce ha un ID (per esempio avatar.zombie_07), un tipo (avatar, frame o badge) e un flag locked. Le voci libere possono essere scelte da qualsiasi giocatore; quelle bloccate solo da chi le ha sbloccate. Il server memorizza solo ID, mai immagini: il tuo gioco associa ogni ID ai propri sprite, quindi la grafica resta nella tua build e una nuova cornice costa una riga di catalogo.

Ecco come si renderizza una riga della classifica in 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)

Gli ID sconosciuti (per esempio dopo che hai eliminato una voce del catalogo) dovrebbero semplicemente essere mostrati come "non impostato": i giocatori mantengono l'ID obsoleto finché non cambiano profilo, e non si rompe nulla.

Sblocchi tramite codici regalo

Gli sblocchi li scrive solo il server, oggi tramite codici regalo con grants o a mano nel Dashboard. Un giocatore può avere fino a 25 sblocchi. Riscattare un codice con grants restituisce gli ID concessi e invalida il profilo in cache, così la successiva GetProfile mostra il nuovo oggetto come disponibile:

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

Scegliere un oggetto bloccato senza averlo sbloccato fallisce con 403 COSMETIC_LOCKED, più di tre badge con 400 INVALID_BADGES. Così ricompense degli eventi, giveaway su Discord e vantaggi per i supporter diventano ciascuno un singolo codice regalo, senza un endpoint di sblocco su misura. Un residuo della vecchia API sparisce per sempre: il parametro metadata negli invii dei punteggi non è mai stato memorizzato ed è ora deprecato. Le informazioni per giocatore appartengono al profilo.

I limiti di punteggio ora contano per API key

Una modifica più piccola ma con un impatto reale per gli studi che gestiscono più giochi o ambienti in un unico account: il limite di punteggi ora si applica per API key. Ogni chiave può contenere tante righe di punteggio quante ne consente il piano (una riga è un giocatore su una classifica), sommate su tutte le classifiche di quella chiave, e ogni chiave aggiuntiva riceve la propria quota completa. Le righe esistenti si possono sempre migliorare; solo il primo punteggio di un giocatore su una classifica può ricevere un 403 quando la chiave è piena.

Best practice per classifiche eque

  1. Parti soft, poi irrigidisci. Fai girare una settimana solo con soglie soft, guarda le partite sus nella scheda Review e trasforma i numeri in regole rigide con un margine di sicurezza dal 20 al 30 percento sopra la miglior partita legittima.
  2. Chiama StartRun quando la partita inizia davvero. Il cronometro parte dal ticket. Avviarlo nel menu o prima di una schermata di caricamento di 40 secondi rende minDurationSeconds privo di senso.
  3. Mantieni i log degli input piccoli e deterministici. Registra frame di input fissi con codifica delta. Le prove sono limitate a 32 KB per log, e una partita di 10 minuti a 30 frame di input al secondo con 1 byte per frame ci sta comodamente.
  4. Versiona tutto nel contesto di avvio. Senza simulationVersion e contentDigest, un pacchetto del mese scorso potrebbe essere riprodotto con il bilanciamento di questo mese e fallire per il motivo sbagliato.
  5. Tratta il salvataggio come uno specchio, mai come una fonte. I saldi passano dal server al Cloud Save, mai al contrario.

Cosa non fa

Preferiamo dirlo chiaramente. Un client modificato che invia valori plausibili entro le tue regole viene comunque accettato: Validated Actions limita quanto può guadagnare un cheater e rende le partite sospette verificabili, ma non rende impossibile barare. Il server controlla limiti, tempi e uso singolo e archivia le prove, ma non esegue né riproduce il codice del tuo gioco, e per ora non esiste un verdetto automatico sulle prove. Validated Actions è disponibile solo in cloud; sul simpleServer self-hosted gli SDK restituiscono NOT_SUPPORTED. Ogni partita richiede un giocatore autenticato, e le partite su una classifica richiedono anche un nome visualizzato.

Capacità per piano

Tutte le funzionalità di questo articolo sono disponibili in ogni piano, FREE compreso. Cresce solo la capacità:

FREE BASIC PRO ENTERPRISE
Partite validate per account e ora UTC 300 3.000 20.000 200.000
Valori gestiti dal server per API key 8 16 32 64
Slot per le prove delle partite top N 50 500 2.500 25.000
Pacchetti sus memorizzati 10 100 1.000 10.000
Conservazione dei pacchetti sus 14 giorni 30 giorni 90 giorni 180 giorni
Catalogo dei cosmetici per API key 50 200 500 1.000
Righe di punteggio per API key 5.000 25.000 200.000 2.500.000

Quando la quota dei pacchetti sus è piena, le nuove partite sus vengono comunque accettate e segnalate come sus, semplicemente non vengono archiviate. I pacchetti memorizzati non vengono mai sostituiti da quelli nuovi.

Inizia subito

Aggiorna all'ultimo SDK per Unity, Godot o Unreal, scegli una classifica e aggiungi un set di regole con sole soglie soft. Aggiungi un ValidatedRunContext alla tua chiamata StartRun, poi riempi il catalogo dei cosmetici con gli avatar e le cornici che già distribuisci. La pagina della funzionalità Validated Actions e la pagina della funzionalità classifiche riassumono regole e limiti, e il quickstart copre tutti e tre gli engine. Pronto a rendere la tua prossima classifica equa e personale? Prova horizOn gratis o tuffati nella documentazione API.