站内有一篇 H5 游戏产业全景 ,它的结论之一是「平台政策会变,要保留多渠道冗余」——但没告诉你在有限人力下到底先接哪几个。本节填的就是这个空。卷一 3.3 品类评分的敏感度 已经示范过一套加权评分 + 敏感度的方法论,本节把它从「选品类」搬到「选平台」:维度换成平台特征,扰动换成权重与预算,产物是一个能在人天预算内最大化净额的平台组合。前两节把平台分了层(1.1)、把条款算了净额(1.2),这一节把两张表合成一个可执行的决策。
平台选择的三个约束
选平台不是比「谁最好」,而是三个约束下的取舍:
| 约束 | 含义 | 本节的字段 |
|---|---|---|
| 适配度 | 这个平台和《合成小岛》这类轻度合成游戏合不合 | scores(五维 0-100) |
| 收益 | 接进去能带来多少月净额 | netFen |
| 成本 | 接入与适配要花多少工程人天 | manDays |
三个约束里,成本最容易被忽略。一个平台净额再高,如果接入要花 20 个人天、而你全公司只有 40 个人天,那它就会挤掉三四个更小的平台。所以「选平台」本质是一个预算受限的组合优化:给定总人天,选一组平台,让净额最大。
组合评分模型(S 层)
下面这段是 S 层仿真脚本 sim/g01_selection.py。它读 config/g01_selection.json,按加权适配度给每个平台打分,算「适配加权净额」与「每工程人天的效率」,再用贪心装箱在预算内选出组合,产出 JSON + CSV + SVG。配置里每个平台的维度原始分、净额、人天都是建模假设(观察时点 2026-10),不是平台数据。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "平台选择与组合策略参数。dimensions=适配度维度与权重(整数,权重是本模型自设的输入,不是平台数据);platforms[].scores=各维度原始分(0-100,建模假设);netFen=该平台满配下的月净额(分,建模假设);manDays=接入与适配的工程人天(建模假设);budgetManDays=总接入预算。全部为「公开可观察机制」口径的建模假设,观察时点 2026-10;真实流量、真实分成与真实接入成本需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"budgetManDays": 40,
"dimensions": [
{ "id": "share", "name": "分成友好度", "weight": 3 },
{ "id": "reach", "name": "流量规模", "weight": 3 },
{ "id": "integration", "name": "接入成本低", "weight": 2 },
{ "id": "review", "name": "审核宽松", "weight": 2 },
{ "id": "iap", "name": "内购支持", "weight": 1 }
],
"platforms": [
{ "id": "poki", "name": "Poki", "netFen": 3500000, "manDays": 6, "scores": { "share": 70, "reach": 60, "integration": 80, "review": 70, "iap": 10 } },
{ "id": "crazygames", "name": "CrazyGames", "netFen": 2500000, "manDays": 5, "scores": { "share": 50, "reach": 55, "integration": 85, "review": 70, "iap": 10 } },
{ "id": "gamedist", "name": "GameDistribution", "netFen": 3500000, "manDays": 4, "scores": { "share": 70, "reach": 50, "integration": 90, "review": 75, "iap": 10 } },
{ "id": "yt_playables", "name": "YouTube Playables", "netFen": 2750000, "manDays": 9, "scores": { "share": 55, "reach": 80, "integration": 55, "review": 45, "iap": 10 } },
{ "id": "fb_instant", "name": "Facebook Instant Games", "netFen": 4900000, "manDays": 12, "scores": { "share": 70, "reach": 75, "integration": 50, "review": 55, "iap": 70 } },
{ "id": "appstore", "name": "App Store", "netFen": 4900000, "manDays": 20, "scores": { "share": 70, "reach": 90, "integration": 35, "review": 30, "iap": 90 } },
{ "id": "gplay", "name": "Google Play", "netFen": 4900000, "manDays": 18, "scores": { "share": 70, "reach": 95, "integration": 40, "review": 35, "iap": 90 } },
{ "id": "steam", "name": "Steam(PC 对照)", "netFen": 4900000, "manDays": 25, "scores": { "share": 70, "reach": 45, "integration": 30, "review": 40, "iap": 85 } }
]
}
#!/usr/bin/env python3
# file: sim/g01_selection.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g01_selection.py --config config/ --out out/
"""平台选择与组合策略:读 config/g01_selection.json,按加权适配度给每个平台打分,再用
「收益效率 = 适配加权净额 / 人天」做贪心装箱,在总人天预算内最大化组合净额。全整数。
weightSum = Σ dimension.weight
score = Σ (weight * platform.scores[dim]) // weightSum (0-100 适配度)
priorityFen = netFen * score // 100 (适配加权后的月净额)
efficiency = priorityFen // manDays (每工程人天换来的净额)
贪心:按 efficiency 降序取平台,装得下就放,装不下就跳过继续试更小的平台,直到遍历完。
口径:维度权重是本模型自设输入,平台原始分、净额、人天均为建模假设,观察时点 2026-10;
真实流量、真实分成与真实接入成本需真实业务环境,命令行不可复现(X)。
产出 out/g01_selection.json + .csv + .svg。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(cfg_dir: str) -> dict:
with open(os.path.join(cfg_dir, "g01_selection.json"), encoding="utf-8") as f:
return json.load(f)
def evaluate(p: dict, dims: list[dict]) -> dict:
weight_sum = sum(d["weight"] for d in dims)
score = sum(d["weight"] * p["scores"][d["id"]] for d in dims) // weight_sum if weight_sum else 0
priority = p["netFen"] * score // 100
return dict(p, score=score, priorityFen=priority,
efficiency=priority // p["manDays"] if p["manDays"] else 0)
def select(rows: list[dict], budget_man_days: int) -> dict:
"""按 efficiency 降序贪心装箱;跳过装不下的,继续试更小的平台。"""
chosen, used = [], 0
for r in sorted(rows, key=lambda r: (-r["efficiency"], r["id"])):
if used + r["manDays"] <= budget_man_days:
chosen.append(r)
used += r["manDays"]
return {"ids": [r["id"] for r in chosen], "count": len(chosen), "manDays": used,
"netFen": sum(r["netFen"] for r in chosen),
"priorityFen": sum(r["priorityFen"] for r in chosen),
"efficiency": sum(r["netFen"] for r in chosen) // used if used else 0,
"avgScore": sum(r["score"] for r in chosen) // len(chosen) if chosen else 0}
def draw_svg(path: str, rows: list[dict], chosen_ids: set) -> int:
"""每个平台一条横条:适配加权净额(分),入选组合标绿。"""
W, H, PAD, ROW = 800, 70 + 40 * len(rows), 230, 40
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": "28", "font-size": "15",
"fill": "#111827"}).text = "各平台适配加权净额(分):绿=入选组合"
span, hi = W - PAD - 170, max([r["priorityFen"] for r in rows] + [1])
for i, r in enumerate(rows):
y = 52 + i * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 18), "font-size": "12",
"fill": "#374151"}).text = r["name"]
w = span * r["priorityFen"] // hi
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(w),
"height": "18", "rx": "3",
"fill": "#16a34a" if r["id"] in chosen_ids else "#94a3b8"})
ET.SubElement(svg, "text", {"x": str(PAD + w + 6), "y": str(y + 18), "font-size": "11",
"fill": "#6b7280"}).text = (
f'分 {r["score"]} · 效率 {r["efficiency"]} · {r["manDays"]}人天')
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)
dims = cfg["dimensions"]
rows = [evaluate(p, dims) for p in cfg["platforms"]]
port = select(rows, cfg["budgetManDays"])
chosen_ids = set(port["ids"])
digest = ",".join(f'{r["id"]}:{r["score"]}:{r["efficiency"]}' for r in rows) + ";" + "|".join(port["ids"])
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "g01_selection.svg"), rows, chosen_ids)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"budgetManDays": cfg["budgetManDays"], "weightSum": sum(d["weight"] for d in dims),
"platformCount": len(rows), "platforms": rows, "portfolio": port, "digest": digest,
"svg": os.path.join(args.out, "g01_selection.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "g01_selection.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "g01_selection.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "score", "priorityFen", "efficiency", "netFen", "manDays", "selected"])
for r in rows:
w.writerow([r["id"], r["name"], r["score"], r["priorityFen"], r["efficiency"],
r["netFen"], r["manDays"], 1 if r["id"] in chosen_ids else 0])
print(json.dumps({
"budgetManDays": cfg["budgetManDays"],
"platforms": [{"id": r["id"], "score": r["score"], "efficiency": r["efficiency"],
"manDays": r["manDays"]} for r in rows],
"portfolio": port, "digest": digest}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑 /opt/homebrew/bin/python3 sim/g01_selection.py --config config/ --out out/,各平台得分与效率是(同机相对量级,绝对数值不可移植):
| 平台 | 适配分 | 效率(分/人天) | 人天 | 入选 |
|---|---|---|---|---|
| GameDistribution | 63 | 551250 | 4 | ✅ |
| Poki | 63 | 367500 | 6 | ✅ |
| CrazyGames | 57 | 285000 | 5 | ✅ |
| Facebook Instant Games | 65 | 265416 | 12 | ✅ |
| Google Play | 66 | 179666 | 18 | — |
| YouTube Playables | 55 | 168055 | 9 | ✅ |
| App Store | 63 | 154350 | 20 | — |
| Steam(PC 对照) | 51 | 99960 | 25 | — |
贪心装箱在 40 人天预算内选出的组合是 GameDistribution + Poki + CrazyGames + Facebook Instant Games + YouTube Playables:共 5 个平台、用掉 36 人天、组合月净额 17,150,000 分。注意两件事:第一,适配分最高的 Google Play(66)没入选——它 18 人天太贵,效率只有 179666,排到了 YouTube 后面;第二,效率最高的 GameDistribution(551250)不是净额最高的——它靠 4 人天的低成本取胜。这就是「组合优化」和「选最好平台」的区别:预算受限时,便宜的平台反而优先。
B 基准:组合对权重与预算有多敏感
上面这个组合是算出来的,不是确定的——它依赖两个人为输入:维度权重与总人天预算。所以必须问:「如果我把某个权重或预算上下调 20%,组合会翻盘吗?」下面这段是 B 层基准脚本 sim/g01_selection_bench.py,逐条扰动后重跑选择,软告警不硬失败。
#!/usr/bin/env python3
# file: sim/g01_selection_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/g01_selection_bench.py --config config/ --out out/
"""B 基准:平台组合选择对「维度权重」与「人天预算」的稳健性。
选择结论依赖 dimensions.weight——而权重是**人的判断**,不是客观常数。所以要问:
「如果我把某个维度权重上调 / 下调 20%,或者预算多给 / 少给 20%,入选组合会翻盘吗?」
做法:逐维度权重 ×0.8 / ×1.2,以及预算 ×0.8 / ×1.2,各自重跑「打分 → 效率 → 贪心装箱」,
逐平台比较是否入选,记录进出组合的平台。
软告警:任一扰动使组合成员发生变化即计入翻转(说明该选择对参数敏感)。退出码恒为 0。
全整数,无浮点。
"""
from __future__ import annotations
import argparse
import json
import os
from g01_selection import evaluate, load, select
PERTURB_PCT = 20
def perturbed_weights(dims: list[dict], key: str, pct: int) -> list[dict]:
out = []
for d in dims:
w = d["weight"] * (100 + pct) // 100 if d["id"] == key else d["weight"]
out.append({**d, "weight": max(w, 1)})
return out
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)
dims = cfg["dimensions"]
base_port = select([evaluate(p, dims) for p in cfg["platforms"]], cfg["budgetManDays"])
base_set = set(base_port["ids"])
cases, flips = [], []
for d in dims:
for pct in (-PERTURB_PCT, PERTURB_PCT):
dims2 = perturbed_weights(dims, d["id"], pct)
port = select([evaluate(p, dims2) for p in cfg["platforms"]], cfg["budgetManDays"])
entered = sorted(set(port["ids"]) - base_set)
left = sorted(base_set - set(port["ids"]))
cases.append({"type": "weight", "key": d["id"], "pct": pct,
"newWeight": next(x["weight"] for x in dims2 if x["id"] == d["id"]),
"portfolio": port["ids"], "entered": entered, "left": left})
if entered or left:
flips.append({"type": "weight", "key": d["id"], "pct": pct,
"entered": entered, "left": left})
for pct in (-PERTURB_PCT, PERTURB_PCT):
budget = cfg["budgetManDays"] * (100 + pct) // 100
port = select([evaluate(p, dims) for p in cfg["platforms"]], budget)
entered = sorted(set(port["ids"]) - base_set)
left = sorted(base_set - set(port["ids"]))
cases.append({"type": "budget", "key": "budgetManDays", "pct": pct, "newValue": budget,
"portfolio": port["ids"], "entered": entered, "left": left})
if entered or left:
flips.append({"type": "budget", "key": "budgetManDays", "pct": pct,
"entered": entered, "left": left})
os.makedirs(args.out, exist_ok=True)
rep = {"version": cfg["version"], "source": "sim/g01_selection_bench.py", "perturbPct": PERTURB_PCT,
"baseBudgetManDays": cfg["budgetManDays"], "basePortfolio": base_port["ids"],
"cases": cases, "flipCount": len(flips), "flips": flips,
"verdict": "PASS(soft)" if not flips else f"WARN(soft) {len(flips)} 条翻转"}
with open(os.path.join(args.out, "g01_selection_bench.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"basePortfolio": base_port["ids"], "flipCount": len(flips),
"flips": flips, "verdict": rep["verdict"]}, ensure_ascii=False, indent=2))
return 0 # B 层软告警:恒返回 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,结果是 2 条翻转(WARN(soft),退出码仍为 0):
| 扰动 | 组合变化 |
|---|---|
| 预算 −20%(40→32 人天) | YouTube Playables 掉出组合 |
| 预算 +20%(40→48 人天) | Google Play 进入、YouTube Playables 掉出 |
读这张表要读出两件事。第一,权重扰动一条都没翻盘——说明在当前配置下,组合对「维度权重怎么设」不敏感,排名足够稳,这是好事:意味着你的主观偏好不会轻易改变结论。第二,预算扰动两次都翻盘——预算 −20% 时最贵的小平台先掉,预算 +20% 时更贵的 Google Play 反而挤掉了 YouTube。这说明组合的真正旋钮是预算,不是权重。所以给决策者的建议不是「把权重调准」,而是「把预算说清楚」:预算一变,最优组合就变,结论必须和预算一起交代。
X 层:真实适配与成本不可复现
边界必须划清。上面算的是选择模型:给定适配分、净额与人天,预算内最优组合是哪个。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的平台适配分(同一款游戏在不同团队眼里分数不同,没有客观标准);
- 真实的接入人天(取决于团队熟悉度、平台文档质量、审核往返次数);
- 真实的月净额(依赖流量结构、条款、内购可用性,是前三节所有模型的合成结果)。
工程上正确的做法是:把可复现的选择模型与不可复现的真实评估分开——模型回答「给定这些输入,组合是哪个」,真实评估用来在接入后反标定参数(把实测人天与真实净额填回 config,组合立刻重排)。这也是第 15 章「把《合成小岛》发到五个海外平台」要串起来的东西。
自检表
| 检查项 | 合格标准 |
|---|---|
| 三约束齐全 | 适配度 / 收益 / 成本都进模型,不缺成本 |
| 预算受限 | 选择在明确的人天预算内做,不是无约束排序 |
| 组合而非单选 | 输出是一组平台,不是一个「最优平台」 |
| 效率留痕 | 每个平台给出效率(净额/人天),可复算 |
| 稳健性有据 | 对权重与预算做 ±20% 扰动,量化翻转数 |
| 边界清晰 | 真实适配分与人天标 X,不写成「实测」 |
平台选择最危险的错觉,是以为存在一个「最好的平台」。真实约束下没有最好的平台,只有在给定预算下最划算的一组平台——而这一组会随预算变化。第 1 章到这里把「平台格局」讲完了:1.1 分层与流量结构、1.2 条款与净额、1.3 选择与组合。下一章进入接入层:同一个功能,在各平台的 SDK 与广告 API 上长得有多不一样。
阅读导航:上一节:1.2 各平台分成与结算条款 · 下一节:2.1 平台 SDK 与广告 API 差异 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。