返回博客

无需服务器代码的公平排行榜:验证运行、sus 数据包与玩家资料

发布于 2026年10月3日
无需服务器代码的公平排行榜:验证运行、sus 数据包与玩家资料 借助 AI 生成

概要

无需编写服务器代码,即可为你的游戏构建服务器验证排行榜:一次性运行票据、可重放的 sus 数据包、服务器托管货币与玩家资料。

新排行榜上线的第一周,结局通常如出一辙:在诚实的前十名和榜单其余玩家之间,夹着一位拥有 2,147,483,647 分的玩家,这正是有符号 32 位整数所能容纳的最大值。没有人真正打出过这一局。它是内存修改器或拦截代理写进去的,因为在大多数排行榜上,游戏客户端既是唯一的证人,又是裁判。

本次发布从两个方向解决这一薄弱环节。Validated Actions 把“什么算数”的判定权移交给服务器,而全新的 sus 运行数据包 则把每一次可疑运行完整保存为可重放的案卷。与此同时,排行榜也变得更有个性:每个条目现在都附带一份包含头像、头像框和最多三枚徽章的玩家资料,外观装饰还可以通过礼品码解锁。下文将介绍每个部分的工作原理、背后的数据、适用于 Unity 和 Godot 的可运行代码,以及在你依赖它之前我们希望你了解的局限。

为什么客户端上报的分数不可信

经典的分数提交只有一个请求:玩家 ID、分数,结束。服务器对这一局所知道的一切,都来自最有动机在这件事上撒谎的那台设备。常见的应对措施都有同一个缺陷:

  • 在客户端对请求签名。 签名密钥随你的构建一起发布。从 IL2CPP 二进制文件或 GDScript 导出中提取它只需一个下午,此后伪造的请求看起来完全合法。
  • 在内存中混淆分数。 这能拖慢随手使用的内存修改器,但改写 HTTP 请求体的代理根本不会碰你的内存布局。
  • 在客户端做合理性检查。 凡是在设备上运行的东西,都能在设备上被打补丁去掉。

可靠的答案是由服务器决定什么算数。教科书式的做法是权威模拟:游戏逻辑运行在你掌控的硬件上,客户端只发送输入。对于一款同时进行数千局的快节奏动作游戏来说,这意味着数周的网络代码工作,外加一笔永久的托管账单。大多数独立团队并不需要完整的模拟。他们需要三项更便宜的保证:服务器知道一局何时开始、适用哪些规则、事后存在哪些证据。这正是 Validated Actions 所填补的空白。

验证运行如何工作

验证运行分为四步,其中真正关键的两步由服务器掌控:

  1. 运行开始。 游戏调用 StartRun。服务器签发一张经过签名的一次性票据,绑定到玩家、API Key,以及可选的一个排行榜。票据携带一个由服务器选定的 Seed(0 到 2,147,483,646)和一个过期时间:默认 2 小时,可在 60 秒到 6 小时之间配置。
  2. 游玩。 游戏用服务器 Seed 初始化随机数,并把玩家输入记录到一份紧凑的字节日志中。
  3. 运行结束。 游戏调用 SubmitValidated,传入分数、可选的关卡、可选的获得值以及输入日志。SDK 发送的是日志的 SHA-256 哈希,因此日志本身暂时留在设备上。
  4. 服务器检查。 服务器验证票据,自行测量时长(从票据签发到提交),并应用你的规则。只有在此之后,它才会写入任何数据。

一张票据在所有区域中只计一次。被拒绝的运行同样会作废其票据,因此没人能靠用同一张票据反复提交略低的分数来试探你的阈值。由于 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 Key 拥有一套规则,可在控制台中以表单或 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 Key 每小时最多只能修改 30 次。

sus 数据包包含什么

每个数据包按运行 ID 存储,打包了审核者所需的一切:启动上下文、绑定的规则、结果、运行前后服务器托管的值,以及输入日志(SDK 会自动上传,与 Top N 运行一样)。在控制台的审核选项卡中,你可以按 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 以及最多三个 badges:

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

服务器会从其用户名缓存中把资料与显示名称一起读取,因此获取 Top 列表时不会为每个条目额外查询。资料变更最迟 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 失败,超过三枚徽章则以 400 INVALID_BADGES 失败。这样一来,活动奖励、Discord 赠品和支持者福利都只需各自一个礼品码,无需自定义解锁端点。旧 API 的一个遗留问题已被彻底移除:分数提交中的 metadata 参数从未被存储,现已弃用。每位玩家的信息应放在资料中。

分数限额现按 API Key 计算

这是一个较小的改动,但对在同一账户中运行多款游戏或多个环境的工作室有实际影响:分数限额现在按 API Key 计算。每个 Key 可容纳的分数行数以套餐允许的上限为准(一行即一位玩家在一个榜单上的记录),按该 Key 的所有榜单汇总,每增加一个 Key 都会获得一份完整的独立额度。已有的行始终可以刷新成绩;只有玩家在某个榜单上的首个分数可能在 Key 已满时遇到 403。

公平排行榜的最佳实践

  1. 先软后硬。 先只用 soft 阈值运行一周,在审核选项卡中查看 sus 运行,再把这些数字变成硬规则,并在最佳合法成绩之上留出 20% 到 30% 的安全余量。
  2. 在运行真正开始时调用 StartRun。 计时从票据开始。在菜单中或在 40 秒的加载画面之前就开始,会让 minDurationSeconds 失去意义。
  3. 保持输入日志小巧且确定。 以固定输入帧记录,并使用增量编码。每份日志的证据上限为 32 KB,一局 10 分钟、每秒 30 个输入帧、每帧 1 字节的运行可以轻松装下。
  4. 在启动上下文中为一切标注版本。 没有 simulationVersion 和 contentDigest,上个月的数据包可能会基于本月的平衡数值重放,并因错误的原因而失败。
  5. 把存档当作镜像,绝不当作数据源。 余额从服务器流向 Cloud Save,永不反向。

它做不到什么

我们更愿意把话说清楚。一个修改过的客户端如果上报的是你规则范围内看似合理的数值,仍然会被接受:Validated Actions 限制了作弊者能获得的好处,并让可疑运行变得可审核,但它并不能让作弊变得不可能。服务器检查边界、时间和一次性使用,并归档证据,但不会运行或重放你的游戏代码,目前也没有基于证据的自动判定。Validated Actions 仅限云端;在自托管的 simpleServer 上,SDK 会报告 NOT_SUPPORTED。每次运行都需要一位已登录的玩家,排行榜上的运行还需要一个显示名称。

各套餐容量

本文中的所有功能在每个套餐中都可用,包括 FREE。只有容量会增长:

FREE BASIC PRO ENTERPRISE
每个账户每 UTC 小时的验证运行数 300 3,000 20,000 200,000
每个 API Key 的服务器托管值 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 Key 的外观目录 50 200 500 1,000
每个 API Key 的分数行数 5,000 25,000 200,000 2,500,000

当 sus 数据包配额已满时,新的 sus 运行仍会被接受并报告为 sus,只是不再归档。已存储的数据包永远不会被新数据包挤掉。

开始使用

将 Unity、Godot 或 Unreal 的 SDK 更新到最新版本,选一个榜单,添加一套仅含软阈值的规则集。在 StartRun 调用中加入 ValidatedRunContext,然后用你已经在游戏中提供的头像和头像框填充外观目录。Validated Actions 功能页面和排行榜功能页面汇总了规则与限额,快速入门则带你走完全部三个引擎。准备好让你的下一个排行榜既公平又有个性了吗?免费试用 horizOn,或深入阅读 API 文档。