《一个人做小游戏,死在广告上》 里最致命的一句话是「先投着,看数据再说」——没有回本模型,就不知道数据要看到哪一天、看到什么数才算数。3.1 算出了「每个活跃用户一天带来 65.5 分」,但它回答不了买量团队真正关心的问题:一个花了 1.5 元买来的用户,第几天能把本赚回来? 本节把 3.1 的 ARPDAU 乘上留存曲线,得到累计 LTV,再让这条曲线去撞 CPA 的水平线。
LTV 是「留存加权的收入累加」
一个安装进来的用户,第 0 天活跃,第 1 天只剩一部分还活跃,第 2 天更少……把每天仍活跃的比例乘上当天的人均收入,逐日累加,就是这个安装的累计 LTV:
dayArpu(d) = ARPDAU × retention(d) / 1000 # 第 d 天这个队列贡献的收入
LTV(D) = Σ_{d=0..D-1} dayArpu(d) # 前 D 天累计
关键在 retention(d):LTV 对留存曲线的形状极其敏感。两条 D1 留存相同的曲线,只要衰减速度不同,30 天累计就能差出 20% 以上。所以 LTV 建模的第一件事不是调 ARPDAU,而是拿到一条可信的留存曲线——这也是为什么第 7 章专门做留存与漏斗。
回本日:曲线穿过水平线的那一天
把 CPA(单个安装的买量成本)画成一条水平线,LTV 曲线第一次抬到这条线以上的那个 D,就是回本日。它的含义非常具体:
- 回本日之前,你在这个用户身上是净流出的——花的钱还没赚回来;
- 回本日之后,这个用户开始产生净利润;
- 回本日越短,同样的现金能滚动买越多量,增长越轻。
回本日的分母是 CPA、分子是 LTV,所以「能不能回本」本质是 LTV/CPA 是否大于 1,「多快回本」则是这条曲线爬升的速度。
D 层:把留存曲线写成配置
下面这份 D 层配置落在 config/l03_ltv.json。arpdauDeciFen 直接取自 3.1 的输出(655),retentionPermille 是 30 天的留存率千分比数组,cpaFen 是买量成本。全部整数,无浮点。
{
"version": 1,
"game": "merge-island",
"note": "LTV 与回本口径(D 层)。arpdauDeciFen 是来自 l03_revenue.json 的日活人均收入(0.1 分/DAU),这里作为「当日留存用户」的人均日收入常量;retentionPermille 是第 d 天留存率(千分比,第 0 天 = 安装日 = 1000),cpaFen 是单个安装的买量成本(分)。LTV(D) = 前 D 天留存加权收入累加;回本日 = 最小的 D 使 LTV(D) >= cpaFen。全部整数、地板除。真实留存曲线与 CPA 需真实业务环境,命令行不可复现(X)。",
"arpdauDeciFen": 655,
"cpaFen": 150,
"horizonDays": 30,
"retentionPermille": [1000, 420, 260, 190, 150, 125, 108, 95, 86, 78, 72, 67, 63, 60, 57, 55, 53, 51, 50, 49, 48, 47, 46, 45, 44, 43, 43, 42, 42, 41]
}
留存曲线是示例假设:第 1 天 420‰、第 7 天 95‰、第 30 天 41‰,属于轻度小游戏里中等偏上的一档。真实留存曲线与 CPA 需真实业务环境,命令行不可复现(X)。
S 层:累计 LTV 与回本日仿真
下面这段是 S 层仿真脚本 sim/l03_ltv.py,逐日累加留存加权收入,产出 LTV 曲线 CSV、回本日,以及一张「LTV 曲线 vs CPA 线」的纯 Python SVG。
#!/usr/bin/env python3
# file: sim/l03_ltv.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l03_ltv.py --config config/ --out out/
"""LTV 建模与回本周期(本卷 3.2 的 S 层)。
以「一个安装」为队列单位:安装当天(第 0 天)留存率 1000‰,此后逐日衰减。把 3.1 的
ARPDAU 当作「当天仍活跃用户的人均日收入常量」:
dayArpuDeciFen(d) = arpdauDeciFen * retentionPermille[d] // 1000
ltvDeciFen(D) = Σ_{d=0..D-1} dayArpuDeciFen(d)
paybackDay = min { D : ltvDeciFen(D) >= cpaFen * 10 }(找不到记 -1)
ltvDeciFen 单位是「0.1 分」,cpaFen 单位是「分」,比较时统一到 0.1 分(×10)。全程整数、
地板除,与 core/src/l03/revenue.ts 的 ltvDeciFen / paybackDay 逐位一致。产出
out/l03_ltv.json + _curve.csv + .svg。
注意:arpdauDeciFen / retentionPermille / cpaFen 均为示例假设参数;真实留存曲线与 CPA
需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def ltv_curve(arpdau: int, retention: list[int]) -> list[dict]:
out, cum = [], 0
for d, r in enumerate(retention):
day = arpdau * r // 1000
cum += day
out.append({"day": d, "retentionPermille": r, "dayArpuDeciFen": day, "cumLtvDeciFen": cum})
return out
def payback_day(curve: list[dict], cpa_fen: int) -> int:
target = cpa_fen * 10
for d in range(1, len(curve) + 1):
if curve[d - 1]["cumLtvDeciFen"] >= target:
return d
return -1
def draw_svg(path: str, curve: list[dict], cpa_deci: int) -> int:
W, H, L, B, T, R = 760, 320, 56, 44, 30, 130
x0, x1, y0, y1 = L, W - R, H - B, T
hi = max(curve[-1]["cumLtvDeciFen"], cpa_deci) or 1
px = lambda d: x0 + (x1 - x0) * d // max(len(curve) - 1, 1)
py = lambda v: y0 - (y0 - y1) * v // hi
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": "22", "font-size": "15",
"fill": "#111827"}).text = "单安装累计 LTV 与回本线(0.1 分,示例参数)"
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y0), "x2": str(x1), "y2": str(y0),
"stroke": "#cbd5e1"})
cy = py(cpa_deci)
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(cy), "x2": str(x1), "y2": str(cy),
"stroke": "#ef4444", "stroke-width": "2", "stroke-dasharray": "6 4"})
ET.SubElement(root, "text", {"x": str(x1 + 4), "y": str(cy + 4), "font-size": "11",
"fill": "#ef4444"}).text = f"CPA {cpa_deci / 10:.1f}分"
ET.SubElement(root, "polyline", {"points": " ".join(f"{px(c['day'])},{py(c['cumLtvDeciFen'])}"
for c in curve), "fill": "none", "stroke": "#2563eb",
"stroke-width": "2.5"})
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, "l03_ltv.json"), encoding="utf-8") as f:
cfg = json.load(f)
arpdau, cpa, horizon = cfg["arpdauDeciFen"], cfg["cpaFen"], cfg["horizonDays"]
curve = ltv_curve(arpdau, cfg["retentionPermille"][:horizon])
pb, ltv30 = payback_day(curve, cpa), curve[-1]["cumLtvDeciFen"]
roi = ltv30 * 1000 // (cpa * 10) if cpa else 0
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "l03_ltv.svg"), curve, cpa * 10)
report = {"version": cfg["version"], "game": cfg["game"],
"inputs": {"arpdauDeciFen": arpdau, "cpaFen": cpa, "horizonDays": horizon},
"ltv30DeciFen": ltv30, "ltv30Fen": ltv30 // 10, "paybackDay": pb,
"roiPermille": roi, "profitable": ltv30 >= cpa * 10, "curve": curve,
"svgBytes": svg_bytes, "svg": os.path.join(args.out, "l03_ltv.svg"),
"csv": os.path.join(args.out, "l03_ltv_curve.csv")}
with open(os.path.join(args.out, "l03_ltv.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "l03_ltv_curve.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["day", "retentionPermille", "dayArpuDeciFen", "cumLtvDeciFen", "cumLtvFen"])
for c in curve:
w.writerow([c["day"], c["retentionPermille"], c["dayArpuDeciFen"], c["cumLtvDeciFen"],
c["cumLtvDeciFen"] // 10])
print(json.dumps({"ltv30DeciFen": ltv30, "ltv30Fen": ltv30 // 10, "paybackDay": pb,
"roiPermille": roi, "profitable": report["profitable"]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
真跑输出:ltv30DeciFen=2300(2.30 元)、paybackDay=8(第 8 天回本)、roiPermille=1533(LTV30/CPA = 1.533×)、profitable=true。逐日累计(0.1 分)为 D0=655、D1=930、D2=1100、D3=1224、D4=1322、D5=1403、D6=1473、D7=1535——第 7 天(下标)刚过 CPA 的 1500,所以回本日落在第 8 天。注意 D0 一天就贡献 655,占了 30 天累计的近 28%:首日收入是 LTV 里最重的一块,这也是为什么「首日体验」值得反复打磨。
W 层:把内核搬进浏览器的计算器
下面两段是 W 层脚本。web/build_l03_calculator.mjs 复用 web/pack.mjs 把 core/src/l03/revenue.ts 内联进单文件 HTML,做成一个可交互的「收入 · LTV · 回本计算器」:拖 DAU / eCPM / CPA 等参数,ARPDAU、LTV30、回本日即时重算。
#!/usr/bin/env node
// file: web/build_l03_calculator.mjs
// tier: W
// verify: node web/build_l03_calculator.mjs
// 把 core/src/l03/revenue.ts 的收入/LTV/回本内核打成单文件 HTML:可交互的「收入 · LTV ·
// 回本计算器」。同一份 config/*.json 与 Python(S)、TS 测试(P)三处消费。产物 web/dist/l03.html。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const rev = JSON.parse(readFileSync(join(ROOT, "config", "l03_revenue.json"), "utf8"));
const ltv = JSON.parse(readFileSync(join(ROOT, "config", "l03_ltv.json"), "utf8"));
const js = bundle(ROOT, ["core/src/l03/revenue.ts"]);
const script = `const base = ${inlineConfig(rev)}, ltvCfg = ${inlineConfig(ltv)};
${js}
const FIELDS = [["dau","DAU",1000,200000,1000],["adRequestsPerDau","人均广告请求",1,80,1],["fillPct","填充率 %",10,100,1],["ecpmFen","eCPM 分",100,6000,50],["payRatePermille","付费率 ‰",1,200,1],["arppuFen","ARPPU 分",100,8000,50],["cpaFen","CPA 分",20,600,10]];
const state = Object.assign({}, base, { cpaFen: ltvCfg.cpaFen });
const controls = document.getElementById("controls");
for (const [k, label, min, max, step] of FIELDS) {
const el = document.createElement("label"); el.className = "ctl";
el.innerHTML = '<span class="k">' + label + '</span><input id="in-' + k + '" type="number" min="' + min + '" max="' + max + '" step="' + step + '" value="' + state[k] + '">';
controls.appendChild(el);
}
function recompute() {
const iaa = iaaArpdauDeciFen(state), iap = iapArpdauDeciFen(state), a = iaa + iap;
const ret = ltvCfg.retentionPermille.slice(0, ltvCfg.horizonDays);
const daily = dailyFen(state), ltv30 = ltvDeciFen(a, ret, ltvCfg.horizonDays);
const pb = paybackDay(a, ret, state.cpaFen), roi = Math.floor(ltv30 * 1000 / (state.cpaFen * 10));
const set = (id, v) => { document.getElementById(id).textContent = v; };
set("arpdau", (a / 10).toFixed(1)); set("iaa", (iaa / 10).toFixed(1)); set("iap", (iap / 10).toFixed(1));
set("daily", (daily / 100).toFixed(0)); set("monthly", (daily * 30 / 100).toFixed(0));
set("ltv30", (ltv30 / 10).toFixed(1)); set("pb", pb > 0 ? "D" + pb : "未回本");
set("roi", (roi / 1000).toFixed(2) + "×");
drawChart(a, ret, state.cpaFen);
globalThis.__pebble = { arpdauDeciFen: a, iaaDeciFen: iaa, iapDeciFen: iap, dailyFen: daily, monthlyFen: daily * 30, ltv30DeciFen: ltv30, paybackDay: pb, roiPermille: roi, canvasDrawn: true };
}
function drawChart(arpdau, ret, cpaFen) {
const cv = document.getElementById("chart"), ctx = cv.getContext("2d");
const W = cv.width, H = cv.height, L = 44, R = 110, T = 20, B = 34;
ctx.fillStyle = "#111827"; ctx.fillRect(0, 0, W, H);
const curve = ltvCurve(arpdau, ret), hi = Math.max(curve[curve.length - 1], cpaFen * 10) || 1;
const px = (d) => L + (W - L - R) * d / (curve.length - 1), py = (v) => (H - B) - ((H - B) - T) * v / hi;
ctx.strokeStyle = "#334155"; ctx.beginPath(); ctx.moveTo(L, T); ctx.lineTo(L, H - B); ctx.lineTo(W - R, H - B); ctx.stroke();
const cy = py(cpaFen * 10);
ctx.strokeStyle = "#ef4444"; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(L, cy); ctx.lineTo(W - R, cy); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = "#ef4444"; ctx.font = "12px system-ui"; ctx.fillText("CPA " + (cpaFen / 10).toFixed(1), W - R + 6, cy + 4);
ctx.strokeStyle = "#38bdf8"; ctx.lineWidth = 2.5; ctx.beginPath();
curve.forEach((v, d) => { const x = px(d), y = py(v); d === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke();
ctx.fillStyle = "#94a3b8"; ctx.font = "11px system-ui";
ctx.fillText("D0", L - 6, H - B + 16); ctx.fillText("D" + (curve.length - 1), W - R - 14, H - B + 16);
ctx.fillText("LTV30 " + (curve[curve.length - 1] / 10).toFixed(1), L, T - 6);
}
controls.addEventListener("input", (e) => { const k = e.target.id.replace("in-", ""); const v = parseInt(e.target.value, 10); if (v > 0) { state[k] = v; recompute(); } });
recompute();
`;
const body = ` <h1>《合成小岛》· 收入 · LTV · 回本计算器</h1>
<div class="sub">同一份 core/src/l03/revenue.ts 内核,浏览器里改参数即时重算</div>
<div class="card row"><div><div class="k">ARPDAU(分/DAU)</div><div class="big" id="arpdau">0</div></div><div><div class="k">IAA / IAP</div><div class="big" id="iaa">0</div><div class="k">IAP <span id="iap">0</span></div></div><div><div class="k">日收入(元)</div><div class="big" id="daily">0</div></div><div><div class="k">月收入(元)</div><div class="big" id="monthly">0</div></div></div>
<div class="card row"><div><div class="k">LTV30(分)</div><div class="big" id="ltv30">0</div></div><div><div class="k">回本日</div><div class="big" id="pb">-</div></div><div><div class="k">LTV30 / CPA</div><div class="big" id="roi">-</div></div></div>
<div class="card"><div class="k">参数(改一个即时重算)</div><div id="controls" class="ctls"></div></div>
<div class="card"><div class="k">单安装累计 LTV 曲线 vs 回本线</div><canvas id="chart" width="720" height="300"></canvas></div>`;
const bytes = writePage(join(ROOT, "web/dist/l03.html"),
page({ title: "《合成小岛》收入 · LTV · 回本计算器 · l03",
css: ".ctls { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 8px; } .ctl { display: flex; justify-content: space-between; align-items: center; gap: 8px; } .ctl input { width: 120px; font: inherit; padding: 4px 8px; border-radius: 6px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; }",
body, script }));
console.log(JSON.stringify({ html: "web/dist/l03.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
web/verify_l03_calculator.mjs 用 Playwright 无头打开产物,断言默认配置下算出的数字与 Python、TS 内核逐位一致,再抬高 eCPM 确认重算生效。
#!/usr/bin/env node
// file: web/verify_l03_calculator.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l03_calculator.mjs web/dist/l03.html
// W 层验收:Playwright 无头打开单文件 HTML,断言默认配置下 ARPDAU / LTV30 / 回本日与
// Python、TS 内核逐位一致;改一个输入后重算生效;页面无报错;截图存档。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/l03.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const page = await browser.newPage({ viewport: { width: 800, height: 900 } });
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const snap = await page.evaluate(() => {
const s = globalThis.__pebble, cv = document.getElementById("chart");
const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
let painted = 0;
for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0) painted += 1;
return { iaa: s.iaaDeciFen, iap: s.iapDeciFen, arpdau: s.arpdauDeciFen, daily: s.dailyFen,
monthly: s.monthlyFen, ltv30: s.ltv30DeciFen, payback: s.paybackDay, roi: s.roiPermille,
domArpdau: document.getElementById("arpdau").textContent,
domPb: document.getElementById("pb").textContent, painted };
});
await page.fill("#in-ecpmFen", "3000");
await page.waitForFunction(() => globalThis.__pebble.arpdauDeciFen > 655, null, { timeout: 5000 });
const after = await page.evaluate(() => ({ arpdau: globalThis.__pebble.arpdauDeciFen,
payback: globalThis.__pebble.paybackDay }));
await page.screenshot({ path: join(ROOT, "out/shots/l03_calculator.png"), fullPage: true });
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(snap.iaa, 343, "IAA ARPDAU 应为 343(0.1 分/DAU)");
assert.equal(snap.iap, 312, "IAP ARPDAU 应为 312(0.1 分/DAU)");
assert.equal(snap.arpdau, 655, `ARPDAU 应为 655,实测 ${snap.arpdau}`);
assert.equal(snap.daily, 1310000, "日收入应为 1310000 分");
assert.equal(snap.monthly, 39300000, "月收入应为 39300000 分");
assert.equal(snap.ltv30, 2300, "LTV30 应为 2300(0.1 分)");
assert.equal(snap.payback, 8, "回本日应为第 8 天");
assert.equal(snap.roi, 1533, "LTV30/CPA 应约 1.533×(千分比 1533)");
assert.equal(snap.domArpdau, "65.5", "DOM 应显示 ARPDAU 65.5");
assert.equal(snap.domPb, "D8", "DOM 应显示回本日 D8");
assert.ok(snap.painted > 1000, "Canvas 应真的画了 LTV 曲线");
assert.ok(after.arpdau > 655, "抬高 eCPM 后 ARPDAU 应上升");
assert.ok(after.payback > 0 && after.payback <= 8, "抬高 eCPM 后回本日不应变晚");
console.log(JSON.stringify({ page: "web/dist/l03.html", arpdau: snap.arpdau, ltv30: snap.ltv30,
payback: snap.payback, roi: snap.roi, painted: snap.painted, afterEcpm3000: after,
screenshot: "out/shots/l03_calculator.png" }, null, 2));
console.log("SMOKE-W3: PASS");
无头验证结果:默认配置下浏览器算出 ARPDAU 655、LTV30 2300、回本日 D8、ROI 1.53×,与 S / P 层逐位一致;把 eCPM 从 1500 抬到 3000 后,ARPDAU 升到 998、回本日提前到 D3(同机相对量级,绝对数值不可移植);页面无报错,截图落在 out/shots/l03_calculator.png。
这一节要记住的三件事
- LTV 的形状由留存决定,量级由 ARPDAU 决定。ARPDAU 乘一个常数,留存曲线的斜率才决定曲线多久穿过 CPA 线。
- 回本日是一个现金流指标,不是一个面子指标。第 8 天回本意味着这 8 天里你是在垫钱买量,现金流能撑多久决定了你能买多大的量。
- LTV30/CPA 大于 1 只是入场券。1.533× 看着健康,但它建立在「留存 30 天不塌方」的假设上;留存一崩,这个数会迅速掉到 1 以下——下一节 3.3 专门算这种「假设错了会怎样」。
必须诚实标注:留存曲线与 CPA 均为示例假设,真实值需真实业务环境,命令行不可复现(X)。
阅读导航:上一节:收入拆解与 ARPDAU 模型 · 下一节:敏感性分析与收入预测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。