5.2 素材设计与创意迭代

素材是买量里唯一由你完全掌控的变量:先按主题 × 形式批量产出,用固定小曝光逐条测试,按 CTR 淘汰、对胜出素材集中放量,同时盯住整轮受众疲劳。本节把「迭代 vs 只用一条素材」的混合 CPI 差异算成可跑的整数仿真,并给出素材矩阵、迭代流程与逐轮自检清单。

上一节把渠道机制 算清楚了:钱怎么花、在哪一步折损、能不能回本。但那条漏斗里,出价、人群、归因这些变量都在渠道手里,你改不动。真正由你完全掌控、且直接决定 CTR的,只有一样东西——素材。这一节就讲素材:怎么设计、怎么批量产出、怎么用小额测试淘汰、怎么把预算集中到胜出者身上。

边界先说清:这一节讲的是付费买量里的素材。至于自然量、裂变、平台免费流量(比如小游戏中心的推荐位),那是下一章增长与裂变 的事,本节不越界。素材-玩法是否一致、会不会「骗点击」,是下一节单独处理的问题。

素材是买量里唯一自己掌控的变量

先把上一节的漏斗公式再看一眼:CPI = CPM × 1000 / (CTR × IPM)。CPM 由竞价决定,IPM 由「素材承诺」与「商店/落地页」共同决定,而你唯一能在几分钟内反复改、立刻看到反馈的,是 CTR——也就是素材的点击率。

这就是为什么成熟的买量团队把大部分精力砸在素材上:素材是可以量产、可以测试、可以迭代的。一条渠道参数你可能一周才调一次,但素材一天能上几十条。素材的质量不是玄学,它是一个可搜索的优化问题:产出足够多的候选、用足够小的成本测出哪个 CTR 高、把预算压到赢家身上。

素材的构成要素与矩阵

一条小游戏买量素材,无论多短,都在回答四个问题。把这四个要素当成设计清单:

要素回答的问题常见手法
钩子前 3 秒凭什么让人停下冲突、悬念、反常识、痛点直击
演示这游戏怎么玩、爽在哪真实合成连击、结果爆点、进度飞涨
结果玩下去能得到什么岛屿成型、装扮变化、数值成长
行动下一步做什么「点这里」「免费玩」「合成试试」

而素材矩阵是把「主题」和「形式」做笛卡尔积,保证每一轮测试覆盖不同的假设。主题是「讲什么」,形式是「怎么讲」:

竖版 9:16 · 15s方版 1:1 · 10s横版 16:9 · 30s
玩法展示合成连击实录单步合成特写完整一局流程
结果爆点岛屿瞬间成型装扮前后对比进度条冲刺
剧情钩子「她只剩一块地」悬念开场三幕小故事
前后对比第 1 天 vs 第 30 天白板到满岛数值对比

矩阵的价值不在于「都做」,而在于每轮测试只变一两个维度——如果这一轮同时换了主题和形式,你测出 CTR 变高了,也不知道是哪个变量起了作用。这是最朴素的 A/B 纪律,和第 8 章的实验设计 是同一套逻辑。

迭代流程:批量产出 → 小额测试 → 淘汰 → 放量

素材迭代是一条闭环,四步一循环:

  1. 批量产出:一轮做 N 条(小游戏常见 6~20 条),覆盖矩阵里的不同格子。
  2. 小额测试:每条给一个小预算 + 固定曝光量(比如每条 2 万次曝光),够统计、又不心疼。
  3. 淘汰:按 CTR 排序,只留头部(topK)进候选池,尾部直接砍掉。
  4. 放量:把主预算压到候选池里 CTR 最高的那条上,让它跑出规模。

关键在第三步的取舍:测出来的 CTR 是小样本估计,带噪声。一条素材在 2 万曝光里跑出高 CTR,可能是真的强,也可能只是运气。所以工程上会留 topK 而不是只留 top1,多轮迭代、把候选池做大,降低「被一次幸运测试骗了」的风险。

素材疲劳:同一个素材会「用旧」

还有一件事必须进模型:素材疲劳。同一条素材反复投给同一批人群,CTR 会逐轮衰减——观众看腻了、系统把它重复曝光给已经点过的人。所以「一条素材一直用」是慢性自杀:它上线第一天 CTR 最高,之后一路下滑。

迭代之所以重要,一部分原因就是用新素材对冲疲劳。但迭代本身也受疲劳影响:如果每一轮面对的还是同一批受众,整轮的平均 CTR 也会被压下去。这就是为什么「迭代」不是无脑多产,而是产出速度要跑赢衰减速度。

S 层仿真:迭代 vs 只用一条素材

下面这段是 S 层仿真脚本 sim/l05_creative_iter.py。它模拟四轮迭代:每轮产出 6 条素材、各给 2 万曝光做小测、保留 CTR 前 2 名进候选池,迭代结束后对候选池最优者放量;同时跑一个对照——只用第一轮第一条素材、在同样总预算下投放。整轮疲劳、质量抖动、小样本噪声都由可播种 LCG 生成,全整数。

#!/usr/bin/env python3
# file: sim/l05_creative_iter.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l05_creative_iter.py --config config/ --out out/
"""素材创意迭代仿真:批量小测 → 保留 topK → 迭代 → 放量,对照「只用一条素材」。

对 config/l05_creative.json:

    每轮 r、每个素材 i:
        theme    = themes[(r*C + i) % len(themes)]
        jitter   = (900 + rng%200) / 1000            # 质量抖动 ±10%
        fatigue  = (1000 - r*fatiguePerRound) / 1000 # 整轮受众疲劳
        trueCtr  = theme.baseCtr * jitter // 1000 * fatigue // 1000
        clicks   = imp * trueCtr // 1000 + (rng%7 - 3)   # 小样本噪声
        obsCtr   = clicks * 1000 // imp
        installs = clicks * ipm // 1000
        cost     = imp * cpmFen // 1000

    每轮保留 obsCtr 最高的 keepTopK 条进候选池;迭代结束后取池内最优者放量。
    对照(baseline):只用第一轮第一条素材,在同样总预算下投放。

    混合 CPI = 总花费 // 总安装;数值越小越好。
全程整数、地板除、可播种 LCG,无浮点。产出 out/l05_creative_iter.json + .csv + .svg。
素材 CTR 与投放结果是建模假设;真实数值需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def make_rng(seed: int, mult: int, inc: int):
    state = seed & 0xFFFFFFFF

    def nxt() -> int:
        nonlocal state
        state = (state * mult + inc) & 0xFFFFFFFF
        return state

    return nxt


def run(cfg: dict) -> dict:
    nxt = make_rng(cfg["seed"], cfg["rngMultiplier"], cfg["rngIncrement"])
    themes = cfg["themes"]
    C = cfg["creativesPerRound"]
    imp = cfg["testImpressionsPerCreative"]
    cost_per = imp * cfg["cpmFen"] // 1000

    pool: list[dict] = []
    rounds_rows: list[dict] = []
    test_spend = 0
    test_installs = 0
    first_creative = None

    for r in range(cfg["rounds"]):
        made = []
        for i in range(C):
            theme = themes[(r * C + i) % len(themes)]
            jitter = (900 + nxt() % 200)
            fatigue = 1000 - r * cfg["fatiguePermillePerRound"]
            true_ctr = theme["baseCtrPermille"] * jitter // 1000 * fatigue // 1000
            clicks = imp * true_ctr // 1000 + (nxt() % 7 - 3)
            clicks = max(0, clicks)
            obs_ctr = clicks * 1000 // imp
            installs = clicks * cfg["ipmPermille"] // 1000
            item = {"round": r, "idx": i, "theme": theme["id"], "obsCtrPermille": obs_ctr,
                    "clicks": clicks, "installs": installs, "costFen": cost_per}
            made.append(item)
            if first_creative is None:
                first_creative = item
        made.sort(key=lambda x: x["obsCtrPermille"], reverse=True)
        kept = made[: cfg["keepTopK"]]
        pool.extend(kept)
        test_spend += cost_per * C
        test_installs += sum(m["installs"] for m in made)
        rounds_rows.append({"round": r, "bestCtrPermille": made[0]["obsCtrPermille"],
                            "meanCtrPermille": sum(m["obsCtrPermille"] for m in made) // C,
                            "testSpendFen": cost_per * C,
                            "testInstalls": sum(m["installs"] for m in made)})

    winner = max(pool, key=lambda x: x["obsCtrPermille"])
    win_cpi = winner["costFen"] // winner["installs"] if winner["installs"] else 10 ** 9
    scale_installs = cfg["scaleBudgetFen"] // win_cpi if win_cpi else 0

    total_budget = test_spend + cfg["scaleBudgetFen"]
    iter_installs = test_installs + scale_installs
    iter_cpi = total_budget // iter_installs if iter_installs else 0

    base_cpi = (first_creative["costFen"] // first_creative["installs"]
                if first_creative and first_creative["installs"] else 10 ** 9)
    base_installs = total_budget // base_cpi if base_cpi else 0
    base_cpi_eff = total_budget // base_installs if base_installs else 0

    return {"rounds": rounds_rows, "winner": winner, "testSpendFen": test_spend,
            "testInstalls": test_installs, "scaleInstalls": scale_installs,
            "totalBudgetFen": total_budget, "iterInstalls": iter_installs, "iterCpiFen": iter_cpi,
            "baseCpiFen": base_cpi_eff, "baseInstalls": base_installs,
            "cpiDeltaPermille": (base_cpi_eff - iter_cpi) * 1000 // base_cpi_eff if base_cpi_eff else 0}


def draw_svg(path: str, rep: dict) -> int:
    """纯 Python SVG:每轮最佳 / 平均 CTR 柱,右侧标注迭代与单素材的混合 CPI。"""
    rows = rep["rounds"]
    W, H, PAD, ROW = 760, 70 + 56 * len(rows), 150, 56
    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(千分比)"
    span = W - PAD - 24
    hi = max(r["bestCtrPermille"] for r in rows) or 1
    for i, r in enumerate(rows):
        y = 56 + i * ROW
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
                                     "fill": "#374151"}).text = f'第 {r["round"] + 1} 轮'
        wb = span * r["bestCtrPermille"] // hi
        wm = span * r["meanCtrPermille"] // hi
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(wb),
                                     "height": "16", "rx": "3", "fill": "#2563eb"})
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 24), "width": str(wm),
                                     "height": "16", "rx": "3", "fill": "#93c5fd"})
        ET.SubElement(root, "text", {"x": str(PAD + max(wb, wm) + 8), "y": str(y + 18),
                                     "font-size": "11", "fill": "#6b7280"}).text = (
            f'最佳 {r["bestCtrPermille"]}‰ · 均 {r["meanCtrPermille"]}‰')
    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_creative.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    rep = run(cfg)

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "l05_creative_iter.svg"), rep)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "rounds": cfg["rounds"], "creativesPerRound": cfg["creativesPerRound"],
              "keepTopK": cfg["keepTopK"], **rep,
              "digest": (f'winner:{rep["winner"]["theme"]}:{rep["winner"]["obsCtrPermille"]}'
                         f'|iterCpi:{rep["iterCpiFen"]}|baseCpi:{rep["baseCpiFen"]}'
                         f'|deltaPermille:{rep["cpiDeltaPermille"]}'),
              "svg": os.path.join(args.out, "l05_creative_iter.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "l05_creative_iter.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "l05_creative_iter.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["round", "bestCtrPermille", "meanCtrPermille", "testSpendFen", "testInstalls"])
        for r in rep["rounds"]:
            w.writerow([r["round"], r["bestCtrPermille"], r["meanCtrPermille"],
                        r["testSpendFen"], r["testInstalls"]])

    print(json.dumps({"rounds": rep["rounds"], "winner": rep["winner"],
                      "iterCpiFen": rep["iterCpiFen"], "iterInstalls": rep["iterInstalls"],
                      "baseCpiFen": rep["baseCpiFen"], "baseInstalls": rep["baseInstalls"],
                      "cpiDeltaPermille": rep["cpiDeltaPermille"],
                      "digest": report["digest"]}, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

迭代参数放在 config/l05_creative.json:4 轮、每轮 6 条、保留前 2 名、整轮疲劳每轮扣 60‰。素材 CTR 与投放结果是建模假设;真实素材 CTR 需真实业务环境,命令行不可复现(X)。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "seed": 20261012,
  "rngMultiplier": 1103515245,
  "rngIncrement": 12345,
  "cpmFen": 1600,
  "ipmPermille": 140,
  "testImpressionsPerCreative": 20000,
  "scaleBudgetFen": 3000000,
  "rounds": 4,
  "creativesPerRound": 6,
  "keepTopK": 2,
  "fatiguePermillePerRound": 60,
  "note": "素材迭代模型参数(公开可观察机制口径的建模假设,非任何渠道后台实测)。baseCtrPermille=主题基线点击率千分比;每轮新素材质量抖动 ±10%、整轮受疲劳衰减 fatiguePermillePerRound 递减;小样本噪声由可播种 LCG 生成。真实素材 CTR 与真实投放结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "themes": [
    { "id": "gameplay", "name": "玩法展示", "baseCtrPermille": 26 },
    { "id": "result", "name": "结果爆点", "baseCtrPermille": 32 },
    { "id": "story", "name": "剧情钩子", "baseCtrPermille": 24 },
    { "id": "contrast", "name": "前后对比", "baseCtrPermille": 29 }
  ]
}

输出解读:迭代把混合 CPI 压下来了

在本机跑 for f in sim/l05_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,结果如下(同机相对量级,绝对数值不可移植):

轮次最佳 CTR‰平均 CTR‰本轮测试花费(分)
第 1 轮3226192000
第 2 轮3025192000
第 3 轮2723192000
第 4 轮2721192000
  • 胜出素材来自第 1 轮的「结果爆点」主题,观测 CTR 32‰;
  • 迭代组混合 CPI = 373 分,**对照组(只用第一条素材)**混合 CPI = 500 分,相对低约 25%(cpiDeltaPermille=254);
  • 同样的总预算(测试花费 768000 分 + 放量 3000000 分)下,迭代组拿到 10079 个安装,对照组 7536 个。

两点值得注意。第一,最佳素材出现在第一轮——这不奇怪,因为疲劳让整轮 CTR 逐轮下降,越往后越难跑出高 CTR;迭代的价值不是「越迭代越好」,而是用多轮候选降低「押错一条」的风险。第二,平均 CTR 从 26‰ 一路掉到 21‰,正是疲劳曲线的形状:产出速度跑不赢衰减,整盘效率就往下走。这两点合起来说明:迭代要持续产出新素材,不能指望「测出赢家就一劳永逸」。

X 层:真实素材表现不可复现

同样划清边界。上面算的是模型侧:给定主题基线 CTR 与疲劳参数,迭代能带来多大改善。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的素材 CTR / IPM(同一素材在不同渠道、不同人群、不同时段差异极大);
  • 真实的疲劳曲线(衰减速度取决于受众规模与投放强度,平台不公开);
  • 真实的小样本噪声分布(2 万曝光估出的 CTR 置信区间有多宽,要真实数据标定);
  • 平台的素材审核与合规(夸大承诺、诱导点击会被拒,这条下一节细讲)。

正确的做法和上一节一致:模型层做「如果…会怎样」,真实数据层反标定模型参数。素材测出来真实 CTR,填回 baseCtrPermille,模型立刻反映现实。

自检表

检查项合格标准
素材矩阵主题 × 形式有覆盖,每轮只变一两个维度
测试预算每条固定小曝光,够统计、不心疼
淘汰规则留 topK 而非只留 top1,防小样本噪声
疲劳监控逐轮记录平均 CTR,识别衰减
产出节奏新素材产出速度跑赢衰减速度
边界清晰真实素材数据标 X,不写成「实测」

素材把 CTR 拉起来,但 CTR 高不等于能赚钱——点进来的人留不留得住,取决于素材承诺的东西游戏里到底有没有。这就是下一节要处理的核心问题:素材-玩法一致性。一条把「装扮」「爆装」吹上天、点进去却发现玩法根本不是那样的素材,CTR 也许最高,D1 留存却会崩,最终把 ROAS 拖垮。

阅读导航:上一节:买量渠道与投放机制 · 下一节:素材-玩法一致性与转化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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