卷三的《上线变现与长线运营》 里有一节专做单平台的 LTV 与回本周期 :给定一个平台的 ARPDAU 与留存,算多久回本。站内还有一篇游戏商业核心指标详解 把 ARPDAU、LTV 这些指标定义清楚。本节不重做单平台的回本模型,它处理的是多平台放在一起时冒出来的新问题:一款游戏同时发在 Poki、CrazyGames、GameDistribution、Playables 上,每个平台的分成、ARPDAU、流量都不同,「优先投谁」该怎么答。
多平台配比要回答什么
单平台的账是「这个平台赚不赚钱」。多平台的账是「四个平台同时存在时,我的力气该往哪使」。这是一个分配问题:运营时间有限、买量预算有限、内容更新有限,投在 A 平台就不能投在 B 平台。而分配的第一步,是把每个平台的收入折算到同一把尺子上——净 ARPDAU。
这里有个陷阱:毛 ARPDAU 不能直接比。Web 平台的发行方分成差异很大(有的五五,有的七三),毛收入高的平台净收入未必高。所以第一步永远是先乘分成,把毛收入压成净收入,再谈配比。
两个口径:总量 vs 边际
「优先投谁」这个问题,答案取决于你问的是哪个口径:
- 总量口径:谁的净收入贡献最大?答案偏向大流量平台——即使它的单位效率不是最高,只要 DAU 份额大,总贡献就大。
- 边际口径:每多一个 DAU,谁带来的净收入最多?答案偏向高 ARPDAU 平台——即使它现在流量小,把资源投进去的边际回报最高。
两个口径常常给出相反的排序。这不是模型出错,而是「配比」这件事的本质:你现在赚谁的钱(总量)和你下一步该去哪增长(边际)本来就是两个问题。把它们混在一个排名里,就会得出「优先投大平台」这种看似正确、实则误导的结论。
S 层仿真:把多平台收入摊平
下面这段是 S 层仿真脚本 sim/g04_revenue_mix.py。它读 config/g04_revenue_mix.json,把每个平台的「毛 ARPDAU → 分成 → 净贡献」摊平,算出混合净 ARPDAU、月净收入,并同时给出总量排名与边际排名,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/g04_revenue_mix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g04_revenue_mix.py --config config/ --out out/
"""多平台收入配比仿真:把每个平台的「毛 ARPDAU → 分成 → 净贡献」摊平到一张表。
对 config/g04_revenue_mix.json 的每个平台:
netArpdauDeciFen[i] = grossArpdauDeciFen[i] * publisherSharePermille[i] // 1000
contributionDeciFen[i] = dauSharePermille[i] * netArpdauDeciFen[i] // 1000 # 每 DAU 净贡献
blendedNetArpdauDeciFen = Σ contributionDeciFen[i]
monthlyNetFen = blendedNetArpdauDeciFen * totalDau * daysPerMonth // 10
revenueSharePermille[i] = contributionDeciFen[i] * 1000 // blendedNetArpdauDeciFen
两个口径必须分开看:
总量排名 = 按 contributionDeciFen(谁能贡献最多净收入 → 大流量平台占优)
边际排名 = 按 netArpdauDeciFen(每单位 DAU 谁的效率最高 → 高 ARPDAU 平台占优)
两者常不一致,配比决策要先声明目标,否则「优先投谁」会各说各话。
全程整数、地板除,无浮点。产出 out/g04_revenue_mix.json + .csv + .svg。
平台参数是「公开可观察机制」口径的建模假设;真实分成 / ARPDAU / 起付门槛需真实业务
环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def evaluate(cfg: dict) -> dict:
blended = 0
rows = []
for p in cfg["platforms"]:
net = p["grossArpdauDeciFen"] * p["publisherSharePermille"] // 1000
contrib = p["dauSharePermille"] * net // 1000
blended += contrib
rows.append({
"id": p["id"], "name": p["name"], "channel": p["channel"],
"dauSharePermille": p["dauSharePermille"],
"grossArpdauDeciFen": p["grossArpdauDeciFen"],
"publisherSharePermille": p["publisherSharePermille"],
"netArpdauDeciFen": net, "contributionDeciFen": contrib,
})
for r in rows:
r["revenueSharePermille"] = r["contributionDeciFen"] * 1000 // blended if blended else 0
monthly = blended * cfg["totalDau"] * cfg["daysPerMonth"] // 10
total_rank = [r["id"] for r in sorted(rows, key=lambda r: r["contributionDeciFen"], reverse=True)]
marginal_rank = [r["id"] for r in sorted(rows, key=lambda r: r["netArpdauDeciFen"], reverse=True)]
return {"platforms": rows, "blendedNetArpdauDeciFen": blended,
"monthlyNetFen": monthly, "totalRank": total_rank, "marginalRank": marginal_rank}
def draw_svg(path: str, res: dict) -> int:
"""纯 Python SVG:每平台两根条——净贡献(总量口径)与净 ARPDAU(边际口径)。"""
rows = res["platforms"]
W, H, PAD, ROW = 780, 96 + 46 * len(rows), 210, 46
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 = (
f'《合成小岛》多平台收入配比:混合净 ARPDAU {res["blendedNetArpdauDeciFen"]} 分/10 · '
f'月净收入 {res["monthlyNetFen"]}分')
ET.SubElement(root, "text", {"x": "16", "y": "48", "font-size": "12", "fill": "#6b7280"}).text = (
f'总量排名 {res["totalRank"]} · 边际排名 {res["marginalRank"]}')
span = W - PAD - 24
hi = max([r["contributionDeciFen"] for r in rows] + [1])
hi_m = max([r["netArpdauDeciFen"] for r in rows] + [1])
for i, r in enumerate(rows):
y = 70 + i * ROW
ET.SubElement(root, "text", {"x": "16", "y": str(y + 18), "font-size": "12",
"fill": "#374151"}).text = r["name"]
for j, (val, hi_v, color, label) in enumerate([
(r["contributionDeciFen"], hi, "#2563eb", "净贡献"),
(r["netArpdauDeciFen"], hi_m, "#f59e0b", "净ARPDAU")]):
w = span * val // hi_v
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4 + j * 20), "width": str(w),
"height": "16", "rx": "3", "fill": color})
ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 16 + j * 20),
"font-size": "11", "fill": "#6b7280"}).text = (
f'{label} {val} · 占比 {r["revenueSharePermille"]}‰')
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, "g04_revenue_mix.json"), encoding="utf-8") as f:
cfg = json.load(f)
res = evaluate(cfg)
digest = ",".join(f'{r["id"]}:{r["netArpdauDeciFen"]}:{r["contributionDeciFen"]}'
for r in res["platforms"])
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "g04_revenue_mix.svg"), res)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"totalDau": cfg["totalDau"], "daysPerMonth": cfg["daysPerMonth"],
"blendedNetArpdauDeciFen": res["blendedNetArpdauDeciFen"],
"monthlyNetFen": res["monthlyNetFen"], "platforms": res["platforms"],
"totalRank": res["totalRank"], "marginalRank": res["marginalRank"], "digest": digest,
"svg": os.path.join(args.out, "g04_revenue_mix.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "g04_revenue_mix.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "g04_revenue_mix.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
cols = ("id", "name", "channel", "dauSharePermille", "grossArpdauDeciFen",
"publisherSharePermille", "netArpdauDeciFen", "contributionDeciFen",
"revenueSharePermille")
w.writerow(cols)
for r in res["platforms"]:
w.writerow([r[k] for k in cols])
print(json.dumps({"digest": digest, "blendedNetArpdauDeciFen": res["blendedNetArpdauDeciFen"],
"monthlyNetFen": res["monthlyNetFen"], "totalRank": res["totalRank"],
"marginalRank": res["marginalRank"],
"platforms": [{k: r[k] for k in ("id", "netArpdauDeciFen",
"contributionDeciFen",
"revenueSharePermille")}
for r in res["platforms"]]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
参数放在 config/g04_revenue_mix.json。数字是「公开可观察机制」口径的建模假设,不是任何平台后台的实测:真实分成、真实 ARPDAU、真实起付门槛属于 X 层——需真实业务环境,命令行不可复现。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "多平台收入配比参数,口径是「公开可观察机制」的建模假设(对照各平台公开的开发者分成说明整理),不是任何平台后台的实测。totalDau=组合内总日活;dauSharePermille=该平台占总 DAU 的千分比(合计 1000);grossArpdauDeciFen=该平台毛 ARPDAU(0.1 分/DAU);publisherSharePermille=发行方分成千分比;payoutThresholdFen=结算起付门槛(分)。真实分成比例、真实 ARPDAU、真实起付门槛需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"totalDau": 200000,
"daysPerMonth": 30,
"platforms": [
{ "id": "poki", "name": "Poki", "channel": "web-h5", "dauSharePermille": 420, "grossArpdauDeciFen": 460, "publisherSharePermille": 500, "payoutThresholdFen": 100000 },
{ "id": "crazygames", "name": "CrazyGames", "channel": "web-h5", "dauSharePermille": 300, "grossArpdauDeciFen": 520, "publisherSharePermille": 500, "payoutThresholdFen": 100000 },
{ "id": "gamedistribution", "name": "GameDistribution", "channel": "web-h5", "dauSharePermille": 180, "grossArpdauDeciFen": 300, "publisherSharePermille": 700, "payoutThresholdFen": 50000 },
{ "id": "playables", "name": "YouTube Playables", "channel": "web-h5", "dauSharePermille": 100, "grossArpdauDeciFen": 610, "publisherSharePermille": 550, "payoutThresholdFen": 200000 }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/g04_revenue_mix.py --config config/ --out out/,摊平结果(同机相对量级,绝对数值不可移植):
| 平台 | 流量份额‰ | 毛 ARPDAU | 分成‰ | 净 ARPDAU | 净贡献 | 收入占比‰ |
|---|---|---|---|---|---|---|
| Poki | 420 | 460 | 500 | 230 | 96 | 393 |
| CrazyGames | 300 | 520 | 500 | 260 | 78 | 319 |
| GameDistribution | 180 | 300 | 700 | 210 | 37 | 151 |
| YouTube Playables | 100 | 610 | 550 | 335 | 33 | 135 |
混合净 ARPDAU 244 分/10(约 0.244 元/DAU),按 20 万 DAU、30 天算月净收入 146400000 分(约 146.4 万元)。这张表把两个口径的分歧摆得很清楚:
- 总量排名(净贡献):Poki > CrazyGames > GameDistribution > Playables——Poki 靠 420‰ 的流量份额拿下 393‰ 的收入占比,即使它的净 ARPDAU 只排第三;
- 边际排名(净 ARPDAU):Playables > CrazyGames > Poki > GameDistribution——Playables 的净 ARPDAU 最高(335),但流量最小(100‰),总贡献反而垫底。
结论:「优先投谁」没有唯一答案。如果你想稳住现有收入,该守着 Poki;如果你想找增量,该去 Playables 试试把流量做大——因为它每多一个 DAU 的净回报最高。把这两件事混为一谈,就会做出「把资源全砸在 Poki」或「全力押注 Playables」这两种各错一半的决策。
B 层基准:扰动收入假设,看排名稳不稳
上面那张表依赖一组建模假设(各平台的毛 ARPDAU)。假设是拍出来的,所以必须问一句:如果假设错了 20%,结论还成立吗? 下面这段是 B 层基准脚本 sim/g04_revenue_mix_bench.py,它复用 S 层的 evaluate(),把每个平台的毛 ARPDAU 单独 ±20%,看两个排名的第一名会不会翻转。
#!/usr/bin/env python3
# file: sim/g04_revenue_mix_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/g04_revenue_mix_bench.py --config config/ --out out/
"""多平台配比稳健性基准(B):把每个平台的毛 ARPDAU 单独 ±20%,看两个排名是否翻转。
复用 sim/g04_revenue_mix.py 的 evaluate(),只改单个平台的 grossArpdauDeciFen。软告警不硬
失败:若总量排名或边际排名的第一名随扰动翻转,说明「优先投谁」的结论对单个平台的收入
假设敏感,配比决策应留缓冲、并以多口径交叉验证。产出 out/g04_revenue_mix_bench.json。
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
def load_sim(path: str):
spec = importlib.util.spec_from_file_location("g04_revenue_mix", path)
mod = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(mod)
return mod
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, "g04_revenue_mix.json"), encoding="utf-8") as f:
base = json.load(f)
sim = load_sim(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"g04_revenue_mix.py"))
base_res = sim.evaluate(base)
rows = []
for i, p in enumerate(base["platforms"]):
for sc in (800, 1200): # 单平台毛 ARPDAU -20% / +20%
cfg = json.loads(json.dumps(base))
cfg["platforms"][i]["grossArpdauDeciFen"] = p["grossArpdauDeciFen"] * sc // 1000
res = sim.evaluate(cfg)
rows.append({"perturbed": p["id"], "scalePermille": sc,
"topTotal": res["totalRank"][0], "topMarginal": res["marginalRank"][0],
"blendedNetArpdauDeciFen": res["blendedNetArpdauDeciFen"]})
top_total = sorted({r["topTotal"] for r in rows})
top_marginal = sorted({r["topMarginal"] for r in rows})
stable = len(top_total) == 1 and len(top_marginal) == 1
report = dict(version=base["version"], game=base["game"],
baseTotalRank=base_res["totalRank"], baseMarginalRank=base_res["marginalRank"],
rows=rows, distinctTopTotal=top_total, distinctTopMarginal=top_marginal,
stable=stable,
note="软告警:两个排名的第一名跨 ±20% 扰动均不变,配比结论才稳健。")
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "g04_revenue_mix_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
if not stable:
print(f"WARN 配比排名随扰动漂移:total={top_total} marginal={top_marginal}(软告警,不硬失败)")
print(json.dumps({"baseTotalRank": base_res["totalRank"],
"baseMarginalRank": base_res["marginalRank"],
"distinctTopTotal": top_total, "distinctTopMarginal": top_marginal,
"stable": stable, "rows": rows}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑 /opt/homebrew/bin/python3 sim/g04_revenue_mix_bench.py --config config/ --out out/,结果是软告警(B 层不硬失败):
WARN 配比排名随扰动漂移:total=['crazygames', 'poki'] marginal=['playables'](软告警,不硬失败)
这个告警很有价值,它告诉我们两件事:
- 边际排名稳健:无论怎么扰动单个平台的毛 ARPDAU,边际第一永远是 Playables——因为它的净 ARPDAU(335)比第二名 CrazyGames(260)高出足够多,20% 的扰动翻不过来。「下一步往哪增长」这个结论是稳的。
- 总量排名脆弱:把 Poki 的毛 ARPDAU 下调 20%,总量第一就变成 CrazyGames。因为 Poki(96)和 CrazyGames(78)的净贡献本来就咬得很近,只差 18 分/10,而它们流量份额是 420 vs 300——流量假设或效率假设任一动,头名就换人。
所以配比决策的正确姿势是:用稳健的口径(边际)定增长方向,对脆弱的口径(总量头名)留缓冲。这也解释了为什么本节的结论不能写成「优先投 Poki」这种绝对话——它依赖一组会漂移的假设。B 层的意义正是把这个脆弱性显式暴露出来,而不是假装排名是确定的。
X 层:真实分成与流量不可复现
上面算的是模型侧。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 各平台真实的发行方分成比例(不同合作档位、不同地区差异很大,且平台侧可调);
- 各平台真实的 ARPDAU 与 DAU 份额(受平台自身用户结构、推荐算法、季节共同影响);
- 各平台真实的结算起付门槛与到账周期(决定现金流,但不影响收入本身)。
工程上正确的做法是:把可复现的模型层与不可复现的真实数据层分开——模型层用来做「如果各平台效率是这样,配比该怎么排」;真实数据层用来在接入后反标定(把平台后台实测的分成、ARPDAU、流量份额填回 config,排名立刻反映现实)。
自检表
| 检查项 | 合格标准 |
|---|---|
| 先分成分口径 | 毛 ARPDAU 先乘分成压成净 ARPDAU,再谈配比 |
| 两口径分离 | 总量排名与边际排名分开输出,不混成一个 |
| 结论有对象 | 「优先投谁」明确声明是稳收入还是找增量 |
| 稳健性显式 | 用 B 层扰动收入假设,暴露排名的脆弱性 |
| 边界清晰 | 真实分成 / ARPDAU / 门槛标 X,不写成「实测」 |
多平台配比最反直觉的一点是:你越想要一个确定的「优先投谁」答案,越容易掉进口径陷阱。正确做法不是找一个排名,而是同时握住总量与边际两把尺子,并用 B 层告诉你哪把尺子更稳。算清楚收入还不够——收入的口径本身在出海场景里会随语言、地区而变。下一章我们进入多语言资源管线:怎么把一套文案拆成可加载、可回退、可测体积的语言包。
阅读导航:上一节:4.2 无内购平台的纯 IAA 结构 · 下一节:5.1 i18n 资源结构与键设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。