Skill v1.0.0
currentAutomated scan100/100name: flame-harness-admob description: Phase 7 — analyze the game, decide a rewarded-ad strategy, guide manual AdMob ad-unit creation, and inject google_mobile_ads + ATT/UMP consent code. argument-hint: "" allowed-tools: [Agent, Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion] metadata: version: "1.0.0"
flame-harness-admob
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.
Input — Read Inputs
Before any action, load:
docs/harness/config.md— extractapp_slug,app_name, andskip_admob(bool).docs/harness/state.md— confirmnext_role: admob.- PRD at
docs/harness/prd.md— read the game loop, monetization intent, and any explicit
ad placement requests.
Skip Flag — skip_admob Handling
Check skip_admob from docs/harness/config.md immediately after loading inputs.
If skip_admob: true:
- Write
docs/harness/config.mdwithadmob.enabled: false(per protocol §1admob:block). - Write
docs/harness/state.mdwith:
``yaml status: running current_phase: admob next_role: build updated_at: "<ISO-8601 UTC now>" ``
- Append to
docs/harness/pipeline-log.md(per protocol §6):
```
```
- Exit immediately. Do not ask questions, do not inject code.
Strategy — Rewarded Ad Placement
Read the PRD and game loop carefully. The goal is rewarded ads that feel like a natural reward mechanic, not an interruption.
Rewarded Ad Placement Principles
- Revive / continue: Offer a rewarded ad when the player dies or fails — "Watch an ad to
continue?". This is the highest-converting rewarded placement for Flame games.
- Double coins / bonus: After a session ends, offer a rewarded ad to double the score or
coins earned.
- Extra life / shield: Mid-game offer for an extra life when health is critical.
- Unlock hint / skip: In puzzle games, offer a rewarded ad to reveal a hint or skip a level.
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.
No Intrusive Interstitials
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.
Banner Ads (conditional)
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.
Manual Unit Creation — AdMob Console
Google's API cannot create AdMob ad units programmatically. The user must create them manually in the AdMob console. Follow these steps:
Step 1 — Collect App IDs
Use AskUserQuestion to ask the user:
"Please open the AdMob console (https://admob.google.com) and:1. Add your iOS app → copy the iOS App ID (format:ca-app-pub-XXXX~YYYY).2. 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: pausedcurrent_phase: admobnext_role: admobpause_reason: manual_actionupdated_at: "<ISO-8601 UTC now>"
Then stop. The harness will resume when the user runs flame-harness-resume.
Step 2 — Collect Ad Unit IDs
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 theAndroid 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.
Step 3 — Write config.md admob block
Once all IDs are collected, write the admob: block in docs/harness/config.md (per protocol §1):
admob:enabled: trueios_app_id: "ca-app-pub-XXXX~YYYY"android_app_id: "ca-app-pub-XXXX~ZZZZ"ad_units:- key: rewarded_reviveios_id: "ca-app-pub-XXXX/IIII"android_id: "ca-app-pub-XXXX/AAAA"format: rewarded# add one entry per placement
Code Injection
After collecting IDs, inject the integration code into the Flutter project at <projects-dir>/<app_slug>/.
1. pubspec.yaml — Add Dependencies
dependencies:google_mobile_ads: ^5.1.0app_tracking_transparency: ^2.0.6
Run flutter pub get after editing.
2. iOS ATT Permission — Info.plist (localize for EVERY configured locale)
Read default_language from docs/harness/config.md. The configured App Store localizations are default_language plus English when `default_language ≠ en` — the same set as the l10n Hard Gate. The ATT prompt string must exist in every one of them, or you get a repeat rejection: a reviewer (or user) in another locale sees a foreign-language permission prompt.
Base string — add NSUserTrackingUsageDescription to ios/Runner/Info.plist in the app's default_language (this is the development-region fallback — NOT hard-coded to any one language):
<key>NSUserTrackingUsageDescription</key><string><ATT reason, written in default_language></string>
Localize it per locale. The generator already created a ios/Runner/<locale>.lproj/InfoPlist.strings for each configured locale (for CFBundleDisplayName, generator §5c.9) and registered them in the Xcode project. Add the ATT key to each of those files — reuse them; create+register any missing one:
ios/Runner/en.lproj/InfoPlist.strings:
"NSUserTrackingUsageDescription" = "We use this to personalize ads and improve the app.";
ios/Runner/ko.lproj/InfoPlist.strings:
"NSUserTrackingUsageDescription" = "광고를 개인화하고 앱 개선을 위해 사용합니다.";
For any other configured locale, add the same key with a natural translation. Keep the wording truthful to what the app does and consistent with the App Privacy / tracking declaration.
The baseInfo.plistvalue is the fallback; the matching<locale>.lproj/InfoPlist.stringsentry overrides it per language. Verify before build: every configured locale'sInfoPlist.stringscontains aNSUserTrackingUsageDescriptionline —for d in ios/Runner/*.lproj; do grep -q NSUserTrackingUsageDescription "$d/InfoPlist.strings" || echo "MISSING ATT string: $d"; done.
Add the iOS AdMob App ID:
<key>GADApplicationIdentifier</key><string>ca-app-pub-XXXX~YYYY</string>
Also add an `SKAdNetworkItems` array to Info.plist — required manual step (the SDK does not inject it). Without it iOS install attribution breaks, and it's an expected config for an ads app at App Store review. At minimum include Google's own ID:
<key>SKAdNetworkItems</key><array><dict><key>SKAdNetworkIdentifier</key><string>cstr6suwn9.skadnetwork</string></dict><!-- + third-party buyer IDs (see below) --></array>
AdMob's exchange serves third-party demand even without mediation, so copy the full current list from Google's docs — it changes over time, so fetch the latest at build time rather than hard-coding an old copy:
- Quick-start Info.plist snippet: <https://developers.google.com/admob/ios/quick-start>
- Full third-party SKAdNetwork IDs: <https://developers.google.com/admob/ios/3p-skadnetworks>
(See docs/game-gotchas.md → Build/platform.)
3. Android — AndroidManifest.xml + minSdk
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-dataandroid:name="com.google.android.gms.ads.APPLICATION_ID"android:value="ca-app-pub-XXXX~ZZZZ"/>
4. ATT Request — lib/admob/att_helper.dart
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() after loadConsentForm() returns and before MobileAds.instance.initialize() (see §5). Never call it synchronously in main() before the first frame — that is what triggers the 2.1 rejection.
5. UMP Consent Flow — lib/admob/consent_helper.dart
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 beforeloadConsentForm()completes.
Use this startup order in main():
WidgetsFlutterBinding.ensureInitialized();await loadConsentForm(); // UMP 동의 폼. ATT 보다 먼저다await requestATT(); // iOS only — no-op on Androidawait MobileAds.instance.initialize();// preload ads / runApp after this point
순서를 뒤집지 말 것. AdMob 콘솔에서 만드는 ATT 안내 메시지는 "다음화면에서 '허용'을 탭하세요" 라고 말한다. ATT 를 먼저 띄우면 그 안내가 뒤에나와 앞뒤가 안 맞고, 추적 허용률이 떨어진다. 허용률은 곧 IDFA 확보율이고eCPM 이다.그리고 **loadConsentForm()의 완료 콜백이 두 경로(폼 종료 / 정보 갱신실패)에서 불린다는 점**을 기억할 것. 위 구현은completer로 한 번만완료되게 막아 두었다. 이 가드를 빼면 폼이 떠 있는 동안 ATT 가 그 위에겹쳐 뜬다 — 실제로 출시된 앱에서 그렇게 나갔다 (2026-08, 51개 앱 점검).
6. ID Switch — lib/admob/ad_ids.dart
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.
7. Rewarded Helper — lib/admob/rewarded_ad_helper.dart
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;}}
8. Game Integration
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))
Output — Write State and Logs
After completing code injection and confirming flutter analyze returns 0 issues:
Write config.md
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 state.md
Write docs/harness/state.md atomically (per protocol §7 admob → build transition and §7 rule 2 — set status: running in the same write):
status: runningcurrent_phase: admobnext_role: buildupdated_at: "<ISO-8601 UTC now>"
Leave current_round, created_at, resume_attempts, and pause_reason unchanged.
Append to pipeline-log.md
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 |
Error Handling
- If
flutter analyzefails after code injection, fix all issues before writingstate.md. - If the user cannot provide AdMob IDs (console access issue, approval pending), pause with
pause_reason: manual_action and explain what is needed for resume.
- If
app_tracking_transparencyorgoogle_mobile_adspub.dev packages are unavailable,
check the current package name with flutter pub search google_mobile_ads and update the version accordingly.