Nicholas Ventimiglia

Nicholas Ventimiglia

Startup focused AI-Native Architect

Scaling Web Games to Mobile

Author: Nicholas Ventimiglia  |  Date: August 2026  |  8 min read  |  Copy as Markdown


You have an HTML5 game that runs in a browser. You want it on Android with ads. Capacitor wraps that HTML, JavaScript, and assets in a native WebView and ships an Android app. I shipped Honey Hex this way.

Wrap the game and play it on a phone. Then add AdMob. Then add Google sign-in for player accounts.

What you need


1. Wrap the game in Capacitor

Create a Capacitor project beside your game folder. This folder holds the generated Android project. Keep it separate from the source you copy from.

Run these from the directory that contains your game folder:

mkdir my-game-app
cd my-game-app
npm init -y
npm install @capacitor/core @capacitor/cli @capacitor/android
npx cap init "My Game" com.yourname.mygame --web-dir=www
npx cap add android

npx cap init writes capacitor.config.ts and records www as webDir. It does not create that folder. npx cap add android generates the Gradle project under android/. It prints sync could not run--missing www directory. That warning is expected.

my-game-app/
  capacitor.config.ts
  package.json
  www/                 webDir; you create and fill this
  android/             Gradle project

Stage the files the app ships

The app loads files from android/app/src/main/assets/public. Those files arrive in two hops:

  1. Copy the playable game into www.
  2. Run npx cap sync android, which copies www into assets/public and updates native plugin dependencies.

www needs an index.html at its root and every file that page references, with relative paths unchanged. The staging script wipes www on every run. Add www to .gitignore. Do not hand-edit it.

Copy the whole game folder into www, then write the launch page over index.html. Honey Hex uses play.html. Save this as stage.js:

const fs = require('fs');
const path = require('path');

const SRC = path.resolve(__dirname, '../my-game');
const OUT = path.resolve(__dirname, 'www');

fs.rmSync(OUT, { recursive: true, force: true });
fs.cpSync(SRC, OUT, { recursive: true });
fs.copyFileSync(path.join(SRC, 'play.html'), path.join(OUT, 'index.html'));

Wire it to npm:

{
  "scripts": {
    "stage": "node stage.js",
    "android": "npm run stage && npx cap sync android"
  }
}

For a compiled game, point SRC at the build output and run the build before npm run stage.

Configure the app

Set identity and webDir in capacitor.config.ts:

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.yourname.mygame',
  appName: 'My Game',
  webDir: 'www',
  backgroundColor: '#121212',
  server: {
    androidScheme: 'https'
  },
  android: {
    allowMixedContent: true
  }
};

export default config;
  • appId: Android applicationId. Reverse-domain, lowercase. This is the Play Console listing identity. Set it once.
  • appName: Launcher label.
  • webDir: Folder npx cap sync copies into assets/public.
  • backgroundColor: Color Android paints before the first frame. Match your splash.
  • server.androidScheme: Use https so the origin is https://localhost (secure context).
  • android.allowMixedContent: Set this when the game loads http resources from that https WebView.

Sync and run

npx cap sync android
npx cap open android

Pick a device and press Run. Create an emulator or enable USB debugging on a phone.

Capacitor injects its JavaScript API into the WebView. Do not add a Capacitor script tag. Confirm the game plays before you install another plugin.

Detect the native shell

function isNativeApp() {
  return typeof window.Capacitor !== 'undefined';
}

Guard plugin calls with that flag. On the website window.Capacitor is undefined, and those calls throw.

Disable the browser's gesture delay on the canvas:

canvas {
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
}

Lock orientation on the existing MainActivity <activity> tag in android/app/src/main/AndroidManifest.xml:

android:screenOrientation="portrait"

Handle the back button

Android's back button exits the app. A stray tap mid-match drops the player to the launcher with no save. @capacitor/app reports back and backgrounding:

npm install @capacitor/app
npx cap sync android
if (isNativeApp()) {
  const { App } = window.Capacitor.Plugins;

  App.addListener('backButton', () => {
    if (game.isRunning()) {
      game.pause();
    } else {
      App.exitApp();
    }
  });

  App.addListener('appStateChange', ({ isActive }) => {
    if (!isActive) game.pause();
  });
}

Update the game after a change

Rebuild the game, then:

npm run android

A change that shows in the browser and not on the phone means a missed sync.


2. Add AdMob

AdMob is Google's mobile ad network. The Mobile Ads SDK is native Android code. A Capacitor plugin bridges your JavaScript to it.

Create an Android app and an ad unit in the AdMob console. You get two IDs. They are not interchangeable:

  1. App ID (ca-app-pub-…~…). One per app. Write it into AndroidManifest.xml.
  2. Ad unit ID (ca-app-pub-…/…). One per format. Put the interstitial unit in ads.js.

Use Google's test IDs until an ad shows. Then switch to your production IDs and set isTesting: false.

Install capacitor-admob-nextgen. It wraps the current SDK and preloads ads so a match-end call does not stall on the network. Do not also install @capacitor-community/admob. Two native SDKs break the Gradle build. Both packages are community plugins. They are not affiliated with Google.

npm install capacitor-admob-nextgen

Declare the App ID in package.json and register the plugin's manifest hook. Capacitor runs capacitor:sync:after at the end of every sync. The value below is Google's sample App ID:

{
  "admob": {
    "androidAppId": "ca-app-pub-3940256099942544~3347511713"
  },
  "scripts": {
    "capacitor:sync:after": "node node_modules/capacitor-admob-nextgen/scripts/admob-manifest.js"
  }
}
npx cap sync android

A thin ads file you own

Put every ad call in ads.js. requestConsentInfo() runs before initialize() so Google can present a consent form where required. bufferSize: 2 keeps two interstitials ready so back-to-back matches both have one waiting.

Save ads.js next to your other game scripts. The staging script already copies the whole folder. The unit ID below is Google's test interstitial:

const INTERSTITIAL_AD_UNIT_ID = 'ca-app-pub-3940256099942544/1033173712';

function admob() {
  return window.Capacitor?.Plugins?.AdMobNextGen || null;
}

export async function initAds() {
  const plugin = admob();
  if (!plugin) return;

  await plugin.requestConsentInfo();
  await plugin.initialize({ isTesting: true });
  await plugin.startPreloadInterstitial({
    adUnitId: INTERSTITIAL_AD_UNIT_ID,
    bufferSize: 2,
    isAutoShow: false
  });
}

export async function showInterstitial() {
  const plugin = admob();
  if (!plugin) return false;

  const ready = await plugin.isInterstitialPreloadAvailable({
    adUnitId: INTERSTITIAL_AD_UNIT_ID
  });
  if (!ready?.isAvailable) return false;

  await plugin.pollAndShowInterstitial();
  return true;
}

For full configuration options and all available ad formats, see the capacitor-admob-nextgen API.

Load it as a module:

<script type="module" src="./ads.js"></script>

Call initAds() once after the game boots. Call showInterstitial() when a match ends, with the game paused:

async function onGameOver() {
  game.pause();
  try {
    await showInterstitial();
  } finally {
    game.resume();
  }
}

3. Google sign-in

This section stops at a verified player ID. Add it after the game runs in Capacitor and an interstitial shows.

Create two OAuth clients in the same Google Cloud project (Android setup):

  1. A Web client. Pass this client ID to the plugin. Your server validates tokens against this same ID.
  2. An Android client with your appId and both debug and release SHA-1 fingerprints from ./gradlew signingReport. Without the Android client, the sign-in sheet opens, closes, and returns no token and no error.

Add the release fingerprint when you sign a store build. Debug-only registration works on your machine and fails for Play installs.

cd android
./gradlew signingReport

Install @capgo/capacitor-social-login:

npm install @capgo/capacitor-social-login
npx cap sync android

Google returns an ID token. Your server verifies it. The game trusts your session. Call this behind isNativeApp(). On the website, use Google Identity Services and POST response.credential to the same endpoint.

async function signInWithGoogle() {
  const SocialLogin = window.Capacitor.Plugins.SocialLogin;
  await SocialLogin.initialize({
    google: { webClientId: 'YOUR_WEB_CLIENT_ID.apps.googleusercontent.com' }
  });

  const res = await SocialLogin.login({
    provider: 'google',
    options: { style: 'bottom', forcePrompt: true }
  });

  const idToken = res?.result?.idToken;
  if (!idToken) throw new Error('No idToken from Google');

  await fetch('https://api.yourdomain.com/api/auth/google', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ idToken })
  });
}

Verify the token on your server

Verify the signature against Google's public keys and confirm the audience is your Web client ID. The payload includes a stable subject and the email. This .NET sample uses Google.Apis.Auth:

dotnet add package Google.Apis.Auth
using Google.Apis.Auth;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    [HttpPost("google")]
    public async Task<IActionResult> GoogleAuth([FromBody] TokenRequest request)
    {
        try
        {
            var settings = new GoogleJsonWebSignature.ValidationSettings
            {
                Audience = new[] { "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com" }
            };

            var payload = await GoogleJsonWebSignature.ValidateAsync(request.IdToken, settings);

            // Create or update the player by payload.Subject, then issue your session
            return Ok(new { email = payload.Email, name = payload.Name });
        }
        catch (InvalidJwtException)
        {
            return Unauthorized();
        }
    }
}

public class TokenRequest
{
    public string IdToken { get; set; } = string.Empty;
}

Watchouts

Area Failure Detection Mitigation
Sync Phone runs an old build Browser shows your change, the app does not Copy into www, then npx cap sync android
Splash White flash on cold start Visible gap before the first frame Set backgroundColor to your splash color
Touch Taps land late or select text Canvas feels laggy on a phone, fine on desktop touch-action: none on the canvas
Back button Player loses a match Android back exits straight to the launcher Take the backButton event and pause
Memory Out-of-memory crash on large assets Native crash, not a JS exception android:largeHeap="true" on <application>
Consent Play review rejects the build Rejection cites EU consent requestConsentInfo() before initialize()
Ad timing Game-over freezes for seconds Only on cellular, not on wifi Preload at boot with startPreloadInterstitial
Ad units AdMob returns 403 One format shows, another never does Match the unit ID to the call; interstitial IDs are not rewarded IDs
Release build Players see test creatives Test ads served to real users isTesting: false and production unit IDs
Self-clicks AdMob restricts the account You tapped a live ad to confirm it works Verify with test units. Do not tap a production ad

All views are my own, and I do not represent any employer. All ownership of attached open source code is waived.