Skip to content

Capacitor

Olivier Biot edited this page May 10, 2026 · 1 revision

Capacitor

Capacitor is the modern native runtime for web apps — it wraps any HTML/JS/CSS bundle as a native iOS, Android, or desktop application. melonJS games built with Vite (or any bundler) drop into Capacitor with no engine changes; Capacitor's WebView runs WebGL, Canvas2D, audio, gamepads, touch input and accelerometer just like a browser.

This page covers two things:

  1. The minimum setup — how to wrap a melonJS game in Capacitor.
  2. The plugin@melonjs/capacitor-plugin, a small ergonomics layer that auto-wires lifecycle (pause on background) and hardware back-button forwarding into the engine.

Start by scaffolding a melonJS project the usual way:

npm create melonjs my-game
cd my-game
npm install

Then follow the rest of this page to add Capacitor and the plugin on top.

1. Wrapping a melonJS game

Add Capacitor to your project

npm install @capacitor/core @capacitor/app
npm install -D @capacitor/cli
npx cap init "My Game" com.example.mygame --web-dir=dist

--web-dir=dist matches melonJS's Vite output. If your bundler emits to a different directory (e.g. build/), point Capacitor there in capacitor.config.ts.

Add native platforms

npm run build              # produce dist/
npx cap add ios            # creates ios/  (macOS only)
npx cap add android        # creates android/
npx cap copy               # syncs dist/ into ios/ and android/
npx cap open ios           # opens Xcode
npx cap open android       # opens Android Studio

Re-run npm run build && npx cap copy after every code change to update the native projects with your latest bundle.

Done

That's it functionally. The game runs in the WebView. pauseOnVisibility (on by default in melonJS) already pauses the engine when the OS sends the app to background, because Capacitor's WebView fires document.visibilitychange.

2. @melonjs/capacitor-plugin

The plugin handles the things you'd otherwise wire by hand:

  • LifecycleApp.addListener("appStateChange")state.pause() / state.resume(). More reliable than visibilitychange on some Android WebViews, and lets you also pause/resume audio.
  • Hardware back-button — Capacitor surfaces Android's hardware back via App.addListener("backButton"). The plugin re-emits this on the engine event bus as a CapacitorBackEvent with a preventDefault() API, so each Stage can intercept it without manual event.on / event.off boilerplate.
  • Orientation lock and splash dismissal — thin lazy wrappers around @capacitor/screen-orientation and @capacitor/splash-screen.

Install

npm install @melonjs/capacitor-plugin @capacitor/app
# optional, only if you call lockOrientation / hideSplash:
npm install @capacitor/screen-orientation @capacitor/splash-screen

Quick start

import { Application, plugin, state, Stage } from "melonjs";
import {
    CapacitorPlugin,
    bindStageBack,
    hideSplash,
    lockOrientation,
} from "@melonjs/capacitor-plugin";

await lockOrientation("landscape");

// `new Application(...)` boots the engine and creates the renderer
// in a single call.
const app = new Application(1024, 768, {
    parent: "screen",
    scaleMethod: "flex",
});

// Wires lifecycle + back-button forwarding in one call.
plugin.register(CapacitorPlugin, "capacitor", {
    pauseAudio: true,
});

class PlayStage extends Stage {
    onResetEvent() {
        bindStageBack(this, (evt) => {
            state.change(state.MENU);
            evt.preventDefault(); // suppress default App.exitApp()
        });
    }
}

state.set(state.PLAY, new PlayStage());
state.change(state.PLAY);
await hideSplash({ fadeOutDuration: 300 });

Per-stage back-button policies

Each stage can define its own back-button behavior. The plugin's bindStageBack subscribes on onResetEvent and unsubscribes on onDestroyEvent automatically:

class MenuStage extends Stage {
    onResetEvent() {
        // Don't intercept — let the default App.exitApp() run.
    }
}

class PlayStage extends Stage {
    onResetEvent() {
        bindStageBack(this, (evt) => {
            state.change(state.MENU);
            evt.preventDefault();
        });
    }
}

class PauseOverlayStage extends Stage {
    onResetEvent() {
        bindStageBack(this, (evt) => {
            state.exit(); // close the overlay
            evt.preventDefault();
        });
    }
}

The result is the conventional Android navigation pattern: back from gameplay returns to the menu; back from the menu quits.

Custom default action

Override onUnhandledBack to do something other than quit:

plugin.register(CapacitorPlugin, "capacitor", {
    onUnhandledBack: () => {
        // show a "Quit?" confirm overlay instead of exiting immediately
        state.change(state.CONFIRM_QUIT);
    },
});

Disabling parts of the plugin

If you only want lifecycle (no back-button) or vice versa:

plugin.register(CapacitorPlugin, "capacitor", {
    pauseOnBackground: true,
    forwardBackButton: false, // handle backButton yourself
});

Teardown

For hot-reload or unit tests, retrieve the registered instance and call its teardown(). The method is async — await it for deterministic detachment, or call without awaiting for opportunistic cleanup:

const cap = plugin.cache.capacitor as CapacitorPlugin;
await cap.teardown();

Notes

  • iOS only — back-button forwarding is a no-op on iOS (no hardware back). The lifecycle adapter still works.
  • @capacitor/app versions — the plugin tracks Capacitor 6.x and later. Older majors used different App.addListener return shapes; if you're locked to Capacitor 5 or below, pin the plugin to a matching version (none yet — file an issue if you need backports).
  • Audio interruptions — Capacitor's appStateChange fires for incoming calls, control-center pulldowns, etc. With pauseAudio: true the plugin pauses Howler-backed audio cleanly during these.
  • Splash screen timing — call hideSplash after your first stage's onResetEvent (or once your loader completes) so the user never sees a black canvas. Capacitor will hide the splash automatically after a timeout, but explicit dismissal is smoother.

See also

Clone this wiki locally