14.3 数据表现与迭代

上线只是起点,数据从第一天起就在自然衰减。这一节把《合成小岛》上线后 12 周的三条核心指标(次日留存、7 日留存、ARPDAU)做成「每周衰减 + 迭代注入」的指数轨迹,用可播种仿真算出每次迭代的边际提升与「每投入一天的产出」,再给一个与 Python 逐位一致的迭代优先级内核,回答「下一版该先做哪件事」。

时间线回答了「什么时候做完」,决策复盘回答了「做得对不对」,最后一节回答**「做完之后,数据到底动了没有」。边界先说清:新手漏斗怎么拆是留存漏斗 那一章、单次改动怎么验是 A/B 实验 那一章、版本怎么排是版本列车 那一章、活动怎么做是活动模板 那一章。本节不重讲这些方法**,只把它们的结果摆到同一张时间轴上,看每一次迭代各自拉动了哪条曲线、每投入一天的产出是多少。

还有一条纪律必须写在最前面:本节的所有数字都是示例假设,不是《合成小岛》的真实后台数据。真实的上线表现需要真实业务环境,命令行不可复现(X)。这里给的是一个可替换参数的模型——读者把配置里的基线、衰减率、每次迭代的增量换成自己游戏的经验值,脚本立刻能跑出属于你的轨迹。

上线后的数据不是一条直线

很多人对上线后的数据有一个错误直觉:上线时是多少,之后大概就维持在多少。真实情况是数据从第一天起就在自然衰减——内容被消耗、新鲜感消退、竞品抢走注意力,留存和 ARPDAU 都会往下走。迭代的作用首先不是「增长」,而是「对抗衰减」:把一个本来会掉到谷底的曲线,靠一次次版本更新拉回来。理解了这一点,评估迭代的标准就变了——不是「这次更新带来了多少新增」,而是「这次更新比不更新多留住了多少」。

模型只做两件事:每周先按固定速率衰减,某次迭代在其所在周注入一次持久增量。三条指标各自维护一条以「基线 = 1000」为起点的指数曲线:次日留存 d1 / 7 日留存 d7 / ARPDAU。这样任何一次改动的影响都能被拆成「衰减吃掉多少」和「迭代补回多少」两部分。

D 层:把基线、衰减与迭代写进配置

下面这份是 D 层(静态层)配置,给出三条指标的基线、每周衰减率,以及五次迭代各自的时间、目标指标、增量与投入人天。所有数值为示例假设,真实数值需真实业务环境,命令行不可复现(X)。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "上线后数据表现与迭代模型(D 层)。所有指标以「基线 = 1000」的指数表示,每周自然衰减 decayPermillePerWeek,迭代在某周注入一次持久增量 liftPermille。基线指标与所有增量均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。全整数(千分比),禁浮点。",
  "units": {
    "index": "基线 = 1000 的相对指数",
    "d1Permille": "次日留存(千分比,示例假设)",
    "d7Permille": "7 日留存(千分比,示例假设)",
    "arpdauTenths": "ARPDAU(0.1 分,示例假设)"
  },
  "weeks": 12,
  "baseline": { "d1Permille": 300, "d7Permille": 90, "arpdauTenths": 90 },
  "decayPermillePerWeek": { "d1": 20, "d7": 12, "arpdau": 25 },
  "iterations": [
    { "week": 2, "version": "v1.1", "name": "新手引导缩短", "metric": "d1", "liftPermille": 180, "effortDays": 5 },
    { "week": 4, "version": "v1.2", "name": "广告频次回调", "metric": "arpdau", "liftPermille": 120, "effortDays": 3 },
    { "week": 6, "version": "v1.3", "name": "关卡难度重标定", "metric": "d7", "liftPermille": 200, "effortDays": 6 },
    { "week": 8, "version": "v1.4", "name": "首充礼包上线", "metric": "arpdau", "liftPermille": 200, "effortDays": 4 },
    { "week": 10, "version": "v1.5", "name": "节点活动", "metric": "d1", "liftPermille": 140, "effortDays": 5 }
  ]
}

基线是 d1 = 300‰(三成次日留存)、d7 = 90‰、ARPDAU = 9.0 分。每周衰减率是指数点:d1 每周掉 20 点、d7 掉 12 点、ARPDAU 掉 25 点。五次迭代分别打在新手引导、广告频次、难度、首充礼包与节点活动上。全整数,禁浮点。

S 层:逐周轨迹与迭代 ROI

下面这段是 S 层脚本,读上面这份配置:对每条指标逐周推进(先衰减、再叠加当周迭代增量),得到三条指数轨迹;同时算一条「纯衰减、不迭代」的对照,两者的差就是迭代对抗衰减的净效果;最后按「每投入一天带来的指数提升」给迭代排 ROI。全整数地板除。

#!/usr/bin/env python3
# file: sim/l14_performance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l14_performance.py --config config/ --out out/
"""上线后数据表现与迭代仿真:读 config/l14_performance.json。

三条指标(次日留存 d1 / 7 日留存 d7 / ARPDAU)各以「基线 = 1000」的指数表示:
- 每周自然衰减 decayPermillePerWeek(指数点);
- 某次迭代在其所在周注入一次持久增量 liftPermille(指数点)。
逐周推进得到三条指数轨迹,再折算回指标原值。
同时算两条对照:① 纯衰减(不迭代)的期末指数,② 每次迭代的边际提升与「每投入一天的提升」
(liftPermille / effortDays)。全整数地板除。
产出 JSON + CSV + SVG。所有数字为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

METRICS = ["d1", "d7", "arpdau"]
COLORS = {"d1": "#2563eb", "d7": "#10b981", "arpdau": "#f59e0b"}


def load(d: str) -> dict:
    with open(os.path.join(d, "l14_performance.json"), encoding="utf-8") as f:
        return json.load(f)


def trajectory(cfg: dict, with_lifts: bool) -> dict:
    """逐周推进指数;with_lifts=False 时只衰减(对照)。返回 metric -> [index_0..index_W]。"""
    weeks = cfg["weeks"]
    decay = cfg["decayPermillePerWeek"]
    lifts: dict[str, dict[int, int]] = {m: {} for m in METRICS}
    if with_lifts:
        for it in cfg["iterations"]:
            w = it["week"]
            lifts[it["metric"]][w] = lifts[it["metric"]].get(w, 0) + it["liftPermille"]
    traj = {}
    for m in METRICS:
        series = [1000]
        for w in range(1, weeks + 1):
            series.append(series[-1] - decay[m] + lifts[m].get(w, 0))
        traj[m] = series
    return traj


def to_value(metric: str, index: int, baseline: dict) -> int:
    if metric == "d1":
        return baseline["d1Permille"] * index // 1000
    if metric == "d7":
        return baseline["d7Permille"] * index // 1000
    return baseline["arpdauTenths"] * index // 1000


def draw_svg(path: str, traj: dict, weeks: int) -> int:
    W, H, PAD = 760, 340, 56
    lo = min(min(s) for s in traj.values())
    hi = max(max(s) for s in traj.values())
    span = hi - lo + 1
    plot_w, plot_h = W - PAD - 30, H - 90
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
        "上线后 12 周指标指数(基线=1000,示例假设)"
    # 基线参考线
    y1000 = H - 40 - (1000 - lo) * plot_h // span
    ET.SubElement(svg, "line", {"x1": str(PAD), "y1": str(y1000), "x2": str(W - 30), "y2": str(y1000),
                                "stroke": "#d1d5db", "stroke-dasharray": "4 4"})
    ET.SubElement(svg, "text", {"x": str(W - 96), "y": str(y1000 - 4), "font-size": "11",
                                "fill": "#9ca3af"}).text = "基线 1000"
    for m in METRICS:
        pts = []
        for w, v in enumerate(traj[m]):
            x = PAD + (W - PAD - 30 - PAD) * w // weeks
            y = H - 40 - (v - lo) * plot_h // span
            pts.append(f"{x},{y}")
        ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none",
                                        "stroke": COLORS[m], "stroke-width": "2"})
        ET.SubElement(svg, "text", {"x": str(W - 26), "y": str(H - 40 - (traj[m][-1] - lo) * plot_h // span + 4),
                                    "font-size": "11", "fill": COLORS[m]}).text = m
    for w in range(0, weeks + 1, 2):
        x = PAD + (W - PAD - 30 - PAD) * w // weeks
        ET.SubElement(svg, "text", {"x": str(x - 4), "y": str(H - 24), "font-size": "10",
                                    "fill": "#6b7280"}).text = f"w{w}"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).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)
    weeks, baseline = cfg["weeks"], cfg["baseline"]
    with_lifts = trajectory(cfg, True)
    decay_only = trajectory(cfg, False)

    final = {m: with_lifts[m][-1] for m in METRICS}
    final_decay = {m: decay_only[m][-1] for m in METRICS}
    iter_roi = []
    for it in cfg["iterations"]:
        roi = it["liftPermille"] * 1000 // it["effortDays"]
        iter_roi.append({"version": it["version"], "name": it["name"], "metric": it["metric"],
                         "week": it["week"], "liftPermille": it["liftPermille"],
                         "effortDays": it["effortDays"], "liftPerDayPermille": roi})
    iter_roi.sort(key=lambda r: -r["liftPerDayPermille"])

    derived = {m: {"baseline": to_value(m, 1000, baseline), "finalIndex": final[m],
                   "final": to_value(m, final[m], baseline),
                   "decayOnlyIndex": final_decay[m], "decayOnly": to_value(m, final_decay[m], baseline),
                   "liftOverDecayIndex": final[m] - final_decay[m]} for m in METRICS}

    os.makedirs(a.out, exist_ok=True)
    sb = draw_svg(os.path.join(a.out, "l14_performance.svg"), with_lifts, weeks)
    with open(os.path.join(a.out, "l14_performance.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["week"] + [f"{m}Index" for m in METRICS] + [f"{m}IndexDecayOnly" for m in METRICS])
        for wk in range(weeks + 1):
            w.writerow([wk] + [with_lifts[m][wk] for m in METRICS] + [decay_only[m][wk] for m in METRICS])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "weeks": weeks, "iterationCount": len(cfg["iterations"]),
           "trajectory": {m: with_lifts[m] for m in METRICS},
           "derived": derived, "iterationRoi": iter_roi, "svgBytes": sb}
    with open(os.path.join(a.out, "l14_performance.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"finalIndex": final, "decayOnlyIndex": final_decay,
                      "derived": derived,
                      "topRoi": [[r["version"], r["liftPerDayPermille"]] for r in iter_roi[:3]]},
                     ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑它,输出如下(同机相对量级,绝对数值不可移植):

{"finalIndex": {"d1": 1080, "d7": 1056, "arpdau": 1020}, "decayOnlyIndex": {"d1": 760, "d7": 856, "arpdau": 700}, "derived": {"d1": {"baseline": 300, "finalIndex": 1080, "final": 324, "decayOnlyIndex": 760, "decayOnly": 228, "liftOverDecayIndex": 320}, "d7": {"baseline": 90, "finalIndex": 1056, "final": 95, "decayOnlyIndex": 856, "decayOnly": 77, "liftOverDecayIndex": 200}, "arpdau": {"baseline": 90, "finalIndex": 1020, "final": 91, "decayOnlyIndex": 700, "decayOnly": 63, "liftOverDecayIndex": 320}}, "topRoi": [["v1.4", 50000], ["v1.2", 40000], ["v1.1", 36000]]}

十二周后,次日留存指数 1080(折算 324‰,比基线高 24)、7 日留存指数 1056(95‰)、ARPDAU 指数 1020(9.1 分)。如果一次迭代都不做,三条线会分别掉到 760 / 856 / 700——折算成原值就是 d1 只剩 228‰、d7 只剩 77‰、ARPDAU 只剩 6.3 分。也就是说,迭代对抗衰减的净效果是 d1 +320 个指数点、ARPDAU +320 个指数点、d7 +200 个指数点:不迭代的话,留存和收入都会塌掉一大截。

P 层:迭代优先级内核

上面是「已经做完的五次迭代各自值多少」,还差一个前瞻问题:如果产能只够再做一件事,该先做哪件。下面这段是 P 层(内核层)TypeScript,把迭代按「每投入一天的指数提升」排序,轨迹推进逻辑与上面的 Python 逐位一致。跑 node --test 'core/test/l14_*.test.ts' 十个断言全过。

// file: core/src/l14/iteration.ts
// tier: P
// verify: node --test 'core/test/l14_*.test.ts'
//
// 迭代优先级内核:给定一批迭代(各带对某指标指数的 liftPermille 与 effortDays),
// 按「每投入一天带来的指数提升」从高到低排序;轨迹推进与 S 层 Python 的 iterationRoi 逐位一致。
// 全整数地板除,无 Math.random(),只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface Iteration {
  readonly version: string;
  readonly metric: string;
  readonly liftPermille: number;
  readonly effortDays: number;
}

export interface RankedIteration {
  readonly version: string;
  readonly metric: string;
  readonly liftPermille: number;
  readonly effortDays: number;
  readonly liftPerDayPermille: number;
}

/** 每投入一天带来的指数提升(整数地板除,与 S 层一致)。 */
export function liftPerDay(liftPermille: number, effortDays: number): number {
  return effortDays > 0 ? Math.floor(liftPermille * 1000 / effortDays) : 0;
}

/** 按 liftPerDayPermille 从高到低排序;并列时按 version 升序(确定性)。 */
export function prioritize(iterations: readonly Iteration[]): RankedIteration[] {
  return iterations
    .map((it) => ({
      version: it.version, metric: it.metric, liftPermille: it.liftPermille, effortDays: it.effortDays,
      liftPerDayPermille: liftPerDay(it.liftPermille, it.effortDays),
    }))
    .sort((a, b) => b.liftPerDayPermille - a.liftPerDayPermille || (a.version < b.version ? -1 : 1));
}

/** 逐周推进某指标的指数:每周先减 decayPerWeek,再叠加当周的 lift。 */
export function finalIndex(
  decayPerWeek: number,
  lifts: readonly { readonly week: number; readonly liftPermille: number }[],
  weeks: number,
): number {
  let v = 1000;
  for (let w = 1; w <= weeks; w++) {
    v -= decayPerWeek;
    for (const l of lifts) if (l.week === w) v += l.liftPermille;
  }
  return v;
}

按 ROI 排序,五次迭代是 v1.4 首充礼包(50000)> v1.2 广告频次回调(40000)> v1.1 新手引导缩短(36000)> v1.3 难度重标定(33333)> v1.5 节点活动(28000)。

三个真正该记住的结论

第一,高 ROI 的迭代往往不是「大内容」,而是「小改动」。排在最前的 v1.4 与 v1.2 都只花了 3–4 人天,打的是首充礼包与广告频次这类变现参数,单位投入的产出却最高;而 v1.3 难度重标定花了 6 天,ROI 反而靠后。这解释了为什么成熟团队总把「数值与变现调参」排在内容更新前面——它们便宜、见效快、且直接作用在收入曲线上。

第二,迭代的账要算「净效果」,不是「绝对提升」。五次迭代一共注入了 840 个指数点(180+120+200+200+140),但 d1 的期末指数只比基线高 80——因为衰减同期吃掉了 240 点(每周 20 点 × 12 周)。只看「这次更新带来 +180」,会严重高估迭代的功劳;减去衰减后的净值,才是真正留下的东西。

第三,衰减是常数敌人,停更等于倒退。对照很清楚:同样十二周,迭代版本 d1 稳在 324‰,纯衰减版本掉到 228‰。两者差的不是「增长」,而是「有没有崩」。所以「上线后要不要持续迭代」根本不该是一个问题——不迭代,模型里的曲线就是一条下滑线;迭代的价值首先是把这条下滑线拉平。

小结与下一步

这一节把上线后的数据做成了一条「每周衰减 + 迭代注入」的可跑曲线:d1/d7/ARPDAU 三条指数轨迹、每次迭代的 ROI、以及一个优先级内核。三件事记住就够——迭代首先是对抗衰减,看净效果而不是绝对提升;高 ROI 的往往是小改动而非大内容;停更等于倒退。至此,第 14 章的完整案例走完了:时间线(14.1)、决策复盘(14.2)、数据与迭代(14.3)。

案例讲的是「做对了会怎样」,但真实的教训往往来自反面。下一章换个角度,看《合成小岛》这类小游戏最容易掉进去的几个坑——玩法数值与变现、买量与留存、运营与合规的典型失败。

阅读导航:上一节:14.2 关键决策复盘 · 下一节:15.1 玩法、数值与变现的失败 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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