In August 2026, my snake game had 8,500 monthly players and made $24 a month from ads. That's about a quarter of a cent per player.
It also had an interstitial click-through rate of 53%. If you've never run ads, that sounds like a dream. If you have, you just winced, because it means half my players were punching an ad in the face by accident.
So I did what every indie dev does eventually: opened the ad code and read every single line. It turns out I'd made almost every ad mistake in the book, all in one codebase.
I shipped the first round of fixes. The next month the same game made $54 from ads alone. Not a controlled experiment (player numbers move month to month), but more than double for the same game.
The game is Snake Classic, built in Flutter, on Google Play and the App Store. It's open source, so every rule in this post points at real code you can read, steal and judge.
TL;DR (for the speedrunners)
- Make rewarded ads the main character, and never grey out the "watch ad" button.
- Full-screen ads only at natural breaks. Never mid-run.
- Cap the frequency, then check how many players your cap actually reaches.
- Tell the player an ad is coming before they tap.
- Design out accidental clicks. A huge CTR is a bug, not a flex.
- Sell an ad-free option, and make "no ads" mean no ads.
- Preload one, retry with backoff, stop over-requesting.
- Consent and compliance, before Google makes it your problem.
- Measure the boring numbers. They tell you everything.
That's the whole post in nine lines. The stories are where the lessons actually stick, though, so grab a coffee. I've turned each rule into a level. Like any good game, it gets harder.
The map: every ad in the game
Before the levels, here's every ad Snake Classic shows and the rule that guards it. Notice what's missing: nothing ever plays while the snake is moving.
| Format | Where it shows | Guard |
|---|---|---|
| Rewarded | Revive after a crash, +30 s in Time Attack, 2× game-over coins, 2× daily bonus, free coins, free power-up, Battle Pass XP, tournament entry | The player taps to watch. No cap. The button never greys out. |
| Rewarded interstitial | Game over, after tapping Play Again or Menu | Same cadence as the interstitial. Always behind a skippable 5-second intro. Pays 25 coins. |
| Interstitial | Game over, after tapping Play Again or Menu, only when no rewarded interstitial has loaded | Every 2nd game over, 90 s since the last one, 2 min since any full-screen ad. Never on the first game over. |
| Banner | Most menu screens; during gameplay only for swipe players | Anchored adaptive, space reserved from the first frame, never next to on-screen controls. |
| App open | Coming back after 30+ minutes away | Never on cold start, never over gameplay, never after a purchase sheet, at most once per 30 min. |
| All of them | — | Pro subscribers see none. |
All of it hangs off one line in ad_service.dart:
/// The single master switch. Everything checks this.
bool get adsEnabled => _sdkReady && _isMobile && !_isPro && _canRequestAds;
Okay. Press start.
Level 1: Make rewarded ads the main character
Rewarded ads are the only ad a player asks for. That makes them the least annoying ad you can show, and also the one that pays best. Rare win-win.
- Players prefer them. In Tapjoy's survey of 2,615 US players, 79% preferred opt-in rewarded video to mandatory ads (PocketGamer.biz). In Unity's survey, 71% called video ads their preferred way to "pay" for in-game stuff (Adweek). Both companies sell rewarded ads, so take that with a pinch of salt, but the direction is clear.
- They pay the most. Playwire's 2025 AdMob benchmarks put rewarded video at $15–30 eCPM in Tier-1 countries, versus $0.50–1.50 for banners (Playwire). In Snake Classic, rewarded earns about 48× what a banner does.
Put them where the player wants something: you just crashed (revive, with a 6-second countdown), the Time Attack clock is dying (+30 s), you just earned coins (double them). And cap the reward, not the ad: +30 s is limited to 2 per run, so nobody watches their way to an infinite game.
The most expensive bug in the codebase
My "Watch ad" buttons greyed out whenever no rewarded ad was preloaded. Sounds responsible, right? Don't offer what you can't deliver.
Here's the problem. At real-world fill rates, the ad pool is empty a lot. So the button disappeared exactly when the player wanted it, the most valuable ad in the game went unshown, and the tap that would have started loading one never happened. I was hiding my best product whenever the shelf was empty, instead of restocking it.
Now every button stays tappable. The tap starts the load, and the player waits up to 4 seconds (simplified from the real code):
enum RewardedOutcome { rewarded, dismissedEarly, unavailable }
Future<RewardedOutcome> showRewardedOrWait({required VoidCallback onReward}) async {
if (!adsEnabled) return RewardedOutcome.unavailable;
if (_rewardedPool.isEmpty) {
_loadRewarded(); // the tap itself kicks off the load
await _waitForLoad(timeout: const Duration(seconds: 4));
if (_rewardedPool.isEmpty) return RewardedOutcome.unavailable;
}
final granted = await showRewarded(onReward: onReward);
return granted ? RewardedOutcome.rewarded : RewardedOutcome.dismissedEarly;
}
Why three outcomes instead of true/false? "No ad available" and "you closed it early" both mean no reward, but only the first one deserves a "try again shortly" message. Telling someone who chose to skip that something went wrong just reads like a bug.
The bug that made players hate me (probably)
The Flutter plugin sends "reward earned" and "ad dismissed" with no ordering guarantee, and the reward event regularly shows up after the dismiss. My old code read the reward flag once, at dismiss time. So: a player sits through a whole ad, Google happily pays me, and my game gives them...
...absolutely nothing.
The fix: wait 800 ms after dismissal before deciding, and grant rewards that arrive late. One unpaid reward costs you more trust than that ad ever earned.
Save point: Rewarded ads are your best ad. Keep the button tappable, let the tap load the ad, and always pay out.
Level 2: Full-screen ads only at natural breaks
This isn't just good manners. It's literally in the rules:
- Google Play: "Ads that appear during game play at the beginning of a level or during the beginning of a content segment are not allowed", and full-screen ads "that show unexpectedly, typically when the user has chosen to do something else, are not allowed" (Play Console Help).
- AdMob: "Interstitial ads should only be implemented at logical breaks in between your app's content (e.g. pages, stages, or levels)", and not on app load or exit (AdMob Help).
In a snake game, the natural break is obvious: you died. The run is over and you're staring at your score, deciding whether to try again.
So Snake Classic has exactly one place where it shows a full-screen ad on its own: the game-over screen, after the player taps Play Again or Menu. Not the instant the snake dies. The player sees their score, makes a choice, and then the ad plays.
Two guards keep it that way: a _fullScreenAdShowing flag so two full-screen ads can never stack, and setGameActive(true) from the game screen so an app open ad can never land on a live run.
App open ads: the most annoying ad I've ever shipped
Google allows app open ads on the loading screen and whenever the user switches back (AdMob Help). So I tried a loose setup: show one if you were away 45 seconds, at most every 4 minutes.
Picture it. You switch to WhatsApp to answer your mum, come back to finish your run, and an ad is sitting there. Your thumb, already on its way to the snake, taps it.
The data agreed: app open's click-through rate ran 17–30%, mostly players tapping at the game they'd just come back to. Total earnings? About $1.30 a month. For the most hated thing in the app.
Now it only greets a genuinely new session (simplified):
if (!wasBackground || suppressed) return; // cold start, or back from a purchase
if (_fullScreenAdShowing || _gameInProgress) return; // never over a live run
if (awayFor < const Duration(minutes: 30)) return; // quick app-switch? no ad
if (sinceLastAppOpen < const Duration(minutes: 30)) return;
if (sinceAnyFullScreenAd < const Duration(minutes: 2)) return;
My favourite line is that suppressed flag. Before the game opens a purchase sheet, it calls suppressNextAppOpen(). Otherwise the player who just paid you money gets greeted by an ad when the store sheet closes. Imagine tipping a waiter and getting slapped.
Save point: Pick one natural break and put your full-screen ad there. Everywhere else, leave the player alone.
Level 3: Cap the frequency (then do the maths)
AdMob sets the ceiling: "no more than one interstitial ad after every two user actions", and never "immediately after another interstitial ad was shown" (AdMob Help). Players set the floor: a 2025 Deloitte study for Google AdMob of 7,000 gamers found one disruptive ad raises churn 6–7%, and repeated exposure pushes 52% of gamers to quit entirely (Deloitte).
Snake Classic's whole cap is one pure function, GameOverAdGate. In plain English:
- A new player's first game over is free.
- After that, at most every 2nd game over.
- At least 90 s since the last game-over ad.
- At least 2 minutes since any full-screen ad, including a rewarded one they chose to watch.
Show the full GameOverAdGate function
static GameOverAdFormat decide({
required bool firstGameOverDone,
required int gamesSinceLastAd,
required int msSinceInterstitial,
required int msSinceAnyFullScreenAd,
required bool rewardedLoaded,
required bool interstitialLoaded,
required int everyNGames, // 2
required int minGapMs, // 90 s
required int fullScreenGapMs, // 2 min
}) {
// The very first game over of an install never interrupts.
if (!firstGameOverDone) return GameOverAdFormat.none;
// Cadence: the slot opens every N game overs.
if (gamesSinceLastAd + 1 < everyNGames) return GameOverAdFormat.none;
// No back-to-back full-screen ads, whatever kind the last one was.
if (msSinceInterstitial < minGapMs) return GameOverAdFormat.none;
if (msSinceAnyFullScreenAd < fullScreenGapMs) return GameOverAdFormat.none;
if (rewardedLoaded) return GameOverAdFormat.rewarded;
if (interstitialLoaded) return GameOverAdFormat.interstitial;
return GameOverAdFormat.none;
}
Because it's pure, the game-over screen can ask "what happens if they tap?" before the tap (that's Level 4), and it's trivial to unit test.
My cap was so polite it basically didn't exist
The cadence used to be every 4th game over. Add the free first one, and a player needed five lifetime games before an interstitial could fire even once.
Quick question: how many players play five games?
About 22%. So for four out of five installs, interstitials effectively didn't exist. Moving to every 2nd game over puts the first one on game three and reaches about half the player base, still inside AdMob's one-per-two-actions limit.
Before you pick a cap, look at how many games your players actually play. A cap only means something against that number.
Plot twist: the cap I was tuning wasn't the one in charge
The "2 minutes since any full-screen ad" rule used to be 5 minutes. And a revive is offered on nearly every crash. So one revive watch blocked the next interstitial for the rest of a typical session, no matter what the 90-second interstitial gap said.
I could have tuned that 90 seconds all day and changed nothing.
When you stack several limits, work out which one is really in charge before you touch any of them.
Save point: A cap protects players only if it's tuned to how they actually play. Too strict is just as broken as too loose.
Level 4: No surprise ads, ever
Apple's guideline 2.5.18 says interstitials "must clearly indicate that they are an ad, must not manipulate or trick users into tapping into them" (App Store Review Guidelines). Google Play bans full-screen ads that "show unexpectedly".
The cheapest way to never be unexpected? Say it first.
When Snake Classic's game-over screen builds, it asks the gate what the next tap will do, and shows one line above the buttons:
- "A short ad plays next" (plain interstitial)
- "Short ad next · +25 coins for watching" (rewarded interstitial, in gold)
Then the tap keeps that promise, even if the ad state changed in between:
// Decided once when the screen builds, shared by both buttons.
final announced = getIt<AdService>().peekGameOverAd();
// ...later, inside maybeShowGameOverAd(announced: announced)
var format = peekGameOverAd();
if (announced == GameOverAdFormat.none) {
format = GameOverAdFormat.none; // told "no ad" -> no ad, even if one just loaded
} else if (announced == GameOverAdFormat.rewarded &&
format == GameOverAdFormat.interstitial) {
format = GameOverAdFormat.none; // promised coins -> never swap in a plain ad
}
The way I put it in a code comment: an ad the player was warned about is a toll they agreed to. The same ad jumping out of a button labelled MENU is what one-star reviews are made of.
Rewarded interstitials need an intro screen, and the SDK won't draw it for you
A rewarded interstitial plays without an opt-in tap but pays a reward. AdMob only allows it behind an intro screen with "clear reward messaging", "a clear, unobstructed option to skip the ad before it starts", and "sufficient time" to decide (AdMob Help).
The SDK does not render that screen. Snake Classic shipped without one until September 2026. Oops.
The intro now (rewarded_interstitial_intro.dart) is a 5-second countdown with WATCH NOW and an equally big NO THANKS. Back gesture counts as a skip. The countdown pauses in the background, so it can't run out unseen. And a skip plays nothing in its place and uses up the slot, so the next game over doesn't nag again.
Why bother with this format at all? Same single interruption as a plain interstitial, but rewarded-tier eCPM, and the player walks away with 25 coins. It reads as a bonus instead of a toll.
Save point: Announce every ad before the tap, and keep the promise you announced.
Level 5: Design out accidental clicks
Remember that 53%? Here's the full crime scene:
| Format | Click-through rate |
|---|---|
| Interstitial | 12.8% (Jun) → 19.3% (Jul) → 53.2% (Aug) |
| Rewarded | 8% → 26% (Jun → Sep) |
| App open | 17–30% |
Here's what was actually happening:
The ad opened in the same instant as the tap that triggered it. New buttons (game-over bar, revive, +30 s) went live the moment they appeared, under a thumb that was still steering. Google Play's policy describes this exactly: ads that "suddenly appear in areas of the app where the user usually taps for another function" (Play Console Help). Google treats those clicks as invalid, and enough of them can get ad serving limited on your account.
A huge CTR on a full-screen ad isn't engagement. It's thumbs.
Four fixes:
1. Arm buttons late. A tiny TapArmGuard widget swallows taps for a moment after a button appears (900 ms on the game-over bar). It absorbs the tap, so it doesn't fall through to whatever is underneath either. The core of it is one line:
Widget build(BuildContext context) =>
AbsorbPointer(absorbing: !_armed, child: widget.child); // _armed flips after the delay
2. Curtain before every full-screen ad. An AdBreakCurtain covers the app with a tap-absorbing "Ad starting…" overlay for about 0.7 s between the press and the ad. The second half of a double-tap lands on the curtain, not on "Install now".
3. Banners away from controls. AdMob literally says "Close proximity of banner ads to other elements within an app is one of the biggest causes of accidental clicks" (AdMob Help). My gameplay banner sat 6 px under the d-pad. Six. Pixels. Now there's no gameplay banner at all with on-screen controls; swipe players get one at the bottom with a 12 px gap; the game-over banner gets 20 px.
4. Reserve banner space from the first frame. The banner's exact height is reserved before the SDK is even ready, so nothing shifts when the ad fills and no button slides under a thumb mid-tap.
Here's the whole fixed flow, from crash to ad and back:
The target now is full-screen CTR back in the single digits to low teens. If yours is way above that, you don't have amazing ads. You have an accidental-click problem.
Save point: Every ad should open at a moment no thumb is already moving.
Level 6: Sell the off switch, and make it total
Ads and in-app purchases aren't enemies. The ads are what make "no ads" worth buying. In Tapjoy's 2017 survey, 55% of players preferred freemium games and 21% fully ad-supported ones; only 14% preferred paying up front for an ad-free game (PocketGamer.biz). Give the free majority a fair ad experience, and the ad-haters a clean exit.
Snake Classic Pro (monthly or yearly) removes all ads, plus themes, skins, double coins and a free revive every game. The part people get wrong is making "no ads" mean no ads:
- One master switch includes
!_isPro, so every format obeys it. - Pro users reserve zero banner space. No sad empty strip where an ad used to live.
- Upgrade mid-session and the banner collapses immediately; a lapsed subscription turns ads back on.
- The revive overlay swaps "watch an ad" for "Get Life · Free for Pro". Paying players get the reward without the ad, not a disabled button.
If your remove-ads purchase still shows an app open ad, or leaves a banner-shaped hole in the layout, players notice. They paid to forget your ads exist.
Save point: The ad-free tier is a product. Make it feel like one.
Level 7: Preload one, retry smart, stop over-requesting
Preloading is non-negotiable; AdMob recommends it so an interstitial doesn't show up late, after the player has already moved on (AdMob Help). But over-preloading is a sneakier problem. September's numbers:
- Plain interstitial: 19,700 requests for 155 impressions. A 1% show rate. It was loaded every game alongside the rewarded interstitial, which almost always won.
- Rewarded, kept two-deep: fewer than 1 in 9 requested ads were ever shown. The second one mostly expired, unseen, on someone's mobile data.
Every wasted request costs your players data and battery. The fixes:
// Keep ONE rewarded ad warm. showRewardedOrWait() covers the gap.
static const int _rewardedBufferTarget = 1;
// Only fetch the plain interstitial if the rewarded one failed.
void preloadGameOverAd() {
_loadRewardedInterstitial();
if (_rewardedInterstitial == null && !_rewardedInterstitialLoading) {
_loadInterstitial();
}
}
Also: app open ads expire after 4 hours, and an expired one used to block new loads forever. One long lunch break and that format stayed dead until the app was killed. Now stale ads get dropped and replaced.
Retry no-fills with a long tail. Before this existed, one failed load left that format empty for the whole session:
static const List<Duration> _loadRetryDelays = [
Duration(seconds: 2), Duration(seconds: 10), Duration(seconds: 45),
Duration(minutes: 3), Duration(minutes: 10),
];
Fill often recovers minutes later, and a retry costs nothing when nothing is loaded.
Two more ways ad code can quietly break your game:
-
Gate on the device's internet, not your backend. My ad loads checked an
isOnlineflag that also required my own server to respond. A slow backend cold start silently killed every ad for the session. AdMob does not care about your API. - Never block the game on an ad. The loading screen used to wait up to 8 seconds for a rewarded fill. Now it's 2. In Snake Classic, 31% of installs never start a single game, so every second before the first run matters more than any ad.
Save point: Load what you'll show, retry what failed, and never let ads hold the game hostage.
Level 8: Consent and compliance (the boss nobody wants to fight)
None of this earns money directly. All of it protects the account that does.
Consent in the EEA and UK. Since January 16, 2024, publishers without a Google-certified consent platform only get Limited Ads on EEA and UK traffic (Google for Developers). Google's UMP SDK ships inside google_mobile_ads. Use it.
Fail closed. My old code assumed "ads allowed" whenever canRequestAds() threw an error. For an EEA player, that could mean serving ads with no consent on record. Now an error means no ads this session:
try {
_canRequestAds = await ConsentInformation.instance.canRequestAds();
} catch (e) {
_canRequestAds = false; // lose one session of ads, not the account
}
Also: only show the "Privacy & ad choices" settings button when UMP says it's needed (no buttons that open nothing), and translate your consent message. Mine (shared across six of my apps) gets a 60% consent rate, but it was English-only while Snake Classic ships in 9 languages. Whoops.
ATT on iOS. Apple's guideline 5.1.2 requires App Tracking Transparency permission to track, and you can't lock features behind it (App Store Review Guidelines). Ask once. Expect about a third to say yes: Adjust measured a 35% average opt-in in Q2 2025, with games among the best (sports 50%, hyper casual 43%, action 40%) (Adjust).
Test ads in debug. Always. "Publishers may not click on their own live ads, even for testing purposes" (Google AdMob blog). Snake Classic switches unit IDs on kDebugMode, so a debug build can't request a live ad:
static String get rewardedUnitId => kDebugMode
? (_isIos ? _testRewardedIos : _testRewardedAndroid)
: (_isIos ? _rewardedIos : _rewardedAndroid);
app-ads.txt. Since January 2025, AdMob requires a verified app-ads.txt for new apps, rolling out to existing apps through 2025, with restricted ad serving for apps that fail (PPC Land). Mine once failed AdMob's crawl; switching to a plain static file (served from ASP.NET Core's wwwroot) fixed it. Snake Classic is now at 99% authorized.
Save point: Boring compliance work is what keeps the money switched on.
Level 9: Measure the boring numbers
Here's the uncomfortable truth: every problem in this post was invisible in the AdMob revenue chart and obvious in one of these five numbers.
| Metric | What it catches | Snake Classic example |
|---|---|---|
| Reach: share of players who can ever see a format | Caps so strict the format is basically off | Only ~22% could ever see an interstitial |
| Show rate: impressions ÷ requests | Over-requesting, wasted data and battery | 155 impressions from 19,700 requests |
| Full-screen CTR | Accidental clicks | Interstitial CTR at 53.2% |
| eCPM by format | Where to spend your effort | Rewarded at ~48× banner |
| Revenue per format and placement | Placements that cost more goodwill than they earn | App open at ~$1.30 a month |
To get per-placement numbers, log every paid event with its format, plus impressions and rewarded completions or skips:
ad.onPaidEvent = (ad, valueMicros, precision, currencyCode) {
_analytics?.trackAdRevenue(
format: 'rewarded',
valueMicros: valueMicros,
currencyCode: currencyCode,
precision: precision.name,
);
};
Then change one thing at a time and give it a week or two of new players before judging it. Day-1 retention is a cohort number, and the first days after a release are mixed with your update crowd.
Mediation can wait. Mediation auctions each ad slot across several networks, but at low traffic there isn't enough volume for the auction to matter, and every adapter adds app size and another third-party SDK on launch. Snake Classic holds off until roughly 1,000+ daily players. Fix placement and frequency first. They're free, and they're yours.
Save point: Watch reach, show rate and CTR, not just revenue.
Game over: the checklist
You made it to the end. Here's your loot:
- Rewarded ads where the player wants something; the button never greys out.
- Full-screen ads only at a real break, never mid-run, never on launch.
- First game over free; then at most every 2nd break, 90 s+ between interstitials, 2 min+ between any full-screen ads.
- You've checked how many players your cap actually reaches.
- Every ad announced before the tap; rewarded interstitials get a skippable intro.
- Buttons that pop up under a moving thumb wait before accepting taps; a curtain covers the gap before full-screen ads.
- Banners keep their distance from controls, reserve their space, and stay off d-pad screens.
- One purchase removes every ad, gap and all.
- One ad of each format kept warm, failed loads retried with backoff, expired ads replaced.
- UMP consent that fails closed, ATT on iOS, test ads in debug, app-ads.txt verified.
- Paid events logged per format; reach, show rate and full-screen CTR on your dashboard.
Everything is open source: theprantadutta/snake_classic. Start with lib/services/ads/ and admob_ad_list.md, where every cap has its reasoning written right next to it. And if you want to see the result (and maybe watch an ad or two, I won't complain), it's on Google Play and the App Store.
Now your turn: what does your ad cap look like, and what's your full-screen CTR? Drop it in the comments. No judgment. I had 53%.
Sources
- Deloitte & Google AdMob: Quality Drives Value, a look into mobile gaming ads (2025)
- Google Play Console Help: Ads policy
- AdMob Help: Disallowed interstitial implementations
- AdMob Help: Discouraged banner implementations
- AdMob Help: Rewarded interstitial ad units
- AdMob Help: App open ad guidance and best practices
- Apple: App Store Review Guidelines (2.5.18, 5.1.2)
- Google for Developers: Disclose to EEA users (Flutter)
- Google AdMob blog: The importance of test ads in your app
- PPC Land: New AdMob policy requires app-ads.txt from January 2025
- Adjust: ATT opt-in rates 2025
- Adweek: Unity survey, 71% of players prefer video ads to pay for in-game content
- PocketGamer.biz: Tapjoy Modern Mobile Gamer report
- Playwire: AdMob eCPM benchmarks (2025)
- Snake Classic numbers: AdMob console and Firebase Analytics, August–September 2026, documented in
admob_ad_list.md






Top comments (0)