上一节把「小游戏」定成了包体 / 加载 / 宿主三重约束。这一节把镜头对准第二重约束——加载——并且回答一个比「有哪些平台」更实际的问题:平台是怎么把用户送到你面前的,中间哪一步最容易掉人。
先给一个反直觉的结论:在一个小游戏的分发链路上,真正决定生死的第一道闸门不是推荐算法,而是首屏加载。平台的推荐位、搜索、分享入口决定了「有多少人看到你」,但从「看到」到「玩上」之间,横着一道由包体体积决定的加载闸门;闸门太窄,前面导入的流量再多也漏掉。本节就用一个可跑的整数模型把这道闸门量化。
分发链路:入口、闸门、转化
把平台的分发拆成三段,每段的角色完全不同:
| 段 | 由谁控制 | 决定什么 | 你能改吗 |
|---|---|---|---|
| 入口 | 平台 | 曝光量(多少人看到) | 基本不能,只能优化素材与标题 |
| 闸门 | 平台规则 + 你的包体 | 加载成功率(看到的人能否玩上) | 能,且这是你唯一能大改的一段 |
| 转化 | 你的玩法 | 进入率、首局完成率、留存 | 能,属于玩法与引导设计 |
平台生态的差异主要体现在入口形态上,而入口形态又反过来约束了闸门怎么设:
- 社交裂变位(微信 / QQ):入口来自好友分享、群、朋友圈,天然带「熟人信任」,加载慢一点用户也愿意等。这里的闸门相对宽松。
- 内容流位(抖音 / 快手):入口来自信息流里的竖屏试玩视频,用户是「滑到你这儿」的,注意力窗口极短。这里的闸门最严——加载多等 2 秒,人就滑走了。
- 门户 / 长尾位(百度 / 支付宝 / Poki / CrazyGames):入口来自搜索、应用内入口或网页聚合站,用户目的性稍强,但耐心也有限。
结论:入口越「被动」(内容流),加载闸门就越致命。 这不是平台主观的规则,而是用户注意力的客观结构。
加载漏斗模型(S 层)
下面这段是 D 层配置:网络分层的占比、每层「每多 1 MB 的流失斜率」、以及漏斗的点击率与首局完成率。网络占比与流失斜率是公开可观察的弱网体验经验区间,作为建模假设使用,不是任何平台后台实测;全整数、无浮点。
{
"version": 1,
"note": "分发漏斗模型参数。网络分层的占比与「每多 1MB 的流失斜率」是公开可观察的弱网体验经验区间,作为建模假设使用,不是任何平台后台实测(不可复现处标 X)。全整数,无浮点。",
"observedAt": "2026-10",
"networkTiers": [
{ "id": "wifi", "name": "Wi-Fi", "sharePct": 52, "baseSuccessPct": 99, "decayPerMbPct": 1, "floorPct": 90 },
{ "id": "4g", "name": "4G", "sharePct": 38, "baseSuccessPct": 97, "decayPerMbPct": 3, "floorPct": 70 },
{ "id": "weak", "name": "弱网 / 3G", "sharePct": 10, "baseSuccessPct": 90, "decayPerMbPct": 8, "floorPct": 40 }
],
"funnel": {
"ctrPct": 6,
"firstGamePct": 82,
"note": "ctrPct=曝光→点击;firstGamePct=加载成功→完成首局。"
},
"sizeSweepMb": [2, 4, 6, 8, 10, 12, 14]
}
下面这段是 S 层仿真脚本,读上面的 config/,把「首屏包体 → 加载成功率 → 进入率」算成一条整数曲线,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/ch01_distribution_funnel.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch01_distribution_funnel.py --config config/ --out out/
"""分发机制的可量化漏斗:读 config/ch01_funnel.json,把「首屏包体 → 加载成功率 →
进入率(完成首局)」算成一条整数曲线,产出 JSON + CSV + SVG。
模型(全整数百分比,无浮点):
- 按网络分层,successPct(tier, S) = clamp(base - decay*(S-1), floor, 100)。
- overallSuccessPct(S) = sum(share * success) // 100。
- enterPct(S) = overallSuccessPct(S) * firstGamePct // 100(加载成功→完成首局)。
- exposurePct(S) = ctrPct * enterPct(S) // 100(曝光→点击→…→首局)。
纯标准库,图表用 xml.etree 手写 SVG,零依赖。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load_config(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def tier_success(tier: dict, size_mb: int) -> int:
raw = tier["baseSuccessPct"] - tier["decayPerMbPct"] * (size_mb - 1)
return max(tier["floorPct"], min(100, raw))
def overall_success(tiers: list[dict], size_mb: int) -> int:
return sum(t["sharePct"] * tier_success(t, size_mb) for t in tiers) // 100
def sweep(cfg: dict) -> list[dict]:
tiers = cfg["networkTiers"]
ctr = cfg["funnel"]["ctrPct"]
first = cfg["funnel"]["firstGamePct"]
rows = []
for s in cfg["sizeSweepMb"]:
load = overall_success(tiers, s)
enter = (load * first) // 100 # 加载成功 → 完成首局
exposure = (ctr * enter) // 100 # 曝光 → 点击 → … → 首局
rows.append({
"sizeMb": s,
"loadSuccessPct": load,
"enterRatePct": enter,
"exposureToFirstGamePct": exposure,
"perTier": {t["id"]: tier_success(t, s) for t in tiers},
})
return rows
def write_csv(path: str, rows: list[dict], tiers: list[dict]) -> None:
cols = ["sizeMb", "loadSuccessPct", "enterRatePct", "exposureToFirstGamePct"] + [t["id"] for t in tiers]
with open(path, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(cols)
for r in rows:
w.writerow([r["sizeMb"], r["loadSuccessPct"], r["enterRatePct"],
r["exposureToFirstGamePct"]] + [r["perTier"][t["id"]] for t in tiers])
def write_svg(path: str, rows: list[dict]) -> int:
W, H, PAD = 720, 400, 56
xs = [r["sizeMb"] for r in rows]
x_max = max(xs)
y_max = 100
sx = lambda x: PAD + (W - 2 * PAD) * (x / x_max)
sy = lambda y: H - PAD - (H - 2 * PAD) * (y / y_max)
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": str(PAD), "y": "24", "font-size": "16",
"font-family": "sans-serif"}).text = (
"首屏包体(MB)× 加载成功率 / 进入率(%,同机相对量级)")
for i in range(6):
y = PAD + (H - 2 * PAD) * i / 5
ET.SubElement(svg, "line", {"x1": str(PAD), "y1": f"{y:.1f}", "x2": str(W - PAD),
"y2": f"{y:.1f}", "stroke": "#e5e7eb"})
ET.SubElement(svg, "text", {"x": str(PAD - 8), "y": f"{y + 4:.1f}", "font-size": "11",
"text-anchor": "end", "fill": "#6b7280"}).text = str(100 - i * 20)
ET.SubElement(svg, "line", {"x1": str(PAD), "y1": str(H - PAD), "x2": str(W - PAD),
"y2": str(H - PAD), "stroke": "#9ca3af"})
ET.SubElement(svg, "line", {"x1": str(PAD), "y1": str(PAD), "x2": str(PAD),
"y2": str(H - PAD), "stroke": "#9ca3af"})
for key, color in (("loadSuccessPct", "#2563eb"), ("enterRatePct", "#16a34a")):
pts = " ".join(f"{sx(r['sizeMb']):.2f},{sy(r[key]):.2f}" for r in rows)
ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": color,
"stroke-width": "2"})
for r in rows:
ET.SubElement(svg, "circle", {"cx": f"{sx(r['sizeMb']):.2f}", "cy": f"{sy(r[key]):.2f}",
"r": "3", "fill": color})
for r in rows:
ET.SubElement(svg, "text", {"x": f"{sx(r['sizeMb']):.1f}", "y": str(H - PAD + 16),
"font-size": "11", "text-anchor": "middle"}).text = str(r["sizeMb"])
ET.SubElement(svg, "text", {"x": str(PAD + 8), "y": str(PAD + 14), "font-size": "12",
"fill": "#2563eb"}).text = "加载成功率"
ET.SubElement(svg, "text", {"x": str(PAD + 8), "y": str(PAD + 30), "font-size": "12",
"fill": "#16a34a"}).text = "进入率(完成首局)"
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load_config(os.path.join(args.config, "ch01_funnel.json"))
os.makedirs(args.out, exist_ok=True)
rows = sweep(cfg)
write_csv(os.path.join(args.out, "ch01_distribution_funnel.csv"), rows, cfg["networkTiers"])
svg_bytes = write_svg(os.path.join(args.out, "ch01_distribution_funnel.svg"), rows)
by_size = {r["sizeMb"]: r["enterRatePct"] for r in rows}
summary = {
"version": 1, "source": "sim/ch01_distribution_funnel.py",
"enterRateBySizePct": by_size,
"loadSuccessBySizePct": {r["sizeMb"]: r["loadSuccessPct"] for r in rows},
"rows": rows, "svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "ch01_distribution_funnel.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
print(json.dumps({
"loadSuccessBySizePct": summary["loadSuccessBySizePct"],
"enterRateBySizePct": by_size,
"perTierAt4Mb": rows[1]["perTier"],
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
同机实跑(节选,同机相对量级,绝对数值不可移植):
loadSuccessBySizePct: {2:94, 4:89, 6:85, 8:80, 10:77, 12:77, 14:77}
enterRateBySizePct: {2:77, 4:72, 6:69, 8:65, 10:63, 12:63, 14:63}
perTierAt4Mb: {wifi:96, 4g:88, weak:66}
读法:加载是唯一能大改的分发杠杆
模型给出的曲线有两个特征值得记住:
- 首屏 2 MB → 8 MB,加载成功率 94% → 80%,进入率 77% → 65%。 也就是每多 2 MB,进入率大约掉 4 到 5 个百分点。对内容流位的平台(抖音 / 快手),这 12 个百分点的进入率差距,往往比素材优化带来的提升更直接。
- 曲线在 10 MB 之后变平:这不是「再大也无所谓」,而是弱网层先触底了。看
perTierAt4Mb:4 MB 时 Wi-Fi 还有 96%,4G 是 88%,弱网只有 66%——弱网层的流失速度是 Wi-Fi 的 8 倍(斜率 8 vs 1)。当包体继续增大,弱网层掉到 40% 的地板后不再下降,整条曲线就被这个地板「托平」了。
这正是「那 10%」的含义:弱网用户只占 10%,但他们贡献了绝大部分因加载失败的流失。把它折算成一个可决策的数:如果首包能从 8 MB 压到 4 MB,整体加载成功率从 80% 回到 89%(+9 个百分点),而这 9 个百分点几乎全部来自弱网层从 40% 回升到 66%(+26 个百分点 × 10% 占比)。换句话说,优化首包的收益高度集中在最不显眼的那部分用户身上,这正是它容易被忽视、也容易被做对的原因。
需要强调:上面所有百分比都来自本机这套假设模型,同机相对量级、绝对数值不可移植;真实平台的加载成功率需要真实业务环境才能测量(标 X,命令行不可复现)。模型的价值不在具体数字,而在它把「包体」和「进入率」之间的因果链显式写成了一个可调的式子——第 4 章会用同一套式子反推「首屏该砍到多少 MB」。
三种分发位对应三种包体策略
把平台生态和上面的模型合起来看,包体策略不是「越小越好」这一句话能概括的,而要按分发位分档:
| 分发位 | 代表平台 | 用户耐心 | 包体策略 |
|---|---|---|---|
| 社交裂变位 | 微信 / QQ | 较高(熟人信任) | 可放宽首屏,用「边玩边下」换体验 |
| 内容流位 | 抖音 / 快手 | 极低(一滑就走) | 首屏极限压缩,宁可玩法晚一步出现 |
| 门户 / 长尾位 | 百度 / 支付宝 / Poki | 中等 | 按平台总包上限决定是否分包 |
同一款《合成小岛》,投微信时可以让首屏带完整新手引导,投抖音时则应把首屏压到「一个能立刻点的合成动作」为止。这不是两套玩法,而是同一套玩法在两套加载闸门下的两种首屏裁剪——本卷后面所有「首屏该放什么」的讨论,都要回到这张表。
最后提醒一句:分发位不是一次选定的,而是会随版本与投放策略变化的。同一款产品可能先投内容流拿量、再导到社交裂变位做留存,这时首屏策略要跟着分发位一起切换,而不是一套包体打天下。
小结
- 分发链路分入口 / 闸门 / 转化三段;入口由平台定,闸门由包体定,只有闸门是你唯一能大改的一段。
- 首屏 2 MB → 8 MB,加载成功率 94% → 80%、进入率 77% → 65%;弱网层(占 10%)是流失的主要来源。
- 优化首包的收益高度集中在弱网用户;投内容流平台时首屏要极限压缩。
- 下一节换一个尺度:从小游戏单体的加载闸门,放大到整个小游戏市场的规模与结构性机会。
阅读导航:上一节:1.1 小游戏的定义与边界 · 下一节:1.3 市场规模、增长与结构性机会 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。