4.2 无内购平台的纯 IAA 结构

很多海外 H5 平台没有内购,收入百分之百来自广告。没有内购兜底,ARPDAU 的恒等式就退化成「会话 × 广告负载 × 展示率 × eCPM × 分成」的连乘。本节用可跑的 Python 仿真把这条恒等式算成净 ARPDAU 与净 D30 LTV,并对照广告负载与留存两个结构杠杆。

卷三的《上线变现与长线运营》 里已经有一节专门做频次与体验平衡 :广告弹多了会掉留存,弹少了少赚钱,中间有个最优点。本节不重做那个频次-流失模型,它处理的是另一个更结构性的问题——当平台根本不给内购通道时,整个收入恒等式会变成什么样。频次模型假设你有一个混合结构(IAA + IAP)在背后;这一节把 IAP 那一项直接删掉,看剩下的东西。

没有内购,收入结构少了一项

移动小游戏的混合变现里,ARPDAU 通常写成两块:广告收入 + 内购收入。内购是长尾极值驱动的——少数大 R 撑起大部分收入,所以你能容忍大部分人不付费,只要那几个人在。这套逻辑在海外很多 Web/H5 平台直接失效:Poki、CrazyGames 这类平台长期不提供内购通道,玩家在你的游戏里根本没法花钱。收入 100% 来自广告。

这不是「少了一块」这么简单。删掉内购项后,收入恒等式从「加法」退化成纯连乘:任何一环掉一点,收入就等比例掉一点,没有大 R 来兜底。这直接改变了设计优先级——在一个纯 IAA 结构里,留存和广告负载是乘法关系,而不是「留存差一点、靠内购补回来」。

纯 IAA 的收入恒等式

把整条链写成整数公式(收入统一用「分」,避免浮点):

激励展示/会话   = perSessionCap × optInPermille / 1000
插屏展示/会话   = perSessionCap × showRatePermille / 1000
每千 DAU 展示   = sessionsPerDauPermille × (激励/会话 + 插屏/会话)
毛收入/千 DAU   = Σ 展示 × ecpmFen / 1000
净收入/千 DAU   = 毛收入 × publisherSharePermille / 1000
净 ARPDAU       = 净收入/千 DAU × 10 / 1000          # 0.1 分/DAU
净 D30 LTV      = 净 ARPDAU × 累计活跃日‰ / 1000

注意两个纯 IAA 独有的量:publisherSharePermille(发行方分成)直接乘在收入上——Web 平台普遍要从广告收入里抽走一大块,这是移动端自发行没有的;currencyPerView(每次激励发放的货币)是唯一的「货币水龙头」——没有内购,玩家攒资源只能靠看广告,于是激励视频从「增收手段」变成了「经济系统的唯一进项」。

S 层仿真:一条恒等式算到底

下面这段是 S 层仿真脚本 sim/g04_pure_iaa.py。它读 config/g04_pure_iaa.json,把上述恒等式按整数地板除算到底,输出净 ARPDAU、累计活跃日、净 D30 LTV 与货币投放量,并做一次「广告负载 vs 留存」的杠杆对照,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/g04_pure_iaa.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g04_pure_iaa.py --config config/ --out out/
"""无内购(纯 IAA)平台的收入结构仿真:把「会话 → 广告机会 → 展示 → 收入」写成恒等式。

对 config/g04_pure_iaa.json:

    rewardedViewsPerSession    = perSessionCap * optInPermille // 1000
    interstitialViewsPerSession= perSessionCap * showRatePermille // 1000
    rewardedViewsPer1000Dau    = sessionsPerDauPermille * rewardedViewsPerSession
    interstitialViewsPer1000Dau= sessionsPerDauPermille * interstitialViewsPerSession
    grossFenPer1000Dau         = Σ views * ecpmFen // 1000
    netFenPer1000Dau           = grossFenPer1000Dau * publisherSharePermille // 1000
    netArpdauDeciFen           = netFenPer1000Dau * 10 // 1000      # 0.1 分/DAU
    cumActivePermille(d)       = Σ_{t=0..d} dailyRetention(t)       # 每安装累计活跃日
    netLtvD30DeciFen           = netArpdauDeciFen * cumActivePermille(30) // 1000
    currencyPer1000Dau         = rewardedViewsPer1000Dau * currencyPerView

纯 IAA 的结构特征:ARPDAU 只有一个来源(广告),恒等式退化成「会话 × 广告负载 ×
展示率 × eCPM × 发行方分成」的连乘;没有内购项来兜底。脚本另做一次杠杆对照:
「加广告负载」只抬高 ARPDAU,「加留存」抬高 LTV(在 30 天窗口内复利)。

全程整数、地板除,无浮点。产出 out/g04_pure_iaa.json + .csv + .svg。
平台参数是「公开可观察机制」口径的建模假设;真实 eCPM / 分成 / 留存需真实业务环境,
命令行不可复现(X)。
"""

from __future__ import annotations

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


def views_per_session(cap: int, rate_permille: int) -> int:
    return cap * rate_permille // 1000


def daily_retention(r: dict, d: int) -> int:
    """第 d 日留存千分比(d=0 恒 1000);D1/D7/D30 锚点之间整数线性插值。"""
    if d <= 0:
        return 1000
    r1, r7, r30 = r["d1Permille"], r["d7Permille"], r["d30Permille"]
    if d <= 7:
        return r1 + (r7 - r1) * (d - 1) // 6
    if d <= 30:
        return r7 + (r30 - r7) * (d - 7) // 23
    return r30


def cum_active_permille(r: dict, n: int) -> int:
    return sum(daily_retention(r, d) for d in range(0, n + 1))


def structure(cfg: dict) -> dict:
    sessions = cfg["sessionsPerDauPermille"]
    rew, inter = cfg["rewarded"], cfg["interstitial"]
    rew_vps = views_per_session(rew["perSessionCap"], rew["optInPermille"])
    int_vps = views_per_session(inter["perSessionCap"], inter["showRatePermille"])
    rew_v1000 = sessions * rew_vps
    int_v1000 = sessions * int_vps
    gross = rew_v1000 * rew["ecpmFen"] // 1000 + int_v1000 * inter["ecpmFen"] // 1000
    net = gross * cfg["publisherSharePermille"] // 1000
    net_arpdau = net * 10 // 1000
    cum = cum_active_permille(cfg["retention"], cfg["horizonDays"])
    net_ltv = net_arpdau * cum // 1000
    currency = rew_v1000 * rew["currencyPerView"]
    return {
        "rewardedViewsPerSession": rew_vps,
        "interstitialViewsPerSession": int_vps,
        "rewardedViewsPer1000Dau": rew_v1000,
        "interstitialViewsPer1000Dau": int_v1000,
        "totalViewsPer1000Dau": rew_v1000 + int_v1000,
        "grossFenPer1000Dau": gross,
        "netFenPer1000Dau": net,
        "netArpdauDeciFen": net_arpdau,
        "cumActivePermille": cum,
        "netLtvD30DeciFen": net_ltv,
        "currencyPer1000Dau": currency,
    }


def lever_sweep(cfg: dict) -> list[dict]:
    """两个结构杠杆:抬广告负载(optIn)与抬留存(三锚点 +20%)。"""
    rows = []

    def run(tag: str, c: dict) -> dict:
        r = structure(c)
        return {"lever": tag, "netArpdauDeciFen": r["netArpdauDeciFen"],
                "cumActivePermille": r["cumActivePermille"], "netLtvD30DeciFen": r["netLtvD30DeciFen"]}

    rows.append(run("baseline", cfg))

    adload = json.loads(json.dumps(cfg))
    adload["rewarded"]["optInPermille"] = adload["rewarded"]["optInPermille"] * 1200 // 1000
    rows.append(run("adload+20%", adload))

    retention = json.loads(json.dumps(cfg))
    for k in ("d1Permille", "d7Permille", "d30Permille"):
        retention["retention"][k] = retention["retention"][k] * 1200 // 1000
    rows.append(run("retention+20%", retention))

    return rows


def draw_svg(path: str, cfg: dict, res: dict, levers: list[dict]) -> int:
    """纯 Python SVG:左半关键指标文字,右半两个杠杆的 LTV 横条。"""
    W, H, PAD, ROW = 780, 240, 240, 34
    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": "28", "font-size": "15", "fill": "#111827"}).text = (
        f'《合成小岛》纯 IAA 结构:净 ARPDAU {res["netArpdauDeciFen"]} 分/10 · '
        f'净 D30 LTV {res["netLtvD30DeciFen"]} 分/10')
    info = [
        f'人均会话 {cfg["sessionsPerDauPermille"]}‰ · 激励 {res["rewardedViewsPerSession"]}/会话 · '
        f'插屏 {res["interstitialViewsPerSession"]}/会话',
        f'每千 DAU 展示 {res["totalViewsPer1000Dau"]} 次 · 毛收入 {res["grossFenPer1000Dau"]}分 · '
        f'净收入 {res["netFenPer1000Dau"]}分',
        f'货币投放 {res["currencyPer1000Dau"]} /千 DAU · 累计活跃 {res["cumActivePermille"]}‰日',
    ]
    for i, t in enumerate(info):
        ET.SubElement(root, "text", {"x": "16", "y": str(56 + i * 20), "font-size": "12",
                                     "fill": "#6b7280"}).text = t
    ET.SubElement(root, "text", {"x": "16", "y": "150", "font-size": "13", "fill": "#374151"}).text = "杠杆对照(净 D30 LTV,分/10)"
    span = W - PAD - 24
    hi = max([l["netLtvD30DeciFen"] for l in levers] + [1])
    for i, l in enumerate(levers):
        y = 168 + i * ROW
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 16), "font-size": "12",
                                     "fill": "#374151"}).text = l["lever"]
        w = span * l["netLtvD30DeciFen"] // hi
        color = "#2563eb" if l["lever"] == "baseline" else "#f59e0b"
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 2), "width": str(w),
                                     "height": "18", "rx": "3", "fill": color})
        ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 16), "font-size": "11",
                                     "fill": "#6b7280"}).text = (
            f'{l["netLtvD30DeciFen"]} · ARPDAU {l["netArpdauDeciFen"]}')
    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()

    with open(os.path.join(args.config, "g04_pure_iaa.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    res = structure(cfg)
    levers = lever_sweep(cfg)

    digest = (f'{res["totalViewsPer1000Dau"]}:{res["grossFenPer1000Dau"]}:{res["netFenPer1000Dau"]}:'
              f'{res["netArpdauDeciFen"]}:{res["cumActivePermille"]}:{res["netLtvD30DeciFen"]}')

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "g04_pure_iaa.svg"), cfg, res, levers)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "publisherSharePermille": cfg["publisherSharePermille"], "structure": res,
              "levers": levers, "digest": digest,
              "svg": os.path.join(args.out, "g04_pure_iaa.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "g04_pure_iaa.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "g04_pure_iaa.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["metric", "value"])
        for k, v in res.items():
            w.writerow([k, v])
        for l in levers:
            w.writerow([f'lever_{l["lever"]}_netLtvD30DeciFen', l["netLtvD30DeciFen"]])

    print(json.dumps({"digest": digest, "structure": res, "levers": levers},
                     ensure_ascii=False, indent=2))
    return 0


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

参数放在 config/g04_pure_iaa.json。注意这些数字是「公开可观察机制」口径的建模假设,不是任何平台后台的实测:真实 eCPM、真实分成、真实留存属于 X 层——需真实业务环境,命令行不可复现。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "无内购(纯 IAA)平台的收入结构参数,口径是「公开可观察机制」的建模假设,不是任何平台后台的实测。sessionsPerDauPermille=人均每日会话数×1000;rewarded.perSessionCap=单会话激励视频机会上限;rewarded.optInPermille=激励位实际观看率千分比;interstitial.showRatePermille=插屏实际展示率千分比;ecpmFen=千次展示收益(分);currencyPerView=每次激励视频发放的游戏内货币;publisherSharePermille=发行方分成千分比(500=五五);retention.dXPermille=第 X 日留存千分比(安装日恒 1000)。真实 eCPM、真实分成比例、真实留存需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "sessionsPerDauPermille": 2600,
  "rewarded": { "perSessionCap": 6, "optInPermille": 620, "ecpmFen": 5200, "currencyPerView": 40 },
  "interstitial": { "perSessionCap": 3, "showRatePermille": 700, "ecpmFen": 3200 },
  "publisherSharePermille": 500,
  "retention": { "d1Permille": 300, "d7Permille": 120, "d30Permille": 60 },
  "horizonDays": 30
}

在本机跑 /opt/homebrew/bin/python3 sim/g04_pure_iaa.py --config config/ --out out/,这条恒等式跑出来的结果(同机相对量级,绝对数值不可移植):

量值含义
激励展示/会话3上限 6 × 观看率 620‰
插屏展示/会话2上限 3 × 展示率 700‰
每千 DAU 展示130002.6 会话 × 5 展示
毛收入/千 DAU57200 分激励 7800×5200÷1000 + 插屏 5200×3200÷1000
净收入/千 DAU28600 分毛收入 × 发行方分成 500‰
净 ARPDAU286 分/100.1 分单位
累计活跃日4499‰日D1/D7/D30 插值累加
净 D30 LTV1286 分/10净 ARPDAU × 累计活跃日

这张表把纯 IAA 的骨架讲清楚了:收入是「展示次数 × 单价」的连乘,而发行方分成直接砍掉一半。注意 28600 分/千 DAU 的净收入对应净 ARPDAU 只有 0.286 元——这就是没有内购的现实:你没法靠一个大 R 把 ARPDAU 拉起来,只能靠把每个普通玩家的展示次数做厚。

两个结构杠杆:广告负载 vs 留存

在纯 IAA 里,能动的结构杠杆只有两个。仿真顺手做了一次对照(同机相对量级):

杠杆净 ARPDAU累计活跃日‰净 D30 LTV
基准28644991286
广告负载 +20%(观看率 620→744‰)35344991588
留存 +20%(D1/D7/D30 各 +20%)28652011487

两个杠杆对 D30 LTV 的推动量级接近(1588 vs 1487),但性质完全不同:广告负载杠杆把 ARPDAU 抬起来(286→353),但累计活跃日不动;留存杠杆 ARPDAU 不动,却把累计活跃日从 4499 推到 5201——也就是让每个用户多活几天。纯 IAA 的结构告诉我们:ARPDAU 和留存是乘法关系,两者都要动才能把 LTV 做大;只堆广告负载会在某个点撞上流失墙,只做留存而不做广告负载则守着一群不产生收入的用户。

X 层:真实收入不可复现

上面算的是模型侧。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 平台真实的发行方分成比例(不同平台、不同合作档位差异很大,且平台侧可调);
  • 平台真实的 eCPM(受地区、时段、广告主预算共同影响,Web 端尤其波动);
  • 平台真实的留存(受平台自身用户结构影响,同一款游戏在不同平台留存能差很多)。

工程上正确的做法是:把可复现的模型层与不可复现的真实数据层分开——模型层用来在立项前算「如果分成 50%、eCPM 是这个数,我的 LTV 撑不撑得住」;真实数据层用来在接入后反标定模型(把平台后台实测的分成、eCPM、留存填回 config)。

自检表

检查项合格标准
恒等式完整会话 / 展示 / 单价 / 分成 / 留存每一环都有数,没有断点
分成显式发行方分成单独成项,不藏在 eCPM 里
货币可追踪激励发放的货币量可算,作为经济系统的唯一进项
杠杆分离广告负载与留存分开对照,不混为一谈
边界清晰真实分成 / eCPM / 留存标 X,不写成「实测」

纯 IAA 最容易被误判的一点是:它看起来「简单」——只有广告一项,但恰恰因为没有内购兜底,它对留存和广告负载的乘积极度敏感。单平台算清楚还不够,因为一款游戏往往同时发在多个平台,每个平台的分成、ARPDAU、流量都不同。下一节我们把多平台的收入摊到一张表上,回答「优先投谁」——并说明这个问题的答案为什么取决于你问的是总量还是边际。

阅读导航:上一节:4.1 Web 广告形态与 H5 广告网络 · 下一节:4.3 多平台收入对比与配比 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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