5.3 生命周期阶段与留存结构

把「玩家会留下来多久」从一句愿望变成一条可递推的留存曲线:用 D1 起点加早晚两段整数衰减参数,递推出《合成小岛》0–30 天留存,输出 D1 / D7 / D30 与逐日序列,产 JSON + CSV + SVG,并用 B 层做衰减参数的敏感性扫描与软告警。

前两节画的是横截面: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%。几个关键日的留存(千分比)如下:

日01235710142030
留存‰10004203532972101481301098351

三个可以直接读出来的结论:

  • 前 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‰告警
−60782901945WARN
−4080292110911WARN
−2082294112825WARN
084296114851ok
+1585797616485ok
+30872991183137ok

两条结论:

  • 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、真实的早/晚衰减率、真实的流失拐点,都需要真实业务环境,命令行不可复现。本节能复现的只有一件事:给定这组假设参数,曲线与三个锚点是确定的一组整数。

生命周期到这里讲完,本章也收尾。下一章从「人」转到「玩法」,进入核心循环:把一局游戏拆成「操作 → 反馈 → 奖励」的最小闭环,并算出循环时长与反馈密度。

阅读导航:上一节:动机模型与付费意愿分层 · 下一节:一句话玩法与核心循环拆解 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制