10.3 变现与体验的平衡

把「广告放多少」从拍脑袋变成可算的权衡:用一份整数配置刻画「频次越高收益越高、但整体留存乘数越低」,把两条曲线乘起来得到净 LTV,扫出净 LTV 最大的频次;再用 B 基准扰动收益与留存惩罚系数,验证最优频次稳不稳、哪类参数才真正需要实测。

前两节把广告(IAA)和内购(IAP)两半都摆上了桌,但都默认了一个前提:广告频次是给定的。这一节正面回答那个被反复推迟的问题——频次到底该定多少。边界同样要说清:LTV 回本周期、ARPDAU 与收入情景预测是卷三第 3 章的主题,本节给的是一个可跑的权衡模型:把「频次 → 日收益」和「频次 → 留存」两条曲线乘起来,找净 LTV 最大的落点,并用 B 基准验证这个落点稳不稳。

这个问题的棘手之处在于,它没有直觉解。频次加一档,收益一定涨;但留存一定掉。到底是赚是赔,取决于两条曲线的斜率之比——而斜率是没法靠感觉估的,只能算。

变现强度是一个旋钮,不是开关

先把「变现强度」这个词落地。它不是一个「要变现 / 不变现」的开关,而是几个可以连续调的旋钮:日均广告次数、插屏占比、激励视频的奖励力度、内购点的价格带。本节选最简单也最关键的一个旋钮——日均广告频次——来做权衡,因为它同时踩住收益和留存两端。

为什么频次会伤留存?因为它对应的是打断。上一节讲过,激励视频是自愿的,打断成本低;但当我们把「日均广告频次」当成一个整体旋钮往上拧时,多出来的部分必然会落到插屏、落地到「本来可以不看广告的时刻」。每多一次强制打断,玩家的耐受度就掉一点,最直接的体现就是 D1 留存下降——而 D1 又通过生命周期,反过来决定这个玩家一生能贡献多少广告收入。于是收益和留存之间形成了一条此消彼长的曲线。

D 层:把权衡写成一个可调参的模型

下面这份是 D 层配置。它把权衡拆成两组参数:一组是基准留存(不带广告惩罚的曲线形状),一组是惩罚与收益(每加一次频次,留存乘数降多少、每次展示值多少分)。全部整数,没有浮点。

{
  "version": 1,
  "game": "merge-island",
  "note": "变现强度 vs 留存权衡模型(S 层)。baseD1Permille / penaltyPermillePerFreq / impressionRevenueDeciFen 均为示例假设参数;真实留存与广告收益需真实业务环境,命令行不可复现(X)。",
  "baseD1Permille": 450,
  "earlyDecayPermille": 850,
  "lateDecayPermille": 960,
  "earlyEndDay": 7,
  "horizonDays": 60,
  "penaltyPermillePerFreq": 100,
  "minRetentionFactorPermille": 100,
  "impressionRevenueDeciFen": 20,
  "freqMax": 8,
  "benchPerturbPct": 20,
  "warnPeakShift": 1
}

核心是两个参数。penaltyPermillePerFreq = 100 表示「日均频次每加 1 次,整体留存乘数下降 100‰」——也就是频次从 0 加到 5,留存乘数从 1000‰ 掉到 500‰(半衰)。impressionRevenueDeciFen = 20 表示「每次展示值 2 分」(等价于 eCPM 20 元的示例量级)。这两个数都是假设:留存惩罚有多狠、单次展示值多少,必须靠真实环境标定,属于「需真实业务环境,命令行不可复现」(X)。本节算的是曲线形状与最优点位置。

S 层:频次 → 收益 × 留存 = 净 LTV

下面这段是 S 层仿真脚本。它扫频次 0 到 8,对每个频次算「整体留存乘数 × 基准留存曲线」得到实际留存,再乘上日收益、对生命周期逐日积分,得到净 LTV,最后找出峰值。全程整数地板除,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/ch10_balance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch10_balance.py --config config/ --out out/
"""变现强度 vs 留存权衡模型:扫「日均广告频次」0..freqMax。频次越高日收益越高,
但整体留存乘数越低、生命周期越短。把两者乘起来得到净 LTV,找净 LTV 最大的频次。
产出 JSON + CSV + SVG。全程整数,收益用「0.1 分」的 deciFen 记账。

模型(整数地板除):
  baseCurve[d] = day0=1000, day1=baseD1, 其后按早/晚两段衰减
  factor(f)    = max(minRetentionFactor, 1000 - f * penaltyPermillePerFreq)   # 整体留存乘数(‰)
  curve(f)[d]  = baseCurve[d] * factor(f) // 1000
  dailyRev(f)  = f * impressionRevenueDeciFen                                 # 0.1 分
  ltv(f)       = sum_d curve(f)[d] * dailyRev(f) // 1000                      # 0.1 分

注意:baseD1Permille / penaltyPermillePerFreq / impressionRevenueDeciFen 均为示例
假设参数,真实留存与广告收益需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

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


def load(cfg_dir):
    with open(os.path.join(cfg_dir, "ch10_balance.json"), encoding="utf-8") as f:
        return json.load(f)


def base_curve(cfg):
    curve = [1000, cfg["baseD1Permille"]]
    for d in range(2, cfg["horizonDays"] + 1):
        decay = cfg["earlyDecayPermille"] if d <= cfg["earlyEndDay"] else cfg["lateDecayPermille"]
        curve.append(curve[-1] * decay // 1000)
    return curve


def factor_of(cfg, freq):
    return max(cfg["minRetentionFactorPermille"], 1000 - freq * cfg["penaltyPermillePerFreq"])


def sweep(cfg):
    base = base_curve(cfg)
    rows = []
    for f in range(0, cfg["freqMax"] + 1):
        fac = factor_of(cfg, f)
        daily = f * cfg["impressionRevenueDeciFen"]
        ltv = sum(v * fac // 1000 * daily // 1000 for v in base)
        rows.append({"freqPerDay": f, "retentionFactorPermille": fac,
                     "d1Permille": base[1] * fac // 1000, "dailyRevDeciFen": daily, "netLtvDeciFen": ltv})
    return rows


def draw_svg(path, rows, peak_freq):
    W, H, pl, pr, pt, pb = 720, 420, 64, 64, 56, 56
    iw, ih = W - pl - pr, H - pt - pb
    mf = max(r["freqPerDay"] for r in rows) or 1
    ml = max(r["netLtvDeciFen"] for r in rows) or 1
    md = max(r["d1Permille"] for r in rows) or 1
    sx = lambda f: pl + iw * f // mf
    sl = lambda v: pt + ih - ih * v // ml
    sd = lambda v: pt + ih - ih * v // md
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
                             "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = (
        "《合成小岛》变现频次 vs 净 LTV 与 D1 留存(示例参数)"
    )
    for i in range(5):
        y = pt + ih * i // 4
        ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(y), "x2": str(W - pr), "y2": str(y), "stroke": "#e5e7eb"})
        ET.SubElement(svg, "text", {"x": str(pl - 8), "y": str(y + 4), "font-size": "11",
                                    "text-anchor": "end", "fill": "#2563eb"}).text = str(ml * (4 - i) // 4)
        ET.SubElement(svg, "text", {"x": str(W - pr + 8), "y": str(y + 4), "font-size": "11",
                                    "fill": "#dc2626"}).text = str(md * (4 - i) // 4)
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt + ih), "x2": str(W - pr), "y2": str(pt + ih), "stroke": "#9ca3af"})
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(pt + ih), "stroke": "#9ca3af"})
    for f in range(0, mf + 1, 2):
        ET.SubElement(svg, "text", {"x": str(sx(f)), "y": str(pt + ih + 18), "font-size": "11",
                                    "text-anchor": "middle", "fill": "#374151"}).text = str(f)
    ET.SubElement(svg, "text", {"x": str(pl + iw // 2), "y": str(H - 16), "font-size": "12",
                                "text-anchor": "middle", "fill": "#374151"}).text = "日均广告频次(次/日)"
    ET.SubElement(svg, "polyline", {"points": " ".join(f"{sx(r['freqPerDay'])},{sl(r['netLtvDeciFen'])}" for r in rows),
                                    "fill": "none", "stroke": "#2563eb", "stroke-width": "2.5"})
    ET.SubElement(svg, "polyline", {"points": " ".join(f"{sx(r['freqPerDay'])},{sd(r['d1Permille'])}" for r in rows),
                                    "fill": "none", "stroke": "#dc2626", "stroke-width": "2", "stroke-dasharray": "5 4"})
    pk = next(r for r in rows if r["freqPerDay"] == peak_freq)
    ET.SubElement(svg, "line", {"x1": str(sx(peak_freq)), "y1": str(pt), "x2": str(sx(peak_freq)), "y2": str(pt + ih),
                                "stroke": "#059669", "stroke-dasharray": "4 4"})
    ET.SubElement(svg, "circle", {"cx": str(sx(peak_freq)), "cy": str(sl(pk["netLtvDeciFen"])), "r": "5", "fill": "#059669"})
    ET.SubElement(svg, "text", {"x": str(sx(peak_freq) + 8), "y": str(sl(pk["netLtvDeciFen"]) - 8), "font-size": "12",
                                "fill": "#059669"}).text = f"净 LTV 峰值 {peak_freq} 次/日 = {pk['netLtvDeciFen'] / 10:.1f}分"
    ET.SubElement(svg, "text", {"x": "16", "y": "46", "font-size": "11", "fill": "#2563eb"}).text = "实线 = 净 LTV(0.1 分,左轴)"
    ET.SubElement(svg, "text", {"x": "240", "y": "46", "font-size": "11", "fill": "#dc2626"}).text = "虚线 = D1 留存(‰,右轴)"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config)
    rows = sweep(cfg)
    peak_freq = max(rows, key=lambda r: r["netLtvDeciFen"])["freqPerDay"]

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "ch10_balance.svg"), rows, peak_freq)
    report = {
        "version": cfg["version"], "game": cfg["game"], "freqMax": cfg["freqMax"],
        "peakFreqPerDay": peak_freq, "peakNetLtvDeciFen": max(r["netLtvDeciFen"] for r in rows),
        "rows": rows, "svg": os.path.join(args.out, "ch10_balance.svg"), "svgBytes": svg_bytes,
    }
    with open(os.path.join(args.out, "ch10_balance.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch10_balance.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["freqPerDay", "retentionFactorPermille", "d1Permille", "dailyRevDeciFen", "netLtvDeciFen"])
        for r in rows:
            w.writerow([r["freqPerDay"], r["retentionFactorPermille"], r["d1Permille"],
                        r["dailyRevDeciFen"], r["netLtvDeciFen"]])

    print(json.dumps({"peakFreqPerDay": peak_freq, "peakNetLtvDeciFen": report["peakNetLtvDeciFen"],
                      "curve": [[r["freqPerDay"], r["netLtvDeciFen"]] for r in rows]}, ensure_ascii=False))
    return 0


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

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"peakFreqPerDay": 5, "peakNetLtvDeciFen": 283, "curve": [[0, 0], [1, 84], [2, 170], [3, 228], [4, 269], [5, 283], [6, 266], [7, 226], [8, 167]]}

读曲线:峰值落在 5 次/日

把 out/ch10_balance.csv 摊开看,整条权衡曲线一目了然:

日均频次留存乘数‰D1 留存‰日收益(0.1分)净 LTV(0.1分)
0100045000
19004052084
280036040170
370031560228
460027080269
5500225100283
6400180120266
7300135140226
820090160167

曲线讲了一个很清楚的故事。频次 0 时净 LTV 为 0——不放广告就没有广告收入,这是最朴素的起点。往后每加一次频次,日收益线性上涨(20、40、60……),但留存乘数同步线性下降(900、800、700……),D1 从 450 一路掉到 90。净 LTV 是这两个数的乘积,先升后降,在 5 次/日 处见顶(283 deciFen),过了 5 就掉头向下:频次 8 时净 LTV 只剩 167,比峰值低了四成还多。

out/ch10_balance.svg 把这条曲线画了出来:蓝实线是净 LTV(左轴),红虚线是 D1 留存(右轴)。两条线一个拱起、一个单调下滑,交点附近正是净 LTV 的峰——多赚的那点广告钱,被掉下去的留存吃掉了。这就是「变现与体验平衡」的量化版本:平衡点不是一个感觉,而是净 LTV 曲线上的一个极值点。

要点:峰值 5 是这一组假设参数下的结论,不是普适常数。但方法可复用——同一份配置、同一个脚本,换参数就能重算你的峰值。真正的问题是:参数本身估不准,峰值会不会跟着飘?这就是 B 层要回答的。

B 层:权衡参数的敏感性

净 LTV 峰值定在 5 次/日,但这个结论依赖三个假设参数:每次展示值多少(impressionRevenueDeciFen)、留存惩罚多狠(penaltyPermillePerFreq)、基准留存多高(baseD1Permille)。它们都会估偏。下面这段是 B 层基准脚本,把每个参数单独上下扰动 ±20%,重算最优频次,看峰值会不会移动。B 层是软告警层:漂移超阈值只打 WARN,不判失败。

#!/usr/bin/env python3
# file: sim/ch10_balance_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch10_balance_bench.py --config config/ --out out/
"""B 基准:权衡参数的敏感性。

分别把 impressionRevenueDeciFen / penaltyPermillePerFreq / baseD1Permille 单独上下
扰动 ±benchPerturbPct%,重算净 LTV 峰值频次,看「最优频次」会不会移动。
软告警:峰值频次漂移超过 warnPeakShift 只打 WARN,不返回非 0。
产出:out/ch10_balance_bench.json
"""

from __future__ import annotations

import argparse
import json
import os

from ch10_balance import load, sweep


def peak_freq(cfg):
    rows = sweep(cfg)
    return max(rows, key=lambda r: r["netLtvDeciFen"])["freqPerDay"]


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config)
    base_peak = peak_freq(cfg)
    pct = cfg["benchPerturbPct"]

    knobs = ["impressionRevenueDeciFen", "penaltyPermillePerFreq", "baseD1Permille"]
    cases = []
    for key in knobs:
        for sign, tag in ((+1, "up"), (-1, "down")):
            trial = dict(cfg)
            trial[key] = cfg[key] * (100 + sign * pct) // 100
            pf = peak_freq(trial)
            cases.append({"knob": key, "dir": tag, "value": trial[key],
                          "peakFreqPerDay": pf, "shift": pf - base_peak})

    max_shift = max(abs(c["shift"]) for c in cases)
    os.makedirs(args.out, exist_ok=True)
    report = {
        "version": cfg["version"],
        "basePeakFreqPerDay": base_peak,
        "perturbPct": pct,
        "cases": cases,
        "maxAbsShift": max_shift,
        "warnPeakShift": cfg["warnPeakShift"],
        "withinTolerance": max_shift <= cfg["warnPeakShift"],
    }
    with open(os.path.join(args.out, "ch10_balance_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    print(json.dumps({"basePeakFreqPerDay": base_peak, "maxAbsShift": max_shift,
                      "withinTolerance": report["withinTolerance"]}, ensure_ascii=False))
    if not report["withinTolerance"]:
        print(f"WARN 参数扰动 {pct}% 后最优频次漂移 {max_shift} > {cfg['warnPeakShift']}")
    return 0  # B 层软告警:恒返回 0


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

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"basePeakFreqPerDay": 5, "maxAbsShift": 1, "withinTolerance": true}

逐个参数看最优频次怎么动(out/ch10_balance_bench.json 里的 cases):

扰动参数方向新值最优频次漂移
impressionRevenueDeciFen+20%2450
impressionRevenueDeciFen-20%1650
penaltyPermillePerFreq+20%1204-1
penaltyPermillePerFreq-20%806+1
baseD1Permille+20%54050
baseD1Permille-20%36050

结果非常干净,而且揭示了一个关键结构。单次展示值(impressionRevenueDeciFen)和基准留存(baseD1Permille)怎么扰动,最优频次都稳稳停在 5——因为这两个参数对每个频次是等比例作用的:展示值翻倍,所有频次的日收益一起翻倍;基准留存抬高,所有频次的留存一起抬高。等比例缩放不改变曲线的形状,自然不移动峰值。换句话说,「每次展示到底值 2 分还是 2.4 分」不影响你该放几次广告。

真正会移动峰值的是留存惩罚系数(penaltyPermillePerFreq):它 +20% 到 120,最优频次掉到 4;-20% 到 80,最优频次升到 6。因为它改变的是曲线的斜率——惩罚越狠,多放一次广告的代价越大,最优频次自然越低。

这个结论有很强的实践指向:别把精力花在「精算 eCPM 到底多少」上,要花在「量清楚广告到底多伤留存」上。前者不影响最优频次的决策,后者直接决定它。而后者恰恰是最难估、最需要真实 A/B 实验去标定的(X)。B 层在这里的作用,不是给你一个确定的峰值,而是告诉你峰值对哪类误差敏感、对哪类不敏感——把有限的实测预算,投到真正会改变决策的那个参数上。

落到设计上的三条规则

  1. 把频次当连续旋钮,找净 LTV 的峰,而不是「越多越好」。 收益随频次单调上升,净 LTV 却是单峰的。不加约束地拉满频次,等于主动滑过峰值、一路亏到曲线的另一侧。
  2. 用净 LTV 而不是当日收益做决策。 只看当天广告收入,频次永远是「再加一次」;把留存衰减折进生命周期,才知道哪一次是赔本赚吆喝。这是变现与体验平衡的全部要义。
  3. 敏感度决定实测优先级。 峰值对收益水平不敏感、对留存惩罚敏感——所以实测预算该压在「广告频次对 D1/D7 留存的真实影响」上,而不是纠结单次展示的精确单价。

本章到此收束:10.1 把广告放进场景,10.2 把内购拼成混合结构,10.3 用净 LTV 把变现和体验拉到一个可算的平衡点上。这三节合起来,就是「变现设计前置」——在写代码之前,先把钱怎么来、从谁身上来、来多少会伤到体验,全部想清楚。下一章进入产品文档、预算与立项决策:把本章和前面九章的结论,装进一页纸,算清预算,划出止损线。

阅读导航:上一节:IAP 内购与混合变现结构 · 下一节:一页纸设计与立项文档 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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