15.1 的失败发生在游戏内部——玩法、数值、变现。15.2 的失败发生在游戏与市场的接口上:一边是买量,一边是留存。这两条链最阴险的地方在于它们会互相放大——素材把卖点吹满,点击率冲高、CPI 变得好看,但点进来的人发现货不对板,首日留存就崩;反过来,留存本来就差的产品,买量只是把「留不住的人」成批搬进来,放量越快亏得越狠。本节要做的,是把这两种失败都变成可跑的坏数字。
延续 15.1 的分工:本站那篇独立开发者的手游广告失败复盘
是叙述型——「我以为靠广告就能赚钱」;本节是可复现型——把「以为」拆成 ctrPermille、retainD1Permille、organicInflationPermille 这些能改的数,看它们各自把回本日推到哪一天。一篇让你共情,一篇让你提前排雷。
两条会互相放大的失败链
买量链是:曝光 → 点击(CTR)→ 安装(IPM)→ CPI,再往上叠 LTV / CPI = ROAS。留存链是:D1 / D7 / D30 锚点,插值出整条留存曲线,累加成「累计活跃日」。两条链在 LTV 处汇合:LTV = 有效 ARPDAU × 累计活跃 ÷ 1000,而 ROAS30 = LTV30 × 100 ÷ CPI。于是任何一侧的失败都会通过这条乘积放大到 ROAS 上——CPI 便宜一倍或累计活跃掉一半,ROAS 就掉一半。这就是为什么「素材数据好看」和「产品数据好看」必须一起看,缺一个就会得出相反的结论。
S 层:把失败参数喂进买量-留存漏斗(S 层)
下面这段是 S 层仿真脚本,读 config/l15_ua_retention.json。它对基线(健康投放)与六条失败各算一遍,产出 out/l15_ua_retention.{json,csv,svg}。六条失败覆盖买量侧(素材夸大承诺、低质网盟人群、放量过快、归因虚增)与留存侧(新手漏斗前段漏、D7 断崖)。脚本额外给出两个诊断字段:roasD1Permille(只看首日的乐观值)与 reportedRoas30Permille(把自然量算进买量后的虚增报表值)。
#!/usr/bin/env python3
# file: sim/l15_ua_retention.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l15_ua_retention.py --config config/ --out out/
"""买量与留存的失败模式对照:读 config/l15_ua_retention.json。
把第 5 章(买量 ROI 漏斗)与第 7 章(留存锚点插值)合成一条链,对 baseline 与每条 failure
(一个 patch)各算:
cpiFen = cpmFen * 1000 // (ctrPermille * ipmPermille)
ret(d) = D1/D7/D30 锚点整数线性插值(安装日恒 1000)
ltv(D) = arpdauDeciFen * cumActive(D) // 1000 # 0.1 分
roas(D)‰ = ltv(D) * 100 // cpiFen # 1000‰ = 回本
paybackDay = 首个 ltv(D) >= cpiFen*10 的 D(找不到记 -1)
reportedRoas = roas30 * (1000 + organicInflationPermille) // 1000
额外给出两个诊断:`roasD1`(只看首日的乐观值)与 `reportedRoas30`(归因虚增后的报表值),
用来钉死两种最常见误判——「只看 D1 就放量」与「把自然量算成买量成效」。渠道参数、留存锚点
与归因口径均为公开可观察机制的建模假设;真实出价、真实归因与真实亏损需真实业务环境,命令行
不可复现(X)。全整数、地板除,无浮点。产出 out/l15_ua_retention.{json,csv,svg}。
"""
from __future__ import annotations
import argparse, csv, json, os, sys, xml.etree.ElementTree as ET
COLOR = {"买量": "#2563eb", "留存": "#f59e0b"}
def ret(p: dict, d: int) -> int:
if d <= 0:
return 1000
r1, r7, r30 = p["retainD1Permille"], p["retainD7Permille"], p["retainD30Permille"]
if d <= 7:
return r1 + (r7 - r1) * (d - 1) // 6
return r7 + (r30 - r7) * (d - 7) // 23 if d <= 30 else r30
def cum(p: dict, d: int) -> int:
return sum(ret(p, i) for i in range(d + 1))
def evaluate(cfg: dict, p: dict) -> dict:
cpi = p["cpmFen"] * 1000 // (p["ctrPermille"] * p["ipmPermille"])
ltv = {n: p["arpdauDeciFen"] * cum(p, n) // 1000 for n in (1, 7, 30)}
roas = {n: ltv[n] * 100 // cpi for n in (1, 7, 30)}
payback = next((d for d in range(cfg["horizonDays"] + 1)
if p["arpdauDeciFen"] * cum(p, d) // 1000 >= cpi * 10), -1)
installs = cfg["budgetFen"] // cpi
reported = roas[30] * (1000 + p["organicInflationPermille"]) // 1000
return {"id": p["id"], "name": p["name"], "cpmFen": p["cpmFen"], "ctrPermille": p["ctrPermille"],
"ipmPermille": p["ipmPermille"], "arpdauDeciFen": p["arpdauDeciFen"],
"retainD1Permille": p["retainD1Permille"], "retainD7Permille": p["retainD7Permille"],
"retainD30Permille": p["retainD30Permille"], "organicInflationPermille": p["organicInflationPermille"],
"cpiFen": cpi, "cumActive30Permille": cum(p, 30), "ltvD30DeciFen": ltv[30],
"roasD1Permille": roas[1], "roasD7Permille": roas[7], "roasD30Permille": roas[30],
"paybackDay": payback, "installs": installs, "d30RevenueFen": installs * ltv[30] // 10,
"d30ProfitFen": installs * ltv[30] // 10 - cfg["budgetFen"],
"reportedRoas30Permille": reported, "attributionGapPermille": reported - roas[30],
"d1Optimistic": roas[1] >= 1000, "d30Payback": roas[30] >= 1000}
def svg(path: str, rows: list[dict], base: dict) -> int:
W, PAD, ROW = 780, 190, 42
H = 78 + ROW * len(rows)
r = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(r, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(r, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
"《合成小岛》买量 / 留存失败对照:D30 ROAS(‰,1000=回本)"
ET.SubElement(r, "text", {"x": "16", "y": "46", "font-size": "12", "fill": "#6b7280"}).text = \
f'基线 D30 ROAS {base["roasD30Permille"]}‰ · 回本 D{base["paybackDay"]} · CPI {base["cpiFen"]}分'
span = W - PAD - 130
hi = max(max(x["roasD30Permille"] for x in rows), base["roasD30Permille"], 1200) or 1
x1000 = PAD + span * 1000 // hi
ET.SubElement(r, "line", {"x1": str(x1000), "y1": "56", "x2": str(x1000), "y2": str(H - 16),
"stroke": "#ef4444", "stroke-dasharray": "4 4"})
for i, row in enumerate(rows):
y, w = 74 + i * ROW, span * row["roasD30Permille"] // hi
ET.SubElement(r, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
"fill": "#374151"}).text = row["name"]
ET.SubElement(r, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "22",
"rx": "4", "fill": COLOR.get(row.get("aspect"), "#6b7280")})
ET.SubElement(r, "text", {"x": str(PAD + w + 8), "y": str(y + 22), "font-size": "12",
"fill": "#6b7280"}).text = f'{row["roasD30Permille"]}‰'
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(r).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 = json.load(open(os.path.join(a.config, "l15_ua_retention.json"), encoding="utf-8"))
base = evaluate(cfg, cfg["baseline"])
rows = []
for fa in cfg["failures"]:
r = evaluate(cfg, dict(cfg["baseline"], **fa["patch"]))
r.update(id=fa["id"], name=fa["name"], aspect=fa["aspect"], mechanism=fa["mechanism"],
patch=fa["patch"], deltaRoasPermille=r["roasD30Permille"] - base["roasD30Permille"])
rows.append(r)
worst = min(rows, key=lambda x: x["roasD30Permille"])
misjudged = [x["id"] for x in [base] + rows if x["d1Optimistic"] != x["d30Payback"]]
inflated = [x["id"] for x in rows if x["attributionGapPermille"] > 0]
os.makedirs(a.out, exist_ok=True)
sb = svg(os.path.join(a.out, "l15_ua_retention.svg"), rows, base)
json.dump({"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"note": cfg["note"], "horizonDays": cfg["horizonDays"], "budgetFen": cfg["budgetFen"],
"baseline": base, "failures": rows, "worstId": worst["id"],
"misjudgedByD1": misjudged, "attributionInflated": inflated, "svgBytes": sb},
open(os.path.join(a.out, "l15_ua_retention.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
cols = ("id", "name", "aspect", "cpmFen", "ctrPermille", "ipmPermille", "cpiFen", "arpdauDeciFen",
"retainD1Permille", "retainD7Permille", "retainD30Permille", "ltvD30DeciFen",
"roasD1Permille", "roasD30Permille", "reportedRoas30Permille", "attributionGapPermille",
"paybackDay", "d30ProfitFen", "deltaRoasPermille")
with open(os.path.join(a.out, "l15_ua_retention.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(cols)
for row in rows:
w.writerow([row[k] for k in cols])
print(f'基线 CPI {base["cpiFen"]}分 ROAS D1 {base["roasD1Permille"]}‰ / D30 '
f'{base["roasD30Permille"]}‰ 回本 D{base["paybackDay"]}')
for row in rows:
print(f'{row["aspect"]} {row["name"]:<12} CPI {row["cpiFen"]:>4}分 ROAS30 '
f'{row["roasD30Permille"]:>5}‰ ({row["deltaRoasPermille"]:+d}) 回本 '
f'{("D" + str(row["paybackDay"])) if row["paybackDay"] >= 0 else "未回本"} '
f'D30盈亏 {row["d30ProfitFen"]:>8}分')
print(f'最差: {worst["id"]} 只看 D1 会误判: {misjudged} 归因虚增: {inflated}')
return 0
if __name__ == "__main__":
sys.exit(main())
真跑输出(同机):
基线 CPI 291分 ROAS D1 314‰ / D30 1412‰ 回本 D16
买量 素材夸大承诺 CPI 212分 ROAS30 729‰ (-683) 回本 未回本 D30盈亏 -812410分
买量 低质网盟人群 CPI 291分 ROAS30 496‰ (-916) 回本 未回本 D30盈亏 -1510350分
买量 放量过快 CPI 458分 ROAS30 787‰ (-625) 回本 未回本 D30盈亏 -636760分
买量 归因虚增 CPI 291分 ROAS30 1412‰ (+0) 回本 D16 D30盈亏 1236999分
留存 新手漏斗前段漏 CPI 291分 ROAS30 1090‰ (-322) 回本 D26 D30盈亏 270014分
留存 D7 断崖 CPI 291分 ROAS30 940‰ (-472) 回本 未回本 D30盈亏 -178427分
最差: low-quality-cohort 只看 D1 会误判: ['healthy', 'attribution-organic', 'funnel-front-leak'] 归因虚增: ['attribution-organic']
三个诊断,专治三种最常见的自欺
诊断一:只看 D1。 脚本的 misjudgedByD1 字段专门标出「首日乐观但长期回不了本」的配置。输出显示,连基线都中招——基线 D1 ROAS 只有 314‰,远没到 1000‰,但 D30 是 1412‰、第 16 天回本。也就是说,任何买量决策都不该看 D1:D1 天然低估,真正的判据是 D7/D30 与回本日。归因虚增(attribution-organic)和新手漏斗前段漏(funnel-front-leak)也被标进「只看 D1 会误判」,因为它们的首日表现与长期表现背离。
诊断二:只看报表 ROAS。 归因虚增 这一条的真实 D30 ROAS 与基线一模一样(1412‰),但报表值是 2118‰——因为它把自然量、平台推荐量一并算进了买量成效(organicInflationPermille=500)。它的 D30 盈亏写着 +1236999 分,看起来是唯一赚钱的,实际却和基线分文不差。这就是买量报表最经典的幻觉:把不属于你的功劳记在自己头上。
诊断三:CPI 幻觉。 素材夸大承诺(overclaim-creative)的 CPI 只有 212 分——比基线的 291 分便宜三成,单看 CPI 会以为捡了便宜。但它把留存从 D1 430 拖到 260、D30 110 拖到 40,最终 D30 ROAS 只有 729‰、亏损 -812410 分。便宜的流量从来不是免费的,它只是把成本从「一次付清的 CPI」转成了「每天都在流血的低留存」。
P 层:这套失败对照内核的 TypeScript 版(P 层)
15.1 的浏览器失败开关台与本节的分析共用同一套留存-变现内核。下面这段是它的 P 层 TypeScript 实现,与 sim/l15_value_failure.py 同一份 config、同一套整数地板除,因此逐位一致——这是「一份 config 三种消费」在失败对照上的落地。配套的 P 层测试覆盖留存锚点插值、广告频次惩罚边界(factor(4,1)=620、factor(12,8)=200)、基线回本日与每条失败低于基线,node --test 'core/test/l15_*.test.ts' 全绿。
// file: core/src/l15/failure.ts
// tier: P
// verify: node --test 'core/test/l15_*.test.ts'
// 《合成小岛》失败模式对照内核:与 sim/l15_value_failure.py 同一份 config、同一套整数地板除,
// 因此逐位一致。把第 1 章(广告频次惩罚)与第 3 章(LTV / 回本)合成一条最小流水线,
// 供浏览器失败开关台复用。全整数、无 Math.random(),只用可擦除语法(禁 enum / namespace /
// 参数属性 / 装饰器)。
export interface ModelConfig {
readonly horizonDays: number;
readonly cpaFen: number;
readonly minFactorPermille: number;
readonly voluntaryPenaltyPermillePerImp: number;
readonly forcedPenaltyPermillePerImp: number;
}
export interface FailureParams {
readonly retainD1Permille: number;
readonly retainD7Permille: number;
readonly retainD30Permille: number;
readonly voluntary: number;
readonly forced: number;
readonly arpdauDeciFen: number;
}
export interface FailureResult {
readonly factorPermille: number;
readonly arpdauEffDeciFen: number;
readonly cumActive30Permille: number;
readonly ltv30DeciFen: number;
readonly roas30Permille: number;
readonly paybackDay: number;
}
const fdiv = (a: number, b: number): number => Math.floor(a / b);
/** 第 d 日留存千分比(d<=0 安装日恒 1000);D1/D7/D30 锚点间整数线性插值。 */
export function dailyRetention(p: FailureParams, d: number): number {
if (d <= 0) return 1000;
const r1 = p.retainD1Permille, r7 = p.retainD7Permille, r30 = p.retainD30Permille;
if (d <= 7) return r1 + fdiv((r7 - r1) * (d - 1), 6);
if (d <= 30) return r7 + fdiv((r30 - r7) * (d - 7), 23);
return r30;
}
/** 第 0..d 日累计活跃,单位千分比日(1000 = 一整日)。 */
export function cumulativeActive(p: FailureParams, d: number): number {
let s = 0;
for (let i = 0; i <= d; i += 1) s += dailyRetention(p, i);
return s;
}
/** 广告频次惩罚:自愿与强制两类各按每展示惩罚扣减,下限 minFactor。 */
export function factorPermille(cfg: ModelConfig, v: number, f: number): number {
return Math.max(cfg.minFactorPermille,
1000 - v * cfg.voluntaryPenaltyPermillePerImp - f * cfg.forcedPenaltyPermillePerImp);
}
/** 对一组参数算:广告惩罚因子、有效 ARPDAU、LTV30、ROAS30、回本日。 */
export function evaluate(cfg: ModelConfig, p: FailureParams): FailureResult {
const fac = factorPermille(cfg, p.voluntary, p.forced);
const arpdauEff = fdiv(p.arpdauDeciFen * fac, 1000);
const cum30 = cumulativeActive(p, cfg.horizonDays);
const ltv30 = fdiv(arpdauEff * cum30, 1000);
const roas30 = fdiv(ltv30 * 100, cfg.cpaFen);
let payback = -1;
for (let d = 0; d <= cfg.horizonDays; d += 1) {
if (fdiv(arpdauEff * cumulativeActive(p, d), 1000) >= cfg.cpaFen * 10) { payback = d; break; }
}
return { factorPermille: fac, arpdauEffDeciFen: arpdauEff, cumActive30Permille: cum30,
ltv30DeciFen: ltv30, roas30Permille: roas30, paybackDay: payback };
}
小结
| 失败 | 侧 | 表面数据 | 真实 D30 ROAS | 回本日 | 陷阱 |
|---|---|---|---|---|---|
| 素材夸大承诺 | 买量 | CPI 便宜三成 | 729‰ | 未回本 | 便宜流量的留存代价 |
| 低质网盟人群 | 买量 | 单价最低 | 496‰ | 未回本 | 一日游用户 |
| 放量过快 | 买量 | 量最大 | 787‰ | 未回本 | 出价被抬高、人群被稀释 |
| 归因虚增 | 买量 | 报表 2118‰ | 1412‰ | D16 | 把自然量算成买量成效 |
| 新手漏斗前段漏 | 留存 | — | 1090‰ | D26 | 加载与引导摩擦 |
| D7 断崖 | 留存 | — | 940‰ | 未回本 | 中期没有可追求目标 |
一句话:买量的失败是「花钱买错人」,留存的失败是「来了也留不住」。前者可以在投放后台里改,后者只能回产品里改。而 15.3 要讲的运营与合规失败,是最不像技术问题、却最容易让一款已经跑起来的游戏一夜归零的那一类。
阅读导航:上一节:15.1 玩法、数值与变现的典型失败 · 下一节:15.3 运营与合规的典型失败 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。