卷三的《上线变现与长线运营》 里已经有一节专门做频次与体验平衡 :广告弹多了会掉留存,弹少了少赚钱,中间有个最优点。本节不重做那个频次-流失模型,它处理的是另一个更结构性的问题——当平台根本不给内购通道时,整个收入恒等式会变成什么样。频次模型假设你有一个混合结构(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 展示 | 13000 | 2.6 会话 × 5 展示 |
| 毛收入/千 DAU | 57200 分 | 激励 7800×5200÷1000 + 插屏 5200×3200÷1000 |
| 净收入/千 DAU | 28600 分 | 毛收入 × 发行方分成 500‰ |
| 净 ARPDAU | 286 分/10 | 0.1 分单位 |
| 累计活跃日 | 4499‰日 | D1/D7/D30 插值累加 |
| 净 D30 LTV | 1286 分/10 | 净 ARPDAU × 累计活跃日 |
这张表把纯 IAA 的骨架讲清楚了:收入是「展示次数 × 单价」的连乘,而发行方分成直接砍掉一半。注意 28600 分/千 DAU 的净收入对应净 ARPDAU 只有 0.286 元——这就是没有内购的现实:你没法靠一个大 R 把 ARPDAU 拉起来,只能靠把每个普通玩家的展示次数做厚。
两个结构杠杆:广告负载 vs 留存
在纯 IAA 里,能动的结构杠杆只有两个。仿真顺手做了一次对照(同机相对量级):
| 杠杆 | 净 ARPDAU | 累计活跃日‰ | 净 D30 LTV |
|---|---|---|---|
| 基准 | 286 | 4499 | 1286 |
| 广告负载 +20%(观看率 620→744‰) | 353 | 4499 | 1588 |
| 留存 +20%(D1/D7/D30 各 +20%) | 286 | 5201 | 1487 |
两个杠杆对 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 多平台收入对比与配比 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。