# Scaling Web Games to Mobile

**Author:** Nicholas Ventimiglia | **Date:** August 2026 | **Read time:** 8 min

![Turn your web games into Android app hits](https://nicholasventimiglia.com/images/blog/scaling-web-to-mobile/hero.png)

---

You have an HTML5 game that runs in a browser. You want it on Android with ads. [Capacitor](https://capacitorjs.com/) wraps that HTML, JavaScript, and assets in a native [WebView](https://developer.android.com/reference/android/webkit/WebView) and ships an Android app. I shipped [Honey Hex](https://honeyhex.xyz) this way.

Wrap the game and play it on a phone. Then add [AdMob](https://admob.google.com/home/). Then add Google sign-in for player accounts.

[![Scaling Web to Mobile Video](https://img.youtube.com/vi/cMX0gIBsVkY/maxresdefault.jpg)](https://www.youtube.com/watch?v=cMX0gIBsVkY)
*[Watch on YouTube](https://www.youtube.com/watch?v=cMX0gIBsVkY)*

## What you need

* A playable web game
* [Node.js](https://nodejs.org/) 20 or newer
* [Android Studio](https://developer.android.com/studio), plus an emulator or a USB-connected phone
* A [Google AdMob](https://admob.google.com/home/get-started/) account for part 2
* A [Google Cloud](https://console.cloud.google.com/apis/credentials) 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:

```bash
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`:

```javascript
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:

```json
{
  "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`](https://capacitorjs.com/docs/config):

```typescript
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](https://play.google.com/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](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts)).
* **`android.allowMixedContent`:** Set this when the game loads `http` resources from that `https` WebView.

### Sync and run

```bash
npx cap sync android
npx cap open android
```

Pick a device and press Run. [Create an emulator](https://developer.android.com/studio/run/managing-avds) or [enable USB debugging](https://developer.android.com/studio/debug/dev-options) 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

```javascript
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:

```css
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`:

```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`](https://capacitorjs.com/docs/apis/app) reports back and backgrounding:

```bash
npm install @capacitor/app
npx cap sync android
```

```javascript
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:

```bash
npm run android
```

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

---

## 2. Add AdMob

[AdMob](https://admob.google.com/home/) is Google's mobile ad network. The [Mobile Ads SDK](https://developers.google.com/admob/android/next-gen/quick-start) is native Android code. A Capacitor plugin bridges your JavaScript to it.

[Create an Android app and an ad unit](https://support.google.com/admob/answer/9989980) 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](https://developers.google.com/admob/android/next-gen/interstitial) unit in `ads.js`.

Use [Google's test IDs](https://developers.google.com/admob/android/test-ads) until an ad shows. Then switch to your production IDs and set `isTesting: false`.

Install [`capacitor-admob-nextgen`](https://www.npmjs.com/package/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`](https://www.npmjs.com/package/@capacitor-community/admob). Two native SDKs break the Gradle build. Both packages are community plugins. They are not affiliated with Google.

```bash
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](https://developers.google.com/admob/android/quick-start):

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

```bash
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](https://support.google.com/admob/answer/10107561) 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](https://developers.google.com/admob/android/test-ads):

```javascript
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](https://github.com/swaplab-engine/capacitor-admob-nextgen#api).

Load it as a module:

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

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

```javascript
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](https://console.cloud.google.com/apis/credentials) project ([Android setup](https://developers.google.com/identity/sign-in/android/start-integrating)):

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.

```bash
cd android
./gradlew signingReport
```

Install [`@capgo/capacitor-social-login`](https://www.npmjs.com/package/@capgo/capacitor-social-login):

```bash
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](https://developers.google.com/identity/gsi/web/guides/overview) and POST `response.credential` to the same endpoint.

```javascript
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`](https://www.nuget.org/packages/Google.Apis.Auth):

```bash
dotnet add package Google.Apis.Auth
```

```csharp
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;
}
```

---

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