站内有两篇要先划清边界。一篇是卷一「小游戏市场全景」 ,那是国内平台的分发机制与设计约束(包体预算、首包加载、平台能力矩阵),回答「装不装得下、发不发得出」;另一组是 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):
| 平台 | 口径 | 主导信号 | 排序(降序) |
|---|---|---|---|
| Poki | engagement | playthrough | 合成龙岛 575 · 合成农场 563 · 放置钓鱼 554 · 合成小岛 554 · 方块消除 494 · 泡泡爆破 475 |
| CrazyGames | engagement | playthrough | 合成龙岛 574 · 合成农场 565 · 合成小岛 552 · 放置钓鱼 547 · 方块消除 501 · 泡泡爆破 483 |
| Y8 | click | playthrough | 合成农场 568 · 合成龙岛 565 · 合成小岛 540 · 方块消除 530 · 泡泡爆破 516 · 放置钓鱼 510 |
| GameDistribution | distribution | installs | 合成龙岛 473 · 合成农场 472 · 合成小岛 457 · 方块消除 450 · 放置钓鱼 449 · 泡泡爆破 428 |
| App Store | store | installs | 合成龙岛 477 · 放置钓鱼 474 · 合成农场 454 · 合成小岛 447 · 方块消除 411 · 泡泡爆破 385 |
| YouTube Playables | engagement | session | 合成龙岛 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 竞品拆解与本地化机会 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。