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
- A playable web game
- Node.js 20 or newer
- Android Studio, plus an emulator or a USB-connected phone
- A Google AdMob account for part 2
- A Google Cloud project for part 3
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:
- Copy the playable game into
www. - Run
npx cap sync android, which copieswwwintoassets/publicand 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: AndroidapplicationId. Reverse-domain, lowercase. This is the Play Console listing identity. Set it once.appName: Launcher label.webDir: Foldernpx cap synccopies intoassets/public.backgroundColor: Color Android paints before the first frame. Match your splash.server.androidScheme: Usehttpsso the origin ishttps://localhost(secure context).android.allowMixedContent: Set this when the game loadshttpresources from thathttpsWebView.
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:
- App ID (
ca-app-pub-…~…). One per app. Write it intoAndroidManifest.xml. - Ad unit ID (
ca-app-pub-…/…). One per format. Put the interstitial unit inads.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):
- A Web client. Pass this client ID to the plugin. Your server validates tokens against this same ID.
- An Android client with your
appIdand 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.
