站内已有一篇H5 游戏产业全景 ,那篇是产业综述口径,把 H5 / 小游戏赛道的平台与流量格局横着讲了一遍;本节给的是可跑的渠道对照脚本:把「平台推荐 / 搜索 / 裂变」与「买量」放进同一张漏斗口径,算出不同组合下的混合 CAC 与自然量占比。区别在写法:那篇描述流量生态,本节把它变成一张能算的对照表——但所有真实平台数字都标成 X,因为平台推荐算法不可复现。
平台流量:只能观察,不能复现
上一节的自然增长模型里,刻意没放「平台自然推荐」这一项。原因是它和裂变、口碑的性质不同:裂变由产品的传播系数决定,口碑由产品质量决定,而平台推荐由平台的算法决定。算法什么时候把你放进推荐位、给多少曝光、哪些用户会看到,这套机制我们只能从后台数据里观察,无法在命令行里复现——这正是 BRIEF 里说的 X 层。
下面这张表是主流小游戏平台公开可观察的流量入口(观察时点:2026 年,仅作机制说明,非任何产品的后台实测):
| 平台 | 主要免费流量入口 | 可控性 |
|---|---|---|
| 微信小游戏 | 游戏圈、好友分享卡片、平台推荐位 | 分享可控 / 推荐不可控 |
| 抖音小游戏 | 短视频挂载、直播挂载、任务中心 | 内容可控 / 推荐不可控 |
| 快手小游戏 | 短视频挂载、直播、推荐位 | 内容可控 / 推荐不可控 |
| 百度 / 支付宝小游戏 | 搜索直达、平台推荐位 | 搜索词可控 / 推荐不可控 |
表里每一格的「不可控」都在提醒:你能优化的只有内容与搜索词,推荐量本身不可控。这不是消极,而是提醒做增长预算时,不能把「平台会推荐我」当成一个可以买到的量——它的正确用法是当作一个外部变量:有,则摊薄成本;没有,也不能指望。
D 层:渠道参数表
要把渠道放进对照,先得把每个渠道的漏斗参数写下来。下面这份是 D 层(静态层)产物:每个渠道用「可触达曝光 × 点击率‰ × 激活率‰」算新增,用「单用户成本」算花费。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "平台流量渠道对照:每渠道用「可触达曝光量 × 点击率‰ × 激活率‰」算新增,再按单用户成本算花费。exposure / ctrPermille / activatePermille 全部是假设参数,真实值由平台推荐算法与投放系统决定,需真实业务环境,命令行不可复现(X)。",
"channels": [
{ "id": "ua", "name": "买量投放", "kind": "paid", "exposure": 400000, "ctrPermille": 30, "activatePermille": 250, "costCentPerUser": 120 },
{ "id": "platform", "name": "平台推荐位", "kind": "organic", "exposure": 260000, "ctrPermille": 24, "activatePermille": 300, "costCentPerUser": 0 },
{ "id": "search", "name": "自然搜索/榜单", "kind": "organic", "exposure": 90000, "ctrPermille": 40, "activatePermille": 320, "costCentPerUser": 0 },
{ "id": "viral", "name": "社交裂变回流", "kind": "organic", "exposure": 150000, "ctrPermille": 22, "activatePermille": 280, "costCentPerUser": 0 }
],
"scenarios": [
{ "id": "paid_only", "name": "纯买量", "channels": ["ua"] },
{ "id": "paid_plus_platform", "name": "买量 + 平台推荐", "channels": ["ua", "platform"] },
{ "id": "full_mix", "name": "买量 + 平台 + 搜索 + 裂变", "channels": ["ua", "platform", "search", "viral"] }
]
}
⚠️ 这份配置里的 exposure / ctrPermille / activatePermille 全部是假设参数。真实值由平台算法与投放系统决定,需真实业务环境,命令行不可复现(X)。这里写具体数字,只是为了把对照方法跑通——换成你产品的真实后台数据,脚本直接可用。
S 层:三组渠道对照
下面这段是 S 层对照脚本,读 config/l06_traffic.json,对配置里的三种组合各跑一遍漏斗,算出总新增、混合 CAC 与自然量占比,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/l06_traffic_compare.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l06_traffic_compare.py --config config/ --out out/
"""平台流量渠道对照:读 config/l06_traffic.json,把「买量」与「平台推荐 / 搜索 / 裂变」放进同一张漏斗口径。
渠道新增 = 曝光量 × 点击率‰ × 激活率‰ // 1_000_000;渠道花费 = 新增 × 单用户成本
混合 CAC = 总花费 // 总新增;自然量占比 = (总新增 - 买量新增) × 1000 // 总新增
⚠️ 真实平台的曝光 / 点击率 / 激活率由平台算法决定,需真实业务环境,命令行不可复现(X);
本脚本参数全是假设值,只用来说明对照方法。产出 JSON + CSV + SVG。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(d: str) -> dict:
with open(os.path.join(d, "l06_traffic.json"), encoding="utf-8") as f:
return json.load(f)
def channel_new(ch: dict) -> int:
"""渠道新增 = 曝光 × 点击率‰ × 激活率‰ // 1_000_000。全整数。"""
return ch["exposure"] * ch["ctrPermille"] * ch["activatePermille"] // 1_000_000
def run_scenario(by_id: dict, ids: list[str]) -> dict:
new_total = paid_new = cost_total = 0
per = []
for cid in ids:
ch = by_id[cid]
n = channel_new(ch)
new_total += n
cost_total += n * ch["costCentPerUser"]
paid_new += n if ch["kind"] == "paid" else 0
per.append({"id": cid, "name": ch["name"], "kind": ch["kind"], "newUsers": n})
return {"newUsers": new_total, "costCent": cost_total,
"blendedCacCent": cost_total // new_total if new_total else 0,
"organicSharePermille": (new_total - paid_new) * 1000 // new_total if new_total else 0,
"perChannel": per}
def bar(svg, x, y, w, h, fill):
ET.SubElement(svg, "rect", {"x": str(x), "y": str(y), "width": str(w), "height": str(h), "rx": "4", "fill": fill})
def label(svg, x, y, s, size=12, fill="#374151"):
ET.SubElement(svg, "text", {"x": str(x), "y": str(y), "font-size": str(size), "fill": fill}).text = s
def draw_svg(path: str, rows: list[dict]) -> int:
W, H, PAD, ROW = 720, 70 + 46 * len(rows), 220, 46
peak = max((r["blendedCacCent"] for r in rows), default=1) or 1
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H), "viewBox": f"0 0 {W} {H}"})
bar(svg, 0, 0, W, H, "#ffffff")
label(svg, 16, 28, "不同渠道组合下的混合 CAC(分/新增,越短越好)", 15, "#111827")
for i, r in enumerate(rows):
y, w = 54 + i * ROW, (W - PAD - 40) * r["blendedCacCent"] // peak
label(svg, 16, y + 20, r["name"])
bar(svg, PAD, y + 6, w, 22, "#2563eb")
label(svg, PAD + w + 8, y + 22, f'{r["blendedCacCent"]} 分 · 新增 {r["newUsers"]} · 自然 {r["organicSharePermille"]}‰', 12, "#6b7280")
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 write_csv(path: str, header: list, rows: list) -> None:
with open(path, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(header)
for r in rows:
w.writerow(r)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg = load(a.config)
by_id = {c["id"]: c for c in cfg["channels"]}
rows = [{"id": sc["id"], "name": sc["name"], "channels": sc["channels"], **run_scenario(by_id, sc["channels"])}
for sc in cfg["scenarios"]]
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "l06_traffic_compare.svg"), rows)
write_csv(os.path.join(a.out, "l06_traffic_compare.csv"),
["scenarioId", "name", "newUsers", "costCent", "blendedCacCent", "organicSharePermille"],
[[r["id"], r["name"], r["newUsers"], r["costCent"], r["blendedCacCent"], r["organicSharePermille"]] for r in rows])
write_csv(os.path.join(a.out, "l06_traffic_channels.csv"),
["id", "name", "kind", "exposure", "ctrPermille", "activatePermille", "costCentPerUser", "newUsers"],
[[ch["id"], ch["name"], ch["kind"], ch["exposure"], ch["ctrPermille"], ch["activatePermille"], ch["costCentPerUser"], channel_new(ch)] for ch in cfg["channels"]])
rep = {"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"], "channelCount": len(cfg["channels"]),
"channels": [{"id": ch["id"], "name": ch["name"], "kind": ch["kind"], "newUsers": channel_new(ch)} for ch in cfg["channels"]],
"scenarios": rows, "csv": os.path.join(a.out, "l06_traffic_compare.csv"),
"svg": os.path.join(a.out, "l06_traffic_compare.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "l06_traffic_compare.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"channels": [(c["id"], c["newUsers"]) for c in rep["channels"]],
"scenarios": [(r["id"], r["newUsers"], r["blendedCacCent"], r["organicSharePermille"]) for r in rows]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
脚本的核心是一行整数运算:新增 = 曝光 × 点击率‰ × 激活率‰ // 1_000_000(两个千分比相乘正好是百万分之一)。然后 混合 CAC = 总花费 // 总新增——自然渠道花费为 0,所以自然量越多、分母越大,CAC 越低。在本机跑它(假设参数,非实测),输出如下(同机相对量级,绝对数值不可移植):
{"channels": [["ua", 3000], ["platform", 1872], ["search", 1152], ["viral", 924]], "scenarios": [["paid_only", 3000, 120, 0], ["paid_plus_platform", 4872, 73, 384], ["full_mix", 6948, 51, 568]]}
三组对照把「平台流量值多少」摊开了:纯买量 3000 新增、混合 CAC 120 分;买量 + 平台推荐 4872 新增、CAC 降到 73 分;全渠道 6948 新增、CAC 降到 51 分、自然量占比 56.8%。平台推荐 + 搜索 + 裂变三项,把买量的有效成本摊薄了一半以上——但这三项里只有裂变是产品自己能放大的,平台推荐和搜索只能优化、不能保证。
W 层:把对照装进单文件 HTML
光看数字不直观,得能在浏览器里切着看。下面这段是 W 层(网页层)构建脚本 web/build_l06_traffic.mjs:复用 web/pack.mjs 的擦除逻辑,把渠道漏斗内核 core/src/l06/traffic.ts(P 内核层,与 6.1 的 growth.ts 同构:全整数、无 Math.random,完整文件见配套仓库)内联进单文件 HTML,产物 web/dist/l06.html。
#!/usr/bin/env node
// file: web/build_l06_traffic.mjs
// tier: W
// verify: node web/build_l06_traffic.mjs
// 把 core/src/l06/traffic.ts 打成单文件 HTML(渠道组合对照探索器)。复用 pack.mjs 的擦除逻辑。
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", "l06_traffic.json"), "utf8"));
const js = bundle(ROOT, ["core/src/l06/traffic.ts"]);
const script = `const cfg = ${inlineConfig(cfg)};
${js}
const cv = document.getElementById("stage"), g = cv.getContext("2d");
const byId = Object.fromEntries(cfg.channels.map((c) => [c.id, c]));
let active = cfg.channels.map((c) => c.id), mix = null;
function compute() { mix = runMix(active.map((id) => byId[id])); draw(); }
function draw() {
g.clearRect(0, 0, cv.width, cv.height);
const peak = Math.max(...mix.perChannel.map((p) => p.newUsers), 1);
mix.perChannel.forEach((p, i) => {
const y = 16 + i * 34, w = Math.floor((cv.width - 130) * p.newUsers / peak);
g.fillStyle = p.kind === "paid" ? "#f59e0b" : "#38bdf8";
g.fillRect(96, y, w, 22);
g.fillStyle = "#e2e8f0"; g.font = "13px system-ui";
g.fillText(p.name, 6, y + 16);
g.fillText(String(p.newUsers), 96 + w + 6, y + 16);
});
document.getElementById("cac").textContent = Math.floor(mix.blendedCacCent / 100) + "." + String(mix.blendedCacCent % 100).padStart(2, "0") + " 元";
document.getElementById("share").textContent = Math.floor(mix.organicSharePermille / 10) + "." + (mix.organicSharePermille % 10) + "%";
document.getElementById("new").textContent = String(mix.newUsers);
globalThis.__pebble = { blendedCacCent: mix.blendedCacCent, organicSharePermille: mix.organicSharePermille,
newUsers: mix.newUsers, perChannel: mix.perChannel.map((p) => [p.id, p.newUsers]), active: active.slice(),
canvasDrawn: true, api };
}
const api = {
set(ids) { active = ids.slice(); compute(); return { newUsers: mix.newUsers, blendedCacCent: mix.blendedCacCent, organicSharePermille: mix.organicSharePermille }; },
get() { return { newUsers: mix.newUsers, blendedCacCent: mix.blendedCacCent, organicSharePermille: mix.organicSharePermille, active: active.slice() }; },
};
compute();
`;
const body = ` <h1>《合成小岛》· 渠道组合对照探索器</h1>
<div class="sub">渠道漏斗内核来自 core/src/l06/traffic.ts:全整数、无 Math.random</div>
<div class="card row"><div><div class="k">混合 CAC</div><div class="big" id="cac">0 元</div></div><div><div class="k">自然量占比</div><div class="big" id="share">0%</div></div><div><div class="k">总新增</div><div class="big" id="new">0</div></div></div>
<div class="card"><canvas id="stage" width="480" height="160"></canvas></div>
<div class="sub">切到「全渠道」:平台推荐 / 搜索 / 裂变把买量花费摊薄,混合 CAC 从 1.20 元降到 0.51 元</div>`;
const bytes = writePage(join(ROOT, "web/dist/l06.html"), page({ title: "《合成小岛》渠道组合对照探索器 · l06", body, script, css: "#stage { width: 480px; height: 160px; }" }));
console.log(JSON.stringify({ html: "web/dist/l06.html", htmlBytes: bytes, bundleLines: js.split("\n").length }));
下面这段是 W 层验证脚本 web/verify_l06_traffic.mjs:用 Playwright 无头打开 HTML,调 api 切换渠道组合,断言混合 CAC 与自然量占比,并截图。playwright 用 createRequire 加载——NODE_PATH 对 ESM 的 import 无效。
#!/usr/bin/env node
// file: web/verify_l06_traffic.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l06_traffic.mjs web/dist/l06.html
// W 层验收:Playwright 无头打开单文件 HTML,调 api 切渠道组合,读回断言 + 截图。
// 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/l06.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: 800, height: 700 } });
p.on("pageerror", (e) => errors.push(String(e)));
await p.goto(url);
await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const full = await p.evaluate(() => globalThis.__pebble.api.get());
const paidOnly = await p.evaluate(() => globalThis.__pebble.api.set(["ua"]));
const paidPlatform = await p.evaluate(() => globalThis.__pebble.api.set(["ua", "platform"]));
const domCac = (await p.textContent("#cac")).trim();
const domShare = (await p.textContent("#share")).trim();
const painted = await p.evaluate(() => {
const d = document.getElementById("stage").getContext("2d").getImageData(0, 0, 480, 160).data;
let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0 && d[i] < 250) n += 1; return n;
});
await p.screenshot({ path: join(ROOT, "out/shots/l06_traffic.png"), fullPage: true });
await p.close();
const p2 = await browser.newPage();
await p2.goto(url);
await p2.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const rerun = await p2.evaluate(() => globalThis.__pebble.api.get());
await p2.close();
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(full.newUsers, 6948, "全渠道总新增应为 6948");
assert.equal(full.blendedCacCent, 51, "全渠道混合 CAC 应为 51 分");
assert.equal(full.organicSharePermille, 568, "全渠道自然量占比应为 568‰");
assert.equal(paidOnly.blendedCacCent, 120, "纯买量 CAC 应为 120 分");
assert.equal(paidOnly.organicSharePermille, 0, "纯买量自然量占比应为 0");
assert.equal(paidPlatform.blendedCacCent, 73, "买量+平台 CAC 应为 73 分");
assert.equal(domCac, "0.73 元", "DOM 混合 CAC 应显示 0.73 元");
assert.equal(domShare, "38.4%", "DOM 自然量占比应显示 38.4%");
assert.ok(painted > 300, `canvas 应有绘制内容,实测 ${painted} px`);
assert.deepEqual(rerun.active, full.active, "同一次加载应可复现");
console.log(JSON.stringify({
page: "web/dist/l06.html", full, paidOnly, paidPlatform, domCac, domShare,
canvasPaintedPx: painted, rerunMatches: true, screenshot: "out/shots/l06_traffic.png",
}));
console.log("SMOKE-W6-2: PASS");
先构建、再验证,本机实跑输出如下(同机相对量级,不可移植):
$ node web/build_l06_traffic.mjs
{"html":"web/dist/l06.html","htmlBytes":6347,"bundleLines":31}
$ NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l06_traffic.mjs web/dist/l06.html
{"page":"web/dist/l06.html","full":{"newUsers":6948,"blendedCacCent":51,"organicSharePermille":568,"active":["ua","platform","search","viral"]},"paidOnly":{"newUsers":3000,"blendedCacCent":120,"organicSharePermille":0},"paidPlatform":{"newUsers":4872,"blendedCacCent":73,"organicSharePermille":384},"domCac":"0.73 元","domShare":"38.4%","canvasPaintedPx":13809,"rerunMatches":true,"screenshot":"out/shots/l06_traffic.png"}
SMOKE-W6-2: PASS
平台流量的正确用法
把这一节收成一句可执行的话:平台流量是杠杆,不是预算项。你不能「买」平台推荐,只能:① 把内容做好(短视频、直播挂载是唯一可控入口);② 把搜索词占住(产品名、品类词);③ 把裂变系数抬上去(这是唯一完全可控的免费量)。前两项是「提高被推荐的概率」,第三项才是「确定能拿到的量」。下一节就把这三项连同买量一起,放进一个预算分配模型——同样一笔钱,怎么分给不同渠道,总新增最大。
阅读导航:上一节:自然增长与裂变机制 · 下一节:增长预算分配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。