4.3 多平台收入对比与配比

同一款游戏发到多个海外平台,每个平台的分成、ARPDAU、流量都不同,配比决策要回答「优先投谁」。但这个问题有两个答案:按净收入总量,大流量平台占优;按单位效率,高 ARPDAU 平台占优。本节用可跑的 Python 仿真摊平多平台收入,并用 B 层基准验证结论对收入假设的稳健性。

卷三的《上线变现与长线运营》 里有一节专做单平台的 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净贡献收入占比‰
Poki42046050023096393
CrazyGames30052050026078319
GameDistribution18030070021037151
YouTube Playables10061055033533135

混合净 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 资源结构与键设计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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