13.2 混合变现的收入结构

把 ARPDAU 拆成 IAA : IAP 的整数千分比,用广告负载与 ARPPU 两个整数百分比改写基准口径,跑出广告主导、均衡混合、内购主导三种结构的收入构成,产出 CSV 与堆叠 SVG,并说清「结构」到底改变了什么、为什么几乎不改变总收入。

13.1 把 ARPDAU 拆成了 IAA 与 IAP 两块,得到 523‰ : 476‰ 这样一个「几乎五五开」的结果。但那个比例不是天上掉下来的,它是变现结构设计的直接后果。站内 小游戏平台专题(minigame) 那 25 篇是平台型 SaaS 的商业计划与财务口径——平台怎么定价、怎么抽成、财务模型怎么搭;本节是单款小游戏自己的收入结构——广告和内购各占多少、这个比例由什么决定。与第 10 章的分工也再明确一次:第 10 章讲形态与场景(放哪些广告位、卖哪些内购点),本节把这些形态换算成收入比例并比较不同结构的含义。LTV 与回本仍是卷三第 3 章的主题,本节只做结构拆解。

同一笔收入,不同的结构

13.1 的基准口径里,IAA 34.3 分、IAP 31.2 分。但换个设计,同一款游戏完全可以做出广告主导(IAA 占七成)或内购主导(IAP 占七成)的结构。结构变了,产品性格就变了:

  • 广告主导:靠高频激励视频赚钱,用户不花钱也能玩得很好,付费意愿低。
  • 内购主导:广告克制,靠礼包与通行证赚钱,付费深度高。
  • 均衡混合:两块都做,风险分散。

问题在于:这三种结构,收入总量会差多少、收入构成会差多少?13.1 的模型只给了一个点,本节要把它变成一条线。

混合结构配置(D 层)

下面这段是 D 层配置:base 是基准口径(与 13.1 同一组数),structures 里每个候选结构用两个整数百分比改写基准——adLoadPct 缩放人均广告请求次数,arppuPct 缩放 ARPPU。这里编码了一个关键假设:广告看得多的用户,付费更浅(看广告免费拿奖励,就没必要掏钱),所以广告负载上调时 ARPPU 下调,反之亦然。

{
  "version": 1,
  "game": "merge-island",
  "note": "混合变现结构(D 层)。base 是基准口径,structures 里每个结构用两个整数百分比改写它:adLoadPct 缩放人均广告请求次数,arppuPct 缩放 ARPPU(广告看得多的用户付费更浅)。比例一律整数百分比,金额一律「分」;真实结构需真实业务环境,命令行不可复现(X)。",
  "base": {
    "adRequestsPerDau": 26,
    "fillPct": 88,
    "ecpmFen": 1500,
    "payRatePermille": 26,
    "arppuFen": 1200
  },
  "structures": [
    { "id": "iaa-heavy", "name": "广告主导", "adLoadPct": 140, "arppuPct": 70 },
    { "id": "balanced", "name": "均衡混合", "adLoadPct": 100, "arppuPct": 100 },
    { "id": "iap-heavy", "name": "内购主导", "adLoadPct": 60, "arppuPct": 160 }
  ]
}

为什么用百分比缩放而不是直接写绝对数?因为这样能表达「相对基准的口径偏移」,而且不同结构可以共享同一套 fillPct / ecpmFen / payRatePermille——那些是平台或大盘变量,不该随你的结构选择而变。你的设计自由度体现在 adLoadPct 与 arppuPct 上:前者是你摆了多少广告位,后者是你设计了多深的付费点。

结构拆解仿真(S 层)

下面这段是 S 层仿真脚本,读 config/ch13_hybrid.json,对每个结构算出 IAA / IAP 两个 ARPDAU 分量,再把「IAA : IAP」折算成整数千分比,产出 JSON + CSV + 堆叠 SVG。

#!/usr/bin/env python3
# file: sim/ch13_hybrid.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch13_hybrid.py --config config/ --out out/
"""混合变现的收入结构:IAA : IAP 的整数千分比拆解。

读 config/ch13_hybrid.json 的基准口径与若干候选结构。每个结构用两个整数百分比
改写基准:adLoadPct 缩放人均广告请求次数,arppuPct 缩放 ARPPU(广告看得多的用户
付费更浅)。再按与 sim/ch13_revenue.py 相同的整数公式算出 IAA / IAP 两个 ARPDAU
分量,并把「IAA : IAP」的比例折算成整数千分比。

    req    = base.adRequestsPerDau * adLoadPct // 100
    arppu  = base.arppuFen         * arppuPct  // 100
    iaa    = req * fillPct * ecpmFen // 10000     (0.1 分/DAU)
    iap    = arppu * payRatePermille // 100       (0.1 分/DAU)
    iaaPermille = iaa * 1000 // (iaa + iap);iapPermille = 1000 - iaaPermille

全程整数、地板除,无浮点。产出 out/ch13_hybrid.json + out/ch13_hybrid.csv + out/ch13_hybrid.svg。
注意:所有参数均为示例假设;真实结构需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

IAA_COLOR = "#2563eb"
IAP_COLOR = "#f59e0b"


def load(cfg_dir: str) -> dict:
    with open(os.path.join(cfg_dir, "ch13_hybrid.json"), encoding="utf-8") as f:
        return json.load(f)


def decompose(base: dict, st: dict) -> dict:
    """把一个结构拆成 IAA / IAP 两个 ARPDAU 分量与千分比。"""
    req = base["adRequestsPerDau"] * st["adLoadPct"] // 100
    arppu = base["arppuFen"] * st["arppuPct"] // 100
    iaa = req * base["fillPct"] * base["ecpmFen"] // 10000
    iap = arppu * base["payRatePermille"] // 100
    total = iaa + iap
    iaa_permille = iaa * 1000 // total if total else 0
    return {
        "id": st["id"], "name": st["name"],
        "adLoadPct": st["adLoadPct"], "arppuPct": st["arppuPct"],
        "adRequestsPerDau": req, "arppuFen": arppu,
        "iaaArpdauDeciFen": iaa, "iapArpdauDeciFen": iap, "arpdauDeciFen": total,
        "iaaPermille": iaa_permille, "iapPermille": 1000 - iaa_permille,
    }


def draw_svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每个结构一根堆叠横条,左段 IAA、右段 IAP。"""
    W, H, PAD, ROW = 760, 60 + 52 * len(rows), 150, 52
    root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                              "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(root, "text", {"x": "16", "y": "26", "font-size": "15",
                                 "fill": "#111827"}).text = (
        "《合成小岛》混合变现结构:IAA / IAP 堆叠(宽度 ∝ 合计 ARPDAU,示例参数)"
    )
    lx = 16
    for color, label in ((IAA_COLOR, "IAA 广告"), (IAP_COLOR, "IAP 内购")):
        ET.SubElement(root, "rect", {"x": str(lx), "y": "38", "width": "12", "height": "12",
                                     "rx": "2", "fill": color})
        ET.SubElement(root, "text", {"x": str(lx + 17), "y": "49", "font-size": "11",
                                     "fill": "#374151"}).text = label
        lx += 96
    bar_max = W - PAD - 24
    hi = max(r["arpdauDeciFen"] for r in rows) or 1
    for i, r in enumerate(rows):
        y = 66 + i * ROW
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
                                     "fill": "#374151"}).text = r["name"]
        full = bar_max * r["arpdauDeciFen"] // hi
        w_iaa = full * r["iaaPermille"] // 1000
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w_iaa),
                                     "height": "22", "rx": "4", "fill": IAA_COLOR})
        ET.SubElement(root, "rect", {"x": str(PAD + w_iaa), "y": str(y + 6),
                                     "width": str(full - w_iaa), "height": "22", "rx": "4",
                                     "fill": IAP_COLOR})
        ET.SubElement(root, "text", {"x": str(PAD + full + 8), "y": str(y + 22),
                                     "font-size": "11", "fill": "#6b7280"}).text = (
            f'{r["iaaPermille"]}‰ : {r["iapPermille"]}‰ · 合计 {r["arpdauDeciFen"] / 10:.1f}分'
        )
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(root))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config)
    base = cfg["base"]
    rows = [decompose(base, st) for st in cfg["structures"]]

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "ch13_hybrid.svg"), rows)
    report = {
        "version": cfg["version"],
        "game": cfg["game"],
        "base": base,
        "structureCount": len(rows),
        "structures": rows,
        "permilleSumCheck": [r["iaaPermille"] + r["iapPermille"] for r in rows],
        "csv": os.path.join(args.out, "ch13_hybrid.csv"),
        "svg": os.path.join(args.out, "ch13_hybrid.svg"),
        "svgBytes": svg_bytes,
    }
    with open(os.path.join(args.out, "ch13_hybrid.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch13_hybrid.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "adRequestsPerDau", "arppuFen", "iaaArpdauDeciFen",
                    "iapArpdauDeciFen", "arpdauDeciFen", "iaaPermille", "iapPermille"])
        for r in rows:
            w.writerow([r["id"], r["name"], r["adRequestsPerDau"], r["arppuFen"],
                        r["iaaArpdauDeciFen"], r["iapArpdauDeciFen"], r["arpdauDeciFen"],
                        r["iaaPermille"], r["iapPermille"]])

    print(json.dumps({
        "structures": [{"name": r["name"], "iaaPermille": r["iaaPermille"],
                        "iapPermille": r["iapPermille"], "arpdauDeciFen": r["arpdauDeciFen"]}
                       for r in rows],
    }, ensure_ascii=False, indent=2))
    return 0


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

脚本用 iapPermille = 1000 - iaaPermille 而不是各自取整,是为了保证两者严格加起来等于 1000——比例表的自洽比各自四舍五入更重要。报告的 permilleSumCheck 会逐行验证这一点。

实跑结果

执行 /opt/homebrew/bin/python3 sim/ch13_hybrid.py --config config/ --out out/,输出:

{
  "structures": [
    { "name": "广告主导", "iaaPermille": 685, "iapPermille": 315, "arpdauDeciFen": 693 },
    { "name": "均衡混合", "iaaPermille": 523, "iapPermille": 477, "arpdauDeciFen": 655 },
    { "name": "内购主导", "iaaPermille": 284, "iapPermille": 716, "arpdauDeciFen": 697 }
  ]
}

permilleSumCheck 三行都是 [1000, 1000, 1000]。CSV 里还留了每档的中间量:

id,name,adRequestsPerDau,arppuFen,iaaArpdauDeciFen,iapArpdauDeciFen,arpdauDeciFen,iaaPermille,iapPermille
iaa-heavy,广告主导,36,840,475,218,693,685,315
balanced,均衡混合,26,1200,343,312,655,523,477
iap-heavy,内购主导,15,1920,198,499,697,284,716

堆叠 SVG 一眼能看出构成差异(左蓝 IAA、右橙 IAP):广告主导那条几乎全是蓝的,内购主导几乎全是橙的,均衡混合蓝橙各半。三个结构的总长度几乎一样——这正是下一节要解释的现象。

三种结构各自的含义

把三档并排看(同机相对量级,绝对数值不可移植):

结构广告请求ARPPUIAA ARPDAUIAP ARPDAU合计IAA : IAP
广告主导36 次8.4 元47.5 分21.8 分69.3 分685‰ : 315‰
均衡混合26 次12 元34.3 分31.2 分65.5 分523‰ : 477‰
内购主导15 次19.2 元19.8 分49.9 分69.7 分284‰ : 716‰

三条可操作的结论:

  • 结构决定「钱从哪来」,也决定「谁在付钱」。广告主导的收入来自绝大多数不付费用户,你依赖的是用户规模;内购主导的收入来自少数高付费用户,你依赖的是付费深度。前者的风险是 eCPM 与填充率波动(X,平台变量),后者的风险是付费用户流失。
  • 广告主导的广告请求是均衡的 1.4 倍(36 vs 26)。这不是免费的——更多广告意味着更强的打断,第 10.3 节讲的「变现与体验平衡」在这里有直接代价:结构越偏广告,留存压力越大(X,需真实业务环境验证)。
  • 内购主导要求 ARPPU 高达 19.2 元,是广告主导的 2.3 倍。这需要足够深的付费点设计(礼包、通行证、装扮),不是随便挂个充值入口就能达到的。

选择哪个结构,不是「哪个赚钱多」的问题(三档总量接近),而是「你的产品性格与团队能力匹配哪个」的问题。

结构的边界:为什么总收入几乎不变

细心的读者会发现:三档的合计 ARPDAU 是 69.3 / 65.5 / 69.7,差别很小。这不是巧合,而是本模型设计使然——广告负载上调(+40%)时 ARPPU 下调(−30%),两者对总收入的贡献此消彼长,于是总量大致守恒。

这个设计传达的是一个真实经验:在同一个大盘里,混合结构主要改变的是收入构成,而不是收入总量。但它也有明确的边界:

  • 总量守恒是假设,不是定律。真实世界里,广告负载与 ARPPU 的替代关系不是线性的(X,需真实业务环境),过度堆广告可能同时压低 ARPPU 和留存,总量反而下降。
  • 模型没有 DAU 的反馈。广告弹太多 → 留存掉 → DAU 掉,这个回路本节不建模(与 13.1 的边界一致)。
  • ARPPU 与付费率被当成独立参数,但内购主导结构往往伴随更高的付费率(更多付费点 = 更多人付费),本模型没有耦合这两者。

换句话说:本节回答「结构改变了什么」,不回答「哪个结构最优」——后者需要引入留存与体验成本,属于第 10.3 节与卷三的范畴。

小结

本节把 ARPDAU 拆成了 IAA : IAP 的整数千分比,用「广告负载」与「ARPPU」两个整数百分比改写基准口径,跑出三种结构:广告主导 685‰ : 315‰、均衡混合 523‰ : 477‰、内购主导 284‰ : 716‰。关键结论是结构主要改变收入构成,而非收入总量,而选哪种结构取决于产品性格与团队能力。下一节把「给定结构」推进到「不确定时怎么办」——用悲观 / 基准 / 乐观三档情景,以及「哪个参数最影响结论」的敏感性排序,给收入预测一个区间。

阅读导航:上一节:收入拆解:DAU × ARPDAU · 下一节:收入预测与情景分析 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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