15.1 《合成小岛》投放与变现复盘

《合成小岛》半年投放与变现复盘:把投放花费、安装数、留存、ARPDAU、平台扣点接成一条链,逐月算出毛收入、到账净额与单月净贡献,定位唯一的负贡献月与累计转正时点;配可跑的 Python 仿真、真实的输出数字与真实平台机制的 X 层边界说明,把「这半年赚没赚」算清楚。

站内《上线变现与长线运营》 (卷三)已经给过《合成小岛》从 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冷启动40000001702352940391682504284−1683948−1683948
M2放量60000001603750075432004676784−1623216−3307164
M3素材疲劳70000001754000073041404382484−2937516−6244680
M4换素材600000015040000112866406997716+677716−5566964
M5结构优化500000013537037132303378335112+3038816−2528148
M6稳态500000013038461150806619500816+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 投放、结算与资质的典型失败 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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