13.1 收入拆解:DAU × ARPDAU

把日收入拆成 DAU × ARPDAU 的乘法链:IAA 用「人均广告请求次数 × 填充率 × eCPM」、IAP 用「付费率 × ARPPU」,一份整数 config 驱动,产出 JSON、CSV 与 SVG 收入阶梯,并说清每个因子各自的口径与边界。

第 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人均广告请求次数(不是展示次数)次/DAU26
fillPct广告填充率:请求中真正拿到广告的比例整数百分比88
ecpmFeneCPM:每千次展示收益分1500
payRatePermille付费率:日活中当天发生付费的比例千分比26
arppuFenARPPU:每付费用户平均收入分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 次/DAU26 × 88%,请求 26 次里约 22.88 次拿到广告
IAA ARPDAU34.3 分占 ARPDAU 的 523‰
IAP ARPDAU31.2 分占 ARPDAU 的 476‰
ARPDAU65.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 万元。下一节把这两块的比例单独拿出来看——同样一笔收入,广告主导、均衡混合、内购主导三种结构,各自的含义完全不同。

阅读导航:上一节:原型迭代与决策 · 下一节:混合变现的收入结构 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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