上一节把 CTR 拉了起来,但 CTR 只解决「点不点」,解决不了「留不留」。这一节处理买量里最容易被忽视、也最容易埋雷的一件事:素材承诺的东西,游戏里到底有没有——素材是钩子,玩法是兑现;钩子越猛、兑现越差,摔得越狠。这一节和上一节素材设计与创意迭代 是同一枚硬币的两面:上一节讲「怎么把 CTR 做高」,这一节讲「做高之后别翻车」;至于自然量与裂变,是下一章增长与裂变 的话题。
点击率高,不等于转化好
先把误区摆出来。很多团队的素材 KPI 就是 CTR,谁素材点击高谁受表扬。但 CTR 只是漏斗的第一个环节。一条把「合成爆装、无限装扮、秒变大佬」全吹满的素材,CTR 可能碾压全场——点进来的人却很快发现游戏根本不是这样,于是首日就流失。结果是:你花钱买了最高的点击,却买来最短命的用户。CTR 高、D1 低,最终 D7 ROAS 反而最差——「素材-玩法不一致」把成本压在了漏斗前端,把损失藏在了漏斗后端。
一致性是什么:素材承诺 vs 玩法兑现
把这件事量化,需要两个概念:
- 素材承诺(promise):素材里对每个卖点宣称的强度。比如素材里「岛屿装扮」占了大半时长,就是对装扮的强承诺。
- 玩法兑现(delivery):游戏里这个卖点实际能做到多少。「装扮」如果只有几个固定皮肤,兑现度就低。
两者的差值就是不一致。只算「承诺 > 兑现」的部分(超额承诺,overclaim),因为「承诺 < 兑现」只是保守,不伤玩家。一致性评分可以简单定义为:
一致性‰ = 1000 - 平均|promise - delivery|
超额承诺‰ = 平均 max(0, promise - delivery)
首日留存‰ = 基础D1 - 惩罚系数 × 超额承诺 / 1000
超额承诺的好处是抬高 CTR(钩子更猛),代价是压低首日留存(点进来发现被骗)。而首日留存又直接决定后续累计活跃日与 LTV——于是 ROAS 被两头拉扯:CPI 降了(CTR 高),LTV 也降了(留存低),谁赢要看哪边拉得狠。
三处一致:素材 → 落地页 → 首局体验
一致性不只是「素材 vs 玩法」,它是一条链,任何一环断裂都会流失:
| 环节 | 一致的要求 | 断裂的后果 |
|---|---|---|
| 素材 → 落地页 | 素材承诺的主玩法,落地页要能一眼看到 | 点了却找不到素材里的画面,直接退 |
| 落地页 → 首局 | 落地页引导的首局,要立刻复现素材的爽点 | 首局没爽到,觉得被骗 |
| 首局 → 长期 | 首局的爽点,玩法要能持续供给 | 前期爆爽、后期断供,长期留存崩 |
「合成小岛」的对照实验里最常见的翻车是第三环:素材用「一键爆装」的夸张演出吸引点击,玩家进来发现合成是要一步步攒的,前几关爽感远不如素材——于是 D1 留存被素材的高承诺反噬。
S 层仿真:夸张承诺的代价
下面这段是 S 层仿真脚本 sim/l05_consistency.py。它读 config/l05_consistency.json,对三条素材(如实展示 / 略偏展示 / 夸张承诺)分别计算一致性评分、CPI、首日留存与 D7 ROAS,把「点击 vs 回收」的冲突算成整数。
#!/usr/bin/env python3
# file: sim/l05_consistency.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l05_consistency.py --config config/ --out out/
"""素材-玩法一致性仿真:把「素材承诺强度」与「玩法实际兑现」的差值量化成对转化的影响。
对 config/l05_consistency.json 的每条素材:
mismatch_f = |promise_f - delivery_f| # 单卖点偏差
overclaim_f = max(0, promise_f - delivery_f) # 只算超额承诺
consistency‰ = 1000 - mean(mismatch_f) # 一致性评分(越高越一致)
cpiFen = cpmFen * 1000 // (ctrPermille * ipmPermille)
d1‰ = baseRetainD1 - mismatchPenalty * mean(overclaim) // 1000 # 首日留存
ltvD7DeciFen = arpdauDeciFen * cumActive7(d1) // 1000 # 0.1 分
roasD7‰ = ltvD7DeciFen * 100 // cpiFen
cumActive7(d1):第 1..7 日留存按 d1 → d1*420/1000 整数线性插值后累加(安装日恒 1000)。
结论导向:承诺越强 CTR 越高、CPI 越低,但超额承诺压低首日留存、进而压低 D7 ROAS——
「夸张承诺」拿到最高点击却拿到最低回收,这就是素材-玩法不一致的代价。
全程整数、地板除,无浮点。产出 out/l05_consistency.json + .csv + .svg。
素材承诺强度是建模假设;真实转化结果需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def cum_active7(d1: int) -> int:
"""第 0..7 日累计活跃,单位千分比日(D7 留存 = d1*420/1000,中间整数线性插值)。"""
r7 = d1 * 420 // 1000
days = [1000, d1]
for d in range(2, 8):
days.append(d1 + (r7 - d1) * (d - 1) // 6)
return sum(days)
def evaluate(cfg: dict, cr: dict) -> dict:
feats = cfg["features"]
delivery = cfg["delivery"]
mismatch = [abs(cr["promises"][f["id"]] - delivery[f["id"]]) for f in feats]
overclaim = [max(0, cr["promises"][f["id"]] - delivery[f["id"]]) for f in feats]
mean_mismatch = sum(mismatch) // len(feats)
mean_overclaim = sum(overclaim) // len(feats)
consistency = 1000 - mean_mismatch
cpi = cfg["cpmFen"] * 1000 // (cr["baseCtrPermille"] * cfg["ipmPermille"])
d1 = cfg["baseRetainD1Permille"] - cfg["mismatchPenaltyPermille"] * mean_overclaim // 1000
d1 = max(0, d1)
ltv7 = cfg["arpdauDeciFen"] * cum_active7(d1) // 1000
roas7 = ltv7 * 100 // cpi
return {"id": cr["id"], "name": cr["name"], "baseCtrPermille": cr["baseCtrPermille"],
"meanMismatchPermille": mean_mismatch, "meanOverclaimPermille": mean_overclaim,
"consistencyPermille": consistency, "cpiFen": cpi, "retainD1Permille": d1,
"ltvD7DeciFen": ltv7, "roasD7Permille": roas7}
def draw_svg(path: str, rows: list[dict]) -> int:
"""纯 Python SVG:每条素材三根横条——CTR(‰) / D1 留存(‰) / D7 ROAS(‰)(各自归一)。"""
W, H, PAD, ROW = 780, 74 + 62 * len(rows), 150, 62
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 = "素材-玩法一致性:点击(CTR)越高,留存(D1)与回收(ROAS)未必越高"
span = W - PAD - 24
bars = [("baseCtrPermille", "#f59e0b", "CTR"), ("retainD1Permille", "#10b981", "D1"),
("roasD7Permille", "#2563eb", "ROAS7")]
hi = max(max(r[k] for k, _, _ in bars) for r in rows) or 1
for i, r in enumerate(rows):
y = 52 + i * ROW
ET.SubElement(root, "text", {"x": "16", "y": str(y + 26), "font-size": "12",
"fill": "#374151"}).text = f'{r["name"]}\n一致性 {r["consistencyPermille"]}‰'
for j, (key, color, label) in enumerate(bars):
w = span * r[key] // hi
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4 + j * 17), "width": str(w),
"height": "13", "rx": "3", "fill": color})
ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 15 + j * 17),
"font-size": "10", "fill": "#6b7280"}).text = f'{label} {r[key]}‰'
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/")
args = ap.parse_args()
with open(os.path.join(args.config, "l05_consistency.json"), encoding="utf-8") as f:
cfg = json.load(f)
rows = [evaluate(cfg, cr) for cr in cfg["creatives"]]
best_ctr = max(rows, key=lambda r: r["baseCtrPermille"])
best_roas = max(rows, key=lambda r: r["roasD7Permille"])
digest = ",".join(f'{r["id"]}:{r["consistencyPermille"]}:{r["cpiFen"]}:'
f'{r["retainD1Permille"]}:{r["roasD7Permille"]}' for r in rows)
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "l05_consistency.svg"), rows)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"creatives": rows, "bestByCtr": best_ctr["id"], "bestByRoas": best_roas["id"],
"ctrRoasConflict": best_ctr["id"] != best_roas["id"], "digest": digest,
"svg": os.path.join(args.out, "l05_consistency.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "l05_consistency.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "l05_consistency.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
cols = ("id", "name", "baseCtrPermille", "meanMismatchPermille", "meanOverclaimPermille",
"consistencyPermille", "cpiFen", "retainD1Permille", "ltvD7DeciFen", "roasD7Permille")
w.writerow(cols)
for r in rows:
w.writerow([r[k] for k in cols])
print(json.dumps({"creatives": rows, "bestByCtr": best_ctr["id"],
"bestByRoas": best_roas["id"], "ctrRoasConflict": report["ctrRoasConflict"],
"digest": digest}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
三条素材与游戏实际兑现度放在 config/l05_consistency.json。素材承诺强度是建模假设;真实转化结果需真实业务环境,命令行不可复现(X)。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"cpmFen": 1600,
"ipmPermille": 140,
"baseRetainD1Permille": 420,
"arpdauDeciFen": 520,
"mismatchPenaltyPermille": 900,
"note": "素材-玩法一致性模型(公开可观察机制口径的建模假设,非任何渠道后台实测)。delivery=游戏实际兑现度千分比;promises=素材对每个卖点的承诺强度千分比;承诺越强 baseCtrPermille 越高(吸引点击),但超额承诺 overclaim 会按 mismatchPenaltyPermille 惩罚首日留存,进而拖低 ROAS。真实素材承诺强度与真实转化结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"features": [
{ "id": "merge", "name": "合成爽感" },
{ "id": "decor", "name": "岛屿装扮" },
{ "id": "idle", "name": "放置收益" },
{ "id": "levels", "name": "关卡挑战" }
],
"delivery": { "merge": 820, "decor": 300, "idle": 700, "levels": 420 },
"creatives": [
{ "id": "honest", "name": "如实展示", "baseCtrPermille": 26,
"promises": { "merge": 820, "decor": 320, "idle": 700, "levels": 420 } },
{ "id": "balanced", "name": "略偏展示", "baseCtrPermille": 30,
"promises": { "merge": 900, "decor": 420, "idle": 760, "levels": 500 } },
{ "id": "overclaim", "name": "夸张承诺", "baseCtrPermille": 38,
"promises": { "merge": 1000, "decor": 900, "idle": 1000, "levels": 850 } }
]
}
输出解读:点击最高的素材,回收最低
在本机跑 for f in sim/l05_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,三条素材的对照结果如下(同机相对量级,绝对数值不可移植):
| 素材 | 一致性‰ | CTR‰ | CPI(分) | D1 留存‰ | D7 ROAS‰ |
|---|---|---|---|---|---|
| 如实展示 | 995 | 26 | 439 | 416 | 362 |
| 略偏展示 | 915 | 30 | 380 | 344 | 370 |
| 夸张承诺 | 623 | 38 | 300 | 81 | 242 |
结果非常清楚:
- 夸张承诺拿到了最高 CTR(38‰)和最低 CPI(300 分)——从「买量便宜」看它是赢家;
- 但它的 D1 留存只有 81‰(如实展示是 416‰),D7 ROAS 只有 242‰,是三档里最低的;
- 略偏展示反而拿到最高的 D7 ROAS(370‰)——它的 CTR 比夸张承诺低,但留存撑住了回收。
ctrRoasConflict = true:CTR 最高的素材和 ROAS 最高的素材不是同一条。这正是本节要钉死的结论——用 CTR 单指标考核素材,一定会奖励「最能骗点击」的那条,最终把回收做崩。正确的素材 KPI 是 ROAS(或 LTV/CPI),CTR 只是过程指标。
W 层原型:拖动承诺,看回收怎么变
下面两段是 W 层单文件 HTML 原型的构建与验收脚本。web/build_l05_consistency.mjs 把 core/src/l05/roi.ts 内联进一个网页,做成可交互计算器:拖动「平均超额承诺」滑杆,实时看到 CPI、D1 留存、D0/D7/D30 ROAS 与回本日怎么变——点击越高(CPI 越低)与回收越好(ROAS 越高)在这里直观地分道扬镳。
#!/usr/bin/env node
// file: web/build_l05_consistency.mjs
// tier: W
// verify: node web/build_l05_consistency.mjs
// 把 core/src/l05/roi.ts 打成单文件 HTML:素材-玩法一致性 → 回收 计算器。产物 web/dist/l05.html。
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", "l05_consistency.json"), "utf8"));
const js = bundle(ROOT, ["core/src/l05/roi.ts"]);
const CONS = inlineConfig({ cpmFen: cfg.cpmFen, ipmPermille: cfg.ipmPermille, baseRetainD1Permille: cfg.baseRetainD1Permille, arpdauDeciFen: cfg.arpdauDeciFen, mismatchPenaltyPermille: cfg.mismatchPenaltyPermille });
const script = `const C = ${CONS};
${js}
const ch = (oc) => { const d1 = Math.max(0, C.baseRetainD1Permille - Math.floor(C.mismatchPenaltyPermille * oc / 1000)); return { id: "c", name: "自定义", model: "c", cpmFen: C.cpmFen, ctrPermille: 26 + Math.floor(oc * 32 / 1000), ipmPermille: C.ipmPermille, arpdauDeciFen: C.arpdauDeciFen, retainD1Permille: d1, retainD7Permille: Math.floor(d1 * 420 / 1000), retainD30Permille: Math.floor(d1 * 256 / 1000) }; };
const draw = () => { const oc = Number(document.getElementById("oc").value); const c = ch(oc), r = evaluate(c, 3000000);
document.getElementById("ocOut").textContent = oc + " ‰";
document.getElementById("d1").textContent = c.retainD1Permille + " ‰";
document.getElementById("cpi").textContent = r.cpiFen + " 分";
document.getElementById("roas").textContent = r.roasD0Permille + " / " + r.roasD7Permille + " / " + r.roasD30Permille + " ‰";
document.getElementById("payback").textContent = r.paybackDay < 0 ? "未回本" : ("D" + r.paybackDay);
globalThis.__pebble = { overclaim: oc, cpiFen: r.cpiFen, retainD1: c.retainD1Permille, roasD7: r.roasD7Permille, roasD30: r.roasD30Permille, paybackDay: r.paybackDay, ready: true }; };
document.getElementById("oc").addEventListener("input", draw);
document.getElementById("reset").addEventListener("click", () => { document.getElementById("oc").value = "0"; draw(); });
draw();
`;
const body = ` <h1>《合成小岛》· 素材-玩法一致性 → 回收 计算器</h1>
<div class="sub">复用 core/src/l05/roi.ts:拖动「平均超额承诺」,看点击变高、留存与回收怎么变</div>
<div class="card"><div class="k">平均超额承诺</div><div class="big" id="ocOut">0 ‰</div>
<input id="oc" type="range" min="0" max="600" step="1" value="0" style="width:100%"></div>
<div class="card row"><div><div class="k">D1 留存</div><div class="big" id="d1">-</div></div>
<div><div class="k">CPI</div><div class="big" id="cpi">-</div></div>
<div><div class="k">回本日</div><div class="big" id="payback">-</div></div></div>
<div class="card"><div class="k">D0 / D7 / D30 ROAS(‰)</div><div id="roas" style="font-size:20px;color:#38bdf8">-</div></div>
<div class="card row"><button id="reset">回到如实展示(0‰)</button></div>`;
const bytes = writePage(join(ROOT, "web/dist/l05.html"), page({ title: "《合成小岛》一致性计算器 · l05", body, script }));
console.log(JSON.stringify({ html: "web/dist/l05.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
web/verify_l05_consistency.mjs 用 Playwright 无头打开页面,把滑杆拨到 0‰ 与 377‰ 两档,断言计算结果与内核一致(0‰ → CPI 439 分、D1 420‰、D7 ROAS 365‰;377‰ → CPI 300 分、D1 81‰、D7 ROAS 242‰),并截图存档。
#!/usr/bin/env node
// file: web/verify_l05_consistency.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l05_consistency.mjs web/dist/l05.html
// W 层验收:Playwright 无头打开一致性计算器,拖动「超额承诺」,断言 CPI / D1 / ROAS 与内核一致。
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/l05.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();
const errors = [];
const page = await browser.newPage({ viewport: { width: 900, height: 900 } });
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.ready === true, null, { timeout: 15000 });
const setOc = (v) => page.evaluate((x) => { const el = document.getElementById("oc"); el.value = String(x); el.dispatchEvent(new Event("input")); return globalThis.__pebble; }, v);
const honest = await setOc(0), over = await setOc(377);
await page.screenshot({ path: join(ROOT, "out/shots/l05_prototype.png"), fullPage: true });
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(honest.cpiFen, 439); assert.equal(honest.retainD1, 420); assert.equal(honest.roasD7, 365);
assert.equal(over.cpiFen, 300); assert.equal(over.retainD1, 81); assert.equal(over.roasD7, 242);
assert.ok(over.cpiFen < honest.cpiFen, "超额承诺应压低 CPI(点击更高)");
assert.ok(over.roasD7 < honest.roasD7, "超额承诺应压低 D7 ROAS(留存更低)");
console.log(JSON.stringify({ page: "web/dist/l05.html", honest, overclaim377: over, screenshot: "out/shots/l05_prototype.png" }, null, 2));
console.log("SMOKE-W-l05: PASS");
在本机跑 node web/build_l05_consistency.mjs 与上面的验收命令,输出 SMOKE-W-l05: PASS:原型在 377‰ 超额承诺下复现了 S 仿真的「夸张承诺」一行(CPI 300 分、D1 81‰、D7 ROAS 242‰),把「点击最高、回收最低」的冲突搬进了浏览器。
合规红线:不一致不只是亏钱,还会被拒
素材-玩法不一致在合规层面也是硬红线(这一层属于 X 层,真实审核结果需真实业务环境,命令行不可复现):各小游戏平台的广告审核都明确禁止虚假宣传、诱导点击、货不对板。「点击就送 999」「素材里的玩法游戏里没有」这类,轻则素材被拒、重则账号受限。所以一致性不只是商业上的最优解,也是过审的前提。第 10 章的内容安全红线 会把审核口径展开。
自检表
| 检查项 | 合格标准 |
|---|---|
| 卖点清单 | 每个素材宣称的卖点,都能在游戏里指到对应玩法 |
| 三处一致 | 素材 / 落地页 / 首局体验讲同一个玩法 |
| KPI 口径 | 素材考核用 ROAS,不用 CTR 单指标 |
| 超额承诺 | 承诺强度不超过玩法兑现,留安全余量 |
| 合规 | 无虚假宣传、无诱导点击、无货不对板 |
| 边界清晰 | 真实审核结果标 X,不写成「实测」 |
买量这一章到这里收口:渠道机制(5.1)决定钱怎么花,素材迭代(5.2)决定点击高不高,素材-玩法一致性(5.3)决定点进来的人留不留。三者合起来才是完整的买量闭环。买量是把外部流量灌进来,而更健康、更便宜的增长来自口碑与裂变——下一章我们就从自然量、平台免费流量与增长预算分配讲起。
阅读导航:上一节:素材设计与创意迭代 · 下一节:自然增长与裂变 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。