Boolean Build

“Multiple consecutive ads”: designing ad frequency that survives App Review

The rejection was two sentences long:

“There are issues with the app's user interface that contribute to a lower-quality user experience than App Store users expect. Specifically, the app displays excessive ads in a manner that negatively impacts the user's experience and displays multiple consecutive ads.”

No screenshot, no timestamp, no reproduction steps. Just a claim that was, unfortunately, true.

The shape of the bug

The game — Byte-Sized Tycoon, an idle tycoon with four prestige paths — had four places where a run could end: a hard reset, a prestige reset, a second prestige path, and a “breakthrough” confirmation. Each one did this:

const interstitialAd = interstitialAdRef.current;
if (interstitialAd && interstitialAdLoaded) {
  interstitialAd.show();
}

Four independent call sites, no shared state, and each one individually correct. The problem is what happens when two of them fire close together: prestige, then breakthrough a few seconds later, and the player gets two full-screen ads back to back. Add a rewarded video to the mix and you can produce three.

This is the classic multi-call-site bug. Nothing is broken; the system as a whole has no opinion about frequency.

Put the policy in one place

Ads should not be triggered by screens. They should be requested from a policy object that owns the decision, with a clock you can inject so the rules are testable:

export interface AdPolicyState {
  sessionStartedMs: number;
  lastInterstitialMs: number | null;
  lastRewardedMs: number | null;
  interstitialsShown: number;
}

// no ads at launch
export const SESSION_GRACE_MS = 90_000;
// at most one interstitial every 3 minutes
export const MIN_INTERSTITIAL_INTERVAL_MS = 180_000;
// never right after a rewarded video
export const MIN_GAP_AFTER_REWARDED_MS = 120_000;
export const MAX_INTERSTITIALS_PER_SESSION = 3;

export function canShowInterstitial(state: AdPolicyState, now: number) {
  if (state.interstitialsShown >= MAX_INTERSTITIALS_PER_SESSION) {
    return { allowed: false, reason: 'session-cap' as const };
  }
  if (now - state.sessionStartedMs < SESSION_GRACE_MS) {
    return { allowed: false, reason: 'session-grace' as const };
  }
  if (state.lastInterstitialMs !== null
      && now - state.lastInterstitialMs < MIN_INTERSTITIAL_INTERVAL_MS) {
    return { allowed: false, reason: 'too-soon' as const };
  }
  if (state.lastRewardedMs !== null
      && now - state.lastRewardedMs < MIN_GAP_AFTER_REWARDED_MS) {
    return { allowed: false, reason: 'after-rewarded' as const };
  }
  return { allowed: true };
}

Two rules beyond frequency matter:

Make the call sites unable to bypass it

Replace every direct show() with a single function, and keep the loaded flag in a ref so stale closures cannot lie to you:

const showInterstitialNow = useCallback(() => {
  const ad = interstitialAdRef.current;
  if (!ad || !interstitialLoadedRef.current) return false;

  const now = Date.now();
  if (!canShowInterstitial(adPolicyRef.current, now).allowed) return false;

  adPolicyRef.current = recordInterstitial(adPolicyRef.current, now);
  ad.show();
  return true;
}, []);

And record rewarded views, because “the player just watched a video on purpose” is the strongest possible reason not to interrupt them again:

ad.addAdEventListener(RewardedAdEventType.EARNED_REWARD, () => {
  adPolicyRef.current = recordRewarded(adPolicyRef.current, Date.now());
  grantReward();
});

Test the exact thing that got rejected

The whole point of a pure policy module is that the rejection case becomes a unit test:

test('two resets seconds apart cannot produce two ads', () => {
  let s = createAdPolicy(T0);
  const firstReset = T0 + SESSION_GRACE_MS;

  assert.equal(canShowInterstitial(s, firstReset).allowed, true);
  s = recordInterstitial(s, firstReset);

  assert.equal(canShowInterstitial(s, firstReset + 4_000).allowed, false);
});

Six tests cover the rules: grace, spacing, post-reward gap, session cap, immutability, and the consecutive-reset case. They run in milliseconds and they encode a promise you made to a reviewer.

Replying to App Review

Vague rejections invite vague replies. Be specific, describe behaviour rather than intentions, and name the build:

Build 9 adds a single ad-frequency policy that every interstitial passes through: no interstitial during the first 90 seconds of a session; at most one every 3 minutes; none within 2 minutes of a rewarded ad the player chose to watch; at most 3 per session; and none on the player's first reset. Interstitials now only appear at reset/prestige moments — never during gameplay, on launch, or over another ad.

What we would do differently

Frequency policy should exist on day one, not after a rejection. It is a twenty-line module and a handful of tests, and it is the difference between “the app shows ads” and “the app shows too many”. If a game cannot justify interstitials under those rules, that is a revenue problem to solve with rewarded video — not a reason to remove the rules.