14.1 单用户全链路账:CAC → ARPDAU → LTV → 回本

单用户全链路账把投放侧「买来一个用户花了多少」(CAC)与变现侧「这个用户一辈子赚回多少」(LTV)接在一起,算出每用户的贡献毛利、LTV/CAC 比与回本日;用 Python 仿真对照自然量与三个付费渠道,再用混合口径证明只看单个付费渠道会误判整盘生意,附 TypeScript 内核跨语言对拍。

站内《小游戏开发实战》卷三目录 的第 3 章已经把变现侧算透了:ARPDAU 怎么拆、LTV 曲线怎么积、回本周期怎么判(对应 3.2 LTV 与回本周期 ),游戏商业核心指标详解 也定义了这些指标的分子分母。那些内容回答的是「一个用户能赚多少」。本章不重做它们,而是补上另一半——买来这个用户花了多少(CAC),并把两侧接成一条账。分工是:卷三 3 章讲 LTV 本身,本章讲 CAC 与 LTV 相遇之后的那本账——单位经济(unit economics)。这是本卷的核心章,因为前面十几章所有的投放、变现、结算动作,最终都要落回这一句话:每买来一个用户,我到底赚还是亏。

两条腿:获取侧与变现侧

一门生意的单位经济只有两条腿:

  • 获取侧:CAC(Customer Acquisition Cost,单用户获取成本)。投放花出去的钱,除以买回来的用户数。这是你能通过出价、素材、渠道组合主动改变的变量。
  • 变现侧:LTV(Lifetime Value,单用户生命周期价值)。一个用户从进来到流失,累计贡献的收入。这是留存与 ARPDAU 共同决定的。

单看任何一条腿都会得出错误结论。只看 CAC「便宜」就大量买量,可能买回来的是永远不回本的僵尸用户;只看 LTV「很高」就以为赚钱,可能被一个高得离谱的 CAC 吃光。必须把两条腿接在一起,算「每个用户的贡献毛利 = LTV − CAC − 变动成本」,才知道这门生意成不成立。

这里要比卷三多算一项:变动成本。每个用户不是免费服务的——服务器要跑、支付通道要抽成,这些钱按人头摊下来,是「获取成本」之外的第三块。把它加进去,LTV − CAC 才是真正的贡献毛利。

单位经济公式

把整条链写成整数公式(金额统一用「分」,比例用千分比,避免浮点):

cacFen(c)          = spendFen(c) // installs(c)                    # 单用户获取成本(分)
cumActive(c,n)     = Σ_{d=0..n} retentionPermille(c,d)             # 每安装累计活跃日(千分比日)
ltvDeciFen(c,n)    = arpdauDeciFen(c) × cumActive(c,n) // 1000     # 0.1 分
ltvFen(c,n)        = ltvDeciFen(c,n) // 10
totalCostFen(c)    = cacFen(c) + variableCostPerUserFen            # 获取成本 + 每用户变动成本
contributionFen(c) = ltvFen(c,30) − totalCostFen(c)                # 单用户贡献毛利
ltvCacPermille(c)  = ltvFen(c,30) × 1000 // cacFen(c)(自然量无 CAC,记 −1)
paybackDay(c)      = 首个 ltvFen(c,d) ≥ totalCostFen(c) 的 d(否则 −1)

contributionFen 是这张账的最终结论:它为正,这个用户才值得买。ltvCacPermille 给出倍数关系——业界常用的经验线是 LTV/CAC ≥ 3,但这条线对小游戏偏乐观,本节更看重绝对贡献毛利与回本日:paybackDay 告诉你钱要压多少天才收得回,直接决定现金流(14.2 会用到)。

S 层仿真:一条链算到底

下面这段是 S 层仿真脚本 sim/o14_unit.py。它读 config/o14_unit.json 的四条队列(自然量 + 信息流 + 达人 + 激励),逐条算出 CAC、LTV、贡献毛利、LTV/CAC 与回本日,再用安装数加权算混合口径(blended)——也就是「一盘生意」的整体单位账,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o14_unit.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o14_unit.py --config config/ --out out/
"""单用户全链路账:把「买来一个用户花了多少(CAC)」与「这个用户一辈子赚回多少(LTV)」接在一起。

读 config/o14_unit.json 的四条队列(自然量 + 三个付费渠道)。每条队列:

    cacFen(c)          = spendFen(c) // installs(c)                # 单用户获取成本(分)
    cumActive(c,n)     = Σ_{d=0..n} retentionPermille(c,d)         # 每安装累计活跃日(千分比日)
    ltvDeciFen(c,n)    = arpdauDeciFen(c) * cumActive(c,n) // 1000 # 0.1 分
    ltvFen(c,n)        = ltvDeciFen(c,n) // 10
    totalCostFen(c)    = cacFen(c) + variableCostPerUserFen        # 获取成本 + 每用户变动成本
    contributionFen(c) = ltvFen(c,30) - totalCostFen(c)            # 单用户贡献毛利(分)
    ltvCacPermille(c)  = ltvFen(c,30) * 1000 // cacFen(c)(自然量无 CAC,记 -1)
    paybackDay(c)      = 首个 ltvFen(c,d) >= totalCostFen(c) 的 d(否则 -1)

再算混合口径(blended):全部队列按安装数加权的 CAC 与 LTV,给出「一盘生意」的整体单位账——
这正是只看单个付费渠道会漏掉的部分:自然量用 0 成本在补贴整体。

全程整数、地板除,无浮点。产出 out/o14_unit.json + .csv + .svg。
真实出价、真实归因结果与真实留存需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def daily_retention(c: dict, d: int) -> int:
    """第 d 日留存千分比(d=0 安装日恒 1000);D1/D7/D30 锚点之间整数线性插值。"""
    if d <= 0:
        return 1000
    r1, r7, r30 = c["retainD1Permille"], c["retainD7Permille"], c["retainD30Permille"]
    if d <= 7:
        return r1 + (r7 - r1) * (d - 1) // 6
    if d <= 30:
        return r7 + (r30 - r7) * (d - 7) // 23
    return r30


def cumulative_active_permille(c: dict, n: int) -> int:
    """每安装的累计活跃日,单位千分比日(1000 = 一整日)。"""
    return sum(daily_retention(c, d) for d in range(0, n + 1))


def evaluate(c: dict, horizon: int, variable_cost_fen: int) -> dict:
    cac = c["spendFen"] // c["installs"]
    ltv_deci = {n: c["arpdauDeciFen"] * cumulative_active_permille(c, n) // 1000
                for n in (0, 7, 30)}
    ltv_fen = {n: ltv_deci[n] // 10 for n in (0, 7, 30)}
    total_cost = cac + variable_cost_fen
    contribution = ltv_fen[30] - total_cost
    ratio = -1 if cac == 0 else ltv_fen[30] * 1000 // cac
    payback = -1
    for d in range(0, horizon + 1):
        if c["arpdauDeciFen"] * cumulative_active_permille(c, d) // 1000 >= total_cost * 10:
            payback = d
            break
    return dict(c, cacFen=cac, ltvD0Fen=ltv_fen[0], ltvD7Fen=ltv_fen[7], ltvD30Fen=ltv_fen[30],
                totalCostFen=total_cost, contributionFen=contribution,
                ltvCacPermille=ratio, paybackDay=payback)


def blend(rows: list[dict], variable_cost_fen: int) -> dict:
    total_spend = sum(r["spendFen"] for r in rows)
    total_installs = sum(r["installs"] for r in rows)
    weighted_ltv = sum(r["ltvD30Fen"] * r["installs"] for r in rows)
    cac = total_spend // total_installs
    ltv = weighted_ltv // total_installs
    contribution = ltv - cac - variable_cost_fen
    ratio = -1 if cac == 0 else ltv * 1000 // cac
    return dict(totalSpendFen=total_spend, totalInstalls=total_installs, blendedCacFen=cac,
                blendedLtvFen=ltv, blendedContributionFen=contribution, blendedLtvCacPermille=ratio)


def draw_svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每个队列两根横条——单用户 LTV 与总成本(CAC+变动),回本线画竖线。"""
    W, H, PAD, ROW = 760, 70 + 60 * len(rows), 190, 60
    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 = "《合成小岛》单用户账:LTV vs 总成本(分,LTV 高于成本才赚)"
    span = W - PAD - 24
    hi = max([max(r["ltvD30Fen"], r["totalCostFen"]) for r in rows] + [1])
    for i, r in enumerate(rows):
        y = 60 + i * ROW
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
                                     "fill": "#374151"}).text = r["name"]
        for j, (key, color, label) in enumerate([("ltvD30Fen", "#2563eb", "LTV30"),
                                                 ("totalCostFen", "#f59e0b", "成本")]):
            w = span * r[key] // hi
            ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 6 + j * 20), "width": str(w),
                                         "height": "16", "rx": "3", "fill": color})
        sign = "+" if r["contributionFen"] >= 0 else ""
        ET.SubElement(root, "text", {"x": str(PAD + span + 4), "y": str(y + 34), "font-size": "11",
                                     "fill": "#6b7280"}).text = (
            f'{sign}{r["contributionFen"]}分 · 回本 D{r["paybackDay"]}')
    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, "o14_unit.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    rows = [evaluate(c, cfg["horizonDays"], cfg["variableCostPerUserFen"]) for c in cfg["cohorts"]]
    mixed = blend(rows, cfg["variableCostPerUserFen"])

    digest = ",".join(f'{r["id"]}:{r["cacFen"]}:{r["ltvD30Fen"]}:{r["contributionFen"]}'
                      f':{r["paybackDay"]}' for r in rows) \
        + f'|B:{mixed["blendedCacFen"]}:{mixed["blendedLtvFen"]}:{mixed["blendedContributionFen"]}'

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "o14_unit.svg"), rows)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "horizonDays": cfg["horizonDays"], "variableCostPerUserFen": cfg["variableCostPerUserFen"],
              "cohorts": rows, "blended": mixed, "digest": digest,
              "bestByContribution": max(rows, key=lambda r: r["contributionFen"])["id"],
              "svg": os.path.join(args.out, "o14_unit.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "o14_unit.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "o14_unit.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "name", "spendFen", "installs", "cacFen", "ltvD0Fen", "ltvD7Fen", "ltvD30Fen",
                "totalCostFen", "contributionFen", "ltvCacPermille", "paybackDay")
        w.writerow(cols)
        for r in rows:
            w.writerow([r[k] for k in cols])

    print(json.dumps({"cohorts": [{"id": r["id"], "cacFen": r["cacFen"], "ltvD30Fen": r["ltvD30Fen"],
                                   "contributionFen": r["contributionFen"],
                                   "ltvCacPermille": r["ltvCacPermille"],
                                   "paybackDay": r["paybackDay"]} for r in rows],
                      "blended": mixed, "digest": digest,
                      "bestByContribution": report["bestByContribution"]},
                     ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

参数放在 config/o14_unit.json。四条队列的口径是「公开可观察机制」的建模假设,不是任何后台实测;自然量的 spendFen 为 0,variableCostPerUserFen 是每用户变动服务成本:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "horizonDays": 30,
  "variableCostPerUserFen": 25,
  "note": "单用户全链路账参数,口径是「公开可观察机制」的建模假设,不是任何投放后台或归因报表的实测。spendFen=该队列获取花费(分);installs=该队列安装数;cacFen 由 spendFen//installs 反推;arpdauDeciFen=0.1 分/DAU/日(含 IAA+IAP);retainD1/D7/D30Permille=第 1/7/30 日留存千分比(安装日恒 1000);variableCostPerUserFen=每用户变动服务成本(分,服务器/支付通道费)。自然量 spendFen=0,无 CAC。真实出价、真实归因结果与真实留存需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "cohorts": [
    { "id": "organic", "name": "自然量", "spendFen": 0, "installs": 12000, "arpdauDeciFen": 480, "retainD1Permille": 400, "retainD7Permille": 170, "retainD30Permille": 105 },
    { "id": "feed", "name": "信息流", "spendFen": 4200000, "installs": 11000, "arpdauDeciFen": 520, "retainD1Permille": 430, "retainD7Permille": 180, "retainD30Permille": 110 },
    { "id": "kol", "name": "达人", "spendFen": 3600000, "installs": 6500, "arpdauDeciFen": 900, "retainD1Permille": 470, "retainD7Permille": 230, "retainD30Permille": 140 },
    { "id": "rewarded", "name": "激励", "spendFen": 2100000, "installs": 9200, "arpdauDeciFen": 380, "retainD1Permille": 360, "retainD7Permille": 150, "retainD30Permille": 90 }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/o14_unit.py --config config/ --out out/,结果如下(同机相对量级,绝对数值不可移植):

队列CAC(分)LTV30(分)贡献毛利(分)LTV/CAC‰回本日
自然量0293+268—D0
信息流381333−73874未回本
达人553688+1101244D22
激励228209−44916未回本
混合口径255350+701372—

这张表要读出三个层次。第一,看单个付费渠道,三条里有两条在亏:信息流每用户亏 73 分、激励每用户亏 44 分,它们的 LTV/CAC 都不到 1000‰,D30 都没回本。第二,只有达人渠道真赚钱:CAC 553 分不低,但它的 ARPDAU 与留存都高,贡献毛利 +110 分,D22 回本。第三,也是最容易误判的一点——混合口径是 +70 分。为什么单看付费渠道亏、整体却赚?因为自然量(CAC=0、贡献 +268 分)在补贴整盘。只看付费渠道会得出「该全线关停」的错误结论,看混合口径才知道这门生意整体成立。这就是「全链路账」和「单渠道账」的区别。

三条判断线

单位账算出来,落到决策上只需要看三条线:

判断线阈值说明
贡献毛利> 0最硬的一条:LTV 覆盖 CAC 与变动成本才成立
LTV/CAC≥ 3(经验)倍数关系,留出运营波动与预估误差的缓冲
回本日≤ 现金流能撑的天数回本越晚,放量时垫付的营运资金越多

三条线要一起看。达人渠道贡献毛利 +110 分、LTV/CAC 1244‰——没到经验线 3 倍,但它 D22 回本、绝对贡献为正,所以仍然值得投。反过来,信息流 LTV/CAC 874‰ 既不过倍数线也不过贡献线,就该收。绝对贡献毛利比倍数更接近真相:倍数是一个比值,CAC 很小时比值容易虚高,掩盖了「赚得太少」的事实。

整数口径与跨语言对拍

本节所有金额用「分」、比例用千分比、除法一律地板除。这不是洁癖——它让 S 层脚本能产出一行可逐字符比对的摘要,把「同一个数」在 Python 与 TypeScript 两侧对齐。跨语言的对偶实现与 node --test 真实输出放在 14.3(同一 o14_ 前缀的内核),两节共用同一套整数语义与同一份 config 口径:同一个摘要,在 Python 仿真与 TS 内核里必须长得一模一样,这也和卷二「一份逻辑、两种宿主」 是同一个信念。本节的 S 层摘要如下:

digest = organic:0:293:268:0,feed:381:333:-73:-1,kol:553:688:110:22,rewarded:228:209:-44:-1|B:255:350:70

X 层:真实 CAC 与归因不可复现

边界必须划清。上面算的是模型侧:给定花费、安装数、留存与 ARPDAU,单位经济长什么样。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实 CAC:spendFen // installs 里的安装数由第三方归因定义(去重规则、归因窗口),同一笔投放换个归因服务商能算出不同的 CAC;
  • 真实留存与 ARPDAU:受素材、人群、版本、活动共同影响,且随获客时间漂移,不是常数;
  • 真实的变动成本分摊:服务器与通道费按人头摊是一个简化,真实账单是总额,摊法本身有口径选择。

工程上正确的做法是:把可复现的单位经济模型与不可复现的真实数据分开——模型做决策前的「如果 CAC 是 300 分会怎样」,投放后用真实数据反标定(把实测 CAC 与留存填回 config,模型立刻反映现实)。这也是 14.3「止损还是加投」能落地的前提:决策依赖的是模型里的贡献毛利,而模型参数必须持续被线上数据校准。

自检表

检查项合格标准
两条腿齐全CAC 与 LTV 都算,不只看一侧
变动成本显式服务器/通道费按人头摊进总成本
单位统一金额用「分」、比例用千分比,无浮点
混合口径给出全盘加权 CAC/LTV,不只看单渠道
回本日每条队列给出 paybackDay,负贡献标「未回本」
边界清晰真实 CAC 与归因标 X,不写成「实测」

单位账算清了「一个用户赚不赚」。但一门生意是按月活的:这个月赚的钱,下个月到账没有?下一节把单用户账放大到月度经营账与现金流,看这门生意每个月能不能自己养活自己。

阅读导航:上一节:13.3 团队、外包与远程协作 · 下一节:14.2 月度经营账与现金流 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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