前两节讲的是怎么把一款还在跑的游戏运营好,这一节讲怎么体面地送它走。游戏的退场(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 从概念到上线的时间线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。