2.2 混合变现的比例与协同

内购和广告不是两条各算各的收入曲线,它们会互相侵蚀:广告看多了付费就浅,卖出去广告点又砍掉一部分曝光。本节给《合成小岛》一份把两条曲线耦合起来的 config,用一段 P 层内核算 IAA / IAP 的相互抑制与去广告点的净协同,再用一个单文件 HTML 原型把两个滑杆拖起来看比例怎么变。

本节目标:把 IAA 与 IAP 从「各算各的」改成「耦合在一起算」,并让「比例定在哪」由相互侵蚀与净协同两个数字回答。

站内有一组 25 篇的小游戏服务平台专题 ,讲的是做一个小游戏服务平台——SaaS 创业口径:平台怎么搭、开发者怎么接。本节和它不重叠:那边是平台侧,这边是在一款游戏里把内购和广告两条收入线配平,主角是《合成小岛》。

广告形态与 eCPM 模型是第 1 章的事,LTV 与回本是第 3 章的事。本节只处理一个交叉问题:内购和广告放在一起时,它们会不会互相抵消,比例该定在哪。

2.2 混合变现的比例与协同

2.2.1 两条曲线不是各算各的

很多团队做混合变现,是把 IAA 和 IAP 分开估:广告这边按「人均请求 × 填充率 × eCPM」算一个 ARPDAU,内购那边按「ARPPU × 付费率」算一个 ARPDAU,加起来就是总收入。这个算法能跑,但它漏掉了两件真实存在的事:

  • 广告打扰付费。广告量越大,付费玩家体验越差,ARPPU 越低。也就是说,把广告量调高,IAA 涨了,但 IAP 会掉——两条曲线是负相关的。
  • 去广告内购点反噬广告。卖出去一个「去广告」点,这个玩家从此不再贡献广告收入。这笔内购的收入,要用损失掉的广告收入来抵扣,相抵之后才是净赚。

把这两件事写进模型,比例问题才从「加起来最大」变成「协同之后最大」。本节的内核就把它们耦合起来。

2.2.2 一份耦合的 config(D 层)

下面这份是 D 层配置:基准假设(人均广告请求、填充率、eCPM、ARPPU、付费率)、耦合系数(广告量对 ARPPU 的抑制)、四个待比场景,以及一条广告量扫描序列。它是 P 层内核、W 层原型与 S 层 Python 仿真共用的同一份输入。

{
  "version": 1,
  "game": "merge-island",
  "note": "混合变现协同模型(S/P/W 三层共用同一份 config)。所有系数为整数百分比/千分比;adRequestsPerDau、fillPct、ecpmFen、arppuFen 均为示例假设;真实 eCPM 与付费分布需真实业务环境,命令行不可复现(X)。",
  "base": {
    "dau": 10000,
    "adRequestsPerDau": 14,
    "fillPct": 88,
    "ecpmFen": 4200,
    "arppuFen": 2400,
    "payRatePermille": 38
  },
  "synergy": {
    "adLoadPenaltyPctPer100": 12,
    "adFreeUpliftPermille": 0
  },
  "scenarios": [
    { "id": "lean",    "name": "轻广告",   "adLoadPct": 70,  "adFreePriceFen": 600, "adFreeRatePermille": 24 },
    { "id": "base",    "name": "基准",     "adLoadPct": 100, "adFreePriceFen": 600, "adFreeRatePermille": 24 },
    { "id": "heavy",   "name": "重广告",   "adLoadPct": 150, "adFreePriceFen": 600, "adFreeRatePermille": 30 },
    { "id": "extreme", "name": "超重广告", "adLoadPct": 200, "adFreePriceFen": 900, "adFreeRatePermille": 36 }
  ],
  "adLoadSweep": [60, 80, 100, 120, 140, 160, 180, 200]
}

两个耦合系数是关键:adLoadPenaltyPctPer100 表示「广告量每多 100%,ARPPU 被压低几个百分点」;adFreeRatePermille 是去广告点的购买率。前者把 IAA 与 IAP 负相关起来,后者把「内购换广告」的账算清楚。

2.2.3 协同内核(P 层)

下面这段是 P 层内核:它按上面那份 config 算出每个场景的 IAA、IAP 与合计 ARPDAU,并单独拆出「去广告内购点」的净协同(内购收入减去被它砍掉的广告收入)。全程整数运算。

// file: core/src/l02/hybrid.ts
// tier: P
// verify: node --test 'core/test/l02_*.test.ts'
//
// 混合变现协同内核:同一份 config/l02_hybrid.json,与 sim/l02_hybrid.py 逐位一致。
// 广告量与内购深度相互耦合:广告量升 → IAA 升、ARPPU 被压 → IAP 降;
// 去广告内购点带来收入,同时也侵蚀一部分广告收入,两者相抵得到净协同。
// 全整数运算(deciFen = 0.1 分/DAU),除法一律地板除,无 Math.random()。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface HybridBase {
  readonly dau: number; readonly adRequestsPerDau: number; readonly fillPct: number;
  readonly ecpmFen: number; readonly arppuFen: number; readonly payRatePermille: number;
}
export interface SynergyCfg { readonly adLoadPenaltyPctPer100: number; readonly adFreeUpliftPermille: number }
export interface HybridRow {
  readonly adLoadPct: number; readonly adRequestsPerDau: number;
  readonly iaaArpdauDeciFen: number; readonly arppuEffFen: number;
  readonly iapCoreDeciFen: number; readonly iapAdFreeDeciFen: number; readonly iapArpdauDeciFen: number;
  readonly arpdauDeciFen: number; readonly iaaPermille: number; readonly iapPermille: number;
  readonly adRevLossDeciFen: number; readonly netAdFreeSynergyDeciFen: number;
}

const fdiv = (a: number, b: number): number => Math.floor(a / b);

export function computeHybrid(
  base: HybridBase, syn: SynergyCfg, adLoadPct: number, adFreePriceFen: number, adFreeRatePermille: number,
): HybridRow {
  const reqRaw = fdiv(base.adRequestsPerDau * adLoadPct, 100);
  const reqEff = fdiv(reqRaw * (1000 - adFreeRatePermille), 1000);
  const iaa = fdiv(reqEff * base.fillPct * base.ecpmFen, 10000);
  const iaaNoAdFree = fdiv(reqRaw * base.fillPct * base.ecpmFen, 10000);
  const penaltyPct = fdiv(adLoadPct * syn.adLoadPenaltyPctPer100, 100);
  const arppuEff = fdiv(base.arppuFen * (100 - penaltyPct), 100);
  const iapCore = fdiv(arppuEff * base.payRatePermille, 100);
  const iapAdFree = fdiv(adFreePriceFen * adFreeRatePermille, 100);
  const iap = iapCore + iapAdFree;
  const total = iaa + iap;
  const iaaPermille = total > 0 ? fdiv(iaa * 1000, total) : 0;
  return {
    adLoadPct, adRequestsPerDau: reqEff, iaaArpdauDeciFen: iaa, arppuEffFen: arppuEff,
    iapCoreDeciFen: iapCore, iapAdFreeDeciFen: iapAdFree, iapArpdauDeciFen: iap,
    arpdauDeciFen: total, iaaPermille, iapPermille: 1000 - iaaPermille,
    adRevLossDeciFen: iaaNoAdFree - iaa, netAdFreeSynergyDeciFen: iapAdFree - (iaaNoAdFree - iaa),
  };
}

/** 广告量扫描:固定去广告价与购买率,只改广告量。 */
export function sweepAdLoad(
  base: HybridBase, syn: SynergyCfg, priceFen: number, ratePermille: number, adLoads: readonly number[],
): HybridRow[] {
  return adLoads.map((p) => computeHybrid(base, syn, p, priceFen, ratePermille));
}

内核自带一个测试 core/test/l02_hybrid.test.ts,本机实跑(node --test 'core/test/l02_hybrid.test.ts')输出:

✔ 基准场景:IAA 480 / IAP 946 / 合计 1426,比例 336:664
✔ 去广告净协同为正:144 收入 − 37 广告损失 = 107
✔ 相互侵蚀:广告量升 IAA 升、IAP 降,比例向右偏移
ℹ tests 3
ℹ pass 3
ℹ fail 0

读这三条:

  • 基准场景(广告量 100%、去广告率 24‰)合计 1426(0.1 分/DAU),比例 IAA:IAP = 336:664,内购仍是主力。
  • 去广告点净协同 = +107:它带来 144 的内购收入,砍掉 37 的广告收入,净赚 107。结论:这个内购点是划算的——但这笔账只有把广告损失算进来才看得见。
  • 相互侵蚀:广告量从 60% 拉到 200%,IAA 从 258 涨到 997,IAP 从 992 掉到 837,比例从 230:770 右移到 485:515。广告越多,收入结构越偏广告,内购这一边被挤。

2.2.4 浏览器原型(W 层)

内核讲完了逻辑,但它只在命令行里跑。下面把它内联进一个单文件 HTML,拖两个滑杆就能实时看比例怎么变。先看 W 层构建脚本,它复用 web/pack.mjs 的擦除逻辑,把 core/src/l02/hybrid.ts 打成 web/dist/l02.html。

#!/usr/bin/env node
// file: web/build_l02_hybrid.mjs
// tier: W
// verify: node web/build_l02_hybrid.mjs
// 把 core/src/l02/hybrid.ts 的混合变现协同内核打成单文件 HTML 可玩原型:拖「广告量」与
// 「去广告购买率」两个滑杆,实时看 IAA / IAP / 合计与净协同。
// 必须复用 web/pack.mjs 的擦除逻辑与页面外壳,产物 web/dist/l02.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", "l02_hybrid.json"), "utf8"));
const js = bundle(ROOT, ["core/src/l02/hybrid.ts"]);

const script = `const CFG = ${inlineConfig(cfg)};
const BASE = CFG.base, SYN = CFG.synergy, PRICE = CFG.scenarios[1].adFreePriceFen;
${js}
let adLoadPct = 100, adFreeRatePermille = 24, row = null;
const cv = document.getElementById("board"), g = cv.getContext("2d");
const put = (id, v) => { document.getElementById(id).textContent = v; };
function render() {
  row = computeHybrid(BASE, SYN, adLoadPct, PRICE, adFreeRatePermille);
  put("load", adLoadPct + "%"); put("rate", adFreeRatePermille + "‰");
  put("iaa", (row.iaaArpdauDeciFen / 10).toFixed(1)); put("iap", (row.iapArpdauDeciFen / 10).toFixed(1));
  put("total", (row.arpdauDeciFen / 10).toFixed(1));
  put("ratio", row.iaaPermille + "‰ : " + row.iapPermille + "‰");
  put("synergy", (row.netAdFreeSynergyDeciFen / 10).toFixed(1));
  const W = cv.width, H = cv.height, barH = 48, y = H / 2 - barH / 2, wIaa = Math.floor((W * row.iaaPermille) / 1000);
  g.clearRect(0, 0, W, H);
  g.fillStyle = "#0ea5e9"; g.fillRect(0, y, wIaa, barH);
  g.fillStyle = "#f59e0b"; g.fillRect(wIaa, y, W - wIaa, barH);
  globalThis.__pebble = { adLoadPct, adFreeRatePermille, row, canvasDrawn: true,
    paintCount: (globalThis.__pebble?.paintCount ?? 0) + 1, api };
}
const api = {
  setAdLoad(v) { adLoadPct = v; document.getElementById("adload").value = String(v); render(); return row.arpdauDeciFen; },
  setAdFree(v) { adFreeRatePermille = v; document.getElementById("adfree").value = String(v); render(); return row.arpdauDeciFen; },
  compute(load, rate) { return computeHybrid(BASE, SYN, load, PRICE, rate); },
};
document.getElementById("adload").addEventListener("input", (e) => api.setAdLoad(Number(e.target.value)));
document.getElementById("adfree").addEventListener("input", (e) => api.setAdFree(Number(e.target.value)));
document.getElementById("reset").addEventListener("click", () => { api.setAdLoad(100); api.setAdFree(24); });
render();
`;

const body = `  <h1>《合成小岛》· 混合变现协同原型</h1>
  <div class="sub">逻辑由 core/src/l02/hybrid.ts 提供:IAA 与 IAP 相互侵蚀,去广告点带来净协同</div>
  <div class="card row"><div><div class="k">IAA(分/DAU)</div><div class="big" id="iaa">-</div></div>
    <div><div class="k">IAP(分/DAU)</div><div class="big" id="iap">-</div></div>
    <div><div class="k">合计 ARPDAU</div><div class="big" id="total">-</div></div></div>
  <div class="card"><div class="k">IAA : IAP</div><div id="ratio">-</div></div>
  <div class="card"><canvas id="board" width="720" height="120"></canvas></div>
  <div class="card"><div class="k">去广告净协同(分/DAU)</div><div id="synergy">-</div></div>
  <div class="card">广告量 <span id="load">100%</span>
    <input id="adload" type="range" min="60" max="200" step="10" value="100" style="width:100%">
    去广告购买率 <span id="rate">24‰</span>
    <input id="adfree" type="range" min="0" max="60" step="2" value="24" style="width:100%">
    <button id="reset">重置</button></div>`;

const bytes = writePage(join(ROOT, "web/dist/l02.html"), page({ title: "《合成小岛》混合变现协同原型 · l02", body, script }));
console.log(JSON.stringify({ html: "web/dist/l02.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));

下面这段是 W 层验证脚本:用 Playwright 无头打开产物,拖滑杆并断言内核给的数字,再采样 canvas 与 globalThis.__pebble,跑两遍验确定性。

#!/usr/bin/env node
// file: web/verify_l02_hybrid.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l02_hybrid.mjs web/dist/l02.html
// W 层验收:Playwright 无头打开单文件 HTML,拖两个滑杆并断言 IAA / IAP / 合计 / 净协同;
// 采样 globalThis.__pebble、检查 canvas 真画了东西、跑两遍验确定性、截图存档。

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/l02.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 open = async () => {
  const p = await browser.newPage({ viewport: { width: 800, height: 1000 } });
  p.on("pageerror", (e) => errors.push(String(e)));
  await p.goto(url);
  await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 15000 });
  return p;
};
const run = (page) => page.evaluate(() => {
  const a = globalThis.__pebble.api;
  const base = a.compute(100, 24), lean = a.compute(60, 24), heavy = a.compute(200, 24), hiRate = a.compute(100, 48);
  a.setAdLoad(150);
  const cv = document.getElementById("board"), px = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
  let painted = 0;
  for (let i = 0; i < px.length; i += 4) if (px[i + 3] > 0 && (px[i] + px[i + 1] + px[i + 2]) > 60) painted += 1;
  return {
    base: [base.iaaArpdauDeciFen, base.iapArpdauDeciFen, base.arpdauDeciFen, base.iaaPermille, base.netAdFreeSynergyDeciFen],
    lean: [lean.iaaArpdauDeciFen, lean.iapArpdauDeciFen], heavy: [heavy.iaaArpdauDeciFen, heavy.iapArpdauDeciFen],
    hiRate: hiRate.netAdFreeSynergyDeciFen, afterLoad: globalThis.__pebble.row.adLoadPct,
    afterTotal: globalThis.__pebble.row.arpdauDeciFen, paintCount: globalThis.__pebble.paintCount, painted,
  };
});

const page = await open();
const a = await run(page);
await page.screenshot({ path: join(ROOT, "out/shots/l02_hybrid.png"), fullPage: true });
await page.close();
const page2 = await open();
const b = await run(page2);
await page2.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.deepEqual(a.base, [480, 946, 1426, 336, 107], "基准 100%/24‰ 应与 Python/TS 内核一致");
assert.deepEqual(a.lean, [258, 992], "60% 广告量:IAA 低、IAP 高");
assert.deepEqual(a.heavy, [997, 837], "200% 广告量:IAA 高、IAP 低");
assert.ok(a.hiRate > a.base[4], "提高去广告购买率,净协同应上升");
assert.deepEqual([a.afterLoad, a.afterTotal], [150, 1630], "滑杆设 150% 时合计 1630");
assert.ok(a.paintCount > 0 && a.painted > 500, `canvas 应有绘制内容,实测 ${a.painted} px`);
assert.equal(b.base[2], a.base[2], "同参数两次加载应逐位一致");

console.log(JSON.stringify({ page: "web/dist/l02.html", baseArpdau: a.base, lean: a.lean, heavy: a.heavy,
  hiRateSynergy: a.hiRate, afterSetAdLoad: { adLoadPct: a.afterLoad, total: a.afterTotal },
  canvasPaintedPx: a.painted, deterministicRerun: { totalA: a.base[2], totalB: b.base[2] },
  screenshot: "out/shots/l02_hybrid.png" }, null, 2));
console.log("SMOKE-W2: PASS");

本机实跑(NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l02_hybrid.mjs web/dist/l02.html)输出摘要:

baseArpdau: [480, 946, 1426, 336, 107]
lean: [258, 992] · heavy: [997, 837]
hiRateSynergy: 251
afterSetAdLoad: { adLoadPct: 150, total: 1630 }
canvasPaintedPx: 34560
deterministicRerun: { totalA: 1426, totalB: 1426 }
SMOKE-W2: PASS

浏览器里的 480/946/1426/336/107 与命令行内核逐位一致——同一份 config,两个宿主,同一组数字。这就是「一份 config 三种消费」:Python 仿真、TS 内核、浏览器原型,三处对齐。

2.2.5 比例怎么定:三条判断

  1. 先看净协同,再看总量。一个内购点是否划算,要用它的内购收入减去它砍掉的广告收入;只有净值为正才加。
  2. 广告量与付费深度负相关,别单独拉满。本机这组参数下 IAA 随广告量单调上升、IAP 单调下降,所以「把广告量拉到最高」会悄悄牺牲内购——要不要拉,取决于你对两类玩家占比的假设。
  3. 比例是结果,不是目标。336:664 是这组假设算出来的,不是定出来的。想改比例,改的是广告量与去广告定价,比例跟着变。

小结

  • 混合变现的关键不是把 IAA 与 IAP 相加,而是把「广告压付费」「内购砍广告」两处耦合写进模型。
  • D 层配置把耦合系数与场景写成唯一真源;P 层内核算 IAA / IAP 与净协同;W 层原型把它变成可拖动的单文件 HTML。
  • 本机实跑:基准 1426(比例 336:664),去广告点净协同 +107;广告量 60%→200% 时 IAA 258→997、IAP 992→837。与站内小游戏服务平台专题 的分工:那边搭平台,这边配平一款游戏的两条收入线。这些数字是同机相对量级,绝对数值不可移植,可移植的是这套耦合模型。

比例定下来了,接下来要问的是:除了单个内购点,礼包、通行证、订阅这些成型的付费产品各自怎么算账。这是下一节的事。

阅读导航:上一节:内购点设计与定价档位 · 下一节:礼包、通行证与订阅 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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