Leaderboard Adil Tanpa Kode Server: Run Tervalidasi, Paket Sus, dan Profil Pemain
Ringkasnya
Bangun leaderboard tervalidasi server tanpa kode server: tiket run sekali pakai, paket sus yang bisa di-replay, mata uang server, dan profil pemain.
Minggu pertama sebuah leaderboard baru biasanya berakhir dengan cara yang sama: di antara sepuluh besar yang jujur dan sisa papan, nangkring seorang pemain dengan 2.147.483.647 poin, nilai terbesar yang bisa ditampung integer 32-bit bertanda. Tidak ada yang benar-benar memainkan run itu. Yang menulisnya adalah memory editor atau Proxy penyadap, karena di kebanyakan leaderboard, klien game adalah satu-satunya saksi sekaligus hakimnya.
Rilis ini menangani titik lemah tersebut dari dua sisi. Validated Actions memindahkan keputusan tentang apa yang dihitung ke server, dan paket sus run yang baru menyimpan setiap run mencurigakan sebagai berkas kasus yang lengkap dan bisa di-replay. Di saat yang sama, leaderboard menjadi lebih personal: setiap entri kini membawa profil pemain dengan avatar, bingkai, dan hingga tiga lencana, dan kosmetik bisa dibuka lewat kode hadiah. Di bawah ini Anda akan menemukan cara kerja setiap bagian, angka di baliknya, kode siap pakai untuk Unity dan Godot, serta batasan yang perlu Anda ketahui sebelum mengandalkannya.
Mengapa Skor yang Dilaporkan Klien Tidak Bisa Dipercaya
Pengiriman skor klasik hanyalah satu request: ID pemain, skor, selesai. Semua yang diketahui server tentang run itu berasal dari perangkat yang paling berkepentingan untuk berbohong tentangnya. Penangkal yang umum dipakai semuanya punya kelemahan yang sama:
- Menandatangani request di klien. Kunci penandatanganan ikut terkirim di dalam build Anda. Mengekstraknya dari binary IL2CPP atau export GDScript cukup butuh satu sore, dan sejak saat itu request palsu terlihat sepenuhnya valid.
- Mengaburkan skor di memori. Ini memperlambat memory editor biasa, tetapi Proxy yang menulis ulang body HTTP sama sekali tidak menyentuh tata letak memori Anda.
- Pemeriksaan kewajaran di klien. Apa pun yang berjalan di perangkat bisa di-patch dan dinonaktifkan di perangkat itu juga.
Jawaban yang kokoh adalah: server yang memutuskan apa yang dihitung. Versi buku teksnya adalah simulasi otoritatif: logika game Anda berjalan di hardware yang Anda kendalikan dan klien hanya mengirim input. Untuk game aksi cepat dengan ribuan run bersamaan, itu berarti berminggu-minggu pekerjaan Netcode ditambah tagihan hosting permanen. Sebagian besar tim indie tidak membutuhkan simulasi penuh. Mereka membutuhkan tiga jaminan yang lebih murah: server tahu kapan sebuah run dimulai, aturan apa yang berlaku untuknya, dan bukti apa yang ada setelahnya. Celah inilah yang diisi oleh Validated Actions.
Cara Kerja Run Tervalidasi
Run tervalidasi memiliki empat langkah, dan server memegang dua langkah yang paling penting:
- Awal run. Game memanggil
StartRun. Server menerbitkan tiket sekali pakai yang ditandatangani dan terikat ke pemain, API key, serta opsional ke satu leaderboard. Tiket membawa Seed pilihan server (0 hingga 2.147.483.646) dan masa berlaku: 2 jam secara default, bisa dikonfigurasi dari 60 detik hingga 6 jam. - Bermain. Game menginisialisasi pengacaknya dengan Seed dari server dan merekam input pemain ke dalam log byte yang ringkas.
- Akhir run. Game memanggil
SubmitValidateddengan skor, stage opsional, nilai perolehan opsional, dan log input. SDK mengirim hash SHA-256 dari log, sehingga log itu sendiri untuk sementara tetap di perangkat. - Pemeriksaan server. Server memverifikasi tiket, mengukur durasinya sendiri (dari penerbitan tiket hingga submit), lalu menerapkan aturan Anda. Baru setelah itu server menulis apa pun.
Sebuah tiket dihitung tepat satu kali, di semua region. Run yang ditolak juga menghanguskan tiketnya, sehingga tidak ada yang bisa meraba ambang batas Anda dengan mencoba ulang tiket yang sama memakai skor yang sedikit lebih rendah. Karena server yang memilih Seed dan membatasi tiket per pemain per jam (60 secara default), berburu Seed yang beruntung menjadi lambat dan mudah terlihat.
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}");
Alur yang sama tersedia di Godot (Horizon.validatedActions.startRun dan submitValidated) dan Unreal (Horizon->ValidatedActions), masing-masing dengan contoh lengkap di SDK.
Aturan yang Hanya Diketahui Server
Setiap API key mendapat satu set aturan, yang diedit di Dashboard sebagai formulir atau sebagai JSON. Aturan tidak pernah muncul di respons aplikasi maupun pesan error: klien hanya pernah mengetahui kode yang bisa dibaca mesin. Berikut set aturan yang realistis untuk game arcade berbasis gelombang:
{
"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 }
}
}
Mari uji beberapa submit dengan aturan ini di papan weekly:
| Run yang dikirim | Jawaban server |
|---|---|
| 9.999.999 poin | 422 SCORE_ABOVE_MAX |
| 21.000 poin, 3 detik setelah tiket | 422 DURATION_TOO_SHORT |
| 150.000 poin dalam 120 detik (1.250 per detik) | 422 SCORE_RATE_TOO_HIGH |
| 190.000 poin dalam 240 detik | diterima, tetapi sus (di atas batas maksimum lunak 180.000) |
| tiket yang sama untuk kedua kalinya | 422 TICKET_CONSUMED |
Server memeriksa dalam urutan tetap (stage, skor, aturan stage, durasi, skor per detik, nilai perolehan, lalu ambang lunak) dan kegagalan pertama yang menentukan. Aturan skor hanya berlaku untuk run yang memiliki papan; run tanpa papan tetap diperiksa aturan stage dan durasinya.
Setelah aturannya pas, ubah papan ke "Hanya kiriman tervalidasi". Sejak saat itu, SubmitScore biasa ke papan tersebut ditolak dengan 403 VALIDATED_SUBMIT_REQUIRED, sementara semua papan lain tetap menerima submit normal. Bahkan dengan set aturan kosong, papan khusus run tervalidasi menjamin tiket dari server, sifat sekali pakai, keterikatan ke pemain, batas per jam, dan hash log yang tersimpan.
Ambang Lunak dan Sus Run
Aturan keras menolak. Ambang lunak hanya menandai sebuah run, dan di situlah sebaiknya Anda memulai: seminggu dengan batas lunak memperlihatkan seperti apa permainan yang sebenarnya sebelum Anda mengubah angka-angka itu menjadi penolakan keras. Sebuah run disebut sus jika diterima dan melewati setidaknya satu ambang lunak. Run yang ditolak dan error teknis tidak pernah sus. Hasil submit membawa flag sus sederhana; ambang mana yang terpicu tetap tersimpan di server.
Bagian menariknya adalah apa yang terjadi selanjutnya. Dengan pembaruan ini, server menyimpan paket sus untuk setiap sus run, terlepas dari skor terbaik pemain, Top N, atau perubahan skor di kemudian hari. Pemain curang yang mengirim satu run absurd lalu satu run normal tidak bisa lagi mengubur buktinya di bawah entri yang lebih baik.
Konteks awal: dengan apa run dimulai
Replay hanya berguna jika Anda bisa mereproduksi kondisi awal yang persis sama. Karena itu, saat StartRun server kini mencatat konteks awal: versi aturan yang berlaku, byte asli Cloud Save pemain (dengan SHA-256 dan revisi), nilai milik server, Seed, dan waktu mulai. Game bisa menambahkan versi ceritanya sendiri:
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);
Nilai server dan nilai klien dipisahkan secara ketat, dan keduanya membentuk teks kanonis yang SHA-256-nya disimpan pada tiket. Submit juga diperiksa terhadap versi aturan saat awal run, sehingga memperketat batas saat sebuah run sedang berlangsung tidak pernah mengubah vonis run tersebut. Agar pengeditan aturan tidak disalahgunakan untuk meraba ambang, perubahan dibatasi 30 kali per API key per jam.
Isi sebuah paket sus
Setiap paket disimpan di bawah ID run dan menggabungkan semua yang dibutuhkan peninjau: konteks awal, aturan yang terikat, hasil, nilai milik server sebelum dan sesudah run, serta log input (SDK mengunggahnya secara otomatis, sama seperti untuk run Top N). Di tab Review pada Dashboard, Anda memfilter berdasarkan sus dan mengekspor paket sebagai 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
Pada setiap ekspor, server menghitung ulang semua checksum dan melaporkan hasilnya di manifest.integrity serta header X-Package-Integrity (ok atau mismatch). Jika satu bagian melebihi batas ukuran, hanya checksum-nya yang disimpan dan bagian itu ditandai OMITTED_SIZE_LIMIT, sehingga Anda selalu tahu apa yang hilang dan mengapa.
Masukkan cloud-save.bin, initial-state.bin, Seed, dan input-log.bin ke simulasi deterministik Anda sendiri, maka hasilnya jelas: skornya bisa direproduksi atau tidak. Replay, vonis, dan sanksi tetap di tangan Anda; server menandai dan mengarsipkan, tetapi tidak pernah menjalankan kode game Anda.
Mata Uang yang Tidak Bisa Ditulis Klien
Leaderboard bukan satu-satunya hal yang menggoda untuk dicurangi. Dengan nilai milik server, Anda mendefinisikan penghitung seperti gold atau gems di set aturan yang sama. Hanya run tervalidasi yang diterima yang bisa mengubahnya: tidak ada endpoint aplikasi maupun metode SDK yang menetapkan saldo. Aturan dari JSON di atas dibaca seperti ini:
maxPerRun: 500: run yang mengklaim 800 gold ditolak denganEARNED_ABOVE_MAX.minPerRun: -1000: sebuah run boleh membelanjakan hingga 1.000 gold; membelanjakan lebih dari saldo gagal denganINSUFFICIENT_BALANCE.dailyCap: 5000: kredit positif per hari UTC dipangkas, bukan ditolak. Pemain yang hari ini sudah mendapatkan 4.800 lalu menyelesaikan run bernilai 500 gold hanya akan dikreditkan 200.
Respons menampilkan requested dan credited untuk setiap key yang tersentuh, sehingga game bisa menampilkan "batas harian tercapai" alih-alih diam-diam kehilangan koin. Untuk pembelian, aturannya sederhana: berikan item hanya jika pembelanjaan dikreditkan sepenuhnya (IsFullyCredited di Unity dan Unreal). Ini alasan yang sama di balik memindahkan toko upgrade dari nilai default sisi klien: perangkat boleh meminta, hanya server yang memberi.
Cloud Save tetap bekerja seperti sebelumnya dan menjadi cermin. Salin nilai server ke save setelah setiap run yang diterima untuk tampilan offline, timpa salinan itu dengan GetState saat game dimulai, dan jangan pernah mengirim nilai dari save kembali sebagai saldo.
Profil Pemain di Setiap Entri Leaderboard
Paruh kedua pembaruan ini lebih berfokus pada orang-orang di papan daripada angkanya. Setiap entri leaderboard di Top, Around, dan Rank kini membawa profil dengan avatarId, frameId, dan hingga tiga badges:
{ "position": 1, "username": "Gravedigger", "score": 15000,
"profile": { "avatarId": "avatar.zombie_07", "frameId": "frame.gold", "badges": ["badge.supporter"] } }
Server membaca profil bersama nama tampilan dari cache username-nya, sehingga daftar teratas tidak memerlukan lookup tambahan per entri. Perubahan profil muncul di setiap server paling lambat dalam 10 menit.
Katalog ID, bukan gambar
Setiap proyek mengelola katalog kosmetik di Dashboard. Sebuah entri memiliki ID (misalnya avatar.zombie_07), tipe (avatar, frame, atau badge), dan flag locked. Entri gratis bisa dipilih oleh pemain mana pun; entri terkunci hanya oleh pemain yang telah membukanya. Server hanya menyimpan ID, tidak pernah gambar: game Anda memetakan setiap ID ke sprite miliknya sendiri, sehingga aset art tetap di build Anda dan bingkai baru hanya butuh satu baris katalog.
Merender satu baris papan di Godot terlihat seperti ini:
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)
ID yang tidak dikenal (misalnya setelah Anda menghapus entri katalog) sebaiknya cukup dirender sebagai "belum diatur": pemain tetap menyimpan ID lama sampai mereka mengubah profilnya, dan tidak ada yang rusak.
Membuka kosmetik lewat kode hadiah
Unlock hanya ditulis oleh server, saat ini melalui kode hadiah dengan grants atau secara manual di Dashboard. Seorang pemain bisa memiliki hingga 25 unlock. Menukarkan kode yang berisi grants akan mengembalikan ID yang diberikan dan membuang profil yang di-cache, sehingga GetProfile berikutnya menampilkan item baru sebagai tersedia:
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"])
Memilih item terkunci tanpa unlock gagal dengan 403 COSMETIC_LOCKED, sedangkan lebih dari tiga lencana gagal dengan 400 INVALID_BADGES. Dengan begitu, hadiah event, giveaway Discord, dan keuntungan supporter masing-masing cukup menjadi satu kode hadiah, tanpa endpoint unlock khusus. Satu sisa dari API lama kini hilang untuk selamanya: parameter metadata pada submit skor tidak pernah disimpan dan kini deprecated. Informasi per pemain tempatnya di profil.
Batas Skor Kini Dihitung per API Key
Perubahan yang lebih kecil namun berdampak nyata bagi studio yang menjalankan beberapa game atau environment dalam satu akun: batas skor kini berlaku per API key. Setiap key boleh menyimpan baris skor sebanyak yang diizinkan tier (satu baris adalah satu pemain di satu papan), dijumlahkan dari semua papan pada key tersebut, dan setiap key tambahan mendapatkan jatah penuhnya sendiri. Baris yang sudah ada selalu bisa ditingkatkan; hanya skor pertama seorang pemain di sebuah papan yang bisa terkena 403 ketika key sudah penuh.
Praktik Terbaik untuk Leaderboard yang Adil
- Mulai lunak, lalu perketat. Jalankan seminggu hanya dengan ambang
soft, lihat sus run di tab Review, lalu ubah angka-angkanya menjadi aturan keras dengan margin keamanan 20 hingga 30 persen di atas run sah terbaik. - Panggil
StartRunsaat run benar-benar dimulai. Jam mulai berjalan sejak tiket diterbitkan. Memulai di menu atau sebelum layar loading 40 detik membuatminDurationSecondstidak berarti. - Jaga log input tetap kecil dan deterministik. Rekam frame input tetap dengan delta encoding. Bukti dibatasi 32 KB per log, dan run 10 menit dengan 30 frame input per detik serta 1 byte per frame muat dengan leluasa.
- Beri versi pada semua hal di konteks awal. Tanpa
simulationVersiondancontentDigest, paket dari bulan lalu bisa di-replay terhadap balancing bulan ini dan gagal karena alasan yang salah. - Perlakukan save sebagai cermin, bukan sumber. Saldo mengalir dari server ke Cloud Save, tidak pernah sebaliknya.
Apa yang Tidak Dilakukannya
Kami lebih suka mengatakannya dengan terus terang. Klien yang dimodifikasi dan melaporkan nilai yang masuk akal dalam batas aturan Anda tetap akan diterima: Validated Actions membatasi seberapa banyak keuntungan yang bisa diraih pemain curang dan membuat run mencurigakan bisa ditinjau, tetapi tidak membuat kecurangan menjadi mustahil. Server memeriksa batas, waktu, dan sifat sekali pakai serta mengarsipkan bukti, tetapi tidak menjalankan atau me-replay kode game Anda, dan belum ada vonis otomatis atas bukti. Validated Actions hanya tersedia di cloud; pada simpleServer self-hosted, SDK melaporkan NOT_SUPPORTED. Setiap run membutuhkan pemain yang sudah login, dan run di leaderboard juga membutuhkan nama tampilan.
Kapasitas per Tier
Setiap fitur dalam artikel ini tersedia di semua paket, termasuk FREE. Hanya kapasitasnya yang bertambah:
| FREE | BASIC | PRO | ENTERPRISE | |
|---|---|---|---|---|
| Run tervalidasi per akun dan jam UTC | 300 | 3.000 | 20.000 | 200.000 |
| Nilai milik server per API key | 8 | 16 | 32 | 64 |
| Slot bukti untuk run Top N | 50 | 500 | 2.500 | 25.000 |
| Paket sus tersimpan | 10 | 100 | 1.000 | 10.000 |
| Retensi paket sus | 14 hari | 30 hari | 90 hari | 180 hari |
| Katalog kosmetik per API key | 50 | 200 | 500 | 1.000 |
| Baris skor per API key | 5.000 | 25.000 | 200.000 | 2.500.000 |
Ketika kuota paket sus penuh, sus run baru tetap diterima dan dilaporkan sebagai sus, hanya saja tidak diarsipkan. Paket yang tersimpan tidak pernah tergeser oleh paket baru.
Mulai Sekarang
Perbarui ke SDK terbaru untuk Unity, Godot, atau Unreal, pilih satu papan, dan tambahkan set aturan yang hanya berisi ambang lunak. Tambahkan ValidatedRunContext ke panggilan StartRun Anda, lalu isi katalog kosmetik dengan avatar dan bingkai yang sudah ada di game Anda. Halaman fitur Validated Actions dan halaman fitur leaderboard merangkum aturan dan batasnya, dan quickstart memandu Anda melalui ketiga engine. Siap membuat leaderboard berikutnya adil sekaligus personal? Coba horizOn gratis atau pelajari dokumentasi API.