站内有一篇 H5 游戏产业全景 ,那是 H5 产业的全景综述——技术栈、生态、变现、政策一网打尽,覆盖面广但每条都浅。本节不重做那张全景图,只做一件事:把海外小游戏平台按「用户从哪来」分成三层,并算清每一层的流量结构。站内还有一组 Steam 发布专题 (166 篇),那是 PC 商店口径;本节末会用一节显式对照「为什么 H5 小游戏不走 Steam」。至于「小游戏被包体、加载、宿主三重约束重新定义」,卷一 1.1 小游戏的定义与边界 已经用一张能力矩阵讲透,本节不重做那张包体表——它回答「装不装得下」,本节回答「装下之后,用户从哪来」。
海外小游戏的三层结构
国内小游戏绕不开微信、抖音、快手这几家超级 App。出海面对的平台要碎得多,但碎归碎,按用户如何发现游戏这个唯一标准,它们仍然只分三层:
| 层 | 代表平台 | 用户怎么发现游戏 | 你的角色 |
|---|---|---|---|
| 超级 App 内嵌 | Facebook Instant Games、YouTube Playables、Snapchat、Discord Activities | 信息流 / 推荐位 / 社交分享,在 App 里点开即玩 | 内容供给方,吃宿主算法与社交链 |
| 独立网页门户 | Poki、CrazyGames、Y8、GameDistribution | 门户首页、搜索引擎、站点联盟嵌入 | 货架上的一个 SKU,吃门户分发 |
| 应用商店 | App Store、Google Play(+ Steam 作 PC 对照) | 商店搜索、榜单、买量 | 需下载安装,吃商店排名与投放 |
三层的差别不在「游戏好不好玩」,而在流量从哪来。同一款《合成小岛》放进这三层,获客成本、留存曲线、可用的变现通道是三套完全不同的账。所以「选平台」的第一步不是比谁分成高,而是先认清这个平台的流量结构——它决定了你能靠什么拿到用户。
平台分层矩阵(D 层)
下面这段是 D 层配置,把十一个平台各记一行:所属层、可触达月活、新游戏被发现的概率、四类流量来源占比、分发入口。数值是按各平台公开机制整理的建模输入,标为公开可观察(观察时点 2026-10),不是任何平台后台实测。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "海外小游戏平台分层与流量结构。reachMillions=平台可触达月活(百万,建模假设);discoverabilityPermille=新游戏在平台内被发现的概率(千分比,建模假设);mix 四类流量来源占比(千分比,四项之和恒 1000):natural=自然/搜索、editorial=平台编辑与算法推荐、social=社交传播、paid=付费/联盟导入。全部为「公开可观察机制」口径的建模假设,观察时点 2026-10;不是任何平台后台实测,真实流量与推荐机制需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"playRatePermille": 150,
"tiers": [
{ "id": 1, "name": "超级 App 内嵌" },
{ "id": 2, "name": "独立网页门户" },
{ "id": 3, "name": "应用商店" }
],
"platforms": [
{ "id": "fb_instant", "name": "Facebook Instant Games", "tier": 1, "isContrast": 0, "reachMillions": 300, "discoverabilityPermille": 120, "mix": { "naturalPermille": 200, "editorialPermille": 300, "socialPermille": 380, "paidPermille": 120 }, "discoveryEntry": "信息流 / 游戏标签页" },
{ "id": "yt_playables", "name": "YouTube Playables", "tier": 1, "isContrast": 0, "reachMillions": 500, "discoverabilityPermille": 90, "mix": { "naturalPermille": 150, "editorialPermille": 450, "socialPermille": 100, "paidPermille": 300 }, "discoveryEntry": "首页 / 推荐位" },
{ "id": "snapchat", "name": "Snapchat 小游戏", "tier": 1, "isContrast": 0, "reachMillions": 400, "discoverabilityPermille": 70, "mix": { "naturalPermille": 120, "editorialPermille": 200, "socialPermille": 480, "paidPermille": 200 }, "discoveryEntry": "社交分享 / 发现页" },
{ "id": "discord", "name": "Discord Activities", "tier": 1, "isContrast": 0, "reachMillions": 150, "discoverabilityPermille": 60, "mix": { "naturalPermille": 100, "editorialPermille": 150, "socialPermille": 600, "paidPermille": 150 }, "discoveryEntry": "服务器内活动" },
{ "id": "poki", "name": "Poki", "tier": 2, "isContrast": 0, "reachMillions": 100, "discoverabilityPermille": 200, "mix": { "naturalPermille": 350, "editorialPermille": 300, "socialPermille": 100, "paidPermille": 250 }, "discoveryEntry": "门户首页 / SEO" },
{ "id": "crazygames", "name": "CrazyGames", "tier": 2, "isContrast": 0, "reachMillions": 60, "discoverabilityPermille": 220, "mix": { "naturalPermille": 400, "editorialPermille": 250, "socialPermille": 120, "paidPermille": 230 }, "discoveryEntry": "门户首页 / SEO" },
{ "id": "y8", "name": "Y8", "tier": 2, "isContrast": 0, "reachMillions": 40, "discoverabilityPermille": 180, "mix": { "naturalPermille": 500, "editorialPermille": 150, "socialPermille": 100, "paidPermille": 250 }, "discoveryEntry": "门户首页 / SEO" },
{ "id": "gamedist", "name": "GameDistribution", "tier": 2, "isContrast": 0, "reachMillions": 80, "discoverabilityPermille": 150, "mix": { "naturalPermille": 200, "editorialPermille": 100, "socialPermille": 50, "paidPermille": 650 }, "discoveryEntry": "联盟分发 / 站点嵌入" },
{ "id": "appstore", "name": "App Store", "tier": 3, "isContrast": 0, "reachMillions": 900, "discoverabilityPermille": 40, "mix": { "naturalPermille": 200, "editorialPermille": 250, "socialPermille": 50, "paidPermille": 500 }, "discoveryEntry": "商店搜索 / 榜单" },
{ "id": "gplay", "name": "Google Play", "tier": 3, "isContrast": 0, "reachMillions": 1200, "discoverabilityPermille": 35, "mix": { "naturalPermille": 250, "editorialPermille": 200, "socialPermille": 40, "paidPermille": 510 }, "discoveryEntry": "商店搜索 / 榜单" },
{ "id": "steam", "name": "Steam(PC 对照)", "tier": 3, "isContrast": 1, "reachMillions": 130, "discoverabilityPermille": 30, "mix": { "naturalPermille": 350, "editorialPermille": 400, "socialPermille": 100, "paidPermille": 150 }, "discoveryEntry": "商店 / 榜单 / 愿望单" }
]
}
读这张表要抓住三个反直觉点。第一,超级 App 层不一定「大」——它的可触达月活很高,但「新游戏被发现的概率」discoverabilityPermille 并不必然高:Discord 只有 60‰,意味着一个新游戏在它里面被看到的概率比 Poki(200‰)还低,因为它的流量几乎全靠服务器内的社交传播。第二,网页门户层的免费流量占比最高:Poki / CrazyGames / Y8 的自然 + 推荐 + 社交加起来都在 750‰ 上下,靠 SEO 与门户首页吃饭。第三,应用商店层的付费流量最重:App Store 与 Google Play 的一半流量是买来的,这是「上架不等于有量」的算术解释。
流量结构:四类来源决定你该投什么
把每个平台的流量拆成四类来源,比只看「月活多大」有用得多:
| 来源 | 含义 | 你该做什么 |
|---|---|---|
natural 自然 | 搜索引擎、直链、老用户回访 | 做好标题 / 描述 / 站内 SEO |
editorial 推荐 | 平台编辑位、算法推荐 | 优化首帧、完播、留存的平台信号 |
social 社交 | 好友分享、群、服务器传播 | 做分享钩子与可炫耀的进度 |
paid 付费 | 平台买量、联盟导入 | 备好预算与归因口径(卷四) |
关键判断是主导来源(dominant channel):它决定你的运营重心。social 主导的平台(Facebook、Snapchat、Discord),分享机制是命脉;natural 主导的平台(Poki / CrazyGames / Y8),SEO 与门户合作是命脉;paid 主导的平台(App Store / Google Play / GameDistribution),买量与归因是命脉。把资源投错来源,就是给平台交学费。
S 层:分层与流量结构仿真
下面这段是 S 层仿真脚本 sim/g01_traffic_structure.py。它读 config/g01_platform_tiers.json,用整数公式算每个平台的免费流量占比、可发现用户规模(千人)与预期开玩量,并按三层聚合,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/g01_traffic_structure.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g01_traffic_structure.py --config config/ --out out/
"""海外小游戏平台分层与流量结构仿真:读 config/g01_platform_tiers.json,算每个平台的免费
流量占比、可发现用户规模与预期开玩量,并按平台分层聚合。全整数、地板除,无浮点。
freeSharePermille = natural + editorial + social
reachableKiloUsers = reachMillions * discoverabilityPermille (千人)
freeReachableKilo = reachableKiloUsers * freeSharePermille // 1000
paidReachableKilo = reachableKiloUsers * paidPermille // 1000
expectedPlaysKilo = reachableKiloUsers * playRatePermille // 1000
口径:平台月活与流量结构是「公开可观察机制」口径的建模假设,观察时点 2026-10;不是任何
平台后台实测,真实流量与推荐机制需真实业务环境,命令行不可复现(X)。
产出 out/g01_traffic_structure.json + .csv + .svg。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
CHANNELS = ("natural", "editorial", "social", "paid")
FREE_CHANNELS = CHANNELS[:3]
def dominant(mix: dict) -> str:
"""流量占比最大的来源;max 稳定取首个最大值(按 CHANNELS 顺序)。"""
return max(CHANNELS, key=lambda c: mix[f"{c}Permille"])
def evaluate(p: dict, play_rate: int) -> dict:
mix = p["mix"]
reach = p["reachMillions"] * p["discoverabilityPermille"] # 千人
free = sum(mix[f"{c}Permille"] for c in FREE_CHANNELS)
return dict(p, reachableKiloUsers=reach, freeSharePermille=free,
paidSharePermille=mix["paidPermille"], dominantChannel=dominant(mix),
freeReachableKiloUsers=reach * free // 1000,
paidReachableKiloUsers=reach * mix["paidPermille"] // 1000,
expectedPlaysKilo=reach * play_rate // 1000)
def draw_svg(path: str, rows: list[dict]) -> int:
"""每个平台一条堆叠横条:免费(蓝)vs 付费(橙)可发现用户(千人)。"""
W, H, PAD, ROW = 780, 70 + 40 * len(rows), 210, 40
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15",
"fill": "#111827"}).text = "各平台可发现用户(千人):免费(蓝)vs 付费(橙)"
span, hi = W - PAD - 120, max([r["reachableKiloUsers"] for r in rows] + [1])
for i, r in enumerate(rows):
y = 52 + i * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 18), "font-size": "12",
"fill": "#374151"}).text = r["name"]
fw = span * r["freeReachableKiloUsers"] // hi
pw = span * r["paidReachableKiloUsers"] // hi
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(fw),
"height": "18", "rx": "3", "fill": "#2563eb"})
ET.SubElement(svg, "rect", {"x": str(PAD + fw), "y": str(y + 4), "width": str(pw),
"height": "18", "rx": "3", "fill": "#f59e0b"})
ET.SubElement(svg, "text", {"x": str(PAD + fw + pw + 6), "y": str(y + 18), "font-size": "11",
"fill": "#6b7280"}).text = f'{r["reachableKiloUsers"]}k · 免费 {r["freeSharePermille"]}‰'
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
with open(os.path.join(args.config, "g01_platform_tiers.json"), encoding="utf-8") as f:
cfg = json.load(f)
rows = [evaluate(p, cfg["playRatePermille"]) for p in cfg["platforms"]]
names = {t["id"]: t["name"] for t in cfg["tiers"]}
by_tier = {}
for r in rows:
if r.get("isContrast"):
continue
t = by_tier.setdefault(r["tier"], {"tier": r["tier"], "name": names[r["tier"]],
"platforms": 0, "reachableKiloUsers": 0,
"freeReachableKiloUsers": 0})
t["platforms"] += 1
t["reachableKiloUsers"] += r["reachableKiloUsers"]
t["freeReachableKiloUsers"] += r["freeReachableKiloUsers"]
tiers = [by_tier[k] for k in sorted(by_tier)]
digest = ",".join(f'{r["id"]}:{r["freeSharePermille"]}:{r["reachableKiloUsers"]}'
f':{r["dominantChannel"]}' for r in rows)
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "g01_traffic_structure.svg"), rows)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"playRatePermille": cfg["playRatePermille"], "platformCount": len(rows),
"platforms": rows, "tiers": tiers, "digest": digest,
"svg": os.path.join(args.out, "g01_traffic_structure.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "g01_traffic_structure.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
cols = ("id", "name", "tier", "isContrast", "reachMillions", "discoverabilityPermille",
"freeSharePermille", "paidSharePermille", "dominantChannel", "reachableKiloUsers",
"freeReachableKiloUsers", "paidReachableKiloUsers", "expectedPlaysKilo")
with open(os.path.join(args.out, "g01_traffic_structure.csv"), "w", encoding="utf-8",
newline="") as f:
w = csv.writer(f)
w.writerow(cols)
for r in rows:
w.writerow([r[k] for k in cols])
print(json.dumps({
"playRatePermille": cfg["playRatePermille"],
"platforms": [{"id": r["id"], "tier": r["tier"], "freeSharePermille": r["freeSharePermille"],
"dominantChannel": r["dominantChannel"],
"reachableKiloUsers": r["reachableKiloUsers"],
"freeReachableKiloUsers": r["freeReachableKiloUsers"]} for r in rows],
"tiers": tiers, "digest": digest}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑 /opt/homebrew/bin/python3 sim/g01_traffic_structure.py --config config/ --out out/,结果是(同机相对量级,绝对数值不可移植):
| 平台 | 层 | 免费流量‰ | 主导来源 | 可发现用户(千人) | 免费可发现(千人) |
|---|---|---|---|---|---|
| Facebook Instant Games | 1 | 880 | 社交 | 36000 | 31680 |
| YouTube Playables | 1 | 700 | 推荐 | 45000 | 31500 |
| Snapchat 小游戏 | 1 | 800 | 社交 | 28000 | 22400 |
| Discord Activities | 1 | 850 | 社交 | 9000 | 7650 |
| Poki | 2 | 750 | 自然 | 20000 | 15000 |
| CrazyGames | 2 | 770 | 自然 | 13200 | 10164 |
| Y8 | 2 | 750 | 自然 | 7200 | 5400 |
| GameDistribution | 2 | 350 | 付费 | 12000 | 4200 |
| App Store | 3 | 500 | 付费 | 36000 | 18000 |
| Google Play | 3 | 490 | 付费 | 42000 | 20580 |
| Steam(PC 对照) | 3 | 850 | 推荐 | 3900 | 3315 |
按三层聚合(不含对照行):超级 App 内嵌层可发现用户 118000 千人、其中免费 93230;独立网页门户层 52400 千人、免费 34764;应用商店层 78000 千人、免费 38580。三层里免费占比最高的是超级 App 内嵌层(79%),但它的绝对规模要靠宿主的社交链撬动;应用商店层规模最大却最贵(免费占比只有 49%)——这正是出海最容易踩的坑:冲着「商店用户多」去,结果把预算全烧在了买量上。
为什么 H5 小游戏不走 Steam
Steam 也在上面的表里,但它是对照行(isContrast: 1)。把它和网页门户层比一比,就明白为什么 H5 小游戏基本不走 Steam:Steam 的可发现用户只有 3900 千人(网页门户里最小的 Y8 都有 7200 千人),discoverabilityPermille 只有 30‰——一个新游戏在 Steam 上被发现的概率比任何一层都低。原因是 Steam 是PC 商店口径:它要求下载安装(不是「点开即玩」)、面向的是愿意为买断制付费的 PC 玩家、分发靠榜单与愿望单。这些特征和 H5 小游戏「免下载、轻量、IAA 为主」的形态天然错位。
本站的 Steam 发布专题 把那套 PC 口径讲得很细——商店页、愿望单、审核、区域定价。本套的结论是:Steam 值得对照,但不值得作为 H5 小游戏的第一落点。要理解 PC 商店那套规则,去读那 166 篇;要理解 H5 怎么出海,留在这一卷。
X 层:真实流量不可复现
边界必须划清。上面算的是流量结构模型:给定月活、可发现概率与来源占比,各层能拿到多少免费与付费用户。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的平台月活与真实的新游戏可发现概率(平台侧不公开,且随算法与季节剧烈波动);
- 真实的流量来源占比(同一款游戏在不同平台、不同时段差异极大,无法用常数刻画);
- 真实的推荐位分配与社交传播系数(由平台算法决定,不透明且随时改)。
工程上正确的做法是:把可复现的结构模型与不可复现的真实数据分开——模型回答「如果流量结构是这样,各层各能拿到多少」,真实数据用来在发行后反标定模型参数(把实测占比填回 config,模型立刻反映现实)。这也是第 8 章「分语言指标口径」要跟模型字段一一对应的原因。
自检表
| 检查项 | 合格标准 |
|---|---|
| 分层有标准 | 按「用户如何发现游戏」分层,不按品类或体量 |
| 流量四类齐全 | 自然 / 推荐 / 社交 / 付费每类都有占比,四项和恒 1000 |
| 主导来源明确 | 每个平台给出唯一主导来源,运营重心不模糊 |
| 免费占比可算 | 每层给出免费流量占比与可发现用户规模 |
| 对照显式 | Steam 作为 PC 口径对照,写清「为什么不走」 |
| 边界清晰 | 真实月活与推荐机制标 X,不写成「实测」 |
平台分层最危险的错觉,是把「月活大」当成「好上架」。月活大不等于你能分到量——真正决定你拿不拿得到用户的是这个平台的流量结构,以及它把流量分给谁。下一节我们把这层结构落到钱上:同样一条收入,经过不同平台的分成与结算条款,最后到你账上的净额与到账时间差多少。
阅读导航:上一节:目录 · 下一节:1.2 各平台分成与结算条款 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。