2.3 生命周期阶段与体验目标

把「第 1 天留人、第 7 天养成、第 30 天沉淀」从口号变成可仿真的阶段模型:定义《王朝纪元》四阶段与其体验目标,用阶段日留存千分比推出 90 天留存与活跃曲线,并用一个 B 基准把曲线与目标留存点做残差对照,让阶段边界成为可检验的拐点。

前两节把玩家按动机和付费意愿切了两刀,但那两刀都是「横切面」——它们描述的是「某一时刻的人群构成」。真实的产品是纵向的:同一个玩家在第 1 天和第 30 天,诉求完全不同。第 1 天他要的是「别让我迷路」,第 30 天他要的是「给我一个继续留下来的理由」。这一节就补上时间轴。

动手前先划边界。本节讲的是阶段划分与各阶段的体验目标,不碰任何付费点(那是卷二第 9 章)、不碰 LTV 建模(那是卷二第 11 章)。站内的经济平衡长文 用的是放置类口径,玩家的「留存」几乎等价于「挂机时长」;SLG 完全不同——玩家会主动上线、主动离线,留存是一条由行为驱动的衰减曲线,所以本卷换用 SLG 大地图的口径来刻画它。同时,站内的 SLG 技术架构专题 讲的是前后端与协议,明确不讨论玩法数值,本节讲的正是它没碰的那一半。

玩家不是静态的:把时间轴加上

做留存分析最容易犯的错,是把「留存」当成一个单点指标:D1 多少、D7 多少、D30 多少。可这三个数字背后是三种完全不同的玩家状态:

  • D1 的玩家在问「这个游戏怎么玩」;
  • D7 的玩家在问「我能不能在这里变强」;
  • D30 的玩家在问「我为什么还要每天上线」。

把三个阶段当成一件事来做,就会出现「新手引导做得太长把 D7 玩家烦走」「成长线开得太慢把 D1 玩家卡住」这类错配。正确的做法是先把生命周期切成阶段,再给每个阶段配一个明确的体验目标——阶段是「什么时候」,体验目标是「那时候要让他感受到什么」。

还有一层更隐蔽的问题:阶段目标会互相打架。新手期的目标是把操作讲清楚,天然要求「慢」;成长期的目标是让人迅速感到变强,天然要求「快」。如果产品只写一句「要留住玩家」,这两条互相矛盾的诉求就会被平均掉,最后两头都不讨好。把阶段显式分开,本质上是把矛盾摊到桌面上,让它成为一条有边界的设计决策,而不是一个含糊的折中。

四个阶段与各自的体验目标

《王朝纪元》把前 90 天切成四个阶段。阶段边界与体验目标如下:

阶段id天区间体验目标(要让他感受到)活跃目标
新手期onboardingD0–D2「我懂了」——理解资源、城建、出征三件事高(教完为止)
成长期growthD3–D7「我在变强」——第一次升主城、第一次出征得手高(养成高峰)
成熟期matureD8–D30「我属于这里」——加入联盟、参与一次集体行动中(日常打卡)
沉淀期sedimentD31–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 章的赛季重置是三条互不相同的时间轴——阶段回答「玩家走到了哪一步」,版本回答「产品走到了哪一步」,赛季回答「经济走到了哪一步」。三者在后续章节会分别展开,本节只负责第一条:把「玩家走到了哪一步」变成一条可仿真的曲线。

阅读导航:上一节:付费意愿分层 · 下一节:资源循环与行为闭环 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引