3.1 收入拆解与 ARPDAU 模型

把「日收入 = DAU × ARPDAU」拆成一条可追溯的整数乘法链:ARPDAU 拆成 IAA 与 IAP 两个分量,各由三个假设参数相乘得到。用一份无浮点的 config,让 Python 仿真、TypeScript 内核与浏览器原型三处消费同一份数字并逐位一致,最后给出 13,100 元/日的示例口径与三档 DAU 阶梯。

站内那篇《一个人做小游戏,死在广告上》 复盘了一个最常见的死法:把「靠广告就能赚钱」当信仰,从没算过一笔账,直到现金流断了才发现收入盖不住成本。本章给出的是一套可跑的 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、浏览器)复现,第一步是统一单位,否则浮点会在乘法链里累积误差:

  1. 金额一律「分」:不用「元」,避免 0.343 这种小数参与后续累加。
  2. ARPDAU 内部用「0.1 分 / DAU」(记作 deciFen):一天 65.5 分写成一个整数 655,既保留一位小数,又不引入浮点。
  3. 比例一律整数百分比或千分比:填充率写 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 ARPDAU3430.343 元/DAU
IAP ARPDAU3120.312 元/DAU
合计 ARPDAU6550.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 做敏感性时会变成一个反直觉的结论。

这一节要记住的三件事

  1. ARPDAU 是两条链的和,不是黑箱。IAA 链动请求数 / 填充率 / eCPM,IAP 链动付费率 / ARPPU,六个数各自可归因、可优化。
  2. 口径先于模型。分、deciFen、千分比三件套定死之后,同一份 config 才能被 Python、TS、浏览器三处消费而逐位一致——这正是「一份逻辑、多种宿主」在收入模型上的形态。
  3. 本节只算「单位收入」,不算「回本」。ARPDAU 告诉你每个活跃用户一天带来多少钱,但没回答「一个花了 1.5 元买来的用户多久能赚回来」——那是下一节 LTV 与回本周期要解决的问题。

必须诚实标注:本节所有输入参数均为示例假设,真实 DAU、填充率、eCPM、付费率与 ARPPU 需真实业务环境,命令行不可复现(X)。能确定的是从假设到结论这条链的确定性,不是任何一个绝对值。

阅读导航:上一节:目录 · 下一节:LTV 建模与回本周期 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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