留存和付费常被当成两个独立的指标,各有一张报表。但真正决定一款小游戏赚多少钱的,是这两个指标的乘积:一个人留得久,才有机会付费;一个人愿意付费,也得先留得住。本节把两者乘在一起算——按「留存档 × 付费档」切段,看每一段贡献了多少 ARPU。本站那篇小游戏开发综述 是单篇综述口径,把变现与留存各扫一遍;本节把两者乘进同一个公式,做成可跑、可调参、可断言的联合模型。
留存与付费为什么必须联合看
把 ARPU 拆开,结构立刻就清楚了:ARPU = 活跃日 × (每活跃日 IAA 收益 + 付费率 × 每付费用户每活跃日 ARPPU)。右边有两块。第一块 活跃日 × 每活跃日 IAA 完全由留存决定——留得越久,广告看得越多,IAA 收入越高。第二块 活跃日 × 付费率 × ARPPU 由留存和付费共同决定——注意 活跃日 同时出现在两项里,它是乘数。这就是联合分析的核心:留存不只是 IAA 的乘数,它也是 IAP 的乘数。抬高留存,两项一起涨;只抬付费率,只有第二项涨。
D 层:把「留存档 × 付费档」落成配置
下面这份是 D 层(静态层)产物,全整数、没有任何浮点。四个段各带 users(人数)、activeDays(期望活跃日,由留存决定)、payerRatePermille(付费率‰)、arppuDeciFen(每付费用户每活跃日 ARPPU);全局 iaaDeciFenPerActiveDay 是每活跃日的 IAA 收益。全部为示例假设参数,真实留存与付费结构需真实业务环境,命令行不可复现(X)。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"observedAt": "2026-10",
"note": "留存与付费的联合分析:把用户按「留存档 × 付费档」切成四段,每段用 activeDays(期望活跃日)、payerRatePermille(付费率‰)、arppuDeciFen(每付费用户每活跃日 ARPPU)与全局 iaaDeciFenPerActiveDay(每活跃日 IAA 收益)算段 ARPU:activeDays × (iaa + payerRate × arppu // 1000)。scenarios 用整数百分比抬高留存或付费率,看哪个杠杆更能撬动混合 ARPU。全部为示例假设参数;真实留存与付费结构需真实业务环境,命令行不可复现(X)。全整数,禁浮点。",
"horizonDays": 30,
"iaaDeciFenPerActiveDay": 12,
"segments": [
{ "id": "hr-hp", "name": "高留存·高付费", "users": 3000, "activeDays": 18, "payerRatePermille": 180, "arppuDeciFen": 420 },
{ "id": "hr-lp", "name": "高留存·低付费", "users": 9000, "activeDays": 18, "payerRatePermille": 40, "arppuDeciFen": 200 },
{ "id": "lr-hp", "name": "低留存·高付费", "users": 3000, "activeDays": 6, "payerRatePermille": 220, "arppuDeciFen": 520 },
{ "id": "lr-lp", "name": "低留存·低付费", "users": 15000, "activeDays": 6, "payerRatePermille": 30, "arppuDeciFen": 180 }
],
"scenarios": [
{ "id": "baseline", "name": "基线", "retentionPct": 100, "payerPct": 100 },
{ "id": "retention-up", "name": "留存 +20%", "retentionPct": 120, "payerPct": 100 },
{ "id": "payer-up", "name": "付费率 +20%", "retentionPct": 100, "payerPct": 120 }
]
}
S 层:把联合模型算成一张段表
下面这段是 S 层仿真脚本,读 config/l07_joint.json:按公式算出每段的 IAA / IAP 拆解与段 ARPU,汇总成混合 ARPU 与 IAA/IAP 占比,再跑三档场景对照。产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/l07_joint.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l07_joint.py --config config/ --out out/
"""留存与付费的联合分析:读 config/l07_joint.json。
把用户按「留存档 × 付费档」切段,每段:iaaArpu = activeDays × iaaDeciFenPerActiveDay;
iapArpu = activeDays × payerRatePermille × arppuDeciFen // 1000;segArpu = iaa + iap。
段收入 = users × segArpu;混合 ARPU = 总收入 // 总用户。scenarios 用整数百分比抬高留存
(activeDays)或付费率(payerRatePermille),回答「哪个杠杆更能撬动 ARPU」。
产出 JSON + CSV + SVG。全整数地板除,无浮点。真实留存与付费结构需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(d: str) -> dict:
with open(os.path.join(d, "l07_joint.json"), encoding="utf-8") as f:
return json.load(f)
def seg_arpu(seg: dict, iaa: int, retention_pct: int = 100, payer_pct: int = 100) -> dict:
"""段 ARPU 与 IAA/IAP 拆解(0.1 分/用户);留存抬高 activeDays,付费抬高 payerRate。"""
days = seg["activeDays"] * retention_pct // 100
payer_rate = seg["payerRatePermille"] * payer_pct // 100
iaa_arpu = days * iaa
iap_arpu = days * payer_rate * seg["arppuDeciFen"] // 1000
return {"id": seg["id"], "name": seg["name"], "users": seg["users"], "activeDays": days,
"payerRatePermille": payer_rate, "iaaArpuDeciFen": iaa_arpu, "iapArpuDeciFen": iap_arpu,
"arpuDeciFen": iaa_arpu + iap_arpu,
"revenueDeciFen": seg["users"] * (iaa_arpu + iap_arpu)}
def evaluate(cfg: dict, retention_pct: int, payer_pct: int) -> dict:
rows = [seg_arpu(s, cfg["iaaDeciFenPerActiveDay"], retention_pct, payer_pct)
for s in cfg["segments"]]
users = sum(r["users"] for r in rows)
revenue = sum(r["revenueDeciFen"] for r in rows)
iaa = sum(r["users"] * r["iaaArpuDeciFen"] for r in rows)
iap = sum(r["users"] * r["iapArpuDeciFen"] for r in rows)
return {"rows": rows, "users": users, "revenueDeciFen": revenue,
"arpuDeciFen": revenue // users if users else 0,
"iaaSharePermille": iaa * 1000 // revenue if revenue else 0,
"iapSharePermille": iap * 1000 // revenue if revenue else 0}
def draw_svg(path: str, rows: list[dict], arpu: int) -> int:
"""纯 Python SVG:每段一根堆叠横条(IAA 蓝 + IAP 橙),长度正比于段 ARPU。"""
W, PAD, ROW = 760, 176, 56
H = 84 + ROW * len(rows)
r = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(r, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(r, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
f"各留存×付费段 ARPU 拆解(0.1 分/用户,混合 {arpu / 10:.1f} 分)"
for k, (c, lab) in enumerate([("#2563eb", "IAA"), ("#f59e0b", "IAP")]):
ET.SubElement(r, "rect", {"x": str(16 + k * 70), "y": "38", "width": "12", "height": "12", "rx": "2", "fill": c})
ET.SubElement(r, "text", {"x": str(33 + k * 70), "y": "49", "font-size": "11", "fill": "#374151"}).text = lab
span, hi = W - PAD - 190, max((s["arpuDeciFen"] for s in rows), default=1) or 1
for i, s in enumerate(rows):
y = 58 + i * ROW
wi, wp = span * s["iaaArpuDeciFen"] // hi, span * s["iapArpuDeciFen"] // hi
ET.SubElement(r, "text", {"x": "16", "y": str(y + 24), "font-size": "12",
"fill": "#374151"}).text = s["name"]
ET.SubElement(r, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(wi), "height": "22",
"rx": "3", "fill": "#2563eb"})
ET.SubElement(r, "rect", {"x": str(PAD + wi), "y": str(y + 6), "width": str(wp),
"height": "22", "rx": "3", "fill": "#f59e0b"})
ET.SubElement(r, "text", {"x": str(PAD + wi + wp + 8), "y": str(y + 22), "font-size": "12",
"fill": "#6b7280"}).text = f'{s["arpuDeciFen"] / 10:.1f}分'
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(r).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg = load(a.config)
base = evaluate(cfg, 100, 100)
scenarios = []
for sc in cfg["scenarios"]:
e = evaluate(cfg, sc["retentionPct"], sc["payerPct"])
scenarios.append({"id": sc["id"], "name": sc["name"], "retentionPct": sc["retentionPct"],
"payerPct": sc["payerPct"], "arpuDeciFen": e["arpuDeciFen"],
"revenueDeciFen": e["revenueDeciFen"]})
top = max(base["rows"], key=lambda s: s["arpuDeciFen"])
top_share = top["revenueDeciFen"] * 1000 // base["revenueDeciFen"]
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "l07_joint.svg"), base["rows"], base["arpuDeciFen"])
with open(os.path.join(a.out, "l07_joint.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "users", "activeDays", "payerRatePermille", "iaaArpuDeciFen",
"iapArpuDeciFen", "arpuDeciFen", "revenueDeciFen", "revenueSharePermille"])
for s in base["rows"]:
w.writerow([s["id"], s["name"], s["users"], s["activeDays"], s["payerRatePermille"],
s["iaaArpuDeciFen"], s["iapArpuDeciFen"], s["arpuDeciFen"], s["revenueDeciFen"],
s["revenueDeciFen"] * 1000 // base["revenueDeciFen"]])
digest = f'arpu:{base["arpuDeciFen"]}:iaa:{base["iaaSharePermille"]}:iap:{base["iapSharePermille"]}|' + \
",".join(f'{s["id"]}:{s["arpuDeciFen"]}' for s in base["rows"])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"horizonDays": cfg["horizonDays"], "iaaDeciFenPerActiveDay": cfg["iaaDeciFenPerActiveDay"],
"segments": base["rows"], "totalUsers": base["users"], "blendedArpuDeciFen": base["arpuDeciFen"],
"totalRevenueDeciFen": base["revenueDeciFen"], "iaaSharePermille": base["iaaSharePermille"],
"iapSharePermille": base["iapSharePermille"], "topSegment": {"id": top["id"], "revenueSharePermille": top_share},
"scenarios": scenarios, "digest": digest, "csv": os.path.join(a.out, "l07_joint.csv"),
"svg": os.path.join(a.out, "l07_joint.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "l07_joint.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"blendedArpuDeciFen": base["arpuDeciFen"], "totalRevenueDeciFen": base["revenueDeciFen"],
"iaaSharePermille": base["iaaSharePermille"], "iapSharePermille": base["iapSharePermille"],
"segArpu": {s["id"]: s["arpuDeciFen"] for s in base["rows"]}, "topSegment": top["id"],
"topRevenueSharePermille": top_share, "scenarioArpu": {s["id"]: s["arpuDeciFen"] for s in scenarios},
"digest": digest}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出如下(同机相对量级,绝对数值不可移植):
{"blendedArpuDeciFen": 393, "totalRevenueDeciFen": 11802000, "iaaSharePermille": 329, "iapSharePermille": 670, "segArpu": {"hr-hp": 1576, "hr-lp": 360, "lr-hp": 758, "lr-lp": 104}, "topSegment": "hr-hp", "topRevenueSharePermille": 400, "scenarioArpu": {"baseline": 393, "retention-up": 458, "payer-up": 445}, "digest": "arpu:393:iaa:329:iap:670|hr-hp:1576,hr-lp:360,lr-hp:758,lr-lp:104"}
读段表:留存是乘数
把四段摊开(ARPU 单位 0.1 分):
| 段 | 用户 | 活跃日 | 付费率 | 段 ARPU | 收入占比 |
|---|---|---|---|---|---|
| 高留存·高付费 | 3000 | 18 | 18.0% | 157.6 分 | 40.0% |
| 高留存·低付费 | 9000 | 18 | 4.0% | 36.0 分 | 27.4% |
| 低留存·高付费 | 3000 | 6 | 22.0% | 75.8 分 | 19.2% |
| 低留存·低付费 | 15000 | 6 | 3.0% | 10.4 分 | 13.2% |
两件事一眼可见。第一,活跃日 是乘法因子:高留存段的 IAA 部分是 216(18×12),低留存段只有 72(6×12)——差 3 倍,纯粹因为留得久。第二,「高付费」救不了「低留存」:低留存·高付费段付费率高达 22%(四段最高),但段 ARPU 只有 75.8 分,仍低于高留存·低付费段的……不,其实它高于它。真正扎眼的是人数与贡献的错位:高留存两段合计 12000 人(占 40%),却贡献了 67.4% 的收入;低留存两段 18000 人(占 60%),只贡献 32.4%。
三档场景把「乘数」这件事量化了:基线混合 ARPU 39.3 分;留存 +20% 抬到 45.8 分(+16.5%);付费率 +20% 只抬到 44.5 分(+13.2%)。同样 +20%,抬留存比抬付费率多撬动约 3 个百分点——因为留存同时放大 IAA 和 IAP 两项,而付费率只放大 IAP 一项。这不是「留存比付费重要」的鸡汤,而是公式里 活跃日 出现在两项、付费率 只出现在一项的算术后果。
W 层:单文件 HTML 探索器
要把这条结论变成能上手拨的直觉,最好的办法是做成一个浏览器里打开就能拖的探索器。下面这段是 W 层的构建脚本,把 core/src/l07/joint.ts(与 Python 同口径的 TypeScript 内核,已由 core/test/l07_joint.test.ts 锁住)打包成单文件 HTML。
#!/usr/bin/env node
// file: web/build_l07_joint.mjs
// tier: W
// verify: node web/build_l07_joint.mjs
// 把 core/src/l07/joint.ts 打成单文件 HTML(留存×付费联合 ARPU 探索器)。复用 pack.mjs 的擦除逻辑。
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 cfg = JSON.parse(readFileSync(join(ROOT, "config", "l07_joint.json"), "utf8"));
const js = bundle(ROOT, ["core/src/l07/joint.ts"]);
const script = `const cfg = ${inlineConfig(cfg)};
${js}
const cv = document.getElementById("stage"), g = cv.getContext("2d");
let retentionPct = 100, payerPct = 100, res = null;
const fmt = (df) => Math.floor(df / 10) + "." + (df % 10) + " 分";
function compute() { res = evaluate(cfg, retentionPct, payerPct); draw(); }
function draw() {
g.clearRect(0, 0, cv.width, cv.height);
const peak = Math.max(...res.rows.map((r) => r.arpuDeciFen), 1);
res.rows.forEach((r, i) => {
const y = 14 + i * 42, span = cv.width - 210;
const wi = Math.floor(span * r.iaaArpuDeciFen / peak), wp = Math.floor(span * r.iapArpuDeciFen / peak);
g.fillStyle = "#2563eb"; g.fillRect(120, y, wi, 24);
g.fillStyle = "#f59e0b"; g.fillRect(120 + wi, y, wp, 24);
g.fillStyle = "#e2e8f0"; g.font = "13px system-ui";
g.fillText(r.name, 6, y + 17);
g.fillText(fmt(r.arpuDeciFen), 120 + wi + wp + 6, y + 17);
});
document.getElementById("arpu").textContent = fmt(res.arpuDeciFen);
document.getElementById("iaa").textContent = Math.floor(res.iaaSharePermille / 10) + "." + (res.iaaSharePermille % 10) + "%";
document.getElementById("iap").textContent = Math.floor(res.iapSharePermille / 10) + "." + (res.iapSharePermille % 10) + "%";
globalThis.__pebble = { arpuDeciFen: res.arpuDeciFen, iaaSharePermille: res.iaaSharePermille,
iapSharePermille: res.iapSharePermille, retentionPct, payerPct,
segArpu: res.rows.map((r) => [r.id, r.arpuDeciFen]), canvasDrawn: true, api };
}
const api = {
set(ret, pay) { retentionPct = ret; payerPct = pay; compute();
return { arpuDeciFen: res.arpuDeciFen, iaaSharePermille: res.iaaSharePermille, iapSharePermille: res.iapSharePermille }; },
get() { return { arpuDeciFen: res.arpuDeciFen, iaaSharePermille: res.iaaSharePermille,
iapSharePermille: res.iapSharePermille, retentionPct, payerPct, segArpu: res.rows.map((r) => [r.id, r.arpuDeciFen]) }; },
};
compute();
`;
const body = ` <h1>《合成小岛》· 留存×付费联合 ARPU 探索器</h1>
<div class="sub">内核来自 core/src/l07/joint.ts:ARPU = 活跃日 ×(IAA + 付费率 × ARPPU),全整数、无 Math.random</div>
<div class="card row"><div><div class="k">混合 ARPU</div><div class="big" id="arpu">0 分</div></div><div><div class="k">IAA 占比</div><div class="big" id="iaa">0%</div></div><div><div class="k">IAP 占比</div><div class="big" id="iap">0%</div></div></div>
<div class="card"><canvas id="stage" width="480" height="184"></canvas></div>
<div class="sub">留存档(蓝= IAA,橙= IAP):抬高留存同时放大两段;只抬付费率仅放大橙段</div>`;
const bytes = writePage(join(ROOT, "web/dist/l07.html"), page({ title: "《合成小岛》留存×付费联合 ARPU 探索器 · l07", body, script, css: "#stage { width: 480px; height: 184px; }" }));
console.log(JSON.stringify({ html: "web/dist/l07.html", htmlBytes: bytes, bundleLines: js.split("\n").length }));
单文件 HTML 的产出要靠 Playwright 无头验证:注入不同的留存 / 付费档,读回页面状态与 canvas 像素,确认「拨动参数 → ARPU 变化」真的发生。下面这段是 W 层验证脚本。
#!/usr/bin/env node
// file: web/verify_l07_joint.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l07_joint.mjs web/dist/l07.html
// W 层验收:Playwright 无头打开单文件 HTML,调 api 切留存/付费档,读回断言 + 截图。
// playwright 用 createRequire 加载——NODE_PATH 对 ESM 的 import 无效。
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/l07.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 p = await browser.newPage({ viewport: { width: 800, height: 720 } });
p.on("pageerror", (e) => errors.push(String(e)));
await p.goto(url);
await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const base = await p.evaluate(() => globalThis.__pebble.api.get());
const domArpu = (await p.textContent("#arpu")).trim();
const domIaa = (await p.textContent("#iaa")).trim();
const retUp = await p.evaluate(() => globalThis.__pebble.api.set(120, 100));
const payerUp = await p.evaluate(() => globalThis.__pebble.api.set(100, 120));
const painted = await p.evaluate(() => {
const d = document.getElementById("stage").getContext("2d").getImageData(0, 0, 480, 184).data;
let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0 && d[i] < 250) n += 1; return n;
});
await p.screenshot({ path: join(ROOT, "out/shots/l07_joint.png"), fullPage: true });
await p.close();
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(base.arpuDeciFen, 393, "基线混合 ARPU 应为 393");
assert.equal(base.iaaSharePermille, 329, "基线 IAA 占比应为 329‰");
assert.equal(base.iapSharePermille, 670, "基线 IAP 占比应为 670‰");
assert.deepEqual(base.segArpu, [["hr-hp", 1576], ["hr-lp", 360], ["lr-hp", 758], ["lr-lp", 104]]);
assert.equal(retUp.arpuDeciFen, 458, "留存 +20% 后 ARPU 应为 458");
assert.equal(payerUp.arpuDeciFen, 445, "付费率 +20% 后 ARPU 应为 445");
assert.equal(domArpu, "39.3 分", "DOM 混合 ARPU 应显示 39.3 分");
assert.equal(domIaa, "32.9%", "DOM IAA 占比应显示 32.9%");
assert.ok(painted > 300, `canvas 应有绘制内容,实测 ${painted} px`);
console.log(JSON.stringify({
page: "web/dist/l07.html", base, retUp, payerUp, domArpu, domIaa,
canvasPaintedPx: painted, screenshot: "out/shots/l07_joint.png",
}));
console.log("SMOKE-W7-3: PASS");
本机跑它,输出如下(同机相对量级,绝对数值不可移植):
{ "page": "web/dist/l07.html", "base": { "arpuDeciFen": 393, "iaaSharePermille": 329, "iapSharePermille": 670 },
"retUp": { "arpuDeciFen": 458, "iaaSharePermille": 329, "iapSharePermille": 670 },
"payerUp": { "arpuDeciFen": 445, "iaaSharePermille": 290, "iapSharePermille": 709 },
"domArpu": "39.3 分", "domIaa": "32.9%", "canvasPaintedPx": 14969 }
SMOKE-W7-3: PASS
浏览器里拨一下两个参数,就能亲手看到那条结论:把留存抬 20%,IAA 和 IAP 占比几乎不变(329‰ / 670‰)——因为两项同比例放大;把付费率抬 20%,占比立刻偏向 IAP(290‰ / 709‰)。留存是乘数,付费率是加法项,这就是全部差别。
小结与下一步
这一节把留存与付费乘进了同一个公式:ARPU = 活跃日 ×(IAA + 付费率 × ARPPU)。Python 仿真、TS 内核、单文件 HTML 探索器三处同口径,得到三个可复现的结论——活跃日是乘法因子(高留存段 IAA 是低留存段的 3 倍)、40% 的高留存用户贡献 67.4% 的收入、同样 +20%,抬留存(+16.5%)比抬付费率(+13.2%)更能撬动 ARPU。
到这里,第 7 章把「留存」这条线走完了:新手漏斗与留存曲线(7.1)、流失预警与召回(7.2)、留存与付费的联合分析(7.3)。但这些结论都建立在假设参数之上——留存锚点是估的、付费率是猜的、召回率是拍的。要确认一个改动到底有没有效,不能靠模型,要靠实验。下一章讲 A/B 实验与数据驱动:怎么设计分流、要多少样本才显著、以及从实验结论到上线决策的那一步。
阅读导航:上一节:流失预警与召回 · 下一节:实验设计与分流 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。