站内《小游戏开发实战》卷三目录 的第 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‰ | 回本日 |
|---|---|---|---|---|---|
| 自然量 | 0 | 293 | +268 | — | D0 |
| 信息流 | 381 | 333 | −73 | 874 | 未回本 |
| 达人 | 553 | 688 | +110 | 1244 | D22 |
| 激励 | 228 | 209 | −44 | 916 | 未回本 |
| 混合口径 | 255 | 350 | +70 | 1372 | — |
这张表要读出三个层次。第一,看单个付费渠道,三条里有两条在亏:信息流每用户亏 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 月度经营账与现金流 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。