失败弹窗里的一个按钮
《潮汐消消》的玩家在第 37 关失败后,会看到「看一段视频,多走三步」;同一个 IP 的《潮汐方舟》在新手期之外几乎不出现这个按钮。两款产品都接广告,但广告位在产品里的位置完全不同:消消把激励视频当成一条稳定的日常路径,方舟只在少数场景保留入口。
这个差异不是审美偏好,而是三个约束共同作用的结果:玩家在当前场景有没有主动意愿、广告中断会不会破坏核心循环、奖励价值会不会侵蚀内购。广告位设计要回答的不是「能放几个广告」,而是「在哪个时刻,玩家愿意用注意力换什么」。
本节只讨论广告位与激励视频的边界。广告加载之后的状态流转放到 6.2,收入怎么核算放到 6.3;把三件事混在一个模块里,最后会变成既不敢改频次、也说不清收入的局面。
三种广告位,三种交易
广告位不是同一种商品。激励视频卖的是玩家主动让渡的一段时间,插屏卖的是场景切换处的注意力,Banner 卖的是界面上的常驻曝光。三者的触发者、用户意图和失败成本都不同:
| 广告位 | 触发者 | 玩家意图 | 是否打断 | 案例位置 | 案例计价 |
|---|---|---|---|---|---|
| 激励视频 | 玩家点击 | 明确:用观看换奖励 | 不打断,玩家自己选择 | 消消、方舟 | eCPM ¥45 / ¥80 |
| 插屏 | 场景切换 | 弱:被动接受 | 打断当前节奏 | 消消 | eCPM ¥18 |
| Banner | 常驻展示 | 无:背景曝光 | 不打断但持续占位 | 消消 | 案例未单列 eCPM |
三者的经济含义也不同。激励视频的每一次展示都对应一个明确的交换承诺,完成率可以直接衡量承诺是否被兑现;插屏没有奖励,玩家接受它是因为「反正要等下一关」,一旦出现得太频繁,玩家会把等待本身归因于广告;Banner 的收入与停留时长相关,但它持续占用界面空间,在小屏幕上尤其容易挤压操作区。
因此,广告位设计的第一步不是排布,而是排除:哪些场景绝对不能放插屏,哪些界面没有空间放 Banner,哪些奖励不能由广告提供。把「不能放」写清楚,比把「可以放」列全更有价值。
场景与广告位的匹配
同样是「放广告」,不同场景的适配度差别很大。下面这张表是案例里实际采用的判断口径:
| 场景 | 适合的广告位 | 不适合的广告位 | 原因 |
|---|---|---|---|
| 关卡失败后复活 | 激励视频 | 插屏 | 失败情绪下打断会放大挫败感 |
| 关卡结算页 | 插屏 | 激励视频 | 没有明确奖励,玩家不会主动点 |
| 主界面底部 | Banner | 激励视频 | 没有主动意图,入口会被误触 |
| 体力不足 | 激励视频 | 插屏 | 玩家有明确目标,交换关系清楚 |
| 首次启动 | 无 | 全部 | 尚未建立游戏预期,广告只会增加流失 |
这张表里最容易被破坏的是「首次启动」。新玩家还没有形成「看广告能得什么」的认知,此时弹插屏或放 Banner,会把广告变成第一印象。消消把广告入口放在失败复活与体力不足两个有明确目标的场景,原因就在这里:玩家不是来看广告的,是来解决一个具体问题的。
另一个原则是「一个场景只放一种主广告位」。失败复活页如果既有激励视频按钮,又在背后弹插屏,玩家会觉得自己被设计了两次。广告位之间要互斥,至少要在同一时刻互斥。
激励视频的五条边界
激励视频看起来最简单:玩家点,广告播,发奖励。真正难的是边界。以下五条是案例里不可退让的口径:
- 必须由玩家主动触发。 自动弹出、把关闭按钮做成广告入口、倒计时结束自动播放,都不算激励视频。主动触发不是交互细节,而是这条广告合法性的来源。
- 奖励必须在播放前写清楚。 播完再随机决定给什么,会把「看广告」变成抽奖;玩家对不确定奖励的容忍度远低于对内购抽卡的容忍度。
- 奖励价值不能接近同档内购。 消消的主力内购档位是 ¥18;如果一次三十秒视频给出的资源接近这个档位,内购就会被广告补贴掉。广告奖励应该解决「差一点」的问题,而不是替代一次完整的付费。
- 频次要有上限和冷却。 上限保护留存与内购,冷却保护单次广告的注意力质量。没有上限的激励视频会把玩家训练成「只在有广告时玩」。
- 奖励以完成观看为准。 点击、展示、关闭都不是完成;只有广告 SDK 的完成回调才能触发发奖。具体状态机在 6.2 展开。
这五条里,第一条和第五条最容易被工程实现破坏。前者常被产品需求压成「自动弹出」,后者常被写成「关闭弹窗就发奖」。两者都会把广告收入变成一次性的透支。
把边界写成判定顺序
边界要能被执行,就不能只写在文档里。判定顺序本身也是设计的一部分:
| 顺序 | 检查项 | 拒绝原因 | 是否消耗当日额度 |
|---|---|---|---|
| 1 | 当日已看次数是否达到上限 | daily-cap | 否 |
| 2 | 距上次观看是否短于冷却 | cooldown | 否 |
| 3 | 当前是否有可用填充 | no-fill | 否 |
| 4 | 全部通过 | ok | 是,由调用方扣减 |
为什么把额度放在最前面?因为额度是产品对玩家的硬承诺。玩家看到「今日还能看 3 次」,这个数字不能因为一次填充失败就减少。冷却放在第二,是因为它约束的是节奏,不是供给;填充放在最后,是因为它由外部广告平台决定,最不稳定,也最不应该影响前两项的语义。
这个顺序还有一个可观测性的好处:拒绝原因只有一个,日志里不会出现「既超限又无填充」的模糊状态。运营看到 daily-cap 就知道是产品策略在起作用,看到 no-fill 就知道要去找广告平台,而不是去改配置。
可运行产物:广告位判定器
下面的实现把广告位元数据、案例配置和激励判定拆开。SLOT_SPECS 描述每种广告位的交互性质,AD_PROFILES 记录两个案例的实际组合与激励参数,checkReward 只做纯函数判定,不接触 SDK、不发网络请求。
// file: src/biz/ad-slot.ts
// tier: L1
// verify: node --test test/biz/ad-slot.test.ts
export type SlotKind = "rewarded" | "interstitial" | "banner";
export interface SlotSpec {
readonly kind: SlotKind;
readonly label: string;
readonly userInitiated: boolean;
readonly interrupting: boolean;
readonly grantsReward: boolean;
}
export const SLOT_SPECS: Record<SlotKind, SlotSpec> = {
rewarded: {
kind: "rewarded",
label: "激励视频",
userInitiated: true,
interrupting: false,
grantsReward: true,
},
interstitial: {
kind: "interstitial",
label: "插屏",
userInitiated: false,
interrupting: true,
grantsReward: false,
},
banner: {
kind: "banner",
label: "Banner",
userInitiated: false,
interrupting: false,
grantsReward: false,
},
};
export interface AdProfile {
readonly product: string;
readonly slots: readonly SlotKind[];
readonly rewardedPerDauPerDay: number;
readonly rewardedEcpm: number;
}
export const AD_PROFILES: Record<"chaoxi" | "ark", AdProfile> = {
chaoxi: {
product: "潮汐消消",
slots: ["rewarded", "interstitial", "banner"],
rewardedPerDauPerDay: 3.5,
rewardedEcpm: 45,
},
ark: {
product: "潮汐方舟",
slots: ["rewarded"],
rewardedPerDauPerDay: 1.5,
rewardedEcpm: 80,
},
};
export interface RewardPolicy {
readonly dailyCap: number;
readonly cooldownSec: number;
}
export interface RewardRequest {
readonly watchedToday: number;
readonly secondsSinceLast: number;
readonly fillAvailable: boolean;
}
export type RewardRejection = "daily-cap" | "cooldown" | "no-fill";
export interface RewardDecision {
readonly allow: boolean;
readonly reason: "ok" | RewardRejection;
readonly remainingToday: number;
readonly retryAfterSec: number;
}
function assertNonNegativeInteger(value: number, name: string): void {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${name} must be a non-negative integer, got ${value}`);
}
}
export function checkReward(request: RewardRequest, policy: RewardPolicy): RewardDecision {
assertNonNegativeInteger(policy.dailyCap, "dailyCap");
assertNonNegativeInteger(policy.cooldownSec, "cooldownSec");
assertNonNegativeInteger(request.watchedToday, "watchedToday");
assertNonNegativeInteger(request.secondsSinceLast, "secondsSinceLast");
const remainingToday = Math.max(0, policy.dailyCap - request.watchedToday);
if (remainingToday === 0) {
return { allow: false, reason: "daily-cap", remainingToday: 0, retryAfterSec: 0 };
}
if (request.secondsSinceLast < policy.cooldownSec) {
return {
allow: false,
reason: "cooldown",
remainingToday,
retryAfterSec: policy.cooldownSec - request.secondsSinceLast,
};
}
if (!request.fillAvailable) {
return { allow: false, reason: "no-fill", remainingToday, retryAfterSec: 0 };
}
return { allow: true, reason: "ok", remainingToday, retryAfterSec: 0 };
}
export function validateSlotPlan(slots: readonly SlotKind[]): string[] {
const errors: string[] = [];
if (slots.length === 0) {
errors.push("广告位组合不能为空");
}
if (new Set(slots).size !== slots.length) {
errors.push("广告位不能重复");
}
return errors;
}
export function assertSlotPlan(slots: readonly SlotKind[]): void {
const errors = validateSlotPlan(slots);
if (errors.length > 0) {
throw new Error(errors.join("; "));
}
}
export function formatProfile(profile: AdProfile): string {
return `${profile.product} slots=${profile.slots.join("+")} reward/day=${profile.rewardedPerDauPerDay} ecpm=¥${profile.rewardedEcpm}`;
}
export function formatReward(decision: RewardDecision): string {
if (decision.allow) {
return `allow remaining=${decision.remainingToday}`;
}
return `deny reason=${decision.reason} remaining=${decision.remainingToday} retry=${decision.retryAfterSec}s`;
}
checkReward 返回的 remainingToday 是「本次判定之前还剩多少次」,允许时不会替调用方扣减。这样调用方可以在真正展示成功后再写回计数,避免「判定通过但广告没播成」却已经消耗额度的情况。formatReward 输出一行稳定文本,客服和运营可以直接拿它对照配置。
validateSlotPlan 只做两件最基础的事:组合不能为空、广告位不能重复。它故意不判断「激励视频必须配插屏」这类产品偏好,因为方舟的单一激励视频组合就是合法的。校验器应该守住结构,而不是替产品做取舍。
双案例:3.5 次与 1.5 次
消消同时开激励视频、插屏与 Banner,人均每日 3.5 次激励,eCPM ¥45,广告月流水约 378 万元,占月流水 38%。高频、低门槛的广告承接不付费玩家,是混合变现的主引擎。
方舟只保留少量激励视频,人均每日 1.5 次,eCPM ¥80,广告月流水约 90 万元,只占月流水 3%。它以内购为主,广告只服务少数有时间的玩家;把消消的 3.5 次直接搬过去,会破坏方舟的战斗节奏。
取舍:频次上限牺牲了什么
频次上限首先牺牲的是短期收入。若把消消的每日上限放宽,短期内激励展示次数会上升,广告流水也会跟着上升;代价是玩家把「玩游戏」重新定义为「看广告」,长期留存与内购转化同时下降。
其次是供给利用率。消消的填充率是 92%,意味着即使玩家愿意看,也总有约 8% 的请求拿不到广告。上限如果设得过高,玩家会在高频请求里反复撞上无填充,体验比「今天看完了」更差。上限在这里反而保护了体验。
第三是运营复杂度。上限、冷却、奖励价值都要能远程配置,还要按渠道、版本、玩家分层做差异;配置一多,测试矩阵就会膨胀。案例里把判定收敛成一个纯函数,就是为了让这些配置可以在不接 SDK 的情况下被穷举测试。
代价可以概括为一句话:上限用可预测的收入换可预测的留存。这个交换是否划算,不能看单日流水,要看 D30 与 D180 的回收;广告收入只是回收模型的一层,不是全部。
边界:广告位决定「可以放」,不决定「放成了」
广告位设计只回答「这个场景允不允许放、以什么条件放」。它不回答广告有没有加载成功、玩家有没有看完、奖励有没有重复发放,也不回答这次展示值多少钱。前者是 6.2 的生命周期问题,后者是 6.3 的核算问题。
把三件事分开,最直接的好处是排障路径清晰:玩家说「点了没反应」,先看广告位判定是不是 no-fill;玩家说「看完了没奖励」,去看完成回调与幂等;财务说「收入对不上」,去查 eCPM 分母与结算口径。混在一起,所有问题都会变成「广告模块有问题」。
小结
- 广告位不是同一种商品:激励视频交易主动注意力,插屏交易场景切换,Banner 交易常驻空间。
- 激励视频的五条边界是主动触发、奖励前置说明、价值低于同档内购、频次上限与冷却、以完成观看为准。
- 判定顺序固定为额度、冷却、填充;拒绝原因唯一,且失败不消耗当日额度。
- 消消用三种广告位与 3.5 次高频激励撑起 38% 的广告收入,方舟只用 1.5 次激励做内购的边角补充。
- 频次上限用短期收入换长期留存,是否划算要看 D30 与 D180 的回收,而不是单日流水。
阅读导航:上一节:5.3 渠道对账与收入确认 · 下一节:6.2 广告 SDK 生命周期与填充失败兜底 。
常见错误与边界
- 把激励视频做成自动弹出。玩家没有主动选择,广告就从交换变成了打断;短期展示量上升,长期留存与内购一起下降。
- 播完才告诉玩家奖励是什么。不确定奖励会让玩家把广告等同于抽奖,投诉与放弃率都会上升。
- 奖励价值接近主力内购档位。消消主力档是 ¥18,一次视频给出接近这个档位的资源,内购会被自己的广告补贴掉。
- 只设冷却不设每日上限。冷却只能控制节奏,不能控制总量;没有上限,重度玩家会把广告当成主要资源来源。
- 把填充失败算作玩家已看。无填充是平台供给问题,不应该消耗玩家的当日额度,否则玩家会认为系统在骗他。
- 在同一场景叠加多种广告位。失败复活页既有激励按钮又弹插屏,玩家会感到被重复收割,客诉成本高于多出来的那一次曝光。
- 把 Banner 当成低干扰广告。Banner 不打断流程,但持续占用屏幕空间,在小屏设备上会挤压操作区,误触率并不低。
- 用广告位判定代替广告生命周期。判定通过只代表「允许请求」,不代表加载成功、展示完成或奖励发放成功。
- 把频次配置写死在客户端。不同渠道、版本、玩家分层的策略不同,写死之后每次调整都要发版,运营窗口被压缩。
- 只看广告流水,不看广告对留存的影响。广告收入的代价可能体现在次日的留存曲线上,单看当日流水会把透支当成增长。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。