《移动游戏产品的架构·商业化与发行工程》6.1 广告位设计与激励视频边界

消消把激励视频做成人均每日 3.5 次的稳定收入,方舟只保留 1.5 次的少量入口。本节从广告位的触发方式、用户意图与失败成本出发,给出激励视频、插屏、Banner 的边界规则,用纯函数实现额度、冷却与填充校验,并讨论频次上限对留存与内购的取舍。

失败弹窗里的一个按钮

《潮汐消消》的玩家在第 37 关失败后,会看到「看一段视频,多走三步」;同一个 IP 的《潮汐方舟》在新手期之外几乎不出现这个按钮。两款产品都接广告,但广告位在产品里的位置完全不同:消消把激励视频当成一条稳定的日常路径,方舟只在少数场景保留入口。

这个差异不是审美偏好,而是三个约束共同作用的结果:玩家在当前场景有没有主动意愿、广告中断会不会破坏核心循环、奖励价值会不会侵蚀内购。广告位设计要回答的不是「能放几个广告」,而是「在哪个时刻,玩家愿意用注意力换什么」。

本节只讨论广告位与激励视频的边界。广告加载之后的状态流转放到 6.2,收入怎么核算放到 6.3;把三件事混在一个模块里,最后会变成既不敢改频次、也说不清收入的局面。

三种广告位,三种交易

广告位不是同一种商品。激励视频卖的是玩家主动让渡的一段时间,插屏卖的是场景切换处的注意力,Banner 卖的是界面上的常驻曝光。三者的触发者、用户意图和失败成本都不同:

广告位触发者玩家意图是否打断案例位置案例计价
激励视频玩家点击明确:用观看换奖励不打断,玩家自己选择消消、方舟eCPM ¥45 / ¥80
插屏场景切换弱:被动接受打断当前节奏消消eCPM ¥18
Banner常驻展示无:背景曝光不打断但持续占位消消案例未单列 eCPM

三者的经济含义也不同。激励视频的每一次展示都对应一个明确的交换承诺,完成率可以直接衡量承诺是否被兑现;插屏没有奖励,玩家接受它是因为「反正要等下一关」,一旦出现得太频繁,玩家会把等待本身归因于广告;Banner 的收入与停留时长相关,但它持续占用界面空间,在小屏幕上尤其容易挤压操作区。

因此,广告位设计的第一步不是排布,而是排除:哪些场景绝对不能放插屏,哪些界面没有空间放 Banner,哪些奖励不能由广告提供。把「不能放」写清楚,比把「可以放」列全更有价值。

场景与广告位的匹配

同样是「放广告」,不同场景的适配度差别很大。下面这张表是案例里实际采用的判断口径:

场景适合的广告位不适合的广告位原因
关卡失败后复活激励视频插屏失败情绪下打断会放大挫败感
关卡结算页插屏激励视频没有明确奖励,玩家不会主动点
主界面底部Banner激励视频没有主动意图,入口会被误触
体力不足激励视频插屏玩家有明确目标,交换关系清楚
首次启动无全部尚未建立游戏预期,广告只会增加流失

这张表里最容易被破坏的是「首次启动」。新玩家还没有形成「看广告能得什么」的认知,此时弹插屏或放 Banner,会把广告变成第一印象。消消把广告入口放在失败复活与体力不足两个有明确目标的场景,原因就在这里:玩家不是来看广告的,是来解决一个具体问题的。

另一个原则是「一个场景只放一种主广告位」。失败复活页如果既有激励视频按钮,又在背后弹插屏,玩家会觉得自己被设计了两次。广告位之间要互斥,至少要在同一时刻互斥。

激励视频的五条边界

激励视频看起来最简单:玩家点,广告播,发奖励。真正难的是边界。以下五条是案例里不可退让的口径:

  1. 必须由玩家主动触发。 自动弹出、把关闭按钮做成广告入口、倒计时结束自动播放,都不算激励视频。主动触发不是交互细节,而是这条广告合法性的来源。
  2. 奖励必须在播放前写清楚。 播完再随机决定给什么,会把「看广告」变成抽奖;玩家对不确定奖励的容忍度远低于对内购抽卡的容忍度。
  3. 奖励价值不能接近同档内购。 消消的主力内购档位是 ¥18;如果一次三十秒视频给出的资源接近这个档位,内购就会被广告补贴掉。广告奖励应该解决「差一点」的问题,而不是替代一次完整的付费。
  4. 频次要有上限和冷却。 上限保护留存与内购,冷却保护单次广告的注意力质量。没有上限的激励视频会把玩家训练成「只在有广告时玩」。
  5. 奖励以完成观看为准。 点击、展示、关闭都不是完成;只有广告 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 生命周期与填充失败兜底 。

常见错误与边界

  1. 把激励视频做成自动弹出。玩家没有主动选择,广告就从交换变成了打断;短期展示量上升,长期留存与内购一起下降。
  2. 播完才告诉玩家奖励是什么。不确定奖励会让玩家把广告等同于抽奖,投诉与放弃率都会上升。
  3. 奖励价值接近主力内购档位。消消主力档是 ¥18,一次视频给出接近这个档位的资源,内购会被自己的广告补贴掉。
  4. 只设冷却不设每日上限。冷却只能控制节奏,不能控制总量;没有上限,重度玩家会把广告当成主要资源来源。
  5. 把填充失败算作玩家已看。无填充是平台供给问题,不应该消耗玩家的当日额度,否则玩家会认为系统在骗他。
  6. 在同一场景叠加多种广告位。失败复活页既有激励按钮又弹插屏,玩家会感到被重复收割,客诉成本高于多出来的那一次曝光。
  7. 把 Banner 当成低干扰广告。Banner 不打断流程,但持续占用屏幕空间,在小屏设备上会挤压操作区,误触率并不低。
  8. 用广告位判定代替广告生命周期。判定通过只代表「允许请求」,不代表加载成功、展示完成或奖励发放成功。
  9. 把频次配置写死在客户端。不同渠道、版本、玩家分层的策略不同,写死之后每次调整都要发版,运营窗口被压缩。
  10. 只看广告流水,不看广告对留存的影响。广告收入的代价可能体现在次日的留存曲线上,单看当日流水会把透支当成增长。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查