站内那篇《一个人做小游戏,死在广告上》 复盘了一个最常见的死法:把「靠广告就能赚钱」当信仰,从没算过一笔账,直到现金流断了才发现收入盖不住成本。本章给出的是一套可跑的 LTV 与回本模型,把「能不能赚」从信念变成一串能在地板除下复现的整数。清单式的广告位与频次设计见《小游戏广告变现设计》 ;广告 eCPM 本身的建模是第 1 章、内购定价是第 2 章,本节只做一件事——把两者汇总成 ARPDAU 这一条乘法链。卷一 13.1 DAU × ARPDAU 已从产品视角给过 DAU×ARPDAU 的框架,本节把它落到能被 LTV/回本消费的日级整数口径,并做跨语言一致验证。
收入是两条乘法链,不是一条
「日收入 = DAU × ARPDAU」这个式子几乎每份 BP 都写,但它把最难的部分藏起来了:ARPDAU 到底由什么决定?拆开看,它其实是两条独立的乘法链之和:
- IAA(广告):
ARPDAU_iaa = 人均广告请求次数 × 填充率 × eCPM / 1000。请求数由广告位与频次决定,填充率是平台侧机制,eCPM 是竞价结果。 - IAP(内购):
ARPDAU_iap = 付费率 × ARPPU。付费率是转化漏斗的产物,ARPPU 是定价与礼包结构的产物。
这两条链的杠杆方向完全不同:IAA 靠加广告位和抬高频次(但会伤留存,第 1 章 1.3 讨论过),IAP 靠把少数用户的价值做深(第 2 章的定价与通行证)。把两者混成一个「ARPDAU」数字,就看不出该动哪个旋钮。本节的模型强制你把它们分开算、分开看占比。
三个口径先对齐
模型要能被三处(Python、TS、浏览器)复现,第一步是统一单位,否则浮点会在乘法链里累积误差:
- 金额一律「分」:不用「元」,避免
0.343这种小数参与后续累加。 - ARPDAU 内部用「0.1 分 / DAU」(记作 deciFen):一天 65.5 分写成一个整数
655,既保留一位小数,又不引入浮点。 - 比例一律整数百分比或千分比:填充率写
88(表示 88%),付费率写26(表示千分之 26),留存率写420(表示 420‰)。
三条链上的每一步都是整数乘法再地板除,所以同一份 config 在任何语言里都会得到逐位相同的整数结果。
D 层:把假设写成一份配置
下面这份 D 层配置落在 config/l03_revenue.json,把六个假设参数与 DAU 阶梯都写成整数。注意配置里没有任何浮点——增长率、系数一律用整数比或整数百分比表示,这是「一份配置三种消费」能成立的前提。
{
"version": 1,
"game": "merge-island",
"note": "收入拆解口径(D 层)。把日收入拆成 DAU × ARPDAU,再把 ARPDAU 拆成 IAA 与 IAP 两条整数乘法链。dau / adRequestsPerDau / fillPct / ecpmFen / payRatePermille / arppuFen 均为示例假设参数;真实 DAU、填充率、eCPM、付费率与 ARPPU 需真实业务环境,命令行不可复现(X)。金额一律「分」,比例一律整数百分比或千分比,全程地板除,禁浮点。",
"dau": 20000,
"adRequestsPerDau": 26,
"fillPct": 88,
"ecpmFen": 1500,
"payRatePermille": 26,
"arppuFen": 1200,
"dauLadder": [5000, 10000, 20000, 50000, 100000]
}
这份配置的六个参数全部是示例假设:真实 DAU、填充率、eCPM、付费率与 ARPPU 必须来自真实业务环境,命令行不可复现(X)。本节要验证的不是这些数字本身,而是从假设到结论这条链的确定性。
S 层:DAU × ARPDAU 仿真
下面这段是 S 层仿真脚本 sim/l03_arpdau.py,读 config/l03_revenue.json,算出 IAA / IAP 两个分量与合计 ARPDAU,再乘 DAU 得到日 / 月 / 年收入,产出 JSON + CSV + 纯 Python SVG。
#!/usr/bin/env python3
# file: sim/l03_arpdau.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l03_arpdau.py --config config/ --out out/
"""收入拆解:DAU × ARPDAU(本卷 3.1 的 S 层)。
iaaArpdauDeciFen = adRequestsPerDau * fillPct * ecpmFen // 10000
iapArpdauDeciFen = arppuFen * payRatePermille // 100
arpdauDeciFen = iaaArpdauDeciFen + iapArpdauDeciFen
dailyFen = dau * arpdauDeciFen // 10
金额一律「分」;ARPDAU 内部用「0.1 分 / DAU」(deciFen)保留一位小数,比例用整数
百分比 / 千分比。全程整数、地板除,配置里无浮点,故同一份 config/l03_revenue.json 被
Python(本脚本)、TS 内核(core/src/l03/revenue.ts)与浏览器原型(web/dist/l03.html)
三处消费时逐位一致。产出 out/l03_arpdau.json + _ladder.csv + .svg。
注意:全部输入为示例假设参数;真实数值需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(cfg_dir: str) -> dict:
with open(os.path.join(cfg_dir, "l03_revenue.json"), encoding="utf-8") as f:
return json.load(f)
def iaa_deci_fen(req: int, fill: int, ecpm: int) -> int:
return req * fill * ecpm // 10000
def iap_deci_fen(pay: int, arppu: int) -> int:
return arppu * pay // 100
def draw_svg(path: str, rows: list[dict]) -> int:
W, PAD, ROW = 720, 210, 46
H = 60 + ROW * len(rows)
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,示例参数)"
hi = max(r["v"] for r in rows) or 1
for i, r in enumerate(rows):
y, w = 50 + i * ROW, (W - PAD - 24) * r["v"] // hi
ET.SubElement(root, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
"fill": "#374151"}).text = r["name"]
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["v"] / 10:.1f}分 · {r["p"]}‰'
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)
req, fill, ecpm = cfg["adRequestsPerDau"], cfg["fillPct"], cfg["ecpmFen"]
pay, arppu = cfg["payRatePermille"], cfg["arppuFen"]
iaa, iap = iaa_deci_fen(req, fill, ecpm), iap_deci_fen(pay, arppu)
arpdau = iaa + iap
daily = cfg["dau"] * arpdau // 10
ladder = [{"dau": d, "dailyFen": d * arpdau // 10, "monthlyFen": d * arpdau // 10 * 30,
"annualFen": d * arpdau // 10 * 365} for d in cfg["dauLadder"]]
rows = [{"name": "IAA ARPDAU", "v": iaa, "color": "#2563eb", "p": iaa * 1000 // arpdau},
{"name": "IAP ARPDAU", "v": iap, "color": "#f59e0b", "p": iap * 1000 // arpdau},
{"name": "合计 ARPDAU", "v": arpdau, "color": "#10b981", "p": 1000}]
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "l03_arpdau.svg"), rows)
report = {"version": cfg["version"], "game": cfg["game"],
"iaaArpdauDeciFen": iaa, "iapArpdauDeciFen": iap, "arpdauDeciFen": arpdau,
"dailyFen": daily, "monthlyFen": daily * 30, "annualFen": daily * 365,
"shareSumPermille": rows[0]["p"] + rows[1]["p"], "components": rows,
"dauLadder": ladder, "svgBytes": svg_bytes,
"svg": os.path.join(args.out, "l03_arpdau.svg"),
"csv": os.path.join(args.out, "l03_arpdau_ladder.csv")}
with open(os.path.join(args.out, "l03_arpdau.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "l03_arpdau_ladder.csv"), "w", encoding="utf-8",
newline="") as f:
w = csv.writer(f)
w.writerow(["dau", "dailyFen", "monthlyFen", "annualFen"])
for r in ladder:
w.writerow([r["dau"], r["dailyFen"], r["monthlyFen"], r["annualFen"]])
print(json.dumps({"iaaArpdauDeciFen": iaa, "iapArpdauDeciFen": iap, "arpdauDeciFen": arpdau,
"dailyFen": daily, "monthlyFen": daily * 30, "annualFen": daily * 365},
ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
真跑输出(同机相对量级,绝对数值不可移植):iaaArpdauDeciFen=343、iapArpdauDeciFen=312、arpdauDeciFen=655(即 65.5 分/DAU)、dailyFen=1310000(13,100 元/日)、monthlyFen=39300000(393,000 元/月)、annualFen=478150000。两个分量各占 523‰ 与 476‰(整数地板除后合计 999‰,少的那 1‰ 就是两次取整的残差,需要显式知道它的存在)。
P 层:同一份配置的 TS 内核
下面这段是 P 层内核 core/src/l03/revenue.ts,把同一条乘法链在 TypeScript 里实现一遍。它与 Python 脚本消费同一份 config/l03_revenue.json,幂运算(3.3 会用到)用 BigInt,除法一律 Math.floor(a / b)。
// file: core/src/l03/revenue.ts
// tier: P
// verify: node --test 'core/test/l03_*.test.ts'
//
// 收入 / LTV / 回本内核(本卷 3.1–3.3 的 P 层)。与 sim/l03_arpdau.py、
// sim/l03_ltv.py、sim/l03_sensitivity.py 消费同一份 config/*.json,逐位一致。
//
// 约束:只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器);
// 配置里禁浮点;幂运算用 BigInt;除法一律整数地板除。
export interface RevenueInputs {
readonly dau: number;
readonly adRequestsPerDau: number;
readonly fillPct: number;
readonly ecpmFen: number;
readonly payRatePermille: number;
readonly arppuFen: number;
}
export interface LtvConfig {
readonly arpdauDeciFen: number;
readonly cpaFen: number;
readonly horizonDays: number;
readonly retentionPermille: number[];
}
/** IAA ARPDAU(0.1 分/DAU):requests × (fill/100) × (ecpm/1000) 分 × 10。 */
export function iaaArpdauDeciFen(p: RevenueInputs): number {
return Math.floor((p.adRequestsPerDau * p.fillPct * p.ecpmFen) / 10000);
}
/** IAP ARPDAU(0.1 分/DAU):arppu × (payRate/1000) 分 × 10。 */
export function iapArpdauDeciFen(p: RevenueInputs): number {
return Math.floor((p.arppuFen * p.payRatePermille) / 100);
}
/** 合计 ARPDAU(0.1 分/DAU)。 */
export function arpdauDeciFen(p: RevenueInputs): number {
return iaaArpdauDeciFen(p) + iapArpdauDeciFen(p);
}
/** 日收入(分):dau × arpdau(0.1 分) 地板除 10。 */
export function dailyFen(p: RevenueInputs): number {
return Math.floor((p.dau * arpdauDeciFen(p)) / 10);
}
/** 单安装逐日 LTV 曲线(0.1 分)。第 d 天 = arpdau × retention[d]/1000,逐日累加。 */
export function ltvCurve(arpdau: number, retentionPermille: number[]): number[] {
const out: number[] = [];
let cum = 0;
for (let d = 0; d < retentionPermille.length; d += 1) {
cum += Math.floor((arpdau * retentionPermille[d]) / 1000);
out.push(cum);
}
return out;
}
/** 前 days 天的累计 LTV(0.1 分)。 */
export function ltvDeciFen(arpdau: number, retentionPermille: number[], days: number): number {
let cum = 0;
for (let d = 0; d < days && d < retentionPermille.length; d += 1) {
cum += Math.floor((arpdau * retentionPermille[d]) / 1000);
}
return cum;
}
/** 回本日:最小的 D 使 ltvDeciFen(D) >= cpaFen × 10;找不到返回 -1。 */
export function paybackDay(arpdau: number, retentionPermille: number[], cpaFen: number): number {
const target = cpaFen * 10;
let cum = 0;
for (let d = 0; d < retentionPermille.length; d += 1) {
cum += Math.floor((arpdau * retentionPermille[d]) / 1000);
if (cum >= target) return d + 1;
}
return -1;
}
/** 第 month 个月的月收入(分):daily × 30 × (num/den)^month,幂用 BigInt 地板除。 */
export function monthlyFen(p: RevenueInputs, month: number, growthNum: number, growthDen: number): number {
const daily = BigInt(dailyFen(p)) * 30n;
const n = BigInt(growthNum) ** BigInt(month);
const d = BigInt(growthDen) ** BigInt(month);
return Number((daily * n) / d);
}
这个文件里已经预置了 3.2 要用的 ltvDeciFen / paybackDay 与 3.3 要用的 monthlyFen,因为它们和 3.1 的乘法链共享同一套整数口径。同目录的 core/test/l03_revenue.test.ts 把三节的跨语言一致写成断言:arpdauDeciFen === 655、ltvDeciFen(...) === 2300、paybackDay(...) === 8、monthlyFen(base, 5, 103, 100) === 45559471——这些期望值全部来自 Python 的真实输出,不是手算的。
数字落在哪里
把 dau=20000 / adRequestsPerDau=26 / fillPct=88 / ecpmFen=1500 / payRatePermille=26 / arppuFen=1200 代进去,得到这样一张表(金额单位「元」):
| 口径 | 整数(分或 0.1 分) | 可读值 |
|---|---|---|
| IAA ARPDAU | 343 | 0.343 元/DAU |
| IAP ARPDAU | 312 | 0.312 元/DAU |
| 合计 ARPDAU | 655 | 0.655 元/DAU |
| 日收入(DAU 20000) | 1,310,000 分 | 13,100 元 |
| 月收入 | 39,300,000 分 | 393,000 元 |
DAU 阶梯(日收入 / 月收入,单位元)也一并落在 CSV 里:5000 → 3,275 / 98,250;10000 → 6,550 / 196,500;20000 → 13,100 / 393,000;50000 → 32,750 / 982,500;100000 → 65,500 / 1,965,000。DAU 是线性乘子——ARPDAU 不变时收入与 DAU 严格成正比,这一点在 3.3 做敏感性时会变成一个反直觉的结论。
这一节要记住的三件事
- ARPDAU 是两条链的和,不是黑箱。IAA 链动请求数 / 填充率 / eCPM,IAP 链动付费率 / ARPPU,六个数各自可归因、可优化。
- 口径先于模型。分、deciFen、千分比三件套定死之后,同一份 config 才能被 Python、TS、浏览器三处消费而逐位一致——这正是「一份逻辑、多种宿主」在收入模型上的形态。
- 本节只算「单位收入」,不算「回本」。ARPDAU 告诉你每个活跃用户一天带来多少钱,但没回答「一个花了 1.5 元买来的用户多久能赚回来」——那是下一节 LTV 与回本周期要解决的问题。
必须诚实标注:本节所有输入参数均为示例假设,真实 DAU、填充率、eCPM、付费率与 ARPPU 需真实业务环境,命令行不可复现(X)。能确定的是从假设到结论这条链的确定性,不是任何一个绝对值。
阅读导航:上一节:目录 · 下一节:LTV 建模与回本周期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。