15.1 玩法、数值与变现的典型失败

前十四章都在讲「怎么做好」,本章反过来讲「怎么翻车」。第一节把玩法、数值、变现三类最典型的失败,各拆成一个可调参数:核心循环太短、难度断崖、离线收益通胀、成长曲线过早封顶、广告频次拉满、过早硬付费墙、定价锚点错位。下面用一段 S 层脚本把这些坏参数喂进第 1、3 章的同一套留存-变现模型,跑出塌掉的 LTV 与回本日,再给一个 W 层失败开关台,一键切换看数字怎么变。

前十四章,卷三一路在讲「怎么把游戏做上线、怎么把它变好」。本章调转方向:讲它是怎么死的。失败比成功更适合做教材,因为失败有稳定的形状——它总是发生在几个固定的位置:玩法留不住人、数值崩了节奏、变现把体验吃光。本节只负责这三类里的玩法 / 数值 / 变现,买量与留存、运营与合规分别交给 15.2、15.3。原则只有一条:每个失败模式都必须能喂进前文的模型、跑出坏数字,而不是写一段「注意不要这样做」的散文。

先说分工。本站有一篇独立开发者的手游广告失败复盘 ,那是叙述型复盘——一个人讲「我以为靠广告就能赚钱,结果呢」。本节做的是它做不到的事:把「以为靠广告赚钱」翻译成可调参数,把广告频次从 4 次/日一路拨到 12 次/日,看 LTV 从 2554 掉到 824、回本日从第 11 天变成「永不回本」。一篇是事后叙述,一篇是可复现的失败参数对照——前者让你共情,后者让你提前知道哪个旋钮一拧就会出事。

失败为什么该写成「可跑的对照」

散文讲失败有一个致命缺陷:没有量纲。「广告别放太多」是一句正确但无用的建议——放多少算多?把广告从 4 次/日提到 6 次/日,LTV 掉 5% 还是 50%?直觉答不出来,但模型答得出来。本节的判据是把每个失败拆成一个 patch:在健康基线参数上改几个数,其余不变,跑一遍模型,对比 LTV、ROAS、回本日的位移。位移越大,这个失败越致命;位移小,说明这个旋钮没那么敏感,不必过度焦虑。这套方法的价值在于把「感觉会出事」变成「知道会掉几个点」——立项时用来排雷,上线后用来定位。

模型本身来自前文:留存曲线用第 7 章的 D1/D7/D30 锚点整数线性插值,广告频次惩罚用第 1 章的自愿 / 强制两类扣减,LTV 与回本用第 3 章的 ltv30 = 有效ARPDAU × 累计活跃 ÷ 1000、ROAS30 = ltv30 × 100 ÷ cpaFen。本节不改这些公式,只喂坏参数。所有参数都是示例假设值,真实留存、真实回收与真实亏损需真实业务环境,命令行不可复现(X);全整数、地板除,无浮点,同机相对量级,绝对数值不可移植。

S 层:一条命令跑出七种失败(S 层)

下面这段是 S 层仿真脚本,读 config/l15_value_failure.json,对基线与七条失败各算一遍,产出 out/l15_value_failure.{json,csv,svg}。七条失败覆盖玩法(核心循环太短、难度断崖)、数值(离线收益通胀、成长曲线过早封顶)、变现(广告频次拉满、过早硬付费墙、定价锚点错位)。

#!/usr/bin/env python3
# file: sim/l15_value_failure.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l15_value_failure.py --config config/ --out out/
"""玩法 / 数值 / 变现失败模式对照:读 config/l15_value_failure.json。

把第 1 章(广告频次惩罚)与第 3 章(LTV / 回本)合成一条最小流水线,对 baseline 与每条
failure(一个 patch)各算:
    ret(d)       = D1/D7/D30 锚点整数线性插值(安装日恒 1000)
    cumActive(D) = Σ_{d=0..D} ret(d)                       # 千分比日
    factor‰      = max(minFactor, 1000 - v*volPen - f*forPen)
    ltv(D)       = (arpdau * factor // 1000) * cumActive(D) // 1000   # 0.1 分
    roas30‰      = ltv30 * 100 // cpaFen                   # 1000‰ = 回本
    paybackDay   = 首个 ltv(D) >= cpaFen*10 的 D(找不到记 -1)

留存锚点、广告惩罚系数与 ARPDAU 均为公开可观察机制的建模假设;真实留存、真实回收与真实
亏损需真实业务环境,命令行不可复现(X)。全整数、地板除,无浮点,无 Math.random。
产出 out/l15_value_failure.{json,csv,svg}。
"""
from __future__ import annotations
import argparse, csv, json, os, sys, xml.etree.ElementTree as ET

COLOR = {"玩法": "#2563eb", "数值": "#f59e0b", "变现": "#dc2626"}


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:
    fac = max(cfg["minFactorPermille"], 1000 - p["voluntary"] * cfg["voluntaryPenaltyPermillePerImp"]
              - p["forced"] * cfg["forcedPenaltyPermillePerImp"])
    eff = p["arpdauDeciFen"] * fac // 1000
    ltv30 = eff * cum(p, cfg["horizonDays"]) // 1000
    payback = next((d for d in range(cfg["horizonDays"] + 1)
                    if eff * cum(p, d) // 1000 >= cfg["cpaFen"] * 10), -1)
    return {"id": p["id"], "name": p["name"], "retainD1Permille": p["retainD1Permille"],
            "retainD7Permille": p["retainD7Permille"], "retainD30Permille": p["retainD30Permille"],
            "voluntary": p["voluntary"], "forced": p["forced"], "arpdauDeciFen": p["arpdauDeciFen"],
            "factorPermille": fac, "arpdauEffDeciFen": eff, "cumActive30Permille": cum(p, 30),
            "ltv30DeciFen": ltv30, "roas30Permille": ltv30 * 100 // cfg["cpaFen"], "paybackDay": payback}


def svg(path: str, rows: list[dict], base: dict) -> int:
    W, PAD, ROW = 760, 214, 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 = \
        "《合成小岛》失败模式对照:LTV30(0.1 分,示例参数)"
    ET.SubElement(r, "text", {"x": "16", "y": "46", "font-size": "12", "fill": "#6b7280"}).text = \
        f'基线 LTV30={base["ltv30DeciFen"]} ROAS30={base["roas30Permille"]}‰ 回本 D{base["paybackDay"]}'
    span = W - PAD - 150
    hi = max(max(x["ltv30DeciFen"] for x in rows), base["ltv30DeciFen"]) or 1
    for i, row in enumerate(rows):
        y, w = 66 + i * ROW, span * row["ltv30DeciFen"] // 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["ltv30DeciFen"]} ({row["deltaPct"]:+d}%)'
    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_value_failure.json"), encoding="utf-8"))
    base = evaluate(cfg, cfg["baseline"])
    rows = []
    for fa in cfg["failures"]:
        p = dict(cfg["baseline"], **fa["patch"])
        r = evaluate(cfg, p)
        r.update(id=fa["id"], name=fa["name"], aspect=fa["aspect"], mechanism=fa["mechanism"],
                 patch=fa["patch"], deltaPct=(r["ltv30DeciFen"] - base["ltv30DeciFen"]) * 100 // base["ltv30DeciFen"],
                 deltaRoasPermille=r["roas30Permille"] - base["roas30Permille"])
        rows.append(r)
    worst = min(rows, key=lambda x: x["ltv30DeciFen"])
    os.makedirs(a.out, exist_ok=True)
    sb = svg(os.path.join(a.out, "l15_value_failure.svg"), rows, base)
    json.dump({"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
               "note": cfg["note"], "horizonDays": cfg["horizonDays"], "cpaFen": cfg["cpaFen"],
               "baseline": base, "failures": rows, "worstId": worst["id"],
               "worstDeltaPct": worst["deltaPct"], "svgBytes": sb},
              open(os.path.join(a.out, "l15_value_failure.json"), "w", encoding="utf-8"),
              ensure_ascii=False, indent=2)
    cols = ("id", "name", "aspect", "retainD1Permille", "retainD7Permille", "retainD30Permille",
            "voluntary", "forced", "arpdauDeciFen", "factorPermille", "arpdauEffDeciFen",
            "cumActive30Permille", "ltv30DeciFen", "roas30Permille", "paybackDay", "deltaPct")
    with open(os.path.join(a.out, "l15_value_failure.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'基线 LTV30 {base["ltv30DeciFen"]}  ROAS30 {base["roas30Permille"]}‰  回本 D{base["paybackDay"]}')
    for row in rows:
        print(f'{row["aspect"]}  {row["name"]:<10} LTV30 {row["ltv30DeciFen"]:>5} ({row["deltaPct"]:+d}%)  '
              f'ROAS30 {row["roas30Permille"]:>5}‰  回本 '
              f'{("D" + str(row["paybackDay"])) if row["paybackDay"] >= 0 else "未回本"}')
    print(f'最差: {worst["id"]} ({worst["deltaPct"]:+d}%)')
    return 0


if __name__ == "__main__":
    sys.exit(main())

真跑输出(同机):

基线 LTV30 2554  ROAS30 1702‰  回本 D11
玩法  核心循环太短     LTV30  1669 (-35%)  ROAS30  1112‰  回本 D22
玩法  难度断崖       LTV30  1674 (-35%)  ROAS30  1116‰  回本 D20
数值  离线收益通胀     LTV30  1813 (-30%)  ROAS30  1208‰  回本 D18
数值  成长曲线过早封顶   LTV30  1805 (-30%)  ROAS30  1203‰  回本 D18
变现  广告频次拉满     LTV30   824 (-68%)  ROAS30   549‰  回本 未回本
变现  过早硬付费墙     LTV30  1849 (-28%)  ROAS30  1232‰  回本 D19
变现  定价锚点错位     LTV30  1673 (-35%)  ROAS30  1115‰  回本 D24
最差: ad-overload (-68%)

W 层:把同一套模型搬进浏览器的失败开关台(W 层)

只跑一遍脚本还不够直观——你希望手动拨:把自愿广告从 4 次/日滑到 12 次/日,看 LTV 实时往下掉。下面两段是 W 层:build_l15_switchboard.mjs 把 core/src/l15/failure.ts 打成单文件 HTML(产物 web/dist/l15.html),verify_l15_switchboard.mjs 用 Playwright 无头打开、切预设、拨滑杆,断言页面数字与内核逐位一致。注意 build 复用 pack.mjs 的擦除逻辑,verify 必须先预热一个 page——冷启动首个 page 读 canvas 像素恒为空白。

#!/usr/bin/env node
// file: web/build_l15_switchboard.mjs
// tier: W
// verify: node web/build_l15_switchboard.mjs
// 把 core/src/l15/failure.ts 打成单文件 HTML:失败开关台。产物 web/dist/l15.html。
// 复用 pack.mjs 的擦除逻辑,不自己重写 stripTypeScriptTypes。

import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "l15_value_failure.json"), "utf8"));
const js = bundle(ROOT, ["core/src/l15/failure.ts"]);
const CFG = inlineConfig({
  horizonDays: cfg.horizonDays, cpaFen: cfg.cpaFen, minFactorPermille: cfg.minFactorPermille,
  voluntaryPenaltyPermillePerImp: cfg.voluntaryPenaltyPermillePerImp,
  forcedPenaltyPermillePerImp: cfg.forcedPenaltyPermillePerImp,
  baseline: cfg.baseline,
  failures: cfg.failures.map((f) => ({ id: f.id, name: f.name, aspect: f.aspect, patch: f.patch })),
});
const script = `const CFG = ${CFG};
${js}
let params = { ...CFG.baseline };
const presets = [{ id: "healthy", name: "健康基线", aspect: "基线", patch: {} }, ...CFG.failures];
const paybackText = (d) => d < 0 ? "未回本" : "D" + d;
function render() {
  const r = evaluate(CFG, params);
  document.getElementById("factor").textContent = r.factorPermille + " ‰";
  document.getElementById("arpdau").textContent = Math.floor(r.arpdauEffDeciFen / 10) + "." + (r.arpdauEffDeciFen % 10);
  document.getElementById("ltv").textContent = Math.floor(r.ltv30DeciFen / 10) + "." + (r.ltv30DeciFen % 10) + " 分";
  document.getElementById("roas").textContent = r.roas30Permille + " ‰";
  document.getElementById("payback").textContent = paybackText(r.paybackDay);
  document.getElementById("vOut").textContent = params.voluntary + " 次/日";
  document.getElementById("fOut").textContent = params.forced + " 次/日";
  document.getElementById("tag").textContent = activeTag;
  return r;
}
let activeTag = "健康基线";
const api = {
  apply(id) { const p = presets.find((x) => x.id === id) ?? presets[0]; params = { ...CFG.baseline, ...p.patch }; activeTag = p.name; return { id, ...render() }; },
  set(v, f) { params = { ...params, voluntary: v, forced: f }; activeTag = "自定义"; return { id: "custom", ...render() }; },
  get() { const r = evaluate(CFG, params); return { params, ...r }; },
};
const bar = document.getElementById("presets");
for (const p of presets) { const b = document.createElement("button"); b.textContent = p.name; b.dataset.id = p.id; b.addEventListener("click", () => api.apply(p.id)); bar.appendChild(b); }
document.getElementById("v").addEventListener("input", (e) => api.set(Number(e.target.value), Number(document.getElementById("f").value)));
document.getElementById("f").addEventListener("input", (e) => api.set(Number(document.getElementById("v").value), Number(e.target.value)));
api.apply("healthy");
globalThis.__pebble = { api, ready: true };
`;
const body = `  <h1>《合成小岛》· 失败开关台</h1>
  <div class="sub">内核来自 core/src/l15/failure.ts:把玩法 / 数值 / 变现的失败参数喂进同一套留存-变现模型,看 LTV 与回本怎么塌</div>
  <div class="card row"><div><div class="k">当前模式</div><div id="tag" style="font-size:18px;color:#f59e0b">健康基线</div></div>
    <div><div class="k">广告惩罚因子</div><div class="big" id="factor">-</div></div>
    <div><div class="k">有效 ARPDAU</div><div class="big" id="arpdau">-</div></div></div>
  <div class="card row"><div><div class="k">LTV30</div><div class="big" id="ltv">-</div></div>
    <div><div class="k">ROAS30</div><div class="big" id="roas">-</div></div>
    <div><div class="k">回本日</div><div class="big" id="payback">-</div></div></div>
  <div class="card"><div class="k">一键切到某种失败</div><div class="row" id="presets"></div></div>
  <div class="card"><div class="k">手动调广告频次</div>
    <div>自愿广告 <span id="vOut">4 次/日</span></div><input id="v" type="range" min="0" max="12" step="1" value="4" style="width:100%">
    <div>强制广告 <span id="fOut">1 次/日</span></div><input id="f" type="range" min="0" max="8" step="1" value="1" style="width:100%"></div>`;
const bytes = writePage(join(ROOT, "web/dist/l15.html"), page({ title: "《合成小岛》失败开关台 · l15", body, script }));
console.log(JSON.stringify({ html: "web/dist/l15.html", htmlBytes: bytes, bundleLines: js.split("\n").length, presetCount: cfg.failures.length + 1 }, null, 2));
#!/usr/bin/env node
// file: web/verify_l15_switchboard.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l15_switchboard.mjs web/dist/l15.html
// W 层验收:Playwright 无头打开失败开关台,切预设 / 拨滑杆,断言 LTV / ROAS / 回本日与内核一致。
// playwright 用 createRequire 加载——NODE_PATH 对 ESM 的 import 无效。

import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";

const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/l15.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const p = await browser.newPage({ viewport: { width: 860, height: 1000 } });
p.on("pageerror", (e) => errors.push(String(e)));
await p.goto(url);
await p.waitForFunction(() => globalThis.__pebble?.ready === true, null, { timeout: 15000 });

const healthy = await p.evaluate(() => globalThis.__pebble.api.apply("healthy"));
const overload = await p.evaluate(() => globalThis.__pebble.api.apply("ad-overload"));
const domLtv = (await p.textContent("#ltv")).trim();
const domRoas = (await p.textContent("#roas")).trim();
const domPayback = (await p.textContent("#payback")).trim();
const custom = await p.evaluate(() => globalThis.__pebble.api.set(0, 0));
await p.screenshot({ path: join(ROOT, "out/shots/l15_switchboard.png"), fullPage: true });
await p.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(healthy.factorPermille, 620);
assert.equal(healthy.ltv30DeciFen, 2554);
assert.equal(healthy.roas30Permille, 1702);
assert.equal(healthy.paybackDay, 11);
assert.equal(overload.factorPermille, 200);
assert.equal(overload.ltv30DeciFen, 824);
assert.equal(overload.roas30Permille, 549);
assert.equal(overload.paybackDay, -1);
assert.equal(custom.factorPermille, 1000);
assert.equal(domLtv, "82.4 分", "切到广告拉满后 DOM LTV 应显示 82.4 分");
assert.equal(domRoas, "549 ‰");
assert.equal(domPayback, "未回本");
assert.ok(overload.ltv30DeciFen < healthy.ltv30DeciFen, "广告拉满应压低 LTV");

console.log(JSON.stringify({
  page: "web/dist/l15.html", healthy, overload, custom, domLtv, domRoas, domPayback,
  screenshot: "out/shots/l15_switchboard.png",
}));
console.log("SMOKE-W-l15: PASS");

真跑输出:SMOKE-W-l15: PASS,页面断言 healthy.ltv30DeciFen=2554、overload.ltv30DeciFen=824、overload.paybackDay=-1、DOM 显示 82.4 分 / 549 ‰ / 未回本。

三类失败里,最该警惕哪几个

把八行输出排开,模式很清楚:玩法类(循环太短、难度断崖)各让 LTV 掉 35%,且回本日从第 11 天推到第 20 天之后——这是「慢性病」,不致命但拖垮回本节奏。数值类(离线收益通胀、成长曲线过早封顶)掉 30%,回本推到第 18 天,同样伤但稍轻。真正要命的是变现类的广告频次拉满:LTV 掉 68%、ROAS 直接跌破 1000‰ 变「永不回本」。原因在模型里一目了然——广告惩罚因子被顶到下限 200‰,有效 ARPDAU 被砍到只剩两成。这也解释了为什么「先把广告加满再说」是新手最常犯、代价最大的错误。

失败类别LTV30 位移回本日致命度
广告频次拉满变现−68%永不回本★★★★★
核心循环太短 / 难度断崖玩法−35%D20–D22★★★★
定价锚点错位变现−35%D24★★★★
离线收益通胀 / 成长过早封顶数值−30%D18★★★
过早硬付费墙变现−28%D19★★★

一句话总结本节:留存类失败是慢性病,变现类失败是急症。慢性的要靠玩法与数值的长期打磨慢慢治,急性的只要不把频次旋钮拧到底就能避开——而「拧到底」的冲动,恰恰来自「想快点回本」的焦虑。15.2 会把镜头转向买量侧:如果买来的人本来就留不住,再健康的变现模型也救不了。

阅读导航:上一节:目录 · 下一节:15.2 买量与留存的典型失败 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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