-
-
Notifications
You must be signed in to change notification settings - Fork 662
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:
- The minimum setup — how to wrap a melonJS game in Capacitor.
-
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 installThen follow the rest of this page to add Capacitor and the plugin on top.
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.
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 StudioRe-run npm run build && npx cap copy after every code change to update the native projects with your latest bundle.
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.
The plugin handles the things you'd otherwise wire by hand:
-
Lifecycle —
App.addListener("appStateChange")→state.pause()/state.resume(). More reliable thanvisibilitychangeon 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 aCapacitorBackEventwith apreventDefault()API, so eachStagecan intercept it without manualevent.on/event.offboilerplate. -
Orientation lock and splash dismissal — thin lazy wrappers around
@capacitor/screen-orientationand@capacitor/splash-screen.
npm install @melonjs/capacitor-plugin @capacitor/app
# optional, only if you call lockOrientation / hideSplash:
npm install @capacitor/screen-orientation @capacitor/splash-screenimport { 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 });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.
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);
},
});If you only want lifecycle (no back-button) or vice versa:
plugin.register(CapacitorPlugin, "capacitor", {
pauseOnBackground: true,
forwardBackButton: false, // handle backButton yourself
});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();- iOS only — back-button forwarding is a no-op on iOS (no hardware back). The lifecycle adapter still works.
-
@capacitor/appversions — the plugin tracks Capacitor 6.x and later. Older majors used differentApp.addListenerreturn 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
appStateChangefires for incoming calls, control-center pulldowns, etc. WithpauseAudio: truethe plugin pauses Howler-backed audio cleanly during these. -
Splash screen timing — call
hideSplashafter your first stage'sonResetEvent(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.