1.2 平台生态与分发机制

分发的第一道闸门不是推荐算法,而是加载。本篇把平台生态拆成入口、闸门、转化三段,用一个全整数的漏斗模型算出:首屏包体从 2MB 增到 8MB 时,加载成功率与进入率各掉多少,弱网层为什么是决定生死的那 10%,以及不同分发位该配什么样的包体策略。

上一节把「小游戏」定成了包体 / 加载 / 宿主三重约束。这一节把镜头对准第二重约束——加载——并且回答一个比「有哪些平台」更实际的问题:平台是怎么把用户送到你面前的,中间哪一步最容易掉人。

先给一个反直觉的结论:在一个小游戏的分发链路上,真正决定生死的第一道闸门不是推荐算法,而是首屏加载。平台的推荐位、搜索、分享入口决定了「有多少人看到你」,但从「看到」到「玩上」之间,横着一道由包体体积决定的加载闸门;闸门太窄,前面导入的流量再多也漏掉。本节就用一个可跑的整数模型把这道闸门量化。

分发链路:入口、闸门、转化

把平台的分发拆成三段,每段的角色完全不同:

段由谁控制决定什么你能改吗
入口平台曝光量(多少人看到)基本不能,只能优化素材与标题
闸门平台规则 + 你的包体加载成功率(看到的人能否玩上)能,且这是你唯一能大改的一段
转化你的玩法进入率、首局完成率、留存能,属于玩法与引导设计

平台生态的差异主要体现在入口形态上,而入口形态又反过来约束了闸门怎么设:

  • 社交裂变位(微信 / 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 市场规模、增长与结构性机会 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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