13.3 生命周期与退场策略

每个游戏都有生命周期,退场不该靠情绪决策。本节给出一份把衰减率、单价、固定与变动成本、阶段阈值写成整数的生命周期配置,一段逐周推进 DAU、收入与利润并自动触发退场的 S 层脚本,让「什么时候转入维护、什么时候该退场」从一次次的争论变成能算出来的数字。

前两节讲的是怎么把一款还在跑的游戏运营好,这一节讲怎么体面地送它走。游戏的退场(sunset,也叫关服 / 停运)是长线运营绕不开的一环,但多数团队处理得很糟——要么因为感情一直拖着,让一款不再赚钱的游戏持续烧钱;要么某天拍脑袋决定「下周关服」,伤了核心玩家、坏了口碑,还留下合规隐患。本节的主张很直接:退场应该是一个提前定义好触发条件的数据决策,而不是一次情绪爆发。边界:本节只讲生命周期阶段与退场触发,运营数据体系是第 11 章、失败复盘是第 15 章。

生命周期不是宿命,是五个可测的阶段

任何一款上线游戏都会走过几个阶段,区别只在于每个阶段持续多久。把阶段定义清楚的价值在于:每个阶段该做什么决策是不同的,用错阶段的心态运营会浪费资源。

成长期:用户还在增长或快速波动,重点是拉新与留存,愿意投入买量。
成熟期:用户进入平台期,重点是变现效率与内容供给,买量趋于保守。
衰退期:用户开始净流出,重点是低成本维护核心玩家、控制成本。
维护期:用户基数已小,只做必要修复,不再投入新内容,收入覆盖基本运维成本即可。
退场期:收入已无法覆盖成本,或用户跌破底线,进入公告、退款、数据导出、关服流程。

这五个阶段用 DAU 阈值就能切分——不是因为它最准,而是因为它最稳定、最好对齐。本节模型里:DAU ≥ 3000 是成长期、≥ 1500 成熟、≥ 400 衰退、≥ 150 维护、低于 150 退场。阈值本身可以调,关键是它必须提前写进配置,而不是事后解释。

退场是数据决策,不是情绪决策

退场的触发条件通常有两条,且应当以利润为主、DAU 为辅。

利润线:当一款游戏的周收入长期低于周成本,它在净烧钱。烧钱本身不一定是坏事——拉新期、新版本期都可能在烧钱换增长;但如果连续多周烧钱且看不到回升,就是在为一段不会回来的曲线付费。模型里定的触发是连续 8 周利润为负。

DAU 线:当 DAU 跌破某个底线(比如 150),即使账面还能微利,游戏也已进入「僵尸运营」——维护成本、合规成本、团队注意力成本都是隐性的,留着它不赚钱,只是舍不得。

两条线里,利润线通常更早触发。这一点反直觉但很关键:很多游戏在 DAU 还有几百时利润就已经转负了,因为固定运维成本(服务器、客服、版号维护、合规)不会随 DAU 等比下降。盯着 DAU 决定退场,往往会晚几个月,白烧几个月的钱。

D 层:把生命周期写成配置

下面这份是 D 层(静态层)配置,全整数。它声明了衰减率(weeklyDecayPermille,每周衰减千分比)、DAU 地板、ARPDAU、固定与变动成本、五个阶段的 DAU 阈值,以及退场规则(连续亏损周数与 DAU 底线)。全整数地板除,禁浮点。

{
  "version": 1,
  "series": "《小游戏开发实战》",
  "volume": "卷三《上线变现与长线运营》",
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "生命周期与退场配置(D 层)。dau 每周按 weeklyDecayPermille 衰减、不低于 floorDau;周收入 = dau × arpdauDeciFen × 7;周成本 = 固定成本 + dau × 变动成本 × 7;利润 = 收入 - 成本。stages 按 dau 下限切生命周期阶段。sunsetRules 给退场触发条件:连续 lossWeeks 周利润为负,或 dau 跌破 dauFloorForSunset。全整数地板除,禁浮点。衰减率、单价、成本均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。",
  "horizonWeeks": 156,
  "initialDau": 6000,
  "weeklyDecayPermille": 35,
  "floorDau": 120,
  "arpdauDeciFen": 28,
  "fixedWeeklyCostDeciFen": 90000,
  "variableCostPerDauDeciFen": 3,
  "stages": [
    { "id": "growth", "name": "成长期", "dauMin": 3000 },
    { "id": "mature", "name": "成熟期", "dauMin": 1500 },
    { "id": "decline", "name": "衰退期", "dauMin": 400 },
    { "id": "maintenance", "name": "维护期", "dauMin": 150 },
    { "id": "sunset", "name": "退场期", "dauMin": 0 }
  ],
  "sunsetRules": {
    "lossWeeks": 8,
    "dauFloorForSunset": 150
  }
}

S 层:衰减与退场触发仿真

配置写完,让机器逐年推演。下面这段是 S 层脚本:逐周按衰减率推进 DAU(不低于地板),算周收入、周成本与周利润,按 DAU 阈值归入阶段;当连续亏损达到设定周数或 DAU 跌破底线时触发退场。产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/l13_lifecycle.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l13_lifecycle.py --config config/ --out out/
"""生命周期与退场仿真:读 config/l13_lifecycle.json。

逐周推进:dau 按 weeklyDecayPermille 衰减(地板 floorDau),算周收入 / 成本 / 利润,
按 dau 下限归入生命周期阶段。退场触发:连续 sunsetRules.lossWeeks 周利润为负,
或 dau 跌破 sunsetRules.dauFloorForSunset。产出 JSON + CSV + SVG。全整数地板除。

衰减率、单价、成本均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


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


def stage_of(cfg: dict, dau: int) -> dict:
    for s in cfg["stages"]:
        if dau >= s["dauMin"]:
            return s
    return cfg["stages"][-1]


def simulate(cfg: dict) -> dict:
    dau, rows, loss_streak = cfg["initialDau"], [], 0
    sunset_week = 0
    for w in range(1, cfg["horizonWeeks"] + 1):
        if w > 1:
            dau = max(cfg["floorDau"], dau * (1000 - cfg["weeklyDecayPermille"]) // 1000)
        revenue = dau * cfg["arpdauDeciFen"] * 7
        cost = cfg["fixedWeeklyCostDeciFen"] + dau * cfg["variableCostPerDauDeciFen"] * 7
        profit = revenue - cost
        loss_streak = loss_streak + 1 if profit < 0 else 0
        st = stage_of(cfg, dau)
        rows.append({"week": w, "dau": dau, "stage": st["id"], "revenue": revenue,
                     "cost": cost, "profit": profit, "lossStreak": loss_streak})
        if sunset_week == 0 and loss_streak >= cfg["sunsetRules"]["lossWeeks"]:
            sunset_week = w
    transitions, seen = [], None
    for r in rows:
        if r["stage"] != seen:
            transitions.append({"week": r["week"], "stage": r["stage"], "dau": r["dau"]})
            seen = r["stage"]
    first_loss = next((r["week"] for r in rows if r["profit"] < 0), 0)
    cum_profit = sum(r["profit"] for r in rows)
    return {"rows": rows, "transitions": transitions, "firstLossWeek": first_loss,
            "sunsetWeek": sunset_week, "cumProfit": cum_profit,
            "endDau": rows[-1]["dau"], "endStage": rows[-1]["stage"]}


def draw_svg(path: str, res: dict, cfg: dict) -> int:
    rows = res["rows"]
    W, L, R, B = 780, 60, 34, 46
    H = 96 + len(rows) * R + B
    max_dau = max(r["dau"] for r in rows)
    max_profit = max(r["profit"] for r in rows)
    min_profit = min(r["profit"] for r in rows)
    span_p = max(max_profit - min_profit, 1)
    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"})
    title = f"《合成小岛》生命周期与退场(退场触发 W{res['sunsetWeek']},示例参数)" if res["sunsetWeek"] else \
        f"《合成小岛》生命周期与退场(观测窗内未触发,示例参数)"
    ET.SubElement(svg, "text", {"x": "16", "y": "24", "font-size": "15", "fill": "#111827"}).text = title
    dw, pw = (W - L - 70) // 2, (W - L - 70) // 2
    for i, r in enumerate(rows):
        y = 48 + i * R
        if i % 13 == 0:
            ET.SubElement(svg, "text", {"x": "14", "y": str(y + 12), "font-size": "10", "fill": "#6b7280"}).text = f"W{r['week']}"
        dwid = r["dau"] * dw // max_dau
        ET.SubElement(svg, "rect", {"x": str(L), "y": str(y + 2), "width": str(max(dwid, 1)), "height": "10", "rx": "2", "fill": "#2563eb"})
        zero_x = L + dw + 40 + (0 - min_profit) * pw // span_p
        py = y + 7
        px = L + dw + 40 + (r["profit"] - min_profit) * pw // span_p
        ET.SubElement(svg, "rect", {"x": str(min(px, zero_x)), "y": str(py - 4),
                                    "width": str(max(abs(px - zero_x), 1)), "height": "8", "rx": "2",
                                    "fill": "#10b981" if r["profit"] >= 0 else "#ef4444"})
    ET.SubElement(svg, "text", {"x": str(L), "y": str(48 + len(rows) * R + 22), "font-size": "11", "fill": "#6b7280"}).text = \
        "左=DAU(蓝)  右=周利润(绿正/红负)"
    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)
    res = simulate(cfg)
    os.makedirs(a.out, exist_ok=True)
    sb = draw_svg(os.path.join(a.out, "l13_lifecycle.svg"), res, cfg)
    with open(os.path.join(a.out, "l13_lifecycle_weeks.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["week", "dau", "stage", "revenueDeciFen", "costDeciFen", "profitDeciFen", "lossStreak"])
        for r in res["rows"]:
            w.writerow([r["week"], r["dau"], r["stage"], r["revenue"], r["cost"], r["profit"], r["lossStreak"]])
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "horizonWeeks": cfg["horizonWeeks"], "initialDau": cfg["initialDau"],
              "firstLossWeek": res["firstLossWeek"], "sunsetWeek": res["sunsetWeek"],
              "cumProfitDeciFen": res["cumProfit"], "endDau": res["endDau"], "endStage": res["endStage"],
              "transitions": res["transitions"], "svgBytes": sb}
    with open(os.path.join(a.out, "l13_lifecycle.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({k: report[k] for k in ("firstLossWeek", "sunsetWeek", "cumProfitDeciFen",
                                             "endDau", "endStage", "transitions", "svgBytes")},
                     ensure_ascii=False, indent=2))
    return 0


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

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

{
  "firstLossWeek": 70,
  "sunsetWeek": 77,
  "cumProfitDeciFen": 16129125,
  "endDau": 120,
  "endStage": "sunset",
  "transitions": [
    { "week": 1, "stage": "growth", "dau": 6000 },
    { "week": 21, "stage": "mature", "dau": 2936 },
    { "week": 40, "stage": "decline", "dau": 1486 },
    { "week": 77, "stage": "maintenance", "dau": 386 },
    { "week": 103, "stage": "sunset", "dau": 144 }
  ],
  "svgBytes": 22617
}

读这组数字:DAU 从 6000 起步,按每周 35‰ 衰减,第 21 周跌出成长期(2936)、第 40 周进入衰退期(1486)。第 70 周利润首次转负,第 77 周凑满连续 8 周亏损,退场触发——此时 DAU 还有 386,仍处在衰退期,离 DAU 底线 150 还差得远。DAU 真正跌破 150 要等到第 103 周。这就是前面说的「利润线比 DAU 线早触发」:利润线在第 77 周就喊停,DAU 线要拖到第 103 周,中间 26 周如果继续运营,就是在为一款已经亏钱的游戏付费。整个观测窗累计利润约 16 万分(示例单位),是正的——退场不是因为这款游戏从头到尾不赚钱,而是因为它的边际收益已经为负。

这个结论纠正了一个常见误解:退场不等于失败。一款游戏完整走完「成长期赚钱 → 成熟期稳收 → 衰退期转负 → 退场」的曲线,累计利润为正,就是一次成功的产品周期;真正的失败是该退的时候不退,把前面赚的钱在衰退期又烧回去。脚本给出的退场周不是命令,而是一个决策锚点:第 77 周这个数字摆在这里,团队要么按它退场,要么给出「为什么还能回正」的具体理由——而不是靠感觉再拖一个季度。

退场执行清单

退场触发之后,真正麻烦的是执行。下面这份是 D 层清单(顺序即优先级),每一项都对应一类真实风险:

  • 提前公告:给用户足够的提前量(业内常见 30–60 天),公告渠道覆盖游戏内弹窗、官方社群、应用商店描述页。
  • 停止充值:公告发布的同时关闭付费入口,这是合规红线——继续卖充值再关服是明确的消费欺诈风险。
  • 未消耗虚拟币处理:明确未使用的付费货币如何退款或折算,保留处理记录。
  • 数据导出:给用户导出存档 / 成就 / 好友关系的通道,或明确告知无法导出。
  • 退款通道:对未消耗的付费内容给出可执行的退款流程与时限。
  • 服务器与域名:保留一段时间只读存档服务,再下线;域名与备案按平台要求注销。
  • 合规收尾:未成年人相关数据、隐私数据的删除与留存记录,按第 10 章的合规要求归档。
  • 复盘归档:把这款游戏的数据曲线、决策记录、退场时间点归档,作为下一个项目的输入(第 14 章会用到)。

这份清单里最容易被跳过的是停止充值与退款两项,因为它们「影响收入」,但恰恰是它们决定退场是「体面结束」还是「事故」。退场是一次有截止线的项目,不是一次关掉服务器就完事的行为。

小结与下一步

这一节把退场从情绪决策变成数据决策:五个生命周期阶段用 DAU 阈值切分,退场触发以连续亏损周数为主、DAU 底线为辅。仿真给出了第 70 周利润转负、第 77 周退场触发、DAU 要到第 103 周才跌破底线,以及「累计利润为正、退场不等于失败」的结论。**退场要提前定义触发条件,利润线通常比 DAU 线更早该喊停。**下一章把全卷收束成一条完整时间线:从概念到上线,这款游戏是怎么一步步走到今天的。

阅读导航:上一节:13.2 内容产能与排期 · 下一节:14.1 从概念到上线的时间线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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