前两节画的是横截面:5.1 把用户按场景分画像,5.2 按动机分意愿层,但两者都是「某一时刻人群长什么样」。真实的玩家是流动的——同一个通勤碎片族玩家,第 1 天新鲜、第 7 天形成习惯、第 30 天要么留下要么流失。本节把时间轴加上,讲生命周期阶段与留存结构。分工照旧——站内那篇广告变现的清单式短文 讲的是「广告位怎么放」;本章给的是用户动机与付费意愿的可量化分层模型,本节是它的时间维度。
留存是整套产品模型里最「牵一发动全身」的一条曲线。它往上走一点,ARPDAU、LTV、买量回本全部跟着变;它往下掉一点,前面所有关于画像和付费的假设都会缩水。所以本节要做的不是「预测留存」,而是给留存建一个可递推、可调参、可复算的结构——让「D30 是多少」这句话,背后有一条能改参重跑的曲线。
生命周期是一条时间轴
小游戏的生命周期,通常按三个锚点切段:
- D1(次日留存)——新鲜感的门槛。它衡量「第一次玩完之后还想不想再来」,几乎完全由首局体验与新手引导决定。
- D7(7 日留存)——习惯的门槛。它衡量「有没有形成每日打开的习惯」,由核心循环与日常目标决定。
- D30(30 日留存)——长线的门槛。它衡量「有没有可持续的内容或社交动力」,由成长曲线、放置收益与社交面决定。
三个锚点之间不是独立的,而是一条衰减曲线的三个采样点。本节的建模思路是:给定一个 D1 起点,再给定两段每日衰减率(早段 D2–D7、晚段 D8–D30),用整数递推把整条曲线算出来,D7 与 D30 自然落在这条曲线上。这样做的好处是改一个参数,整条曲线一起动,而不是三个锚点各填一个数、互相打架。
需要强调:D1 起点与两段衰减率都是假设参数(X),来自对公开可观察的小游戏留存结构的归纳,不是任何产品的后台还原。本节的可复现部分,是「给定这组参数,曲线与 D1/D7/D30 是多少」;「真实留存是多少」需要真实业务环境,命令行不可复现。
D 层:把留存结构写进配置
下面这份是 D 层配置。它把 D1 起点、早段衰减、晚段衰减、早段截止日与警告阈值写成整数千分比参数。
{
"version": 1,
"note": "示例留存结构:全部为整数千分比假设参数;真实留存需真实业务环境,命令行不可复现(X)",
"d1Permille": 420,
"earlyDecayPermille": 842,
"lateDecayPermille": 961,
"earlyEndDay": 7,
"horizonDays": 30,
"warnD30Permille": 50
}
配置里没有浮点:留存与衰减全部是整数千分比。d1Permille = 420 表示 D1 留存 42.0%;earlyDecayPermille = 842 表示早段每天保留 84.2%;lateDecayPermille = 961 表示晚段每天保留 96.1%。晚段衰减明显更慢,这是刻意为之——早期流失快、后期流失慢是留存曲线的普遍形态。
S 层:两段衰减递推留存曲线
下面这段是 S 层仿真脚本。它读上面那份配置,用「D1 起点 + 早/晚两段每日衰减」递推 0–30 天留存,写出 JSON、CSV 与一张留存曲线 SVG。全程整数递推,没有任何浮点。
#!/usr/bin/env python3
# file: sim/ch05_lifecycle.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch05_lifecycle.py --config config/ --out out/
"""生命周期留存结构:读 config/ch05_lifecycle.json 的示例参数,
用「D1 起点 + 早/晚两段每日衰减(千分比)」递推 0–30 天留存曲线,
输出 D1 / D7 / D30 与逐日序列,产出 JSON + CSV + SVG。全程整数,无浮点。
注意:留存参数为假设值,真实留存需真实业务环境,命令行不可复现(X)。
"""
import argparse
import json
import os
import xml.etree.ElementTree as ET
def load_config(config_dir, name):
with open(os.path.join(config_dir, name), encoding="utf-8") as f:
return json.load(f)
def retention_curve(cfg):
"""day0=1000‰;day1=d1;day2..earlyEnd 用早段衰减;之后用晚段衰减。"""
d1 = cfg["d1Permille"]
early = cfg["earlyDecayPermille"]
late = cfg["lateDecayPermille"]
end = cfg["earlyEndDay"]
horizon = cfg["horizonDays"]
curve = [1000, d1]
for d in range(2, horizon + 1):
decay = early if d <= end else late
curve.append(curve[-1] * decay // 1000)
return curve
def anchors(curve):
return {"d1Permille": curve[1], "d7Permille": curve[7], "d30Permille": curve[30]}
def write_svg(path, curve, title):
W, H, pl, pr, pt, pb = 640, 360, 72, 24, 32, 48
xs = list(range(len(curve)))
hi = max(curve) or 1
iw, ih = W - pl - pr, H - pt - pb
svg = ET.Element(
"svg",
{"xmlns": "http://www.w3.org/2000/svg", "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)},
)
ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#ffffff"})
t = ET.SubElement(svg, "text", {"x": str(pl), "y": "22", "font-size": "14", "fill": "#111827"})
t.text = title
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt + ih), "x2": str(W - pr), "y2": str(pt + ih), "stroke": "#9ca3af"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(pt + ih), "stroke": "#9ca3af"})
pts = []
for x, y in zip(xs, curve):
sx = pl + iw * x // (len(curve) - 1)
sy = pt + ih - ih * y // hi
pts.append(f"{sx},{sy}")
ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none", "stroke": "#7c3aed", "stroke-width": "2"})
for d in (1, 7, 30):
sx = pl + iw * d // (len(curve) - 1)
sy = pt + ih - ih * curve[d] // hi
ET.SubElement(svg, "circle", {"cx": str(sx), "cy": str(sy), "r": "3", "fill": "#7c3aed"})
lab = ET.SubElement(svg, "text", {"x": str(sx), "y": str(sy - 8), "font-size": "10", "text-anchor": "middle", "fill": "#4c1d95"})
lab.text = f"D{d}={curve[d]}"
xml = ET.tostring(svg, encoding="unicode")
with open(path, "w", encoding="utf-8") as f:
f.write(xml)
ET.fromstring(xml)
return len(xml.encode())
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load_config(args.config, "ch05_lifecycle.json")
curve = retention_curve(cfg)
anch = anchors(curve)
res = {"version": 1, "horizonDays": cfg["horizonDays"], "anchors": anch, "curve": curve}
with open(os.path.join(args.out, "ch05_lifecycle.json"), "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch05_lifecycle.csv"), "w", encoding="utf-8") as f:
f.write("day,retentionPermille\n")
for d, v in enumerate(curve):
f.write(f"{d},{v}\n")
size = write_svg(os.path.join(args.out, "ch05_lifecycle.svg"), curve, "《合成小岛》示例留存曲线(‰)")
print(json.dumps(anch, ensure_ascii=False))
print(f"# svg bytes={size}")
if __name__ == "__main__":
main()
脚本除 JSON 外还写出 out/ch05_lifecycle.csv(0–30 天逐日留存)和 out/ch05_lifecycle.svg(留存曲线,标注 D1/D7/D30)。运行命令:
/opt/homebrew/bin/python3 sim/ch05_lifecycle.py --config config/ --out out/
{"d1Permille": 420, "d7Permille": 148, "d30Permille": 51}
# svg bytes=1088
读结果:一条早陡晚缓的衰减曲线
在本机(Python 3.14.6)跑出来的三个锚点是 D1 = 42.0%、D7 = 14.8%、D30 = 5.1%。几个关键日的留存(千分比)如下:
| 日 | 0 | 1 | 2 | 3 | 5 | 7 | 10 | 14 | 20 | 30 |
|---|---|---|---|---|---|---|---|---|---|---|
| 留存‰ | 1000 | 420 | 353 | 297 | 210 | 148 | 130 | 109 | 83 | 51 |
三个可以直接读出来的结论:
- 前 7 天掉了七成多:从 420‰ 掉到 148‰,D1→D7 的损失占了整条曲线的大头。这意味着运营干预的黄金窗口是第 1–7 天——晚段每天只掉 3.9%(961‰ 保留),早段每天掉 15.8%(842‰ 保留),两者的干预性价比差一个数量级。
- D7 之后曲线趋于平缓:D7 = 148‰、D30 = 51‰,中间 23 天只再掉约 65%。这正是「留下来的人更忠诚」的量化表达。对设计的意义是:长线内容(放置收益、成长曲线)服务的是那 5% 的长期留存者,而不是用来挽回早期流失。
- 锚点不是独立的:改
d1Permille会同时抬高 D7 和 D30;改lateDecayPermille只动 D30 附近。这种参数与锚点的对应关系,正是「两段衰减」建模相对于「三个数各填一个」的价值。
再次强调:以上全部是同机相对量级,绝对数值不可移植,且输入是假设参数而非真实后台数据。
B 层:衰减参数的敏感性
留存曲线的形状取决于两个衰减参数,而这两个参数是假设。B 层要回答的问题是:假设偏了,结论会偏多少? 下面这段是 B 层基准脚本,它对早/晚两段衰减同时施加一个统一扰动(−60‰ 到 +30‰),看 D7 与 D30 怎么动,并对 D30 低于阈值(50‰)的场景打软告警。
#!/usr/bin/env python3
# file: sim/ch05_lifecycle_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch05_lifecycle_bench.py --config config/ --out out/
"""衰减参数敏感性基准:对 config/ch05_lifecycle.json 的早/晚两段衰减
同时施加 [-60, +60] 千分比的统一扰动,看 D1 / D7 / D30 怎么动,
并对 D30 低于 warnD30Permille 的场景打软告警(exit 仍为 0)。
注意:这是模型对参数假设的敏感性,不是真实留存观测(X)。
"""
import argparse
import json
import os
def load_config(config_dir, name):
with open(os.path.join(config_dir, name), encoding="utf-8") as f:
return json.load(f)
def curve_with(cfg, early, late):
d1 = cfg["d1Permille"]
end = cfg["earlyEndDay"]
horizon = cfg["horizonDays"]
curve = [1000, d1]
for d in range(2, horizon + 1):
decay = early if d <= end else late
curve.append(curve[-1] * decay // 1000)
return curve
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load_config(args.config, "ch05_lifecycle.json")
warn = cfg["warnD30Permille"]
rows = []
# 扰动范围只取到 +30‰,保证晚段衰减 961+30=991 仍 ≤1000(>1000 意味着留存回升,非物理)
for delta in (-60, -40, -20, 0, 15, 30):
early = cfg["earlyDecayPermille"] + delta
late = cfg["lateDecayPermille"] + delta
c = curve_with(cfg, early, late)
rows.append(
{
"decayDeltaPermille": delta,
"earlyDecayPermille": early,
"lateDecayPermille": late,
"d7Permille": c[7],
"d30Permille": c[30],
"warn": c[30] < warn,
}
)
base = next(r for r in rows if r["decayDeltaPermille"] == 0)
d30_values = [r["d30Permille"] for r in rows]
res = {
"version": 1,
"warnThresholdPermille": warn,
"baseD30Permille": base["d30Permille"],
"d30MinPermille": min(d30_values),
"d30MaxPermille": max(d30_values),
"d30SpreadPermille": max(d30_values) - min(d30_values),
"warnings": sum(1 for r in rows if r["warn"]),
"rows": rows,
}
with open(os.path.join(args.out, "ch05_lifecycle_bench.json"), "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
print(json.dumps({k: res[k] for k in ("baseD30Permille", "d30MinPermille", "d30MaxPermille", "d30SpreadPermille", "warnings")}, ensure_ascii=False))
for r in rows:
flag = "WARN" if r["warn"] else "ok"
print(f"delta={r['decayDeltaPermille']:+d} early={r['earlyDecayPermille']} late={r['lateDecayPermille']} D7={r['d7Permille']} D30={r['d30Permille']} {flag}")
if __name__ == "__main__":
main()
注意 B 层的纪律:它只告警、不失败——exit 始终为 0。敏感性扫描的目的是暴露「结论对假设有多敏感」,而不是判定对错。运行命令与输出:
/opt/homebrew/bin/python3 sim/ch05_lifecycle_bench.py --config config/ --out out/
{"baseD30Permille": 51, "d30MinPermille": 5, "d30MaxPermille": 137, "d30SpreadPermille": 132, "warnings": 3}
delta=-60 early=782 late=901 D7=94 D30=5 WARN
delta=-40 early=802 late=921 D7=109 D30=11 WARN
delta=-20 early=822 late=941 D7=128 D30=25 WARN
delta=+0 early=842 late=961 D7=148 D30=51 ok
delta=+15 early=857 late=976 D7=164 D30=85 ok
delta=+30 early=872 late=991 D7=183 D30=137 ok
结果读出来是:
| 衰减扰动‰ | 早段衰减 | 晚段衰减 | D7‰ | D30‰ | 告警 |
|---|---|---|---|---|---|
| −60 | 782 | 901 | 94 | 5 | WARN |
| −40 | 802 | 921 | 109 | 11 | WARN |
| −20 | 822 | 941 | 128 | 25 | WARN |
| 0 | 842 | 961 | 148 | 51 | ok |
| +15 | 857 | 976 | 164 | 85 | ok |
| +30 | 872 | 991 | 183 | 137 | ok |
两条结论:
- D30 对衰减假设极度敏感:衰减参数只挪动 90‰(−60 到 +30),D30 就从 5‰ 涨到 137‰,跨度 132‰——比基准值 51‰ 还大两倍多。这说明 D30 是一个「精度很差」的指标:在参数不确定时,它几乎不能作为决策依据,只能作为方向参考。
- 告警线在 −20‰ 就触发:基准 D30 = 51‰ 只比阈值 50‰ 高 1‰,所以只要衰减稍微变差(−20‰),D30 就跌破告警线。这提醒一件事:当你的模型结论刚好卡在阈值边缘时,它其实是「不确定」而不是「达标」。B 层把这个不确定性显式打出来,正是为了避免「拿一个边缘值当通过」。
顺带说明为什么扫描范围到 +30‰ 就停:晚段基准衰减是 961‰,再加超过 39‰ 就超过 1000‰,而衰减 > 1000‰ 意味着留存逐日回升,这在物理上不成立。扫描一旦越过这条线,结果就失去意义——所以 B 层把上界卡在 +30‰。
留存结构服务的是分段运营
回到本章的主线:画像(5.1)、意愿(5.2)、留存(5.3)三条线,最终要拼成一张分阶段、分人群的运营地图。留存曲线给出的最实用的一条是阶段划分:
- D1–D7 早段——流失最快,干预性价比最高。运营动作应集中在新手引导、首周目标、每日登录奖励。这一段对应下一章要讲的核心循环与反馈密度。
- D8–D30 晚段——流失放缓,留下的人动机更强。运营动作应转向成长曲线、放置收益与社交面,服务「愿意留的人」而不是「已经走的人」。
必须把边界画清楚:本节产出的是一条可调参的假设留存曲线,不是真实留存预测。它有三个不可复现点(X):真实的 D1/D7/D30、真实的早/晚衰减率、真实的流失拐点,都需要真实业务环境,命令行不可复现。本节能复现的只有一件事:给定这组假设参数,曲线与三个锚点是确定的一组整数。
生命周期到这里讲完,本章也收尾。下一章从「人」转到「玩法」,进入核心循环:把一局游戏拆成「操作 → 反馈 → 奖励」的最小闭环,并算出循环时长与反馈密度。
阅读导航:上一节:动机模型与付费意愿分层 · 下一节:一句话玩法与核心循环拆解 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。