前两节把玩家按动机和付费意愿切了两刀,但那两刀都是「横切面」——它们描述的是「某一时刻的人群构成」。真实的产品是纵向的:同一个玩家在第 1 天和第 30 天,诉求完全不同。第 1 天他要的是「别让我迷路」,第 30 天他要的是「给我一个继续留下来的理由」。这一节就补上时间轴。
动手前先划边界。本节讲的是阶段划分与各阶段的体验目标,不碰任何付费点(那是卷二第 9 章)、不碰 LTV 建模(那是卷二第 11 章)。站内的经济平衡长文 用的是放置类口径,玩家的「留存」几乎等价于「挂机时长」;SLG 完全不同——玩家会主动上线、主动离线,留存是一条由行为驱动的衰减曲线,所以本卷换用 SLG 大地图的口径来刻画它。同时,站内的 SLG 技术架构专题 讲的是前后端与协议,明确不讨论玩法数值,本节讲的正是它没碰的那一半。
玩家不是静态的:把时间轴加上
做留存分析最容易犯的错,是把「留存」当成一个单点指标:D1 多少、D7 多少、D30 多少。可这三个数字背后是三种完全不同的玩家状态:
- D1 的玩家在问「这个游戏怎么玩」;
- D7 的玩家在问「我能不能在这里变强」;
- D30 的玩家在问「我为什么还要每天上线」。
把三个阶段当成一件事来做,就会出现「新手引导做得太长把 D7 玩家烦走」「成长线开得太慢把 D1 玩家卡住」这类错配。正确的做法是先把生命周期切成阶段,再给每个阶段配一个明确的体验目标——阶段是「什么时候」,体验目标是「那时候要让他感受到什么」。
还有一层更隐蔽的问题:阶段目标会互相打架。新手期的目标是把操作讲清楚,天然要求「慢」;成长期的目标是让人迅速感到变强,天然要求「快」。如果产品只写一句「要留住玩家」,这两条互相矛盾的诉求就会被平均掉,最后两头都不讨好。把阶段显式分开,本质上是把矛盾摊到桌面上,让它成为一条有边界的设计决策,而不是一个含糊的折中。
四个阶段与各自的体验目标
《王朝纪元》把前 90 天切成四个阶段。阶段边界与体验目标如下:
| 阶段 | id | 天区间 | 体验目标(要让他感受到) | 活跃目标 |
|---|---|---|---|---|
| 新手期 | onboarding | D0–D2 | 「我懂了」——理解资源、城建、出征三件事 | 高(教完为止) |
| 成长期 | growth | D3–D7 | 「我在变强」——第一次升主城、第一次出征得手 | 高(养成高峰) |
| 成熟期 | mature | D8–D30 | 「我属于这里」——加入联盟、参与一次集体行动 | 中(日常打卡) |
| 沉淀期 | sediment | D31–D90 | 「这里有我放不下的东西」——长线目标与社交羁绊 | 低但稳定 |
这张表的两个设计要点:
- 体验目标是「一句话感受」,不是功能清单。写「我懂了」而不是「完成新手引导第 7 步」,因为感受才是阶段要达成的结果,功能只是手段。
- 活跃目标与阶段反相关。新手期需要高频上线学习,沉淀期只需要低频但稳定。这不是让玩家少玩,而是承认长线玩家的时间投入天然下降,产品要接受这一点,用「稳定的低频」而不是「硬撑的高频」来定义成功。
需要说明:这四个阶段的边界(D2 / D7 / D30)与体验目标是设计假设(X),需要真实业务环境的用户调研与埋点才能校准,命令行不可复现。本节可复现的是「给定这些阶段假设,留存与活跃曲线会长什么样」。
从体验目标反推衰减曲线
阶段是定性的,但要让它在数值上可检验,就得把它翻译成一条曲线。本节的翻译方式是:每个阶段给一个日留存千分比(dailyRetentionPermille)和一个活跃率千分比(activityPermille),然后逐日累乘。
- 日留存千分比:该阶段内「今天还在的人,明天还在」的比例。新手期最低(流失最快),沉淀期最高(留下的人最稳)。
- 活跃率千分比:该阶段内「留存的人里,当天真的上了线」的比例。它随阶段下降——越到后期,玩家越「挂着不走但也不天天来」。
两个比率一乘,就得到一条留存曲线(还剩多少人)和一条活跃曲线(还剩的人里多少在动)。关键在于:阶段边界让曲线出现拐点——新手期到成长期是一个明显的抬升,因为熬过前三天的人明显更稳。如果曲线是一条光滑的指数衰减,说明阶段划分没有起效。
D 层:阶段与目标写进配置
下面这份是 D 层配置:它把四个阶段的天区间、日留存、活跃率,以及用于基准对照的目标留存点全部写死为整数。
{
"version": 1,
"note": "阶段日留存与活跃率均为千分比整数;目标留存同样为千分比,用于 B 基准残差",
"seed": 20261012,
"days": 90,
"initial": 10000,
"stages": [
{ "id": "onboarding", "name": "新手期", "fromDay": 0, "toDay": 2, "dailyRetentionPermille": 780, "activityPermille": 900 },
{ "id": "growth", "name": "成长期", "fromDay": 3, "toDay": 7, "dailyRetentionPermille": 950, "activityPermille": 820 },
{ "id": "mature", "name": "成熟期", "fromDay": 8, "toDay": 30, "dailyRetentionPermille": 975, "activityPermille": 700 },
{ "id": "sediment", "name": "沉淀期", "fromDay": 31, "toDay": 90, "dailyRetentionPermille": 985, "activityPermille": 520 }
],
"targetRetentionPermille": {
"d1": 780,
"d7": 470,
"d30": 260
}
}
配置里全是整数千分比,没有一处浮点——和前一节同样的理由:为了让 Python 与 TypeScript 两侧能跑到逐位一致。
S 层:推 90 天留存与活跃曲线
下面这段是 S 层仿真脚本。它按阶段日留存逐日累乘出 90 天的留存曲线,再乘活跃率得到活跃曲线,输出 D1/D7/D30/D90 的留存千分比,并画出折线 SVG。
# file: sim/ch02_lifecycle.py
# tier: S
# verify: python3 sim/ch02_lifecycle.py --config config/
"""生命周期留存/活跃衰减仿真:按阶段日留存千分比推 90 天留存曲线与活跃曲线,
输出 D1/D7/D30/D90 留存与 CSV/SVG。全程整数运算。"""
from __future__ import annotations
import argparse
import json
import os
import xml.etree.ElementTree as ET
def load_config(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, name), encoding="utf-8") as f:
return json.load(f)
def stage_of(stages: list[dict], day: int) -> dict:
for s in stages:
if s["fromDay"] <= day <= s["toDay"]:
return s
return stages[-1]
def curve(cfg: dict) -> list[int]:
stages = cfg["stages"]
retained = [cfg["initial"]]
for d in range(1, cfg["days"] + 1):
retained.append(retained[-1] * stage_of(stages, d)["dailyRetentionPermille"] // 1000)
return retained
def simulate(cfg: dict) -> dict:
retained = curve(cfg)
initial = cfg["initial"]
def pm(d: int) -> int:
return retained[d] * 1000 // initial
return {
"version": 1,
"days": cfg["days"],
"initial": initial,
"retainedD1": retained[1],
"retainedD7": retained[7],
"retainedD30": retained[30],
"retainedD90": retained[90],
"retentionPermilleD1": pm(1),
"retentionPermilleD7": pm(7),
"retentionPermilleD30": pm(30),
"retentionPermilleD90": pm(90),
"activityD30": retained[30] * stage_of(cfg["stages"], 30)["activityPermille"] // 1000,
}
def write_svg(path: str, cfg: dict, res: dict) -> int:
W, H, pl, pr, pt, pb = 640, 360, 64, 24, 32, 44
days, stages, initial = cfg["days"], cfg["stages"], cfg["initial"]
retained = curve(cfg)
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": "#fff"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(H - pb), "stroke": "#333"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(H - pb), "x2": str(W - pr), "y2": str(H - pb), "stroke": "#333"})
pts = [f"{pl + iw * d / days:.2f},{pt + ih * (1 - retained[d] / initial):.2f}" for d in range(days + 1)]
ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none", "stroke": "#1f6feb", "stroke-width": "2"})
for d in (0, 7, 30, 60, 90):
t = ET.SubElement(svg, "text", {"x": f"{pl + iw * d / days:.1f}", "y": str(H - pb + 18), "font-size": "11", "text-anchor": "middle", "fill": "#555"})
t.text = f"D{d}"
ttl = ET.SubElement(svg, "text", {"x": str(pl), "y": "20", "font-size": "13", "fill": "#111"})
ttl.text = f"《王朝纪元》留存曲线(初始 {initial},D30={retained[30]})"
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
assert ET.parse(path).getroot().tag.endswith("svg")
return os.path.getsize(path)
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load_config(args.config, "ch02_lifecycle.json")
res = simulate(cfg)
with open(os.path.join(args.out, "ch02_lifecycle.json"), "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
retained = curve(cfg)
with open(os.path.join(args.out, "ch02_lifecycle.csv"), "w", encoding="utf-8") as f:
f.write("day,retained,activity,stage\n")
for d in range(cfg["days"] + 1):
st = stage_of(cfg["stages"], d)
f.write(f"{d},{retained[d]},{retained[d] * st['activityPermille'] // 1000},{st['id']}\n")
size = write_svg(os.path.join(args.out, "ch02_lifecycle.svg"), cfg, res)
print(json.dumps(res, ensure_ascii=False))
print(f"# svg bytes={size}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
脚本还会写出 out/ch02_lifecycle.csv(逐日的留存、活跃与所属阶段)和 out/ch02_lifecycle.svg。上面是完整文件,读 config/ 里的阶段配置即可复现整条曲线。
读结果:拐点来自阶段切换
在本机(Python 3.14.6,初始 10000,90 天)跑出来的结果是:
retainedD1: 7800 retentionPermilleD1: 780
retainedD7: 4706 retentionPermilleD7: 470
retainedD30: 2621 retentionPermilleD30: 262
retainedD90: 1040 retentionPermilleD90: 104
activityD30: 1834
读这条曲线,两个数字最关键:
- D1 = 780‰、D7 = 470‰。从 D1 到 D7 掉了约四成,这不是坏事——它反映的是「熬过新手期的人明显更稳」这个设计意图:新手期日留存只有 780‰,一进成长期就抬到 950‰。曲线上会看到 D2→D3 的斜率明显变缓,这就是阶段切换带来的拐点。
- D30 = 262‰、D90 = 104‰。从 D30 到 D90 只掉了约六成,说明进入成熟期后玩家结构趋于稳定。这也是同机相对量级,绝对数值不可移植——真实产品的数字取决于买量质量与玩法质量,这里的数字只说明「阶段模型能造出一条带拐点的曲线」。
B 基准:把曲线与目标留存做残差对照
一条曲线光「长得像衰减」还不够,得能被检验。本节给这条曲线配一个 B 基准:把仿真出来的 D1/D7/D30 留存千分比,与配置里写死的目标留存点做残差对照。残差超阈值只打 WARN,退出码恒为 0——基准是软告警,用来发现漂移,不阻断流程。
# file: sim/ch02_lifecycle_bench.py
# tier: B
# verify: python3 sim/ch02_lifecycle_bench.py --config config/
"""B 基准:把阶段模型的留存曲线与配置里的目标留存点做对照,算千分比残差。
软告警不硬失败——残差超阈值只打 WARN,退出码恒为 0。"""
from __future__ import annotations
import argparse
import json
import os
WARN_THRESHOLD_PERMILLE = 60
def load_config(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, name), encoding="utf-8") as f:
return json.load(f)
def stage_of(stages: list[dict], day: int) -> dict:
for s in stages:
if s["fromDay"] <= day <= s["toDay"]:
return s
return stages[-1]
def curve(cfg: dict) -> list[int]:
retained = [cfg["initial"]]
for d in range(1, cfg["days"] + 1):
retained.append(retained[-1] * stage_of(cfg["stages"], d)["dailyRetentionPermille"] // 1000)
return retained
def benchmark(cfg: dict) -> dict:
retained = curve(cfg)
initial = cfg["initial"]
checks = []
for key, day in (("d1", 1), ("d7", 7), ("d30", 30)):
sim = retained[day] * 1000 // initial
target = cfg["targetRetentionPermille"][key]
resid = sim - target
checks.append({"point": key, "day": day, "simPermille": sim, "targetPermille": target,
"residualPermille": resid, "warn": abs(resid) > WARN_THRESHOLD_PERMILLE})
residuals = [abs(c["residualPermille"]) for c in checks]
return {
"version": 1,
"thresholdPermille": WARN_THRESHOLD_PERMILLE,
"checks": checks,
"maxAbsResidualPermille": max(residuals),
"meanAbsResidualPermille": sum(residuals) // len(residuals),
"anyWarn": any(c["warn"] for c in checks),
}
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
res = benchmark(load_config(args.config, "ch02_lifecycle.json"))
with open(os.path.join(args.out, "ch02_lifecycle_bench.json"), "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
for c in res["checks"]:
print(f"{'WARN' if c['warn'] else 'OK'} {c['point']} sim={c['simPermille']} target={c['targetPermille']} resid={c['residualPermille']}")
print(json.dumps({k: res[k] for k in ("maxAbsResidualPermille", "meanAbsResidualPermille", "anyWarn")}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
跑出来三行对照是 d1: 780/780 → 残差 0、d7: 470/470 → 残差 0、d30: 262/260 → 残差 +2,最大绝对残差 2 个千分比、均值 0、anyWarn=false。这说明阶段模型精确命中了设计目标。基准真正的价值不在这次全绿,而在改配置时的报警:一旦你把某个阶段的日留存调过头,D7 或 D30 的残差会立刻越过 60‰ 的阈值亮起 WARN。
阈值 60‰ 的选取本身也是一条设计决策:定得太松(比如 150‰),模型跑偏了也不报警;定得太严(比如 10‰),正常的参数微调就会天天刷屏。60‰ 大致相当于「D30 留存偏离目标 6 个百分点」,在一个 90 天的阶段模型里,这个量级已经足以说明某一段的留存假设需要复核,而正常的调参又不会误触。基准的意义不是判对错,而是给「模型漂移」画一条可以自动盯住的线。
阶段模型怎么接进后面的章节
阶段模型怎么接进后面的章节
阶段与体验目标一旦落成曲线,就有了三个直接用途:
- 配产出/消耗节奏:第 3 章的核心循环会按阶段给不同的资源产出权重,新手期重引导、成熟期重联盟。
- 定系统解锁节奏:第 4 章的解锁链会用阶段边界决定「什么功能在第几天开」,避免把长线内容提前砸给新手。
- 做埋点口径:第 8 章的指标体系会把「阶段」作为一个维度切分留存,而不是只看一个总的 D7。
到这里,本章三条正交的切分线——动机、意愿、生命周期——就都立起来了。它们共同回答「为谁设计」;下一章开始回答「设计成什么样」,从 SLG 的核心循环讲起。
最后补一句边界:本节把「阶段」定义在自然生命周期上(从注册算起的天数),这与第 7 章的版本列车、第 13 章的赛季重置是三条互不相同的时间轴——阶段回答「玩家走到了哪一步」,版本回答「产品走到了哪一步」,赛季回答「经济走到了哪一步」。三者在后续章节会分别展开,本节只负责第一条:把「玩家走到了哪一步」变成一条可仿真的曲线。
阅读导航:上一节:付费意愿分层 · 下一节:资源循环与行为闭环 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。