Building Nova Drop · Part 5 of 6
Monetizing a relaxing game without wrecking the vibe: AdMob & IAP in Flutter
TL;DR — Nova Drop is free, and it's supposed to be relaxing — so the monetization can't nag. The rules: rewarded ads are the primary format and always opt-in; interstitials are rare and never appear in your first runs; a one-tap IAP removes them forever; and ads never block the game if the network or SDK is missing. Plus a war story: a one-line format mismatch shipped a dead "watch a video" button — exactly the kind of thing Apple rejects under guideline 2.1.
Casual players will forgive an ad they chose to watch and resent one they didn't. The whole design here follows from that. Everything runs through a single AdsService with two ad objects, a pile of pacing guards, and one hard rule stated at the top of the file: without network or SDK, the game plays identically. Ads are a bonus layer, never a dependency.
Rewarded-first, and always a real choice
The primary format is the rewarded ad: you tap "watch a video," you get something concrete — a recharge of the special-moves energy pool (half the max, 600 credits) or a second chance. The in-game offer only even appears when it makes sense: you're actually playing, your balance is low, you're not mid-aim, and you're within the per-run limit and off cooldown.
bool get energyAdAvailable =>
phase == GamePhase.playing &&
energyAdsUsed < GameConstants.energyAdMaxPerRun &&
_energyAdCooldown <= 0 &&
credits < GameConstants.energyMax * GameConstants.energyAdThreshold;
Showing one returns a clean bool — if there's no ad ready, it returns false and the caller carries on offline; the reward callback fires only on an actual completion:
bool showRewarded(VoidCallback onReward,
{VoidCallback? onDismissed, String placement = 'unknown'}) {
final ad = _rewarded;
if (ad == null) { _loadRewarded(); return false; } // offline-safe
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) { ad.dispose(); _rewarded = null; _loadRewarded(); onDismissed?.call(); },
onAdFailedToShowFullScreenContent: (ad, _) { ad.dispose(); _rewarded = null; _loadRewarded(); onDismissed?.call(); },
);
_rewarded = null;
ad.show(onUserEarnedReward: (_, _) { // reward only on completion
AnalyticsService.instance.logRewardedWatched(placement: placement);
onReward();
});
return true;
}
The placement string ("second_chance", "energy_recharge", …) is just there so analytics can tell which prompt actually earns watches — useful for tuning where the opt-in offer appears.
The war story: a dead "watch a video" button
Here's a bug worth the price of the whole post. In AdMob, "Rewarded" and "Rewarded Interstitial" are different ad formats, and the Flutter SDK has a different class for each: RewardedAd vs RewardedInterstitialAd. Nova Drop's units were created as Rewarded Interstitial, but the code loaded them with RewardedAd. The result: every load failed with "Ad unit doesn't match format," _rewarded stayed null forever, and the "watch a video" button silently did nothing. A live release shipped like that.
That's not just lost revenue — it's an App Store rejection risk under guideline 2.1 (a non-functional feature). The fix was to load the correct class:
// NOTE: Nova Drop's "rewarded" units are *Rewarded Interstitial* format.
// Loading them as RewardedAd → "Ad unit doesn't match format" → never loads.
RewardedInterstitialAd? _rewarded;
RewardedInterstitialAd.load(
adUnitId: fallbackTest ? _testRewardedId : _rewardedId,
request: _adRequest,
rewardedInterstitialAdLoadCallback: RewardedInterstitialAdLoadCallback(
onAdLoaded: (ad) => _rewarded = ad,
onAdFailedToLoad: (e) { _rewarded = null; if (!fallbackTest) _loadRewarded(fallbackTest: true); },
),
);
Two lessons baked into that snippet. First, the format on the AdMob unit and the SDK class must match exactly — verify it, don't assume. Second, note the fallback: if the real (revenue) unit fails to fill, it falls back once to Google's test Rewarded-Interstitial unit (which always fills). That guarantees "watch a video" is never inert — protecting against the 2.1 rejection — and the moment the real unit fills again, it monetizes on its own.
Interstitials: rare, paced, and never up front
Interstitials are the format players hate, so they're throttled hard. They never show during a grace period of your first runs, then only after a minimum number of runs and a minimum wall-clock gap since the last one. The guard reads like a list of promises:
void maybeShowInterstitial() {
final s = Storage.instance;
if (s.adsRemoved || !_initialized) return;
if (s.gamesPlayed <= GameConstants.gracePeriodRuns) return; // not in early runs
s.runsSinceInterstitial++;
if (s.runsSinceInterstitial < GameConstants.minRunsBetweenInterstitials) return;
if (now - s.lastInterstitialMs < GameConstants.minSecondsBetweenInterstitials * 1000) return;
// …show, then reset both counters…
}
First impressions matter most for retention on a casual title, so the grace period is non-negotiable: a new player should fall in love before they ever see a full-screen ad.
Remove Ads: the IAP, and a deliberate asymmetry
A single non-consumable IAP (via in_app_purchase) removes ads. The interesting design choice is the asymmetry: buying it kills interstitials forever, but rewarded ads stay available — because a rewarded ad is always the player's choice in exchange for a bonus, and removing that option would punish the paying customer. The intent is stated right where it's enforced:
Future<void> init() async {
if (Storage.instance.adsRemoved) {
// Rewarded ads stay available even after remove-ads: they're always an
// opt-in choice for a bonus. Interstitials, on the other hand, are gone forever.
}
// …
}
Consent (UMP): request the right ads, don't block the game
GDPR/UMP is where a lot of apps quietly break: a misconfigured consent form leaves ads unable to load and the developer staring at "requests yes, impressions no." Nova Drop's rule is that consent shapes the request, it never gates it. If consent isn't available, ads are still requested — just non-personalized (npa=1):
AdRequest get _adRequest => _canRequestAds
? const AdRequest()
: const AdRequest(extras: {'npa': '1'}); // no consent → non-personalized, still served
Blocking init() on consent was the original sin that left the rewarded ad unloaded and the "watch a video" button dead in the EEA — the same 2.1 trap from a different direction. UMP ships inside google_mobile_ads (no extra SDK), and it also drives iOS ATT: there's no manual requestTrackingAuthorization call, just the UMP flow plus an NSUserTrackingUsageDescription in Info.plist. Three messages are configured — GDPR (Europe), US state regulations, and ATT (iOS) — in every language the game supports.
The takeaway
Monetizing a relaxing game is mostly restraint expressed in code: rewarded-first and opt-in, interstitials paced behind a grace period, an IAP that removes the annoying format but keeps the helpful one, and consent that shapes requests instead of blocking them. And two hard-won operational rules: the AdMob unit format must match the SDK class exactly, and a "watch a video" button must never be able to do nothing — guard it with a test-unit fallback so it always works, online or off. Last part: shipping all of this in 18 languages.
The series
- Building a physics merge puzzle in pure Dart — no engine, no Box2D
- Making merges feel oddly satisfying: juice on one CustomPainter
- Beyond Suika: cosmic storms and a hand-rolled obstacle system
- An infinite Odyssey: a star map, 200 levels and a hand-painted creation myth
- Monetizing a relaxing game without wrecking the vibe: AdMob & IAP
- Shipping Nova Drop in 18 languages with Flutter
Nova Drop is a relaxing cosmic merge puzzle — drop cosmic bodies, chain reactions and chase the Supernova. Out now, free on iOS and Android.
Where to find Nova Drop
- Official site: novadrop.nurale.games
- iOS: App Store · Android: Google Play