卷三的《上线变现与长线运营》 已经把移动小游戏的广告形态讲透:激励视频、插屏、Banner 各占什么位置,eCPM 怎么建模,频次与流失怎么平衡。本节不重做那套框架,只处理一件它没覆盖的事——把同一款游戏发到海外 Web/H5 平台时,广告这件事的宿主换了:不再是微信/抖音的激励视频 SDK,而是 Poki、CrazyGames、GameDistribution 这类网页平台的广告覆盖层。站内那篇广告变现设计清单 是清单式短文,列了该做什么;本节给你一台能判定的机器:给定平台和时刻,回答「这个形态现在能不能弹」。三者分工清楚,不重复。
前三章我们把「怎么发到多个平台」讲完了:平台格局与分成、SDK 与上架、一套代码多平台的构建矩阵。但发上去之后要赚钱,就得面对一个残酷现实——Web 平台的广告 API 与移动端根本不是一套东西。你在微信里写惯的 wx.createRewardedVideoAd,在 Poki 里不存在;取而代之的是一个「商业中断 / 激励中断」的抽象,具体播什么、能不能播,由平台决定。
广告形态换了宿主
移动小游戏的广告是你主动拉、你主动播:调一次 API,拿到一个视频,自己决定什么时候 show。Web/H5 平台的广告是你只报告时机、平台负责注入:你告诉平台「玩家要拿奖励了」或「这一关结束了」,平台往画布上叠一层 iframe 广告,播完回调你。
这个差别带来两个后果:
- 形态更多样:Web 端除了激励,还有「商业中断」(商业 break,近似插屏)、常驻横幅、加载前贴片;
- 控制权在平台:开放哪些形态、两次之间至少隔多久、一天最多几次,全是平台属性,你只能查、不能改。
四类 Web 广告形态
| 形态 | 触发方式 | 玩家视角 | 典型平台 | 近似移动端 |
|---|---|---|---|---|
| 商业中断 commercial break | 你报告一个安全时机 | 强制的全屏视频 | Poki / CrazyGames | 插屏 |
| 激励中断 rewarded break | 玩家主动点「看广告换奖励」 | 自愿的全屏视频 | 全部 Web 平台 | 激励视频 |
| 横幅 banner | 常驻画布外 | 页面边缘的图 | CrazyGames / 微信 | Banner |
| 前贴片 preroll | 加载完成时 | 进游戏前的视频 | GameDistribution | 开屏 |
一句话记住:移动端是「三种形态一套 API」,Web 端是「一种抽象四种形态、能力看平台」。你写代码的方式必须从「调哪个 API」变成「先问平台支不支持」。
还有一点容易被忽略:商业中断与激励中断在玩家心里是两种东西。商业中断是平台替你做的「强制广告」,玩家不会因为看了它而感谢你;激励中断是玩家主动点的「换奖励」,看了会觉得自己赚了。所以同一个广告位,做成商业中断还是激励中断,不只是收入口径不同,对留存的影响也不同——这也是为什么能力档案里两者要分开建模,而不是笼统写一个「视频广告」。
为什么「能力」是平台属性
同一个激励位,在 Poki 上是 PokiSDK.rewardedBreak(),在 CrazyGames 上是 CrazyGames.SDK.ad.requestAd('rewarded'),在 GameDistribution 上是 gdsdk.showAd('rewarded')。而插屏的频控更离谱:Poki 的两次商业中断之间有一段平台侧的最短间隔,CrazyGames 有自己的日上限,GameDistribution 又不同。这些数字属于 X 层——真实频控与真实可用形态需真实业务环境,命令行不可复现。
工程上正确的做法不是把每家的 API 硬编码进业务逻辑,而是抽出一份平台能力档案(D 层),让业务只依赖归一化后的语义:rewarded / interstitial / banner / preroll。这正是卷二「平台 API 抽象层」
的主张在广告侧的又一次出现。
报告时机:平台为什么需要你配合
Web 平台还有一个移动端没有的概念:游戏状态上报。你需要在玩家真正开始玩的时候调 gameplayStart(),在暂停、弹窗、切后台时调 gameplayStop()。这不是可选项——平台靠这两个信号做两件事:
- 决定何时插广告:玩家在「游玩中」时平台不会硬插全屏广告,只在你报告安全时机(关卡结算、回到菜单)时才允许商业中断;
- 统计有效会话:
gameplayStart/gameplayStop之间的时长才是「有效游戏时长」,直接影响平台对你的流量评级与推荐。
这带来一个设计层面的连锁反应:如果你的游戏有很长的、不可中断的连续操作(比如一段不可暂停的合成动画),你就错过了报告安全时机的窗口,商业中断插不进去,收入直接少一块。所以「什么时候允许广告」这件事,反向决定了关卡节奏与结算点的设计——这正是本套反复强调的「平台约束反向决定设计」在广告侧的落点。
降级链:弹不了就退一档
有了能力档案,业务代码就能写出一条降级链:优先想要高单价的形态,弹不了就退到次优,全弹不了就不弹。比如「关卡结算」这个时机:
| 优先级 | 形态 | 条件 | 退到下一档的触发 |
|---|---|---|---|
| 1 | 商业中断 interstitial | 平台支持 + 过了最短间隔 + 未到日上限 | 频控未到 / 不支持 |
| 2 | 激励中断 rewarded | 平台支持(通常无限) | 不支持 |
| 3 | 不弹 | 全部不支持 | —— |
关键是降级不能靠 if 硬编码,而要读能力档案动态决定——同一个「结算时机」的处理函数,在 Poki(无横幅)和 CrazyGames(有横幅)上跑出不同的行为,而业务逻辑一行不改。这就是把能力写成配置的价值。
D 层:跨平台广告能力档案
下面这份是 D 层配置 config/g04_adformats.json。它给四个平台(三个 Web + 一个移动对照)各列一组形态规则:是否支持、最小间隔、日上限、归一化调用符号。注意数字是「公开可观察机制」口径的建模假设(对照各平台公开 SDK 文档整理),不是任何平台后台的实测。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "各海外 H5 平台与国内小游戏平台的广告能力档案,口径是「公开可观察机制」的建模假设(对照各平台公开 SDK 文档整理),不是任何平台后台的实测。format 取 rewarded(激励) / interstitial(插屏/商业中断) / banner(横幅) / preroll(前贴片);supported=该平台是否开放该形态;minIntervalSec=两次同形态之间的最小间隔秒(0=平台不设下限);dailyCap=单日上限次数(0=不设上限);apiCall=归一化后的平台调用符号。真实可用形态、真实频控与真实结算需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"platforms": [
{
"id": "poki", "name": "Poki", "channel": "web-h5",
"rules": [
{ "format": "rewarded", "supported": true, "minIntervalSec": 0, "dailyCap": 0, "apiCall": "PokiSDK.rewardedBreak" },
{ "format": "interstitial", "supported": true, "minIntervalSec": 180, "dailyCap": 12, "apiCall": "PokiSDK.commercialBreak" },
{ "format": "banner", "supported": false, "minIntervalSec": 0, "dailyCap": 0, "apiCall": "" },
{ "format": "preroll", "supported": false, "minIntervalSec": 0, "dailyCap": 0, "apiCall": "" }
]
},
{
"id": "crazygames", "name": "CrazyGames", "channel": "web-h5",
"rules": [
{ "format": "rewarded", "supported": true, "minIntervalSec": 0, "dailyCap": 0, "apiCall": "CrazyGames.SDK.ad.requestAd('rewarded')" },
{ "format": "interstitial", "supported": true, "minIntervalSec": 120, "dailyCap": 20, "apiCall": "CrazyGames.SDK.ad.requestAd('midgame')" },
{ "format": "banner", "supported": true, "minIntervalSec": 0, "dailyCap": 0, "apiCall": "CrazyGames.SDK.banner.requestBanner" },
{ "format": "preroll", "supported": false, "minIntervalSec": 0, "dailyCap": 0, "apiCall": "" }
]
},
{
"id": "gamedistribution", "name": "GameDistribution", "channel": "web-h5",
"rules": [
{ "format": "rewarded", "supported": true, "minIntervalSec": 0, "dailyCap": 0, "apiCall": "gdsdk.showAd('rewarded')" },
{ "format": "interstitial", "supported": true, "minIntervalSec": 90, "dailyCap": 30, "apiCall": "gdsdk.showAd('interstitial')" },
{ "format": "banner", "supported": false, "minIntervalSec": 0, "dailyCap": 0, "apiCall": "" },
{ "format": "preroll", "supported": true, "minIntervalSec": 0, "dailyCap": 3, "apiCall": "gdsdk.preloadAd('interstitial')" }
]
},
{
"id": "wechat", "name": "微信小游戏", "channel": "mobile-mini",
"rules": [
{ "format": "rewarded", "supported": true, "minIntervalSec": 0, "dailyCap": 0, "apiCall": "wx.createRewardedVideoAd" },
{ "format": "interstitial", "supported": true, "minIntervalSec": 300, "dailyCap": 10, "apiCall": "wx.createInterstitialAd" },
{ "format": "banner", "supported": true, "minIntervalSec": 0, "dailyCap": 0, "apiCall": "wx.createBannerAd" },
{ "format": "preroll", "supported": false, "minIntervalSec": 0, "dailyCap": 0, "apiCall": "" }
]
}
]
}
一眼能看出三家的差异:Poki 和 GameDistribution 都没有横幅,只有 GameDistribution 有前贴片;Poki 的商业中断最短间隔 180 秒、日上限 12 次,CrazyGames 是 120 秒 / 20 次。而激励视频四家都不设频控——因为它是玩家自愿的,平台没有理由限制。
P 层内核:一个「此刻能不能弹」的纯函数
光有一张档案表没用,业务要的是一个决策函数:给定平台、形态、当前状态(现在几点、上次弹是几点、今天弹了几次),回答能不能弹、不能弹是为什么、还要等多久。下面这段是 P 层内核 core/src/g04/adformat.ts,全整数、无随机、无浮点,只用可擦除语法。
// file: core/src/g04/adformat.ts
// tier: P
// verify: node --test 'core/test/g04_adformat.test.ts'
//
// 海外 H5 平台广告形态路由器:把「某平台是否支持某广告形态、现在能不能弹」这件事
// 收敛成一个纯函数 decide()。读同一份 config/g04_adformats.json;全整数、无随机、无浮点。
// 只用可擦除语法(禁 enum / namespace)。
export interface FormatRule {
readonly format: string; // rewarded | interstitial | banner | preroll
readonly supported: boolean;
readonly minIntervalSec: number; // 0 = 平台不设下限
readonly dailyCap: number; // 0 = 不设上限
readonly apiCall: string; // 归一化平台调用符号,未支持时为空串
}
export interface PlatformProfile {
readonly id: string; readonly name: string; readonly channel: string;
readonly rules: readonly FormatRule[];
}
export interface AdFormatsConfig {
readonly version: number; readonly game: string; readonly observedAt: string;
readonly platforms: readonly PlatformProfile[];
}
export interface AdRequestState {
readonly nowSec: number; // 单调时钟秒
readonly lastAdSec: number; // 上次同形态广告时刻(从未弹过为 -1)
readonly countToday: number; // 今日已弹同形态次数
}
export interface AdDecision {
readonly allowed: boolean;
readonly apiCall: string; // 不允许时为空串
readonly reason: string; // ok | unsupported | cap_reached | too_soon
readonly waitSec: number; // too_soon 时还需等待的秒数,否则 0
}
export function findRule(p: PlatformProfile, format: string): FormatRule | undefined {
return p.rules.find((r) => r.format === format);
}
/** 归一化决策:先判支持,再判日上限,最后判最小间隔。全整数。 */
export function decide(p: PlatformProfile, format: string, s: AdRequestState): AdDecision {
const rule = findRule(p, format);
if (!rule || !rule.supported) {
return { allowed: false, apiCall: "", reason: "unsupported", waitSec: 0 };
}
if (rule.dailyCap > 0 && s.countToday >= rule.dailyCap) {
return { allowed: false, apiCall: "", reason: "cap_reached", waitSec: 0 };
}
if (rule.minIntervalSec > 0 && s.lastAdSec >= 0 && s.nowSec - s.lastAdSec < rule.minIntervalSec) {
return { allowed: false, apiCall: "", reason: "too_soon",
waitSec: rule.minIntervalSec - (s.nowSec - s.lastAdSec) };
}
return { allowed: true, apiCall: rule.apiCall, reason: "ok", waitSec: 0 };
}
/** 某平台支持的形态清单(按配置顺序)。 */
export function supportedFormats(p: PlatformProfile): string[] {
return p.rules.filter((r) => r.supported).map((r) => r.format);
}
/** 跨平台能力摘要:`id=[f1,f2,...]` 以 `|` 连接,与 Python 侧口径一致。 */
export function capabilityDigest(cfg: AdFormatsConfig): string {
return cfg.platforms.map((p) => `${p.id}=[${supportedFormats(p).join(",")}]`).join("|");
}
配套测试 core/test/g04_adformat.test.ts 把能力档案写成金标准,断言路由器对四种平台的决策,并覆盖「不支持 / 频控未到 / 日上限封顶」三条拒绝路径。在本机跑 node --test 'core/test/g04_adformat.test.ts',6 个用例全过:
✔ 能力摘要:四平台支持的形态清单与配置一致
✔ 不支持的形态直接拒绝(Poki 无横幅)
✔ 首次插屏放行,间隔未到则 too_soon 并给出剩余秒
✔ 日上限封顶(CrazyGames 插屏 20 次)
✔ 前贴片日上限 3(GameDistribution)
✔ 激励视频通常不设频控(Poki / 微信均为无限)
ℹ tests 6 ℹ pass 6 ℹ fail 0
能力摘要(与 Python 侧口径一致,可用于跨语言对拍):
poki=[rewarded,interstitial]|crazygames=[rewarded,interstitial,banner]|gamedistribution=[rewarded,interstitial,preroll]|wechat=[rewarded,interstitial,banner]
X 层:真实能力与频控不可复现
必须把边界划清。上面这份档案是模型侧:给定各平台公开文档描述的形态与频控,业务该如何决策。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 平台当前实际开放的形态(SDK 版本迭代、灰度、地区差异都会变,且平台侧不保证长期稳定);
- 平台真实的频控参数(最短间隔、日上限可能随流量与库存动态调整,文档与实现常有出入);
- 平台真实的填充与 eCPM(同一广告位、同一地区,不同时段差异很大)。
工程上正确的做法是:把可复现的能力档案与不可复现的真实参数分开——档案层用来在接入前做「如果平台只给这些形态,我的广告设计能不能成立」,真实参数层用来在接入后反标定档案(把平台后台实测到的频控填回 config,决策函数立刻反映现实)。这也是为什么这份档案要写成可路由的配置而不是散落的 if 判断。
接入 Web 广告最容易踩的四个坑
- 把移动端的形态名硬搬:以为有「激励视频广告位 ID」就能在 Web 上用,其实 Web 端根本没有广告位 ID 这个概念,形态与时机由平台管;
- 忘了报告游戏状态:不调
gameplayStart/gameplayStop,平台就不知道什么时候可以插广告,商业中断直接少一大截; - 把频控写死在业务里:把「插屏最短 3 分钟」硬编码,换平台就失效;应该从能力档案读;
- 把不可复现的频控当实测:把某次在某个平台观察到的最短间隔写进文档当成「平台规则」,下次平台一改就全错——正确做法是标成 X 并在接入后反标定。
自检表
| 检查项 | 合格标准 |
|---|---|
| 形态归一 | 业务只依赖 rewarded/interstitial/banner/preroll 四语义,不硬编码各家 API |
| 能力可查 | 每个平台的支持形态、频控、日上限都在 config 里,可单独改 |
| 决策纯粹 | decide() 无随机、无副作用,同输入同输出 |
| 拒绝有因 | 不支持 / 频控未到 / 日上限封顶三种原因分开,能指导降级 |
| 边界清晰 | 真实形态与频控标 X,不写成「实测」 |
Web 平台的广告形态最容易被低估的一点是:它不是一个「接了就能播」的开关,而是一组由平台掌握的约束。你能做的不是绕过约束,而是把约束写进配置、让业务优雅降级——插屏弹不了就退到激励,横幅没有就不放。下一节我们处理一个更极端的场景:有些 Web 平台根本没有内购,收入 100% 来自广告,这时候整个收入结构会退化成什么样。
阅读导航:上一节:3.3 版本一致性与回滚 · 下一节:4.2 无内购平台的纯 IAA 结构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。