서버 코드 없이 만드는 공정한 리더보드: 검증된 런, sus 패키지, 플레이어 프로필
핵심 요약
서버 코드 없이 서버 검증 리더보드를 구축하세요: 일회용 런 티켓, 재생 가능한 sus 패키지, 서버 소유 재화, 플레이어 프로필까지 한 번에.
새 리더보드의 첫 주는 대개 같은 방식으로 끝납니다. 정직한 상위 10명과 나머지 사이 어딘가에 2,147,483,647점을 가진 플레이어가 끼어 있는데, 이는 부호 있는 32비트 정수가 담을 수 있는 최댓값입니다. 실제로 그 런을 플레이한 사람은 없습니다. 메모리 에디터나 가로채기 프록시가 기록한 것입니다. 대부분의 리더보드에서 게임 클라이언트가 유일한 증인이자 심판이기 때문입니다.
이번 릴리스는 이 약점을 두 방향에서 공략합니다. Validated Actions는 무엇을 인정할지에 대한 판단을 서버로 옮기고, 새로운 sus 런 패키지는 의심스러운 런을 모두 재생 가능한 완전한 사건 기록으로 보관합니다. 동시에 리더보드는 더 개인화됩니다. 이제 모든 항목에 아바타, 프레임, 최대 3개의 배지를 담은 플레이어 프로필이 붙고, 코스메틱은 기프트 코드로 잠금 해제할 수 있습니다. 아래에서 각 요소의 작동 방식, 그 뒤의 수치, Unity와 Godot용 작동 코드, 그리고 실제로 의존하기 전에 알아 두셨으면 하는 한계를 소개합니다.
클라이언트가 보고한 점수를 신뢰할 수 없는 이유
전통적인 점수 제출은 요청 하나입니다. 플레이어 ID, 점수, 끝. 서버가 그 런에 대해 아는 모든 정보는 그것에 대해 거짓말할 동기가 가장 강한 기기에서 옵니다. 흔한 대응책들은 모두 같은 결함을 공유합니다.
- 클라이언트에서 요청에 서명하기. 서명 키는 빌드 안에 담겨 배포됩니다. IL2CPP 바이너리나 GDScript 익스포트에서 키를 추출하는 데는 오후 한나절이면 충분하고, 그 순간부터 위조된 요청은 완벽하게 유효해 보입니다.
- 메모리에서 점수 난독화하기. 가벼운 메모리 에디터의 속도를 늦출 수는 있지만, HTTP 본문을 다시 쓰는 프록시는 메모리 레이아웃을 전혀 건드리지 않습니다.
- 클라이언트에서 타당성 검사하기. 기기에서 실행되는 것은 무엇이든 기기에서 패치로 제거할 수 있습니다.
견고한 해답은 무엇을 인정할지 서버가 결정하는 것입니다. 그 교과서적인 형태가 서버 권한 시뮬레이션으로, 게임 로직은 여러분이 통제하는 하드웨어에서 실행되고 클라이언트는 입력만 보냅니다. 수천 개의 런이 동시에 진행되는 빠른 액션 게임이라면, 이는 몇 주간의 넷코드 작업에 영구적인 호스팅 비용까지 의미합니다. 대부분의 인디 팀에게는 완전한 시뮬레이션이 필요하지 않습니다. 필요한 것은 더 저렴한 세 가지 보장입니다. 서버가 런이 언제 시작되었는지, 어떤 규칙이 적용되는지, 그리고 이후에 어떤 증거가 남는지를 아는 것입니다. Validated Actions가 메우는 공백이 바로 이것입니다.
검증된 런의 작동 방식
검증된 런은 네 단계로 이루어지며, 중요한 두 단계는 서버가 담당합니다.
- 런 시작. 게임이
StartRun을 호출합니다. 서버는 플레이어, API 키, 그리고 선택적으로 리더보드 하나에 묶인 서명된 일회용 티켓을 발급합니다. 티켓에는 서버가 선택한 Seed(0부터 2,147,483,646까지)와 만료 시간이 담깁니다. 만료 시간은 기본 2시간이며 60초부터 6시간까지 설정할 수 있습니다. - 플레이. 게임은 서버 Seed로 난수를 초기화하고 플레이어의 입력을 간결한 바이트 로그에 기록합니다.
- 런 종료. 게임은 점수, 선택적인 스테이지, 선택적인 획득 값, 입력 로그를 넘겨
SubmitValidated를 호출합니다. SDK는 로그의 SHA-256 해시를 보내므로, 로그 자체는 당분간 기기에 남습니다. - 서버 검사. 서버는 티켓을 검증하고, 소요 시간을 직접 측정(티켓 발급부터 제출까지)한 뒤 여러분의 규칙을 적용합니다. 그 후에야 무언가를 기록합니다.
티켓은 모든 리전을 통틀어 정확히 한 번만 인정됩니다. 거부된 런도 티켓을 소진하므로, 같은 티켓으로 조금씩 낮은 점수를 재시도하며 임곗값을 탐색할 수 없습니다. Seed는 서버가 고르고 플레이어당 시간당 티켓 수도 제한되므로(기본 60개), 운 좋은 Seed를 노린 파밍은 느리고 눈에 띄게 됩니다.
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}");
같은 흐름이 Godot(Horizon.validatedActions.startRun과 submitValidated)와 Unreal(Horizon->ValidatedActions)에도 있으며, 각각 SDK에 완전한 예제가 포함되어 있습니다.
서버만 아는 규칙
API 키마다 하나의 규칙 세트가 있으며, 대시보드에서 폼이나 JSON으로 편집합니다. 규칙은 앱 응답이나 오류 메시지에 절대 나타나지 않습니다. 클라이언트가 알게 되는 것은 기계가 읽을 수 있는 코드뿐입니다. 웨이브 기반 아케이드 게임을 위한 현실적인 규칙 세트는 다음과 같습니다.
{
"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 }
}
}
weekly 보드에서 몇 가지 제출을 따라가 봅시다.
| 제출된 런 | 서버 응답 |
|---|---|
| 9,999,999점 | 422 SCORE_ABOVE_MAX |
| 21,000점, 티켓 발급 3초 후 | 422 DURATION_TOO_SHORT |
| 120초 동안 150,000점(초당 1,250점) | 422 SCORE_RATE_TOO_HIGH |
| 240초 동안 190,000점 | 수락되지만 sus(소프트 최댓값 180,000 초과) |
| 같은 티켓으로 두 번째 제출 | 422 TICKET_CONSUMED |
서버는 정해진 순서(스테이지, 점수, 스테이지 규칙, 소요 시간, 초당 점수, 획득 값, 마지막으로 소프트 임곗값)로 검사하며, 첫 번째 실패가 결과가 됩니다. 점수 규칙은 보드가 있는 런에만 적용됩니다. 보드가 없는 런도 스테이지와 소요 시간 규칙은 검사를 받습니다.
규칙이 자리를 잡으면 보드를 "검증된 제출만"으로 전환하세요. 그때부터 해당 보드에 대한 일반 SubmitScore는 403 VALIDATED_SUBMIT_REQUIRED로 거부되고, 다른 모든 보드는 계속 일반 제출을 받습니다. 규칙 세트가 비어 있더라도, 검증 전용 보드는 서버 티켓, 일회성 사용, 플레이어 바인딩, 시간당 한도, 저장된 로그 해시를 보장합니다.
소프트 임곗값과 sus 런
하드 규칙은 거부합니다. 소프트 임곗값은 런에 표시만 남기며, 바로 여기서 시작해야 합니다. 일주일 동안 소프트 한도를 운영해 보면, 숫자를 하드 거부로 바꾸기 전에 실제 플레이가 어떤 모습인지 알 수 있습니다. 런이 수락되었고 소프트 임곗값을 하나 이상 넘었다면 그 런은 sus입니다. 거부된 런과 기술적 오류는 절대 sus가 아닙니다. 제출 결과에는 단순한 sus 플래그가 담기며, 어떤 임곗값이 작동했는지는 서버에만 남습니다.
흥미로운 부분은 그다음입니다. 이번 업데이트부터 서버는 모든 sus 런에 대해 sus 패키지를 보관하며, 이는 플레이어의 최고 점수, Top N, 이후의 점수 변경과 무관합니다. 터무니없는 런을 한 번 올린 뒤 정상적인 런을 올리는 치터는 더 이상 더 좋은 기록 밑에 증거를 묻을 수 없습니다.
시작 컨텍스트: 런이 무엇으로 시작했는가
리플레이는 정확히 같은 시작 조건을 재현할 수 있을 때만 쓸모가 있습니다. 그래서 이제 서버는 StartRun 시점에 시작 컨텍스트를 기록합니다. 적용 중인 규칙 버전, 플레이어 Cloud Save의 실제 바이트(SHA-256 및 리비전 포함), 서버 소유 값, Seed, 시작 시간입니다. 게임 쪽에서도 자신의 정보를 추가할 수 있습니다.
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);
서버 값과 클라이언트 값은 엄격하게 분리되며, 둘 다 정규화된 텍스트에 반영되고 그 SHA-256이 티켓에 저장됩니다. 제출은 시작 시점의 규칙 버전을 기준으로도 검사되므로, 런이 진행 중일 때 한도를 강화해도 그 런의 판정은 절대 바뀌지 않습니다. 규칙 편집이 탐색 수단으로 악용되지 않도록, 변경은 API 키당 시간당 30회로 제한됩니다.
sus 패키지에 담기는 것
각 패키지는 런 ID 아래에 저장되며 리뷰어에게 필요한 모든 것을 묶습니다. 시작 컨텍스트, 바인딩된 규칙, 결과, 런 전후의 서버 소유 값, 입력 로그(Top N 런과 마찬가지로 SDK가 자동으로 업로드)입니다. 대시보드의 리뷰 탭에서 sus로 필터링하고 패키지를 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
내보낼 때마다 서버는 모든 체크섬을 다시 계산하고 그 결과를 manifest.integrity와 X-Package-Integrity 헤더(ok 또는 mismatch)로 알려 줍니다. 어떤 파트가 크기 한도에 맞지 않으면 그 체크섬만 보관하고 해당 파트를 OMITTED_SIZE_LIMIT로 표시하므로, 무엇이 왜 빠졌는지 항상 알 수 있습니다.
cloud-save.bin, initial-state.bin, Seed, input-log.bin을 여러분의 결정론적 시뮬레이션에 넣으면 점수가 재현되거나, 재현되지 않거나 둘 중 하나입니다. 리플레이, 판정, 제재는 여러분의 몫입니다. 서버는 표시하고 보관할 뿐, 여러분의 게임 코드를 실행하지 않습니다.
클라이언트가 쓸 수 없는 재화
치트의 대상은 리더보드만이 아닙니다. 서버 소유 값을 사용하면 같은 규칙 세트에서 gold나 gems 같은 카운터를 정의할 수 있습니다. 이 값은 수락된 검증 런만 변경할 수 있으며, 잔액을 설정하는 앱 엔드포인트나 SDK 메서드는 존재하지 않습니다. 위 JSON의 규칙은 다음과 같이 읽힙니다.
maxPerRun: 500: 골드 800을 얻었다고 주장하는 런은EARNED_ABOVE_MAX로 거부됩니다.minPerRun: -1000: 한 런에서 최대 1,000골드까지 쓸 수 있으며, 잔액보다 많이 쓰면INSUFFICIENT_BALANCE로 실패합니다.dailyCap: 5000: UTC 기준 하루 동안의 양수 적립은 거부되지 않고 상한에 맞춰 잘립니다. 오늘 이미 4,800을 번 플레이어가 500골드짜리 런을 마치면 200이 적립됩니다.
응답에는 변경된 키마다 requested와 credited가 표시되므로, 게임은 코인을 조용히 잃게 하는 대신 "일일 한도 도달"을 보여 줄 수 있습니다. 구매의 규칙은 간단합니다. 지출이 전액 반영되었을 때만(Unity와 Unreal에서는 IsFullyCredited) 아이템을 지급하세요. 이는 업그레이드 상점을 클라이언트 측 기본값에서 분리하는 것과 같은 논리입니다. 기기는 요청할 수 있고, 지급은 서버만 할 수 있습니다.
Cloud Save는 이전처럼 작동하며 미러가 됩니다. 수락된 런마다 서버 값을 오프라인 표시용으로 세이브에 복사하고, 시작할 때 GetState로 그 사본을 덮어쓰며, 세이브의 값을 잔액으로 되돌려 보내는 일은 절대 하지 마세요.
모든 리더보드 항목에 플레이어 프로필
이번 업데이트의 후반부는 숫자가 아니라 보드 위의 사람들에 관한 것입니다. 이제 Top, Around, Rank의 모든 리더보드 항목에 avatarId, frameId, 최대 3개의 badges를 담은 프로필이 포함됩니다.
{ "position": 1, "username": "Gravedigger", "score": 15000,
"profile": { "avatarId": "avatar.zombie_07", "frameId": "frame.gold", "badges": ["badge.supporter"] } }
서버는 프로필을 표시 이름과 함께 사용자 이름 캐시에서 읽으므로, 상위 목록을 가져올 때 항목마다 추가 조회가 발생하지 않습니다. 프로필 변경은 늦어도 10분 안에 모든 서버에 반영됩니다.
이미지가 아닌 ID 카탈로그
각 프로젝트는 대시보드에서 코스메틱 카탈로그를 관리합니다. 항목에는 ID(예: avatar.zombie_07), 타입(avatar, frame, badge), locked 플래그가 있습니다. 무료 항목은 어떤 플레이어든 선택할 수 있고, 잠긴 항목은 잠금 해제한 플레이어만 선택할 수 있습니다. 서버는 ID만 저장하고 이미지는 저장하지 않습니다. 게임이 각 ID를 자체 스프라이트에 매핑하므로 아트는 빌드 안에 머물고, 새 프레임을 추가하는 데는 카탈로그 한 행이면 됩니다.
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)
알 수 없는 ID(예를 들어 카탈로그 항목을 삭제한 후)는 그냥 "설정 안 됨"으로 렌더링하면 됩니다. 플레이어는 프로필을 바꿀 때까지 오래된 ID를 유지하며, 아무것도 깨지지 않습니다.
기프트 코드로 잠금 해제
잠금 해제는 서버만 기록하며, 현재는 grants가 포함된 기프트 코드나 대시보드에서의 수동 작업으로 이루어집니다. 플레이어 한 명이 보유할 수 있는 잠금 해제는 최대 25개입니다. grants가 포함된 코드를 사용하면 부여된 ID가 반환되고 캐시된 프로필이 삭제되므로, 다음 GetProfile에서 새 아이템이 사용 가능한 것으로 표시됩니다.
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"])
잠금 해제 없이 잠긴 아이템을 선택하면 403 COSMETIC_LOCKED로, 배지를 3개 넘게 선택하면 400 INVALID_BADGES로 실패합니다. 덕분에 이벤트 보상, Discord 경품, 서포터 혜택을 별도의 잠금 해제 엔드포인트 없이 각각 기프트 코드 하나로 처리할 수 있습니다. 이전 API의 잔재 하나는 완전히 사라졌습니다. 점수 제출의 metadata 파라미터는 한 번도 저장된 적이 없으며 이제 지원 중단되었습니다. 플레이어별 정보는 프로필에 담으세요.
점수 한도는 이제 API 키 단위로 계산
한 계정에서 여러 게임이나 환경을 운영하는 스튜디오에게는 작지만 실질적인 영향을 주는 변경입니다. 점수 한도가 이제 API 키 단위로 적용됩니다. 각 키는 요금제가 허용하는 만큼의 점수 행(한 행은 한 보드 위의 플레이어 한 명)을 보유할 수 있고, 이는 해당 키의 모든 보드를 합산한 수치이며, 키를 추가할 때마다 각각 전체 할당량을 받습니다. 기존 행은 언제든 갱신할 수 있습니다. 키가 가득 찼을 때 403이 날 수 있는 것은 플레이어가 어떤 보드에 처음 제출하는 점수뿐입니다.
공정한 리더보드를 위한 모범 사례
- 소프트로 시작해 하드로 강화하세요. 일주일 동안
soft임곗값만으로 운영하고, 리뷰 탭에서 sus 런을 살펴본 뒤, 가장 좋은 정당한 런보다 20에서 30퍼센트 높은 안전 마진을 두고 그 숫자를 하드 규칙으로 바꾸세요. - 런이 실제로 시작될 때
StartRun을 호출하세요. 시계는 티켓에서 시작합니다. 메뉴에서, 또는 40초짜리 로딩 화면 이전에 시작하면minDurationSeconds가 무의미해집니다. - 입력 로그는 작고 결정론적으로 유지하세요. 고정된 입력 프레임을 델타 인코딩으로 기록하세요. 증거는 로그당 32 KB로 제한되지만, 초당 30 입력 프레임, 프레임당 1바이트로 10분간 진행되는 런이라면 여유 있게 들어갑니다.
- 시작 컨텍스트에서 모든 것에 버전을 매기세요.
simulationVersion과contentDigest가 없으면, 지난달의 패키지가 이번 달의 밸런스로 리플레이되어 엉뚱한 이유로 실패할 수 있습니다. - 세이브는 원본이 아니라 미러로 취급하세요. 잔액은 서버에서 Cloud Save로 흐르며, 절대 반대로 흐르지 않습니다.
할 수 없는 것
솔직하게 말씀드리겠습니다. 규칙 범위 안에서 그럴듯한 값을 보고하는 변조된 클라이언트는 여전히 수락됩니다. Validated Actions는 치터가 얻을 수 있는 이득을 제한하고 의심스러운 런을 검토 가능하게 만들 뿐, 치트를 불가능하게 만들지는 않습니다. 서버는 범위, 타이밍, 일회성 사용을 검사하고 증거를 보관하지만, 여러분의 게임 코드를 실행하거나 리플레이하지는 않으며, 아직 증거에 대한 자동 판정도 없습니다. Validated Actions는 클라우드 전용이며, 셀프 호스팅 simpleServer에서는 SDK가 NOT_SUPPORTED를 보고합니다. 모든 런에는 로그인한 플레이어가 필요하고, 리더보드에 올라가는 런에는 표시 이름도 필요합니다.
요금제별 용량
이 글의 모든 기능은 FREE를 포함한 모든 요금제에서 사용할 수 있습니다. 늘어나는 것은 용량뿐입니다.
| FREE | BASIC | PRO | ENTERPRISE | |
|---|---|---|---|---|
| 계정당 UTC 시간당 검증된 런 | 300 | 3,000 | 20,000 | 200,000 |
| API 키당 서버 소유 값 | 8 | 16 | 32 | 64 |
| Top N 런용 증거 슬롯 | 50 | 500 | 2,500 | 25,000 |
| 저장되는 sus 패키지 | 10 | 100 | 1,000 | 10,000 |
| sus 패키지 보존 기간 | 14일 | 30일 | 90일 | 180일 |
| API 키당 코스메틱 카탈로그 | 50 | 200 | 500 | 1,000 |
| API 키당 점수 행 | 5,000 | 25,000 | 200,000 | 2,500,000 |
sus 패키지 할당량이 가득 차도 새 sus 런은 계속 수락되고 sus로 보고됩니다. 단지 보관되지 않을 뿐입니다. 저장된 패키지가 새 패키지에 밀려나는 일은 없습니다.
시작하기
Unity, Godot 또는 Unreal용 최신 SDK로 업데이트하고, 보드 하나를 골라 소프트 임곗값만 있는 규칙 세트를 추가하세요. StartRun 호출에 ValidatedRunContext를 추가한 다음, 이미 게임에 포함된 아바타와 프레임으로 코스메틱 카탈로그를 채우세요. Validated Actions 기능 페이지와 리더보드 기능 페이지에 규칙과 한도가 정리되어 있고, 퀵스타트에서는 세 엔진을 모두 단계별로 안내합니다. 다음 리더보드를 공정하면서도 개성 있게 만들 준비가 되셨나요? horizOn을 무료로 사용해 보거나 API 문서를 살펴보세요.