Analyzes a Flutter Flame game loop to place rewarded ads, guides AdMob ad-unit creation, and injects google_mobile_ads with ATT/UMP consent code.
How this skill is triggered — by the user, by Claude, or both
Slash command
/flutter-flame-harness:flame-harness-admobThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Phase 7 of the flutter-flame-harness pipeline. Analyzes the game's loop, decides where rewarded
Phase 7 of the flutter-flame-harness pipeline. Analyzes the game's loop, decides where rewarded
ads fit naturally, guides the user through manual ad-unit creation in the AdMob console, then
injects google_mobile_ads, iOS ATT, and UMP consent code.
All file schemas (config.md, state.md, pipeline-log.md) and the phase transition table are
defined in docs/harness-protocol.md — that document is the single source of truth (§1 for
config.md including the admob: block; §2 for state.md; §6 for log schemas; §7 for the
admob → build transition and the skip_admob branch rule). Do not redefine schemas here.
Before any action, load:
docs/harness/config.md — extract app_slug, app_name, and skip_admob (bool).docs/harness/state.md — confirm next_role: admob.docs/harness/prd.md — read the game loop, monetization intent, and any explicit
ad placement requests.Check skip_admob from docs/harness/config.md immediately after loading inputs.
If skip_admob: true:
Write docs/harness/config.md with admob.enabled: false (per protocol §1 admob: block).
Write docs/harness/state.md with:
status: running
current_phase: admob
next_role: build
updated_at: "<ISO-8601 UTC now>"
Append to docs/harness/pipeline-log.md (per protocol §6):
| <ISO-8601 UTC now> | complete | admob | skip_admob=true; AdMob skipped; next: build |
Exit immediately. Do not ask questions, do not inject code.
Read the PRD and game loop carefully. The goal is rewarded ads that feel like a natural reward mechanic, not an interruption.
Choose placements that match the PRD's described game loop. Document the chosen placements
explicitly — a fresh Claude running flame-harness-generator must know exactly where to add the
RewardedAdHelper.show() call.
Do not add interstitial ads unless the PRD explicitly requests them. Interstitials between levels or at session start hurt retention and risk App Store policy violations. Rewarded ads are opt-in and always preferred.
Add a banner ad only if the PRD explicitly requests one. If a banner is used, apply the SafeArea
banner gap pattern: wrap the game canvas in a Column with a SafeArea-padded BannerAdWidget
at the bottom so the ad never overlaps game content.
Google's API cannot create AdMob ad units programmatically. The user must create them manually in the AdMob console. Follow these steps:
Use AskUserQuestion to ask the user:
"Please open the AdMob console (https://admob.google.com) and:
- Add your iOS app → copy the iOS App ID (format:
ca-app-pub-XXXX~YYYY).- Add your Android app → copy the Android App ID (format:
ca-app-pub-XXXX~ZZZZ).Paste both IDs here. If you haven't done this yet, type 'defer' and I'll pause."
If the user types defer or says they haven't done it yet, write docs/harness/state.md with:
status: paused
current_phase: admob
next_role: admob
pause_reason: manual_action
updated_at: "<ISO-8601 UTC now>"
Then stop. The harness will resume when the user runs flame-harness-resume.
For each rewarded placement identified in the Strategy section, use AskUserQuestion to ask the
user to create a rewarded ad unit in the AdMob console and paste back both the iOS and Android
unit IDs. Example for a "revive" unit:
"In the AdMob console, under your app → Ad units → Add ad unit → Rewarded:
- Unit name:
<app_slug>_rewarded_revive- Copy the iOS unit ID (format:
ca-app-pub-XXXX/IIII) and the Android unit ID (format:ca-app-pub-XXXX/AAAA).Paste both here. Type 'defer' to pause."
Again apply the same pause_reason: manual_action pause if the user defers.
Once all IDs are collected, write the admob: block in docs/harness/config.md
(per protocol §1):
admob:
enabled: true
ios_app_id: "ca-app-pub-XXXX~YYYY"
android_app_id: "ca-app-pub-XXXX~ZZZZ"
ad_units:
- key: rewarded_revive
ios_id: "ca-app-pub-XXXX/IIII"
android_id: "ca-app-pub-XXXX/AAAA"
format: rewarded
# add one entry per placement
After collecting IDs, inject the integration code into the Flutter project at
<projects-dir>/<app_slug>/.
dependencies:
google_mobile_ads: ^5.1.0
app_tracking_transparency: ^2.0.6
Run flutter pub get after editing.
Add to ios/Runner/Info.plist:
<key>NSUserTrackingUsageDescription</key>
<string>광고를 개인화하고 앱 개선을 위해 사용합니다.</string>
Add the iOS AdMob App ID:
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-XXXX~YYYY</string>
Also add SKAdNetworkItems (Google's SKAdNetwork IDs from the official google_mobile_ads docs) to
Info.plist — required for an ads build to pass App Store review (see docs/game-gotchas.md).
Set minSdk = 23 in android/app/build.gradle.kts — google_mobile_ads requires API 23+ and a
lower value crashes at startup (docs/game-gotchas.md). Then add to
android/app/src/main/AndroidManifest.xml inside <application>:
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-XXXX~ZZZZ"/>
Create lib/admob/att_helper.dart:
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:app_tracking_transparency/app_tracking_transparency.dart';
/// Requests iOS App Tracking Transparency permission. No-op on Android.
///
/// CRITICAL — App Store Guideline 2.1: the ATT system dialog ONLY presents
/// while the app is active/foregrounded. On a fresh launch the app may not be
/// `resumed` yet, so requesting too early silently no-ops and the prompt never
/// appears — the exact cause of the "NSUserTrackingUsageDescription present but
/// the ATT alert never appears" 2.1 rejection on the latest iOS. So: wait until
/// resumed, add a small settle delay, and only request when notDetermined.
Future<void> requestATT() async {
if (!Platform.isIOS) return;
try {
await _waitUntilResumed();
final status = await AppTrackingTransparency.trackingAuthorizationStatus;
if (status == TrackingStatus.notDetermined) {
await Future<void>.delayed(const Duration(milliseconds: 400));
await AppTrackingTransparency.requestTrackingAuthorization();
}
} catch (e) {
debugPrint('ATT request failed: $e');
}
}
/// Wait (up to ~4s) for the app to reach the foreground/active state, which is
/// required for the ATT prompt to actually present.
Future<void> _waitUntilResumed() async {
for (var i = 0; i < 40; i++) {
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
return;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
Call requestATT() from a post-first-frame callback (WidgetsBinding.instance.addPostFrameCallback), and await it before loadConsentForm() and MobileAds.instance.initialize() so the ATT decision precedes any ad/tracking request (see §5). Never call it synchronously in main() before the first frame — that is what triggers the 2.1 rejection.
Create lib/admob/consent_helper.dart:
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
/// Loads and (if required) shows the UMP consent form.
/// Returns only after the consent callback fires — ads must NOT be
/// requested before this Future completes.
Future<void> loadConsentForm() async {
final completer = Completer<void>();
final params = ConsentRequestParameters();
ConsentInformation.instance.requestConsentInfoUpdate(
params,
() async {
if (await ConsentInformation.instance.isConsentFormAvailable()) {
await ConsentForm.loadAndShowConsentFormIfRequired((_) {});
}
if (!completer.isCompleted) completer.complete();
},
(FormError error) {
debugPrint('UMP consent error: ${error.message}');
if (!completer.isCompleted) completer.complete(); // non-fatal, continue
},
);
return completer.future;
}
Important: ads must NOT be requested before
loadConsentForm()completes.
Use this startup order in main():
WidgetsFlutterBinding.ensureInitialized();
await requestATT(); // iOS only — no-op on Android
await loadConsentForm(); // waits for UMP consent decision
await MobileAds.instance.initialize();
// preload ads / runApp after this point
Create lib/admob/ad_ids.dart. Use Google's official test IDs in debug builds to avoid
policy violations; switch to real IDs in release:
import 'dart:io';
const bool _isDebug = bool.fromEnvironment('dart.vm.product') == false;
class AdIds {
// ── Rewarded: revive ────────────────────────────────────────────────────
static String get rewardedRevive {
if (_isDebug) {
return Platform.isIOS
? 'ca-app-pub-3940256099942544/1712485313' // Google iOS test
: 'ca-app-pub-3940256099942544/5224354917'; // Google Android test
}
return Platform.isIOS
? 'ca-app-pub-XXXX/IIII' // from config.md admob.ad_units[rewarded_revive].ios_id
: 'ca-app-pub-XXXX/AAAA'; // from config.md admob.ad_units[rewarded_revive].android_id
}
// Add one getter per ad unit.
}
Replace the placeholder strings with the IDs collected from the user.
Create lib/admob/rewarded_ad_helper.dart:
import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
/// Loads and shows a rewarded ad for a single placement.
/// Pass the ad unit ID from [AdIds] at construction time so the same
/// class can be reused for every placement (revive, bonus coins, etc.)
/// without subclassing.
///
/// Example:
/// final _reviveAd = RewardedAdHelper(AdIds.rewardedRevive);
/// final _bonusAd = RewardedAdHelper(AdIds.rewardedBonus);
///
/// [onRewarded] is called when the user earns the reward.
/// [onDismissed] is called when the ad is dismissed (with or without reward).
class RewardedAdHelper {
RewardedAdHelper(this.adUnitId);
final String adUnitId;
RewardedAd? _ad;
Future<void> load() async {
await RewardedAd.load(
adUnitId: adUnitId,
request: const AdRequest(),
rewardedAdLoadCallback: RewardedAdLoadCallback(
onAdLoaded: (ad) => _ad = ad,
onAdFailedToLoad: (error) {
debugPrint('RewardedAd failed to load: $error');
_ad = null;
},
),
);
}
/// Returns true if an ad is ready.
bool get isLoaded => _ad != null;
/// Shows the ad. [onRewarded] fires when the user earns the reward.
void show({
required VoidCallback onRewarded,
VoidCallback? onDismissed,
}) {
final ad = _ad;
if (ad == null) {
onDismissed?.call();
return;
}
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
ad.dispose();
_ad = null;
onDismissed?.call();
},
onAdFailedToShowFullScreenContent: (ad, error) {
ad.dispose();
_ad = null;
onDismissed?.call();
},
);
ad.show(onUserEarnedReward: (_, __) => onRewarded());
}
void dispose() {
_ad?.dispose();
_ad = null;
}
}
In the game-over or revive overlay (identified in the Strategy section), call:
if (_rewardedAdHelper.isLoaded) {
_rewardedAdHelper.show(
onRewarded: () {
// Grant the reward: restore health, give extra life, double coins, etc.
},
onDismissed: () {
// User skipped ad — proceed to normal game-over flow.
},
);
}
Pre-load the ad when the game session starts so it is ready when needed.
Instantiate one RewardedAdHelper per placement, passing the matching AdIds getter:
_reviveAdHelper = RewardedAdHelper(AdIds.rewardedRevive);
await _reviveAdHelper.load();
// Add one line per placement (e.g. RewardedAdHelper(AdIds.rewardedBonus))
After completing code injection and confirming flutter analyze returns 0 issues:
Ensure docs/harness/config.md admob: block is complete and accurate (see Step 3 above under
Manual Unit Creation). Per protocol §1, the enabled field must be true when ads are injected.
Write docs/harness/state.md atomically (per protocol §7 admob → build transition and §7
rule 2 — set status: running in the same write):
status: running
current_phase: admob
next_role: build
updated_at: "<ISO-8601 UTC now>"
Leave current_round, created_at, resume_attempts, and pause_reason unchanged.
Append one row to docs/harness/pipeline-log.md (per protocol §6):
| <ISO-8601 UTC now> | complete | admob | rewarded ad injected; ATT+UMP wired; next: build |
flutter analyze fails after code injection, fix all issues before writing state.md.pause_reason: manual_action and explain what is needed for resume.app_tracking_transparency or google_mobile_ads pub.dev packages are unavailable,
check the current package name with flutter pub search google_mobile_ads and update the
version accordingly.npx claudepluginhub tjdrhs90/flutter-flame-harness --plugin flutter-flame-harnessBuilds a Flame game in three gated sub-phases (scaffold/core loop, systems/components, UI/content) with hard gates and round-based feedback intake for iterative fixes.
Measures ad effectiveness with privacy-preserving attribution using AdAttributionKit for iOS 17.4+ / Swift 6.3. Use when registering ad impressions, handling postbacks, updating conversion values, implementing re-engagement, or configuring publisher/advertiser apps.
Manages Google Play, App Store Connect, Firebase, and AdMob releases and operations via mimi-seed MCP in Claude Code. Includes deferred tool loading and safety rules for irreversible actions.