Engineering

Don't Quit Your Day Job: Here's How I Wired AdMob Into My Android Game

A practical look at banner and App Open ads — and the Android lifecycle details behind a small survival game.

I built a small survival game for Android where you tap angry vegetables before they eat your tree. It's not going to make me rich. Almost no indie game does. But I wanted to actually ship the monetization layer instead of just leaving a “TODO: ads” comment in the code forever, so here's how I did it, warts and all.

Let's set expectations first

If you're reading this hoping for a “how I made $10k with a mobile game” story, that's not what this is. Most indie games are made for fun, and if you break even on the Play Store developer fee, that's already a decent outcome. Making real money from mobile ad revenue is closer to buying a lottery ticket than running a business: everyone tries, almost nobody wins big, and I'm not going to pretend otherwise.

Here is a rough, illustrative sense of the ranges people report publicly for these formats. This isn't a forecast or a guarantee for any specific app; actual eCPM varies widely by geography, seasonality, ad-format fill, and niche.

FormatTier-1 eCPM (US/UK/CA/AU)Global eCPM
Banner$0.50–$1.50$0.20–$0.80
Interstitial / App Open$2.50–$8.00$2.50–$5.00
Rewarded video$15–$30$8–$18

eCPM means “earnings per 1,000 ad impressions.” Banners are the weakest format per impression, but they're shown constantly in the background, so volume adds up. App Open ads — the full-screen ad shown when a user returns to your app from the background — behave more like interstitials: fewer impressions, but each one is worth more.

Rough napkin math: to clear something like $10/month from banners alone, at a global-average eCPM around $0.30–$0.50, you'd need roughly 20,000–35,000 banner impressions a month. A player who opens the game twice a day, with each visit counting as roughly one impression, generates about 60 impressions a month — which puts the target at somewhere around 300–500 daily active users. Shift those assumptions and the number moves a fair amount either way, so treat it as a sense of scale, not a target to hit.

So: try it, learn from it, don't quit your job over it.

Why banner + App Open, and not interstitials or rewarded video

My game has two natural calm moments: the main menu and the game-over screen. Both are static, non-time-critical screens, which makes them a good fit for a banner: it just sits at the bottom and doesn't interrupt anything.

I also added an App Open ad, shown when the player returns to the app after being backgrounded for a while. It fits a short survival game where people bounce in and out of sessions frequently.

What I deliberately skipped:

  • Interstitials between waves. My game already has fast, punchy pacing between waves, and a forced full-screen ad there would kill the flow.
  • Rewarded video. There's no in-game currency or extra life to reward yet. Rewarded ads work best when you can offer something the player actually wants in exchange, and right now I don't have that hook.

Setting up AdMob

  1. Create an account at admob.google.com with a Google account.
  2. Add your app in the AdMob console. If it is already published, search by app name or package name and AdMob links it automatically. If not, add it manually, choose Android, and answer “No” when asked if it is listed on a supported app store. AdMob issues an App ID right away, marked as unlinked until you publish.
  3. Complete the readiness review. Getting an App ID is instant, but AdMob separately reviews the app itself for policy compliance and, where applicable, app-ads.txt verification.
  4. Create ad units inside the app: one Banner unit and one App Open unit. Each has its own ad-unit ID, separate from the App ID.
  5. Set up app-ads.txt. This feeds into the readiness review and is the step that trips people up.

The app-ads.txt step people skip

app-ads.txt is an IAB Tech Lab standard that AdMob and other exchanges use as part of app verification. It is a public declaration of which sellers are authorized to sell your inventory, helping exchanges distinguish genuine inventory from a spoofed copy of your app.

  1. Your Google Play listing needs a developer website in the App support section. Without it, AdMob's crawler has nowhere to look.
  2. Host a plain text file named app-ads.txt at the exact root of that domain:
    google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0
  3. AdMob's crawler visits the developer website in your listing and checks for the exact root path, not a subfolder.
  4. Once it finds and parses the file, your status moves to verified. This can take up to 24 hours, sometimes longer after a store-listing update.
  5. Verification feeds into app readiness. An unverified file can limit how inventory is served, on top of leaving you exposed to spoofing.

You don't need paid hosting for this. GitHub Pages works fine and is free. I used an account-level site, which serves root files at https://yourusername.github.io/app-ads.txt. Enable Pages, put that URL in the Play Store developer-website field, wait for the crawl, and check the status in AdMob.

Wiring the IDs into the project

The app-level App ID goes into the manifest as metadata:

<!-- AndroidManifest.xml -->
<meta-data
    android:name="com.google.android.gms.ads.APPLICATION_ID"
    android:value="ca-app-pub-XXXXXXXXXXXXXXXX~XXXXXXXXXX" />

<meta-data
    android:name="com.google.android.gms.ads.flag.OPTIMIZE_INITIALIZATION"
    android:value="true" />
<meta-data
    android:name="com.google.android.gms.ads.flag.OPTIMIZE_AD_LOADING"
    android:value="true" />

These two optional flags let the SDK defer non-critical initialization work and batch ad-loading calls rather than doing everything eagerly at cold start. The dependency in Gradle is:

// build.gradle.kts
dependencies {
    implementation("com.google.android.gms:play-services-ads:23.1.0")
}

The individual ad-unit IDs — different from the App ID above — live wherever you actually request an ad.

The banner: an adaptive AndroidView

Compose doesn't have a native AdMob composable, so the banner is wrapped in an AndroidView:

@Composable
fun AdBanner(modifier: Modifier = Modifier) {
    val context = LocalContext.current
    val displayMetrics = context.resources.displayMetrics
    val adWidth = (displayMetrics.widthPixels / displayMetrics.density).toInt()

    AndroidView(
        modifier = modifier.fillMaxWidth(),
        factory = { ctx ->
            AdView(ctx).apply {
                adUnitId = "ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX"
                setAdSize(AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(ctx, adWidth))
                loadAd(AdRequest.Builder().build())
            }
        }
    )
}

The important part is getCurrentOrientationAnchoredAdaptiveBannerAdSize instead of a fixed 320×50 banner. Adaptive banners size themselves to the actual screen width and pick the tallest banner that still fits. If you ever put a banner in a narrower container, measure that container instead of using displayMetrics.widthPixels.

The App Open ad and the Application-level lifecycle

This took the most iteration, because App Open ads aren't tied to a single screen. They need the process lifecycle, not an individual Activity's lifecycle. Show one when the user navigates between your own screens and “welcome back” becomes “please stop interrupting me.”

The manager itself is a standard wrapper around AppOpenAd.load():

class AppOpenAdManager(private val context: Context) {
    private var appOpenAd: AppOpenAd? = null
    private var isLoadingAd = false
    var isShowingAd = false
    private var loadTime: Long = 0
    var onAdLoadedListener: (() -> Unit)? = null

    init { loadAd() }

    fun loadAd() {
        if (isLoadingAd || isAdAvailable()) return
        isLoadingAd = true
        AppOpenAd.load(context, adUnitId, AdRequest.Builder().build(), callback)
    }

    fun isAdAvailable() = appOpenAd != null && Date().time - loadTime < 4 * 60 * 60 * 1000L

    fun showAdIfAvailable(activity: Activity, onComplete: () -> Unit) {
        if (isShowingAd || !isAdAvailable()) { onComplete(); loadAd(); return }
        appOpenAd?.fullScreenContentCallback = fullScreenCallback
        isShowingAd = true
        appOpenAd?.show(activity)
    }
}

Two details were not obvious to me at first:

  • The four-hour cache window. This isn't an AdMob-side setting and the SDK doesn't enforce it for you. It is a client-side check: discard an ad that is more than four hours old, because it may no longer be valid or earn revenue.
  • The isShowingAd guard. This protects against overlapping show attempts if the process lifecycle fires again while an ad is already visible.

Then the Application class decides when an ad should appear:

class AngryVegetablesApp : Application(), Application.ActivityLifecycleCallbacks, DefaultLifecycleObserver {
    private var showAdOnLoad = false
    private var currentActivity: Activity? = null
    private var isColdStart = true

    override fun onStart(owner: LifecycleOwner) {
        if (isColdStart) { isColdStart = false; return }
        Handler(Looper.getMainLooper()).post {
            currentActivity?.let {
                if (!appOpenAdManager.isShowingAd) {
                    if (appOpenAdManager.isAdAvailable()) appOpenAdManager.showAdIfAvailable(it) {}
                    else { showAdOnLoad = true; appOpenAdManager.loadAd() }
                }
            }
        }
    }

    override fun onActivityStarted(activity: Activity) {
        if (!appOpenAdManager.isShowingAd) currentActivity = activity
    }
    override fun onActivityResumed(activity: Activity) { currentActivity = activity }
    override fun onActivityStopped(activity: Activity) { if (currentActivity == activity) currentActivity = null }
}

The key split is between ActivityLifecycleCallbacks, which fires constantly as you navigate, and ProcessLifecycleOwner, which fires once per app-level foreground/background transition. App Open ads should react to the second one, not the first.

The isColdStart flag guarantees the first launch cannot show an ad, while the manager preloads one immediately. The showAdOnLoad flag closes a race condition: if the app foregrounds before an ad finishes loading, it triggers the show once that ad arrives. Reset that flag as soon as it is checked; otherwise a stale value can make an unrelated later load trigger a show.

Emulators are automatically treated as test devices by the SDK. Physical devices aren't: add each device ID reported by logcat while developing, and never tap production ads while testing.

val testDeviceIds = listOf(AdRequest.DEVICE_ID_EMULATOR)
val config = RequestConfiguration.Builder().setTestDeviceIds(testDeviceIds).build()
MobileAds.setRequestConfiguration(config)

Wrap-up

None of this is going to fund early retirement. But building it properly — adaptive banner sizing and the process-versus-activity lifecycle distinction for App Open ads — was a genuinely useful Android exercise, independent of whatever pocket change it eventually brings in. If you're doing the same thing, start with the banner, and get the App Open lifecycle right before you touch anything else.

If you want to see what all this ad code is actually running inside of, the game itself is on Google Play: Angry Vegetables Remaster.