第 10 章讲了变现设计的形态与场景——广告摆在哪、内购卖什么、什么时候弹。这一节换个角度:把那些形态换算成钱。站内 小游戏平台专题(minigame) 那 25 篇是平台型 SaaS 的商业计划与财务口径(编辑器怎么收费、平台怎么抽成、BP 里的财务模型怎么搭);本节是单款小游戏的收入拆解与情景分析——给定一款已经设计好的《合成小岛》,它的日收入到底由哪几个数乘出来、每个数错了会差多少。这也划清了与第 10 章的分工:第 10 章回答「怎么设计变现」,本章回答「设计完之后账是多少」。
还有一条边界必须先说:LTV 建模与回本周期是卷三第 3 章的主题,本节不展开。LTV 关心的是「一个用户一辈子值多少钱」,那要引入留存曲线与买量成本;本节只做收入侧的拆解——一天赚多少、这笔钱由哪几项构成。两者用的原料相同(ARPDAU),但一个看单日流量,一个看单个用户的整个生命周期。
收入为什么是乘法
任何一款游戏的日收入都能写成一行乘法:
日收入 = DAU × ARPDAU
- DAU(Daily Active Users):日活跃用户数,衡量「有多少人来」。
- ARPDAU(Average Revenue Per Daily Active User):日均每活跃用户收入,衡量「每个人贡献多少」。
这个式子之所以有用,是因为它把两个完全不同的问题分开了:DAU 是增长与留存的结果(第 5 章、卷三第 6–7 章),ARPDAU 是变现设计的结果(第 10 章)。做增长的人和做变现的人,盯的是不同的乘数。
ARPDAU 本身还能再拆。混合变现的小游戏里,它由两块相加:
ARPDAU = IAA ARPDAU + IAP ARPDAU
IAA ARPDAU = 人均广告请求次数 × 填充率 × eCPM
IAP ARPDAU = 付费率 × ARPPU
- IAA(In-App Advertising)广告收入:次数 × 填充率 × 千次展示收益。
- IAP(In-App Purchase)内购收入:付费率 × 每付费用户平均收入。
到这里,日收入已经被拆成 6 个可独立讨论的因子。本章要做的,就是让这 6 个因子在同一份配置里各就各位,然后让脚本把它们乘出结果。
收入口径配置(D 层)
下面这段是 D 层配置:它不跑逻辑,只声明口径。6 个因子全在里面,全部整数——金额一律用「分」,比例一律用整数百分比(fillPct)或千分比(payRatePermille),config 里不出现任何浮点。这份约束不是洁癖:只有全整数,Python 仿真、TypeScript 内核与浏览器原型三处读同一份 config 才能得到逐位一致的数字。
{
"version": 1,
"game": "merge-island",
"note": "收入拆解口径(D 层)。dau / adRequestsPerDau / fillPct / ecpmFen / payRatePermille / arppuFen 全部为示例假设参数;真实 DAU、填充率、eCPM、付费率与 ARPPU 需真实业务环境,命令行不可复现(X)。金额一律以「分」记账,比例一律整数百分比或千分比。",
"dau": 20000,
"adRequestsPerDau": 26,
"fillPct": 88,
"ecpmFen": 1500,
"payRatePermille": 26,
"arppuFen": 1200
}
几个字段要特别说明口径,因为它们最容易被含糊过去:
| 字段 | 含义 | 单位 | 本值 |
|---|---|---|---|
dau | 日活跃用户数 | 人 | 20000 |
adRequestsPerDau | 人均广告请求次数(不是展示次数) | 次/DAU | 26 |
fillPct | 广告填充率:请求中真正拿到广告的比例 | 整数百分比 | 88 |
ecpmFen | eCPM:每千次展示收益 | 分 | 1500 |
payRatePermille | 付费率:日活中当天发生付费的比例 | 千分比 | 26 |
arppuFen | ARPPU:每付费用户平均收入 | 分 | 1200 |
注意 adRequestsPerDau 与 fillPct 是分开的两个数。很多人在 PPT 上只写「人均广告展示 22 次」,把请求与填充混成一个数——那样一来「填充率下降」这个最常见的风险就没法建模了。本模型坚持拆开:请求是你设计出来的(激励视频点几次、插屏弹几次),填充是平台给你的(X,需真实业务环境,命令行不可复现)。
同理,payRatePermille 是日均付费千分比:把一次性购买(首充、成长基金)按周期摊平到每天,才能和 DAU 相乘。若直接用「付费渗透率」,算出来的是「累计有多少人付过费」,和日收入不是一个量纲。
DAU × ARPDAU 拆解仿真(S 层)
下面这段是 S 层仿真脚本,读 config/ch13_revenue.json,把 6 个因子乘成 ARPDAU,再乘 DAU 得到日 / 月 / 年收入,并在 5 档 DAU 上重算收入阶梯,产出 JSON + CSV + SVG。全程整数地板除。
#!/usr/bin/env python3
# file: sim/ch13_revenue.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch13_revenue.py --config config/ --out out/
"""收入拆解:DAU × ARPDAU。
把日收入拆成乘法链:
ARPDAU = IAA ARPDAU + IAP ARPDAU
IAA ARPDAU(deciFen) = adRequestsPerDau * fillPct * ecpmFen // 10000
IAP ARPDAU(deciFen) = arppuFen * payRatePermille // 100
日收入(fen) = dau * arpdauDeciFen // 10
全程整数、地板除,无浮点。金额一律「分」,比例一律整数百分比 / 千分比。
产出 out/ch13_revenue.json + out/ch13_revenue_ladder.csv + out/ch13_revenue.svg。
注意:dau / adRequestsPerDau / fillPct / ecpmFen / payRatePermille / arppuFen 均为
示例假设参数;真实数值需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
DAU_LADDER = [5000, 10000, 20000, 50000, 100000]
def load(cfg_dir: str) -> dict:
with open(os.path.join(cfg_dir, "ch13_revenue.json"), encoding="utf-8") as f:
return json.load(f)
def filled_impressions_milli(requests: int, fill_pct: int) -> int:
"""人均填充后展示次数,单位「次」的千分之一(×1000 保留两位)。"""
return requests * fill_pct * 10
def iaa_deci_fen(requests: int, fill_pct: int, ecpm_fen: int) -> int:
"""IAA ARPDAU,单位 0.1 分。requests*(fill/100)*(ecpm/1000) 分 ×10。"""
return requests * fill_pct * ecpm_fen // 10000
def iap_deci_fen(pay_rate_permille: int, arppu_fen: int) -> int:
"""IAP ARPDAU,单位 0.1 分。arppuFen*(payRate/1000) 分 ×10。"""
return arppu_fen * pay_rate_permille // 100
def draw_svg(path: str, rows: list[dict]) -> int:
"""纯 Python SVG:IAA / IAP / 合计三条 ARPDAU 横条,单位 0.1 分。"""
W, H, PAD, ROW = 720, 60 + 46 * len(rows), 210, 46
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 = (
"《合成小岛》ARPDAU 拆解(0.1 分/DAU,示例参数)"
)
bar_max = W - PAD - 24
hi = max(r["valueDeciFen"] for r in rows) or 1
for i, r in enumerate(rows):
y = 50 + i * ROW
ET.SubElement(root, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
"fill": "#374151"}).text = r["name"]
w = bar_max * r["valueDeciFen"] // hi
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 8), "width": str(w), "height": "22",
"rx": "4", "fill": r["color"]})
ET.SubElement(root, "text", {"x": str(PAD + w + 8), "y": str(y + 24), "font-size": "12",
"fill": "#6b7280"}).text = (
f'{r["valueDeciFen"] / 10:.1f}分 · {r["sharePermille"]}‰'
)
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)
dau = cfg["dau"]
req, fill, ecpm = cfg["adRequestsPerDau"], cfg["fillPct"], cfg["ecpmFen"]
pay, arppu = cfg["payRatePermille"], cfg["arppuFen"]
imp_milli = filled_impressions_milli(req, fill)
iaa = iaa_deci_fen(req, fill, ecpm)
iap = iap_deci_fen(pay, arppu)
arpdau = iaa + iap
daily_fen = dau * arpdau // 10
monthly_fen = daily_fen * 30
annual_fen = daily_fen * 365
ladder = []
for d in DAU_LADDER:
dfen = d * arpdau // 10
ladder.append({
"dau": d,
"iaaDailyFen": d * iaa // 10,
"iapDailyFen": d * iap // 10,
"dailyFen": dfen,
"monthlyFen": dfen * 30,
"annualFen": dfen * 365,
})
rows = [
{"name": "IAA ARPDAU", "valueDeciFen": iaa, "color": "#2563eb",
"sharePermille": iaa * 1000 // arpdau if arpdau else 0},
{"name": "IAP ARPDAU", "valueDeciFen": iap, "color": "#f59e0b",
"sharePermille": iap * 1000 // arpdau if arpdau else 0},
{"name": "合计 ARPDAU", "valueDeciFen": arpdau, "color": "#10b981",
"sharePermille": 1000},
]
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "ch13_revenue.svg"), rows)
report = {
"version": cfg["version"],
"game": cfg["game"],
"inputs": {"dau": dau, "adRequestsPerDau": req, "fillPct": fill, "ecpmFen": ecpm,
"payRatePermille": pay, "arppuFen": arppu},
"filledImpressionsPerDauMilli": imp_milli,
"iaaArpdauDeciFen": iaa,
"iapArpdauDeciFen": iap,
"arpdauDeciFen": arpdau,
"dailyFen": daily_fen,
"monthlyFen": monthly_fen,
"annualFen": annual_fen,
"componentShareSumPermille": rows[0]["sharePermille"] + rows[1]["sharePermille"],
"components": rows,
"dauLadder": ladder,
"csv": os.path.join(args.out, "ch13_revenue_ladder.csv"),
"svg": os.path.join(args.out, "ch13_revenue.svg"),
"svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "ch13_revenue.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch13_revenue_ladder.csv"), "w",
encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["dau", "iaaDailyFen", "iapDailyFen", "dailyFen", "monthlyFen", "annualFen"])
for r in ladder:
w.writerow([r["dau"], r["iaaDailyFen"], r["iapDailyFen"], r["dailyFen"],
r["monthlyFen"], r["annualFen"]])
print(json.dumps({
"filledImpressionsPerDauMilli": imp_milli,
"iaaArpdauDeciFen": iaa, "iapArpdauDeciFen": iap, "arpdauDeciFen": arpdau,
"dailyFen": daily_fen, "monthlyFen": monthly_fen, "annualFen": annual_fen,
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
两处整数约定值得单独点出。第一,IAA 的公式除以 10000:requests × fillPct × ecpmFen 里 fillPct 是百分比(÷100)、ecpmFen 是千次(÷1000),合起来是 ÷100000;但结果要保留到 0.1 分(×10),于是净除以 10000。第二,IAP 的公式除以 100:arppuFen × payRatePermille 里千分比是 ÷1000、0.1 分是 ×10,净除以 100。两处都只用地板除,绝不引入浮点。
实跑结果
执行 /opt/homebrew/bin/python3 sim/ch13_revenue.py --config config/ --out out/,控制台输出:
{"filledImpressionsPerDauMilli": 22880, "iaaArpdauDeciFen": 343, "iapArpdauDeciFen": 312, "arpdauDeciFen": 655, "dailyFen": 1310000, "monthlyFen": 39300000, "annualFen": 478150000}
换成好读的单位(同机相对量级,绝对数值不可移植):
| 指标 | 数值 | 说明 |
|---|---|---|
| 人均填充后展示 | 22.88 次/DAU | 26 × 88%,请求 26 次里约 22.88 次拿到广告 |
| IAA ARPDAU | 34.3 分 | 占 ARPDAU 的 523‰ |
| IAP ARPDAU | 31.2 分 | 占 ARPDAU 的 476‰ |
| ARPDAU | 65.5 分 | 两块相加 |
| 日收入(DAU 20000) | 13100 元 | 20000 × 0.655 |
| 月收入(30 天) | 393000 元 | |
| 年收入(365 天) | 4781500 元 |
两个分量几乎五五开(523‰ : 476‰),这是《合成小岛》这类「IAA + IAP 混合」产品的典型形态:广告撑起一半收入,内购撑起另一半。注意 523 + 476 = 999 而不是 1000——这是千分比取整的必然损耗,报告里用 componentShareSumPermille 显式记下这个 999,而不是硬凑成 1000。
生成的 SVG 是三条横条(IAA / IAP / 合计),长度正比于 ARPDAU,可直接看出两块谁大:
out/ch13_revenue.svg 三横条:IAA 34.3分(523‰) · IAP 31.2分(476‰) · 合计 65.5分(1000‰)
DAU 阶梯:把 ARPDAU 摊到不同规模
ARPDAU 是「单价」,DAU 是「量」。CSV 里给了 5 档 DAU 的收入阶梯,看的是同样的单价在不同规模下的钱:
dau,iaaDailyFen,iapDailyFen,dailyFen,monthlyFen,annualFen
5000,171500,156000,327500,9825000,119537500
10000,343000,312000,655000,19650000,239075000
20000,686000,624000,1310000,39300000,478150000
50000,1715000,1560000,3275000,98250000,1195375000
100000,3430000,3120000,6550000,196500000,2390750000
读法很直接:月收入对 DAU 是严格线性的。DAU 从 20000 翻到 40000(表中没有,但可推),月收入就从 39.3 万翻到 78.6 万。这不是巧合,是乘法结构决定的——ARPDAU 一旦确定,收入就是 DAU 的线性函数。
这条线性关系给出一个重要的决策含义:在立项阶段,DAU 的量级假设比 ARPDAU 的小数点更值钱。把 ARPDAU 从 65.5 分调到 70 分(+7%),月收入 +7%;但把 DAU 假设从 20000 调到 40000,月收入直接翻倍。第 11 章的预算与第 13.3 的情景分析都会反复用到这一点。
这个模型的边界与陷阱
模型只有 6 个参数,但每一个都能出错。把边界写清楚,比把模型做大更有用:
dau不是你能设定的,是市场给你的结果。配置里的 20000 是一个假设,不是目标。真实 DAU 由留存与买量决定(X,需真实业务环境)。本模型只回答「给定 DAU,收入是多少」,不回答「怎么得到 DAU」。- eCPM 与填充率是平台变量,不可控(X,需真实业务环境,命令行不可复现)。它们是外部输入,只能观察、不能设计。把 eCPM 当成设计变量是新手最常见的错误。
payRatePermille摊平了一次性购买。首充、成长基金这类一次性内购,被按周期摊到每天才能进这个乘法式。真实付费分布是长尾的(X),摊平是一种简化。- ARPDAU 与 DAU 不独立。本模型把它们当两个独立乘数,但现实中它们会互相影响:广告弹太多,留存掉,DAU 跟着掉。这个耦合关系本节不建模,留给卷三的留存-变现联合分析。
- 不涉及 LTV 与回本。想知道「这个 ARPDAU 多久能收回买量成本」,那是卷三第 3 章的主题——那里要把 ARPDAU 与留存曲线、获客成本一起看。
一句话总结边界:本节算的是「流量 × 单价」的账面收入,不含获客成本、不含留存衰减、不含时间价值。它是收入侧的起点,不是完整的商业模型。
小结
本节把日收入拆成了 DAU × ARPDAU,再把 ARPDAU 拆成 IAA(次数 × 填充 × eCPM) 与 IAP(付费率 × ARPPU)。6 个因子全整数、全由一份 config 驱动,脚本跑出 IAA 34.3 分 + IAP 31.2 分 = 65.5 分/DAU,DAU 20000 时月收入 39.3 万元。下一节把这两块的比例单独拿出来看——同样一笔收入,广告主导、均衡混合、内购主导三种结构,各自的含义完全不同。
阅读导航:上一节:原型迭代与决策 · 下一节:混合变现的收入结构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。