14.1 各平台榜单结构

海外平台的榜单不是一张排名表,而是各不相同的排序机制:同一款游戏在参与型、点击型、分发型、商店型榜单上排位可能完全不同。本节给出一张榜单结构矩阵与一段读配置算加权排序的 D 层扫描,再用 P 层 TypeScript 内核跨语言对拍,说明榜单口径如何决定谁上榜。

站内有两篇要先划清边界。一篇是卷一「小游戏市场全景」 ,那是国内平台的分发机制与设计约束(包体预算、首包加载、平台能力矩阵),回答「装不装得下、发不发得出」;另一组是 Steam 发布专题 (166 篇),那是 PC 买断制发行口径(商店页、愿望单、区域定价),与 H5 小游戏的形态天然错位。本章都不重做:它只做三件事——海外 H5 小游戏平台的榜单结构与排序机制(14.1)、竞品拆解与本地化机会(14.2)、同品类在不同海外市场的表现差异(14.3);不重做卷一的平台机制综述,也不写 PC 发行流程。卷一 2.1 榜单结构与品类分布 已经给过一套与市场无关的榜单结构分析方法(Top-N 占比、HHI 集中度);本节把镜头对准海外平台各自的排序机制——同一款游戏,为什么在不同平台上排位天差地别。

榜单不是一张表,是四套排序机制

把榜单当成「谁第几名」是读不懂的。榜单的本质是排序机制:平台把一批候选游戏按某组信号打分、排序、展示。国内小游戏的榜单基本是平台算法黑箱(微信、抖音的「热门」),你只能观察结果、猜不到权重。海外 H5 平台要透明得多,而且各家的排序口径互不相同,按主导信号大致分四类:

榜单口径代表平台主导信号你该优化的
engagement 参与型Poki、CrazyGames、YouTube Playables完播、留存、单次时长前 30 秒的钩子、节奏、可重复玩
click 点击型Y8完播、分享缩略图与标题的点击率、可炫耀的进度
distribution 分发型GameDistribution安装、跨站嵌入表现首包体积、联盟站点的嵌入适配
store 商店型App Store、Google Play安装、收入商店页转化、买量与归因(卷四)

四类口径的差别不是「谁更公平」,而是它奖励什么行为。同一款《合成小岛》:它节奏慢、单次时长高、社交分享强,在参与型榜单上占优;但它在「点击型」榜单上就未必——Y8 更看首屏点击,慢热的合成玩法反而吃亏。所以「冲榜」的第一步不是把游戏做好,而是先看清这个平台的榜单在奖励什么。

榜单结构矩阵(D 层)

下面这段是 D 层配置,把六个平台各记一行:榜单口径、更新间隔、统计窗口,以及六个排序信号的权重(六项之和恒 1000)。权重是按各平台公开可观察的榜单机制整理的建模输入,标为公开可观察(观察时点 2026-10),不是任何平台后台的真实排序公式。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "海外各平台榜单结构的可机读配置。口径是「公开可观察机制」的建模假设(观察时点 2026-10,非实测):把各平台榜单的「排序信号」与「信号权重」整理成可机读的矩阵,不是任何平台后台的真实排序公式。signals 是六个可观察排序信号:playthrough=完播/通关、retention=D1 留存、session=单次时长、shares=分享、installs=安装、revenue=收入。每个平台一行,weights 六项之和恒 1000(千分比);chartModel 是榜单口径(engagement 参与型 / click 点击型 / distribution 分发型 / store 商店型);updateHours=榜单更新间隔小时、windowDays=统计窗口天数。candidates 是候选游戏在各信号上的取值(0–1000)。score(game,platform)=floor(Σ signal×weight/1000)。真实榜单排序与推荐机制需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "signals": ["playthrough", "retention", "session", "shares", "installs", "revenue"],
  "platforms": [
    { "id": "poki", "name": "Poki", "chartModel": "engagement", "updateHours": 24, "windowDays": 30, "weights": { "playthrough": 280, "retention": 240, "session": 220, "shares": 120, "installs": 80, "revenue": 60 } },
    { "id": "crazygames", "name": "CrazyGames", "chartModel": "engagement", "updateHours": 24, "windowDays": 30, "weights": { "playthrough": 300, "retention": 230, "session": 200, "shares": 130, "installs": 80, "revenue": 60 } },
    { "id": "y8", "name": "Y8", "chartModel": "click", "updateHours": 12, "windowDays": 14, "weights": { "playthrough": 380, "retention": 170, "session": 120, "shares": 180, "installs": 100, "revenue": 50 } },
    { "id": "gamedist", "name": "GameDistribution", "chartModel": "distribution", "updateHours": 24, "windowDays": 30, "weights": { "playthrough": 180, "retention": 140, "session": 130, "shares": 100, "installs": 350, "revenue": 100 } },
    { "id": "appstore", "name": "App Store", "chartModel": "store", "updateHours": 3, "windowDays": 7, "weights": { "playthrough": 120, "retention": 180, "session": 150, "shares": 80, "installs": 250, "revenue": 220 } },
    { "id": "yt_playables", "name": "YouTube Playables", "chartModel": "engagement", "updateHours": 6, "windowDays": 21, "weights": { "playthrough": 200, "retention": 260, "session": 300, "shares": 140, "installs": 60, "revenue": 40 } }
  ],
  "candidates": [
    { "id": "merge-island", "name": "合成小岛", "signals": { "playthrough": 620, "retention": 540, "session": 700, "shares": 480, "installs": 300, "revenue": 260 } },
    { "id": "merge-farm", "name": "合成农场", "signals": { "playthrough": 700, "retention": 500, "session": 650, "shares": 520, "installs": 320, "revenue": 280 } },
    { "id": "idle-fish", "name": "放置钓鱼", "signals": { "playthrough": 560, "retention": 620, "session": 760, "shares": 300, "installs": 260, "revenue": 420 } },
    { "id": "puzzle-blocks", "name": "方块消除", "signals": { "playthrough": 780, "retention": 430, "session": 380, "shares": 350, "installs": 400, "revenue": 250 } },
    { "id": "bubble-pop", "name": "泡泡爆破", "signals": { "playthrough": 820, "retention": 400, "session": 340, "shares": 260, "installs": 380, "revenue": 220 } },
    { "id": "merge-dragon", "name": "合成龙岛", "signals": { "playthrough": 600, "retention": 580, "session": 680, "shares": 620, "installs": 280, "revenue": 360 } }
  ]
}

读这张表要抓住三个反直觉点。第一,更新频率差三个数量级:App Store 的榜单是小时级(updateHours: 3),Y8 是半天级(12),而 Poki / CrazyGames / GameDistribution 是日级(24)。更新越快,冲榜窗口越短——小时级榜单需要持续买量维持,日级榜单可以靠一次运营冲上去、守一天。第二,统计窗口决定「长跑还是短跑」:App Store 只统计近 7 天,Poki / CrazyGames 看 30 天——同一款游戏在短窗口榜单上拼爆发、在长窗口榜单上拼留存。第三,权重结构完全不同:参与型平台把 70% 以上的权重压在完播 / 留存 / 时长上,商店型把 47% 压在安装 / 收入上——你以为在同一个游戏里冲榜,其实面对的是两套完全不同的规则。

D 层:把榜单结构扫成参考排序

下面这段是 D 层扫描脚本 sim/g14_rank_scan.py。它读配置,先 lint(每个平台权重和必须恒 1000),再对每个平台按 score = floor(Σ signal×weight/1000) 给六个候选游戏打分排序,取 top-1 并识别主导信号,产出 JSON + CSV:

#!/usr/bin/env python3
# file: sim/g14_rank_scan.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g14_rank_scan.py --config config/ --out out/
"""海外各平台榜单结构扫描(D 层:配置表 lint + 参考排序)。

读 config/g14_rank_structure.json,做两件事:

  1) lint:每个平台 weights 六项之和必须恒 1000;signals 与 candidates 的信号键必须对齐。
  2) 参考排序:对每个平台,按 score = floor(Σ signal×weight/1000) 给候选游戏打分排序,
     取 top-1,并算出信号集中度(dominant signal = 权重最大的信号)。

口径:榜单排序信号与权重是「公开可观察机制」口径的建模假设(观察时点 2026-10),
不是任何平台后台的真实排序公式;真实榜单排序与推荐机制需真实业务环境,命令行不可复现(X)。
与 core/src/g14/rank.ts 逐位等价,故 digest 逐字符一致。产出 out/g14_rank_scan.json + .csv。
全整数、地板除,无浮点。
"""
from __future__ import annotations

import argparse
import csv
import json
import os

WEIGHT_SUM = 1000


def score(signals: dict, weights: dict) -> int:
    """与 core/src/g14/rank.ts 的 score 逐位等价的加权分(0–1000,整数地板除)。"""
    acc = 0
    for k, w in weights.items():
        acc += signals[k] * w
    return acc // WEIGHT_SUM


def dominant_signal(weights: dict) -> str:
    """权重最大的排序信号;max 稳定取首个最大值(按 weights 插入顺序)。"""
    return max(weights, key=lambda k: weights[k])


def rank(cfg: dict, platform: dict) -> list[dict]:
    rows = [{"id": c["id"], "name": c["name"], "score": score(c["signals"], platform["weights"])}
            for c in cfg["candidates"]]
    # 分数降序,同分按 id 升序(稳定 tie-break)
    return sorted(rows, key=lambda r: (-r["score"], r["id"]))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    with open(os.path.join(a.config, "g14_rank_structure.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    signals = cfg["signals"]
    # ---- lint ----
    lint_errs = []
    for p in cfg["platforms"]:
        s = sum(p["weights"].get(k, 0) for k in signals)
        if s != WEIGHT_SUM:
            lint_errs.append(f'{p["id"]} 权重和 {s} != {WEIGHT_SUM}')
        if set(p["weights"]) != set(signals):
            lint_errs.append(f'{p["id"]} 信号键与 signals 不一致')
    for c in cfg["candidates"]:
        if set(c["signals"]) != set(signals):
            lint_errs.append(f'{c["id"]} 信号键与 signals 不一致')

    # ---- 参考排序 ----
    per_platform = []
    for p in cfg["platforms"]:
        order = rank(cfg, p)
        per_platform.append({
            "id": p["id"], "name": p["name"], "chartModel": p["chartModel"],
            "updateHours": p["updateHours"], "windowDays": p["windowDays"],
            "dominantSignal": dominant_signal(p["weights"]),
            "topId": order[0]["id"], "topName": order[0]["name"], "topScore": order[0]["score"],
            "order": [{"id": r["id"], "score": r["score"]} for r in order],
        })
    digest = "|".join(f'{r["id"]}:{r["topId"]}:{r["topScore"]}' for r in per_platform)

    os.makedirs(a.out, exist_ok=True)
    with open(os.path.join(a.out, "g14_rank_scan.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["platform", "name", "chartModel", "dominantSignal", "candidate", "score", "rank"])
        for r in per_platform:
            for i, o in enumerate(r["order"]):
                w.writerow([r["id"], r["name"], r["chartModel"], r["dominantSignal"],
                            o["id"], o["score"], i + 1])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "signals": signals, "platformCount": len(per_platform),
           "candidateCount": len(cfg["candidates"]), "lintErrors": lint_errs,
           "platforms": per_platform, "digest": digest}
    with open(os.path.join(a.out, "g14_rank_scan.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "lintErrors": lint_errs,
                      "platforms": [{"id": r["id"], "chartModel": r["chartModel"],
                                     "dominantSignal": r["dominantSignal"],
                                     "topId": r["topId"], "topScore": r["topScore"]}
                                    for r in per_platform]}, ensure_ascii=False, indent=2))
    return 1 if lint_errs else 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑 /opt/homebrew/bin/python3 sim/g14_rank_scan.py --config config/ --out out/,六个平台 × 六个候选的排序摘要是(同机相对量级,绝对数值不可移植):

digest = poki:merge-dragon:575|crazygames:merge-dragon:574|y8:merge-farm:568|gamedist:merge-dragon:473|appstore:merge-dragon:477|yt_playables:merge-dragon:592

逐平台看完整排序(括号内为加权分,满分 1000):

平台口径主导信号排序(降序)
Pokiengagementplaythrough合成龙岛 575 · 合成农场 563 · 放置钓鱼 554 · 合成小岛 554 · 方块消除 494 · 泡泡爆破 475
CrazyGamesengagementplaythrough合成龙岛 574 · 合成农场 565 · 合成小岛 552 · 放置钓鱼 547 · 方块消除 501 · 泡泡爆破 483
Y8clickplaythrough合成农场 568 · 合成龙岛 565 · 合成小岛 540 · 方块消除 530 · 泡泡爆破 516 · 放置钓鱼 510
GameDistributiondistributioninstalls合成龙岛 473 · 合成农场 472 · 合成小岛 457 · 方块消除 450 · 放置钓鱼 449 · 泡泡爆破 428
App Storestoreinstalls合成龙岛 477 · 放置钓鱼 474 · 合成农场 454 · 合成小岛 447 · 方块消除 411 · 泡泡爆破 385
YouTube Playablesengagementsession合成龙岛 592 · 放置钓鱼 575 · 合成小岛 570 · 合成农场 568 · 方块消除 464 · 泡泡爆破 438

这张表最该记住的是两处不一致。第一处是 Y8 的榜首换了人:五个平台都由「合成龙岛」或「合成农场」领跑,唯独点击型的 Y8 把「合成农场」(568)排在「合成龙岛」(565)前面——因为 Y8 把 38% 的权重压在完播上,「合成农场」的完播分(700)比「合成龙岛」(600)高。第二处是 App Store 里「放置钓鱼」冲到第二:商店型榜单给安装与收入合计 47% 权重,「放置钓鱼」的收入分(420)最高,于是在商店榜上反超了在参与型榜单里更靠前的「合成农场」。同一款游戏,换个平台就是另一个名次——这正是「按平台调优」的算术解释。

P 层内核:跨语言对拍,证明排序没算错

一张表看不出「是排序对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/g14/rank.ts 复算同一套排序:同样的权重、同样的 floor 地板除、同样的「同分按 id 升序」。它读同一份 config/g14_rank_structure.json,产出与 Python 逐字符一致的摘要。

// file: core/src/g14/rank.ts
// tier: P
// verify: node --test 'core/test/g14_*.test.ts'
//
// 海外各平台榜单结构内核:与 sim/g14_rank_scan.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g14_rank_structure.json;全整数、无浮点、无随机、禁 Math.random。
// score(game,platform) = floor(Σ signal×weight/1000);排序降序、同分按 id 升序。

export const WEIGHT_SUM = 1000;

export interface RawPlatform {
  readonly id: string; readonly name: string; readonly chartModel: string;
  readonly updateHours: number; readonly windowDays: number;
  readonly weights: Readonly<Record<string, number>>;
}
export interface RawCandidate {
  readonly id: string; readonly name: string;
  readonly signals: Readonly<Record<string, number>>;
}
export interface RankConfig {
  readonly version: number; readonly game: string; readonly observedAt: string;
  readonly signals: readonly string[];
  readonly platforms: readonly RawPlatform[]; readonly candidates: readonly RawCandidate[];
}
export interface RankedGame { readonly id: string; readonly name: string; readonly score: number }
export interface PlatformRank {
  readonly id: string; readonly name: string; readonly chartModel: string;
  readonly dominantSignal: string; readonly topId: string; readonly topScore: number;
  readonly order: readonly RankedGame[];
}

/** 加权分(0–1000,整数地板除):与 sim/g14_rank_scan.py 的 score 逐位等价。 */
export function score(signals: Readonly<Record<string, number>>, weights: Readonly<Record<string, number>>): number {
  let acc = 0;
  for (const k of Object.keys(weights)) acc += signals[k] * weights[k];
  return Math.floor(acc / WEIGHT_SUM);
}

/** 权重最大的排序信号;并列时按 Object.keys 顺序取首个(与 Python dict 插入顺序一致)。 */
export function dominantSignal(weights: Readonly<Record<string, number>>): string {
  let best = ""; let bestW = -1;
  for (const k of Object.keys(weights)) {
    if (weights[k] > bestW) { best = k; bestW = weights[k]; }
  }
  return best;
}

/** 对单个平台排序:分数降序、同分 id 升序。 */
export function rank(cfg: RankConfig, platform: RawPlatform): RankedGame[] {
  const rows = cfg.candidates.map((c) => ({ id: c.id, name: c.name, score: score(c.signals, platform.weights) }));
  rows.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
  return rows;
}

/** 全平台排序结果。 */
export function rankAll(cfg: RankConfig): PlatformRank[] {
  return cfg.platforms.map((p) => {
    const order = rank(cfg, p);
    return { id: p.id, name: p.name, chartModel: p.chartModel,
      dominantSignal: dominantSignal(p.weights), topId: order[0].id, topScore: order[0].score, order };
  });
}

/** 配置 lint:每个平台权重和必须恒 1000,信号键必须对齐。返回错误列表(空为通过)。 */
export function lint(cfg: RankConfig): string[] {
  const errs: string[] = [];
  for (const p of cfg.platforms) {
    let s = 0;
    for (const k of cfg.signals) s += p.weights[k] ?? 0;
    if (s !== WEIGHT_SUM) errs.push(`${p.id} 权重和 ${s} != ${WEIGHT_SUM}`);
    for (const k of cfg.signals) if (!(k in p.weights)) errs.push(`${p.id} 缺信号 ${k}`);
  }
  for (const c of cfg.candidates) for (const k of cfg.signals) if (!(k in c.signals)) errs.push(`${c.id} 缺信号 ${k}`);
  return errs;
}

/** 跨语言对拍摘要:与 sim/g14_rank_scan.py 的 digest 逐字符一致。 */
export function digest(cfg: RankConfig): string {
  return rankAll(cfg).map((r) => `${r.id}:${r.topId}:${r.topScore}`).join("|");
}

配套测试 core/test/g14_rank.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并验权重 lint、可复现、口径决定谁上榜、主导信号识别。在本机跑 node --test 'core/test/g14_*.test.ts',本节 6 个用例全过:

✔ 跨语言一致:TS 排序摘要 == Python 金标准
✔ 权重 lint 通过:每平台六项之和恒 1000,信号键对齐
✔ 可复现:两次排序结果逐位相同
✔ 口径决定谁上榜:点击型 Y8 的 top-1 与参与型平台不同
✔ 主导信号随榜单口径变:参与型看 playthrough/session,商店型看 installs
✔ 加权分与手工算一致:合成小岛在 Poki 上 = floor(Σ signal×weight/1000)
ℹ tests 6 / pass 6 / fail 0

第四个用例把「换平台就换榜首」写进了断言——y8 的 top-1 是「合成农场」,poki 的 top-1 是「合成龙岛」,两者必须不同。只要有一边的权重、地板除或 tie-break 写错,摘要立刻分叉。这与卷一 2.1 榜单结构与品类分布 是同一种思路:把随市场变化的规则关进配置,让代码只负责「按表执行」——那一节算的是「市场由哪些品类构成」(HHI 集中度),本节算的是「平台按什么规则排序」。

口径决定谁上榜:一个可复用的判断

把上面两处不一致抽出来,得到一条可复用的判断:冲榜前先问「这个平台的榜单在奖励什么」,再决定优化什么。engagement 平台投「前 30 秒的钩子与单次时长」,click 平台投「缩略图与标题的点击率」,distribution 平台投「首包体积与跨站嵌入」(安装分权重 35%,四口径最高),store 平台投「商店页转化与买量」(安装 + 收入合计 47% 权重,接「海外渠道与投放口径」 )。反过来,不要用一套素材打所有平台——同一款游戏的商店截图、宣传语、甚至玩法节奏,在参与型与商店型榜单上的最优解不同。这也是本节与 Steam 发布专题 的分工:那 166 篇讲 PC 商店这一套口径怎么玩透,本节讲海外 H5 的四种榜单口径如何分别应对。

X 层:真实榜单排序不可复现

必须把边界划清。上面算的是排序模型:给定一组信号权重,六个候选的排位是什么。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 各平台的真实排序公式(平台侧不公开,且随算法迭代与反刷策略调整,本节只作「公开可观察机制」引用并标注观察时点 2026-10);
  • 各平台的真实榜单名次与流量分配(同一款游戏在不同时段、不同地区差异极大,无法用一组权重刻画);
  • 真实的编辑推荐位分配(哪些游戏被人工加进首页,平台不透明)。

工程上正确的做法是:把可复现的排序模型与不可复现的真实名次分开——模型回答「如果平台按这些信号排序,谁会靠前」,真实名次用来在发行后反标定权重(把观测到的名次反推回权重,模型立刻反映现实)。这也是第 8 章「分语言指标口径」要跟模型字段一一对应的原因。

自检表

检查项合格标准
榜单当机制读按「奖励什么信号」分口径,不当排名表读
权重可 lint每平台权重和恒 1000,信号键对齐,脚本报错即停
跨语言一致Python 扫描与 TS 内核摘要逐字符相同
口径差异可见至少一处「换平台换榜首」写进断言
更新与窗口明示给出 updateHours / windowDays,不混谈
边界清晰真实排序公式与名次标 X,不写成「实测」

榜单结构最危险的错觉,是把「我游戏好」当成「我能上榜首」。榜单奖励的不是「好」,而是平台看重的信号——同一款游戏换个平台就是另一个名次。下一节把镜头从「平台怎么排」转向「竞品怎么本地化」:拆开同品类竞品的本地化深度,找出「需求大、但竞品都没做」的语言机会。

阅读导航:上一节:目录 · 下一节:14.2 竞品拆解与本地化机会 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制