站内《上线变现与长线运营》 (卷三)已经给过《合成小岛》从 0 到 1 的完整案例:立项时间线、决策复盘、数据表现迭代。本节与它分工不同——卷三回答「这款游戏是怎么一步步做出来并上线的」,本节回答上线之后这半年的钱是怎么花的、怎么赚回来的、最后算总账是赚是亏。它不重做时间线,只做一件事:把投放、变现、结算接成一条链,逐月把账算清楚。这也是整卷的收官复盘,第 1 到第 12 章讲的每一条规则,最后都要落到这张账上。
复盘要回答的三个问题
一次像样的复盘,不是「这个月涨了没涨」的一句话结论,而是三个能被逐位复算的问题:
| 问题 | 对应环节 | 关键量 |
|---|---|---|
| 这个月花了多少、买来多少用户? | 投放 | 花费、CPI、安装数 |
| 这些用户带来了多少收入? | 变现 | 留存、ARPDAU、毛收入 |
| 扣完平台分成之后还剩多少? | 结算 | 综合扣点、到账净额、净贡献 |
三个环节里,任何一个环节的假设错了,最后的净贡献都会失真。复盘的价值就在于:把三个环节摊开,让「这个月到底赚没赚」不再是一句感觉,而是一个能一条条对出来的数。
从投放到结算的一条链
把三个环节写成整数公式(金额统一用「分」,比例用千分比,全整数地板除):
installs = spendFen // cpiFen # 当月安装数
cumActive = Σ_{d=0..30} retentionPermille(d) # 每安装累计活跃日(千分比日)
activeDays = installs × cumActive // 1000 # 当月总活跃日
grossIaaFen = arpdauIaaDeciFen × activeDays // 10 # 广告毛收入(分)
grossIapFen = arpdauIapDeciFen × activeDays // 10 # 内购毛收入(分)
grossFen = grossIaaFen + grossIapFen
netFen = grossFen × (1000 − takePermille) // 1000 # 扣平台分成后的到账净额
contribution = netFen − spendFen − installs × variableCostFen # 单月净贡献
这条链的关键,是 cumActive:它把「留存」折成一个乘数,决定了「买来的一个用户能活跃几天」。留存越高、生命周期越长,activeDays 越大,同样的 ARPDAU 就能滚出更多收入。留存不是运营指标,它是收入公式里的一个因子。
S 层仿真:把半年的账接成一条链
下面这段是 S 层仿真脚本 sim/o15_retro.py。它读 config/o15_retro.json 的六个月,按上面的整数公式逐月算出安装数、活跃日、毛收入、到账净额与单月净贡献,再给出累计贡献、累计转正月与最差月,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o15_retro.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o15_retro.py --config config/ --out out/
"""《合成小岛》投放—变现—结算全流程复盘:把半年的六个月的账接成一条链。
对 config/o15_retro.json 的每个月:
installs(m) = spendFen(m) // cpiFen(m) # 当月安装数
cumActive(m) = Σ_{d=0..30} retentionPermille(m, d) # 每安装累计活跃日(千分比日)
activeDays(m) = installs(m) * cumActive(m) // 1000 # 当月总活跃日
grossIaaFen(m) = arpdauIaaDeciFen(m) * activeDays(m) // 10 # 广告毛收入(分)
grossIapFen(m) = arpdauIapDeciFen(m) * activeDays(m) // 10 # 内购毛收入(分)
grossFen(m) = grossIaaFen(m) + grossIapFen(m)
netFen(m) = grossFen(m) * (1000 - takePermille(m)) // 1000 # 扣平台分成后净额(分)
contribution(m) = netFen(m) - spendFen(m) - installs(m) * variableCostPerInstallFen // 1
cumulative(m) = Σ contribution
再给出两个复盘结论:**回本月**(累计贡献首次 >= 0 的月份,否则 -1)与**最差月**(单月贡献最低)。
这是一次「事后把账算清楚」的复盘:投放花多少、变现赚多少、结算扣多少,最后落到净贡献。
口径:真实 CPI、真实后台流水、真实分成与账期需真实业务环境,命令行不可复现(X)。
产出 out/o15_retro.json + .csv + .svg。全整数、地板除,无浮点。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def daily_retention(m: dict, d: int) -> int:
"""第 d 日留存千分比(d=0 安装日恒 1000);D1/D7/D30 锚点之间整数线性插值。"""
if d <= 0:
return 1000
r1, r7, r30 = m["retainD1Permille"], m["retainD7Permille"], m["retainD30Permille"]
if d <= 7:
return r1 + (r7 - r1) * (d - 1) // 6
if d <= 30:
return r7 + (r30 - r7) * (d - 7) // 23
return r30
def cumulative_active(m: dict, n: int) -> int:
"""每安装的累计活跃日,单位千分比日(1000 = 一整日)。"""
return sum(daily_retention(m, d) for d in range(0, n + 1))
def evaluate(m: dict, horizon: int, variable_cost_fen: int) -> dict:
installs = m["spendFen"] // m["cpiFen"]
active_days = installs * cumulative_active(m, horizon) // 1000
gross_iaa = m["arpdauIaaDeciFen"] * active_days // 10
gross_iap = m["arpdauIapDeciFen"] * active_days // 10
gross = gross_iaa + gross_iap
net = gross * (1000 - m["takePermille"]) // 1000
contribution = net - m["spendFen"] - installs * variable_cost_fen
return dict(m, installs=installs, cumActivePermille=cumulative_active(m, horizon),
activeDays=active_days, grossIaaFen=gross_iaa, grossIapFen=gross_iap,
grossFen=gross, netFen=net, contributionFen=contribution)
def draw_svg(path: str, rows: list) -> int:
"""纯 Python SVG:每个月一根柱——单月净贡献(分),零线标出盈亏分界。"""
W, H, PAD_B, ROW = 760, 120 + 44 * len(rows), 60, 44
lo = min([r["contributionFen"] for r in rows] + [0])
hi = max([r["contributionFen"] for r in rows] + [1])
span = W - 200
scale = lambda v: PAD_B + span * (v - lo) // (hi - lo) if hi > lo else PAD_B
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15",
"fill": "#111827"}).text = "《合成小岛》单月净贡献复盘(分)· 零线=盈亏分界"
x0 = scale(0)
ET.SubElement(root, "line", {"x1": str(x0), "y1": "44", "x2": str(x0), "y2": str(H - 16),
"stroke": "#ef4444", "stroke-dasharray": "4 4"})
for i, r in enumerate(rows):
y = 56 + i * ROW
color = "#16a34a" if r["contributionFen"] >= 0 else "#ef4444"
xs, xe = sorted((x0, scale(r["contributionFen"])))
ET.SubElement(root, "text", {"x": "16", "y": str(y + 18), "font-size": "12",
"fill": "#374151"}).text = f'{r["id"]} · {r["name"]}'
ET.SubElement(root, "rect", {"x": str(xs), "y": str(y + 4), "width": str(max(xe - xs, 1)),
"height": "20", "rx": "3", "fill": color})
ET.SubElement(root, "text", {"x": str(max(xs, xe) + 6), "y": str(y + 19), "font-size": "11",
"fill": "#6b7280"}).text = f'{r["contributionFen"]} 分'
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(root))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
with open(os.path.join(a.config, "o15_retro.json"), encoding="utf-8") as f:
cfg = json.load(f)
horizon = cfg["horizonDaysPerMonth"]
vc = cfg["variableCostPerInstallFen"]
rows = [evaluate(m, horizon, vc) for m in cfg["months"]]
cum, running = [], 0
for r in rows:
running += r["contributionFen"]
cum.append(running)
crossover = next((rows[i]["id"] for i in range(len(rows)) if cum[i] >= 0), "-1")
worst = min(rows, key=lambda r: r["contributionFen"])["id"]
best = max(rows, key=lambda r: r["contributionFen"])["id"]
digest = ",".join(
f'{r["id"]}:{r["cpiFen"]}:{r["installs"]}:{r["grossFen"]}:{r["netFen"]}:{r["contributionFen"]}'
for r in rows) + f'|cum={running},crossover={crossover},worst={worst}'
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "o15_retro.svg"), rows)
with open(os.path.join(a.out, "o15_retro.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
cols = ("id", "name", "spendFen", "cpiFen", "installs", "activeDays", "grossIaaFen",
"grossIapFen", "grossFen", "netFen", "contributionFen", "decision")
w.writerow(cols)
for r in rows:
w.writerow([r[k] for k in cols])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"horizonDaysPerMonth": horizon, "variableCostPerInstallFen": vc, "months": rows,
"cumulativeContributionFen": cum, "crossoverMonth": crossover,
"worstMonth": worst, "bestMonth": best, "totalContributionFen": running,
"digest": digest, "csv": os.path.join(a.out, "o15_retro.csv"),
"svg": os.path.join(a.out, "o15_retro.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "o15_retro.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"months": [{"id": r["id"], "installs": r["installs"], "grossFen": r["grossFen"],
"netFen": r["netFen"], "contributionFen": r["contributionFen"]}
for r in rows],
"cumulativeContributionFen": cum, "crossoverMonth": crossover,
"worstMonth": worst, "bestMonth": best, "totalContributionFen": running,
"digest": digest}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
复盘参数放在 config/o15_retro.json,一个月一行。这里的 CPI、留存、ARPDAU、扣点都是「公开可观察机制」口径的建模假设,不是任何投放后台或结算单的实测:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"horizonDaysPerMonth": 30,
"variableCostPerInstallFen": 8,
"note": "《合成小岛》半年投放—变现—结算复盘参数。口径是「公开可观察机制」的建模假设,不是任何投放后台或结算单的实测。spendFen=当月投放花费(分);cpiFen=当月单安装成本(分,由 spendFen//cpiFen 反推安装数);arpdauIaaDeciFen/arpdauIapDeciFen=当月每 DAU 每日广告/内购收入(0.1 分);retainD1/D7/D30Permille=当月新装第 1/7/30 日留存千分比;takePermille=当月综合平台扣点千分比(广告+内购加权后的建模假设);decision=当月关键动作(D 层复盘备注)。真实 CPI、真实后台流水、真实分成与账期需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"months": [
{ "id": "M1", "name": "冷启动", "spendFen": 4000000, "cpiFen": 170, "arpdauIaaDeciFen": 220, "arpdauIapDeciFen": 60, "retainD1Permille": 395, "retainD7Permille": 170, "retainD30Permille": 108, "takePermille": 380, "decision": "小预算试跑,CPI 高、留存未打磨" },
{ "id": "M2", "name": "放量", "spendFen": 6000000, "cpiFen": 160, "arpdauIaaDeciFen": 250, "arpdauIapDeciFen": 70, "retainD1Permille": 405, "retainD7Permille": 175, "retainD30Permille": 112, "takePermille": 380, "decision": "预算爬坡,CPI 仍偏高" },
{ "id": "M3", "name": "素材疲劳", "spendFen": 7000000, "cpiFen": 175, "arpdauIaaDeciFen": 240, "arpdauIapDeciFen": 65, "retainD1Permille": 385, "retainD7Permille": 165, "retainD30Permille": 105, "takePermille": 400, "decision": "老素材衰减,CPI 抬头,未及时换素材(最差月)" },
{ "id": "M4", "name": "换素材", "spendFen": 6000000, "cpiFen": 150, "arpdauIaaDeciFen": 330, "arpdauIapDeciFen": 100, "retainD1Permille": 415, "retainD7Permille": 185, "retainD30Permille": 120, "takePermille": 380, "decision": "新素材上线,CPI 回落,首次单月转正" },
{ "id": "M5", "name": "结构优化", "spendFen": 5000000, "cpiFen": 135, "arpdauIaaDeciFen": 400, "arpdauIapDeciFen": 130, "retainD1Permille": 428, "retainD7Permille": 190, "retainD30Permille": 125, "takePermille": 370, "decision": "砍掉低效渠道,ARPDAU 抬升" },
{ "id": "M6", "name": "稳态", "spendFen": 5000000, "cpiFen": 130, "arpdauIaaDeciFen": 420, "arpdauIapDeciFen": 150, "retainD1Permille": 430, "retainD7Permille": 195, "retainD30Permille": 130, "takePermille": 370, "decision": "稳定投放,累计转正" }
]
}
在本机跑 for f in sim/o15_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,半年的复盘结果是(同机相对量级,绝对数值不可移植):
| 月 | 动作 | 花费(分) | CPI(分) | 安装 | 毛收入(分) | 到账净额(分) | 单月净贡献(分) | 累计(分) |
|---|---|---|---|---|---|---|---|---|
| M1 | 冷启动 | 4000000 | 170 | 23529 | 4039168 | 2504284 | −1683948 | −1683948 |
| M2 | 放量 | 6000000 | 160 | 37500 | 7543200 | 4676784 | −1623216 | −3307164 |
| M3 | 素材疲劳 | 7000000 | 175 | 40000 | 7304140 | 4382484 | −2937516 | −6244680 |
| M4 | 换素材 | 6000000 | 150 | 40000 | 11286640 | 6997716 | +677716 | −5566964 |
| M5 | 结构优化 | 5000000 | 135 | 37037 | 13230337 | 8335112 | +3038816 | −2528148 |
| M6 | 稳态 | 5000000 | 130 | 38461 | 15080661 | 9500816 | +4193128 | +1664980 |
这张表讲了一个很典型的故事。前三季度连亏:M1、M2 的 CPI 高、留存未打磨,单月净贡献都是负的;M3 是最惨的一个月,CPI 冲到 175 分(素材疲劳),扣点还从 380‰ 升到 400‰,单月亏掉 −2937516 分,累计亏损扩到 −6244680 分。转折点在 M4:换素材之后 CPI 回落到 150 分、ARPDAU 抬升,单月净贡献首次转正(+677716 分)。到 M6,累计终于转正(+1664980 分)。复盘结论只有两句:回本发生在 M6(累计首次 ≥ 0);最差月是 M3,根因是素材疲劳叠加扣点上升——这也正是第 3 章要专门盯素材衰减的原因。
X 层:真实后台数据不可复现
边界必须划清。上面算的是模型侧:给定 CPI、留存、ARPDAU 与扣点,逐月账是多少。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的投放后台数据(实际 CPI、学习期状态、计划/广告组的真实消耗曲线);
- 真实的留存与 ARPDAU(由真实用户行为决定,且后台口径与三方归因常有差异);
- 真实的结算单与账期(含对账差异、退款扣减、作弊扣减)。
工程上正确的做法是:把可复现的模型层与不可复现的真实数据层分开——模型回答「在假设成立时这笔生意值多少」,真实数据在拿到后台报表后反标定模型参数(把实际 CPI、留存、扣点填回 config,复盘立刻反映现实)。这也正是第 16 章全链路对账要跟这些字段一一对应的原因。
自检表
| 检查项 | 合格标准 |
|---|---|
| 三环节齐全 | 投放、变现、结算三段都在公式里,不省略结算 |
| 留存折成因子 | 留存进入 activeDays,不是只当运营指标看 |
| 全整数 | CPI、活跃日、毛收入、净贡献全为整数地板除,无浮点 |
| 结论可复算 | 回本月、最差月由累计贡献推出,不靠拍脑袋 |
| 边界清晰 | 真实后台、留存、结算单标 X,不写成「实测」 |
复盘把「这半年赚没赚」算清楚了,但真实的教训往往来自没做成的那些。下一节我们不再看一个完整的成功案例,而是把投放、结算、资质三条线上最典型的失败模式列成一张可判定的清单。
阅读导航:上一节:14.3 止损与再投入决策 · 下一节:15.2 投放、结算与资质的典型失败 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。