Leaderboards justos sem código de servidor: partidas validadas, pacotes sus e perfis de jogador
Em resumo
Crie leaderboards validados pelo servidor sem código de servidor: tickets de uso único, pacotes sus reproduzíveis, moeda do servidor e perfis.
A primeira semana de um novo leaderboard costuma terminar do mesmo jeito: em algum lugar entre o top 10 honesto e o resto da tabela aparece um jogador com 2.147.483.647 pontos, o maior valor que um inteiro de 32 bits com sinal consegue armazenar. Ninguém jogou essa partida. Quem a escreveu foi um editor de memória ou um Proxy interceptador, porque na maioria dos leaderboards o cliente do jogo é a única testemunha e também o juiz.
Esta versão ataca esse ponto fraco por dois lados. Validated Actions leva ao servidor a decisão sobre o que conta, e os novos pacotes de partidas sus guardam cada partida suspeita como um dossiê completo e reproduzível. Ao mesmo tempo, os leaderboards ficam mais pessoais: cada entrada agora traz um perfil de jogador com avatar, moldura e até três insígnias, e itens cosméticos podem ser desbloqueados com códigos de presente. A seguir você vê como cada peça funciona, os números por trás dela, código funcional para Unity e Godot e os limites que queremos que você conheça antes de confiar nela.
Por que não dá para confiar em pontuações informadas pelo cliente
Um envio clássico de pontuação é uma única requisição: ID do jogador, pontuação, pronto. Tudo o que o servidor sabe sobre aquela partida vem do dispositivo que tem o maior interesse em mentir sobre ela. As contramedidas habituais têm todas a mesma falha:
- Assinar a requisição no cliente. A chave de assinatura vai junto dentro do seu build. Extraí-la de um binário IL2CPP ou de um export GDScript leva uma tarde, e a partir daí requisições forjadas parecem perfeitamente válidas.
- Ofuscar a pontuação na memória. Isso atrasa editores de memória casuais, mas um Proxy que reescreve o corpo HTTP nunca toca no layout da sua memória.
- Verificações de plausibilidade no cliente. Tudo o que roda no dispositivo pode ser removido com um patch no próprio dispositivo.
A resposta robusta é que o servidor decide o que conta. A versão de manual disso é uma simulação autoritativa: a lógica do seu jogo roda em hardware que você controla e o cliente só envia inputs. Para um jogo de ação rápido com milhares de partidas simultâneas, isso significa semanas de trabalho de Netcode mais uma conta de hospedagem permanente. A maioria dos times indie não precisa da simulação completa. Eles precisam de três garantias mais baratas: o servidor sabe quando uma partida começou, quais regras se aplicam a ela e quais evidências existem depois. É exatamente essa lacuna que o Validated Actions preenche.
Como funciona uma partida validada
Uma partida validada tem quatro etapas, e o servidor controla as duas que importam:
- Início da partida. O jogo chama
StartRun. O servidor emite um ticket de uso único assinado, vinculado ao jogador, à chave de API e, opcionalmente, a um leaderboard. O ticket traz um Seed escolhido pelo servidor (de 0 a 2.147.483.646) e uma validade: 2 horas por padrão, configurável de 60 segundos a 6 horas. - Jogo. O jogo inicializa sua aleatoriedade com o Seed do servidor e grava os inputs do jogador em um log de bytes compacto.
- Fim da partida. O jogo chama
SubmitValidatedcom a pontuação, um estágio opcional, valores ganhos opcionais e o log de inputs. O SDK envia o hash SHA-256 do log, então o log em si fica no dispositivo por enquanto. - Verificação no servidor. O servidor verifica o ticket, mede a duração por conta própria (da emissão do ticket até o envio) e aplica suas regras. Só então ele grava alguma coisa.
Um ticket conta exatamente uma vez, em todas as regiões. Uma partida rejeitada também queima o seu ticket, então ninguém consegue sondar seus limites tentando de novo o mesmo ticket com pontuações um pouco menores. Como o servidor escolhe o Seed e limita os tickets por jogador por hora (60 por padrão), farmar um Seed de sorte fica lento e visível.
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}");
O mesmo fluxo existe no Godot (Horizon.validatedActions.startRun e submitValidated) e no Unreal (Horizon->ValidatedActions), cada um com um exemplo completo no SDK.
Regras que só o servidor conhece
Cada chave de API recebe um conjunto de regras, editado no Dashboard como formulário ou como JSON. As regras nunca aparecem em respostas do app nem em mensagens de erro: o cliente só fica sabendo de um código legível por máquina. Aqui está um conjunto de regras realista para um jogo arcade baseado em ondas:
{
"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 }
}
}
Passe alguns envios por ele no leaderboard weekly:
| Partida enviada | Resposta do servidor |
|---|---|
| 9.999.999 pontos | 422 SCORE_ABOVE_MAX |
| 21.000 pontos, 3 segundos após o ticket | 422 DURATION_TOO_SHORT |
| 150.000 pontos em 120 segundos (1.250 por segundo) | 422 SCORE_RATE_TOO_HIGH |
| 190.000 pontos em 240 segundos | aceita, mas sus (acima do máximo flexível de 180.000) |
| o mesmo ticket uma segunda vez | 422 TICKET_CONSUMED |
O servidor verifica em uma ordem fixa (estágio, pontuação, regra de estágio, duração, pontuação por segundo, valores ganhos e, por fim, limites flexíveis) e a primeira falha decide. As regras de pontuação só valem para partidas com leaderboard; uma partida sem leaderboard ainda tem suas regras de estágio e duração verificadas.
Quando as regras estiverem ajustadas, mude o leaderboard para "Apenas envios validados". A partir daí, um SubmitScore simples para esse leaderboard é recusado com 403 VALIDATED_SUBMIT_REQUIRED, enquanto todos os outros leaderboards continuam aceitando envios normais. Mesmo com um conjunto de regras vazio, um leaderboard somente validado garante tickets do servidor, uso único, vínculo com o jogador, os limites por hora e um hash de log armazenado.
Limites flexíveis e partidas sus
Regras rígidas rejeitam. Limites flexíveis apenas marcam uma partida, e é por aí que você deve começar: uma semana de limites flexíveis mostra como é o jogo real antes de você transformar os números em rejeições rígidas. Uma partida é sus quando foi aceita e ultrapassou pelo menos um limite flexível. Partidas rejeitadas e erros técnicos nunca são sus. O resultado do envio traz uma flag sus simples; qual limite disparou fica no servidor.
A parte interessante é o que acontece depois. Com esta atualização, o servidor guarda um pacote sus para cada partida sus, independentemente da melhor pontuação do jogador, do Top N ou de alterações posteriores na pontuação. Um trapaceiro que posta uma partida absurda e depois uma normal não consegue mais enterrar a evidência sob uma entrada melhor.
O contexto inicial: com o que a partida começou
Um Replay só é útil se você consegue reproduzir as condições iniciais exatas. Por isso, no StartRun o servidor agora registra o contexto inicial: a versão de regras em vigor, os bytes reais do Cloud Save do jogador (com SHA-256 e revisão), os valores controlados pelo servidor, o Seed e o horário de início. O jogo pode acrescentar o seu lado da história:
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);
Valores do servidor e valores do cliente ficam estritamente separados, e ambos alimentam um texto canônico cujo SHA-256 é armazenado no ticket. O envio também é verificado contra a versão de regras do início, então apertar um limite enquanto uma partida está em andamento nunca muda o veredito dela. Para evitar que edições de regras sejam usadas como sonda, as alterações são limitadas a 30 por chave de API por hora.
O que um pacote sus contém
Cada pacote é armazenado sob o ID da partida e reúne tudo o que um revisor precisa: o contexto inicial, as regras vinculadas, o resultado, os valores controlados pelo servidor antes e depois da partida e o log de inputs (o SDK faz o upload automaticamente, exatamente como nas partidas do Top N). Na aba Review do Dashboard você filtra por sus e exporta um pacote como arquivo 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 cada exportação, o servidor recalcula todos os checksums e informa o resultado em manifest.integrity e no header X-Package-Integrity (ok ou mismatch). Se uma única parte não couber no limite de tamanho, só o checksum dela é mantido e a parte é marcada como OMITTED_SIZE_LIMIT, então você sempre sabe o que está faltando e por quê.
Alimente sua própria simulação determinística com cloud-save.bin, initial-state.bin, o Seed e input-log.bin, e você reproduz a pontuação ou não. Replays, vereditos e sanções ficam com você; o servidor marca e arquiva, ele nunca executa o código do seu jogo.
Moeda que o cliente não consegue gravar
Leaderboards não são a única coisa em que vale a pena trapacear. Com valores controlados pelo servidor você define contadores como gold ou gems no mesmo conjunto de regras. Só partidas validadas e aceitas os alteram: não existe endpoint de app nem método de SDK que defina um saldo. A regra do JSON acima funciona assim:
maxPerRun: 500: uma partida que alega 800 de ouro é rejeitada comEARNED_ABOVE_MAX.minPerRun: -1000: uma partida pode gastar até 1.000 de ouro; gastar mais do que o saldo falha comINSUFFICIENT_BALANCE.dailyCap: 5000: créditos positivos por dia UTC são limitados, não rejeitados. Um jogador que já ganhou 4.800 hoje e termina uma partida de 500 de ouro recebe 200 creditados.
A resposta mostra requested e credited para cada chave afetada, então o jogo pode exibir "limite diário atingido" em vez de perder moedas silenciosamente. Para compras a regra é simples: entregue o item só quando o gasto tiver sido creditado por completo (IsFullyCredited no Unity e no Unreal). É o mesmo raciocínio por trás de tirar as lojas de upgrades dos valores padrão do lado do cliente: o dispositivo pode pedir, só o servidor concede.
O Cloud Save continua funcionando como antes e vira um espelho. Copie os valores do servidor para o save após cada partida aceita para exibição offline, sobrescreva essa cópia com GetState na inicialização e nunca envie um valor do save de volta como saldo.
Perfis de jogador em cada entrada do leaderboard
A segunda metade desta atualização é sobre as pessoas no leaderboard, e não sobre os números. Cada entrada de leaderboard em Top, Around e Rank agora traz um perfil com avatarId, frameId e até três badges:
{ "position": 1, "username": "Gravedigger", "score": 15000,
"profile": { "avatarId": "avatar.zombie_07", "frameId": "frame.gold", "badges": ["badge.supporter"] } }
O servidor lê o perfil junto com o nome de exibição a partir do seu cache de usernames, então uma lista de top não custa nenhuma consulta extra por entrada. Alterações de perfil aparecem em todos os servidores em no máximo 10 minutos.
Um catálogo de IDs, não de imagens
Cada projeto mantém um catálogo de cosméticos no Dashboard. Uma entrada tem um ID (por exemplo avatar.zombie_07), um tipo (avatar, frame ou badge) e uma flag locked. Entradas gratuitas podem ser escolhidas por qualquer jogador; entradas bloqueadas, só por jogadores que as desbloquearam. O servidor armazena apenas IDs, nunca imagens: seu jogo mapeia cada ID para os próprios sprites, então a arte fica no seu build e uma nova moldura custa uma linha no catálogo.
Renderizar uma linha do leaderboard no Godot fica assim:
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)
IDs desconhecidos (por exemplo depois que você exclui uma entrada do catálogo) devem simplesmente ser renderizados como "não definido": os jogadores mantêm o ID obsoleto até mudarem o perfil, e nada quebra.
Desbloqueios com códigos de presente
Desbloqueios são gravados apenas pelo servidor, hoje por meio de códigos de presente com grants ou manualmente no Dashboard. Um jogador pode ter até 25 desbloqueios. Resgatar um código com grants retorna os IDs concedidos e descarta o perfil em cache, então o próximo GetProfile mostra o novo item como disponível:
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"])
Escolher um item bloqueado sem o desbloqueio falha com 403 COSMETIC_LOCKED, e mais de três insígnias com 400 INVALID_BADGES. Isso transforma recompensas de eventos, sorteios no Discord e benefícios para apoiadores em um código de presente cada, sem um endpoint de desbloqueio personalizado. Uma sobra da API antiga se foi de vez: o parâmetro metadata nos envios de pontuação nunca foi armazenado e agora está obsoleto. Informações por jogador pertencem ao perfil.
Limites de pontuação agora contam por chave de API
Uma mudança menor, mas com impacto real para estúdios que rodam vários jogos ou ambientes em uma conta: o limite de pontuações agora vale por chave de API. Cada chave pode ter tantas linhas de pontuação quanto o plano permite (uma linha é um jogador em um leaderboard), somadas em todos os leaderboards daquela chave, e cada chave adicional recebe sua própria cota completa. Linhas existentes sempre podem ser melhoradas; só a primeira pontuação de um jogador em um leaderboard pode receber 403 quando a chave estiver cheia.
Boas práticas para leaderboards justos
- Comece flexível, depois endureça. Rode uma semana só com limites
soft, analise as partidas sus na aba Review e transforme os números em regras rígidas com uma margem de segurança de 20 a 30 por cento acima da melhor partida legítima. - Chame
StartRunquando a partida realmente começa. O relógio começa no ticket. Começar no menu ou antes de uma tela de carregamento de 40 segundos tornaminDurationSecondsinútil. - Mantenha os logs de inputs pequenos e determinísticos. Grave frames de input fixos com delta encoding. As evidências têm limite de 32 KB por log, e uma partida de 10 minutos a 30 frames de input por segundo com 1 byte por frame cabe com folga.
- Versione tudo no contexto inicial. Sem
simulationVersionecontentDigest, um pacote do mês passado pode ser reproduzido com o balanceamento deste mês e falhar pelo motivo errado. - Trate o save como espelho, nunca como fonte. Os saldos fluem do servidor para o Cloud Save, nunca no sentido contrário.
O que ele não faz
Preferimos dizer isso com clareza. Um cliente modificado que informa valores plausíveis dentro das suas regras continua sendo aceito: o Validated Actions limita o quanto um trapaceiro pode ganhar e torna partidas suspeitas revisáveis, mas não torna a trapaça impossível. O servidor verifica limites, tempo e uso único e arquiva evidências, mas não executa nem reproduz o código do seu jogo, e ainda não há veredito automático sobre as evidências. O Validated Actions é exclusivo da nuvem; no simpleServer self-hosted os SDKs retornam NOT_SUPPORTED. Toda partida precisa de um jogador logado, e partidas em um leaderboard também precisam de um nome de exibição.
Capacidade por plano
Todos os recursos deste post estão disponíveis em todos os planos, inclusive no FREE. Só a capacidade cresce:
| FREE | BASIC | PRO | ENTERPRISE | |
|---|---|---|---|---|
| Partidas validadas por conta e hora UTC | 300 | 3.000 | 20.000 | 200.000 |
| Valores controlados pelo servidor por chave de API | 8 | 16 | 32 | 64 |
| Slots de evidência para partidas do Top N | 50 | 500 | 2.500 | 25.000 |
| Pacotes sus armazenados | 10 | 100 | 1.000 | 10.000 |
| Retenção de pacotes sus | 14 dias | 30 dias | 90 dias | 180 dias |
| Catálogo de cosméticos por chave de API | 50 | 200 | 500 | 1.000 |
| Linhas de pontuação por chave de API | 5.000 | 25.000 | 200.000 | 2.500.000 |
Quando a cota de pacotes sus estiver cheia, novas partidas sus continuam sendo aceitas e informadas como sus, elas só não são arquivadas. Pacotes armazenados nunca são substituídos pelos novos.
Comece agora
Atualize para o SDK mais recente para Unity, Godot ou Unreal, escolha um leaderboard e adicione um conjunto de regras só com limites flexíveis. Adicione um ValidatedRunContext à sua chamada de StartRun e depois preencha seu catálogo de cosméticos com os avatares e molduras que você já distribui. A página do recurso Validated Actions e a página do recurso de leaderboard resumem regras e limites, e o quickstart mostra o passo a passo nas três engines. Pronto para deixar seu próximo leaderboard justo e pessoal ao mesmo tempo? Experimente o horizOn de graça ou mergulhe na documentação da API.