Kembali ke Blog

Leaderboard Adil Tanpa Kode Server: Run Tervalidasi, Paket Sus, dan Profil Pemain

Diterbitkan pada 3 Oktober 2026
Leaderboard Adil Tanpa Kode Server: Run Tervalidasi, Paket Sus, dan Profil Pemain Dibuat dengan bantuan AI

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:

  1. 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.
  2. Bermain. Game menginisialisasi pengacaknya dengan Seed dari server dan merekam input pemain ke dalam log byte yang ringkas.
  3. Akhir run. Game memanggil SubmitValidated dengan 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.
  4. 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 dengan EARNED_ABOVE_MAX.
  • minPerRun: -1000: sebuah run boleh membelanjakan hingga 1.000 gold; membelanjakan lebih dari saldo gagal dengan INSUFFICIENT_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

  1. 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.
  2. Panggil StartRun saat run benar-benar dimulai. Jam mulai berjalan sejak tiket diterbitkan. Memulai di menu atau sebelum layar loading 40 detik membuat minDurationSeconds tidak berarti.
  3. 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.
  4. Beri versi pada semua hal di konteks awal. Tanpa simulationVersion dan contentDigest, paket dari bulan lalu bisa di-replay terhadap balancing bulan ini dan gagal karena alasan yang salah.
  5. 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.