Skip to content

Commit c9ed493

Browse files
obiotclaude
andcommitted
feat(application): two-phase async startup + experimental WebGPU bootstrap (#1184) — 20.0.0
Breaking: starting a game is now construct + mandatory `await app.init()`. The constructor owns everything renderer-independent (settings resolution, game world, DOM event bridging); init() builds the renderer and everything downstream, and is asynchronous because a WebGPU device cannot be acquired synchronously. The exported `game` names the most recently initialized Application — set at the END of init(), undefined before the first one. `video.init()`, `video.renderer`, `video.createCanvas()`, `video.getParent()` and the `legacy` setting are removed. destroy() is terminal; a repeated init() is a warned no-op. The experimental WebGPU backend is the first consumer of the async path: GPUCanvasContext at construction, adapter/device negotiation + canvas configuration in renderer.init(), and a real per-frame clear pass; every other drawing method stays a base-class no-op. Opt-in only — AUTO never selects it, and init() rejects when WebGPU is unavailable. Renderers gained an argument-less async init() lifecycle hook awaited by Application.init(). Tests and examples migrated to `const app = new Application(...); await app.init()`; spec files no longer read the `game` global (the singleton spec excepted). Lifecycle hardening from adversarial review: destroy() during a pending init() aborts instead of resurrecting the app, each backend stamps its own context type so a WEBGPU rejection can be retried as Canvas, and the MutationObserver / GAME_RESET subscriptions are released on destroy. New Hello WebGPU example; all 43 examples verified via Playwright; full suite 5223 passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
1 parent 07965de commit c9ed493

178 files changed

Lines changed: 2905 additions & 1673 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/debug-plugin/src/index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { event, input, plugin, pool, timer, utils, video } from "melonjs";
1+
import { event, input, plugin, pool, timer, utils } from "melonjs";
22
import { homepage, name, peerDependencies, version } from "../package.json";
33
import Counters from "./counters.js";
44
import { drawFrameGraph, drawMemGraph } from "./graphs.js";
@@ -329,7 +329,7 @@ export class DebugPanelPlugin extends plugin.BasePlugin {
329329

330330
/** @private */
331331
_syncPosition() {
332-
const rect = video.renderer.getCanvas().getBoundingClientRect();
332+
const rect = this.app.renderer.getCanvas().getBoundingClientRect();
333333
const s = this.panelWrap.style;
334334
s.top = `${rect.top}px`;
335335
s.left = `${rect.left}px`;
@@ -453,7 +453,7 @@ export class DebugPanelPlugin extends plugin.BasePlugin {
453453
if (!this.options.quadtree) {
454454
return;
455455
}
456-
const renderer = video.renderer;
456+
const renderer = this.app.renderer;
457457
renderer.save();
458458
const { x, y } = this.app.viewport.pos;
459459
renderer.translate(-x, -y);

packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242
import { GameController } from "./GameController";
4343
import { SkyboxStage } from "./SkyboxStage";
4444

45-
const createGame = () => {
45+
const createGame = async () => {
4646
// NOTE: an `unmounted` guard around the preload callback would in
4747
// principle fix the "user navigates away before preload finishes"
4848
// race that Copilot flagged. It can't be added cleanly today —
@@ -68,6 +68,7 @@ const createGame = () => {
6868
scale: "auto",
6969
cameraClass: Camera3d,
7070
});
71+
await app.init();
7172
} catch (err) {
7273
const reason = err instanceof Error ? err.message : String(err);
7374
globalThis.alert(

packages/examples/src/examples/aquarium/ExampleAquarium.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
*/
2424
import { DebugPanelPlugin } from "@melonjs/debug-plugin";
2525
import {
26-
type Application,
26+
Application,
2727
loader,
2828
NoiseTexture2d,
2929
plugin,
@@ -295,8 +295,8 @@ class PlayScreen extends Stage {
295295
}
296296
}
297297

298-
const createGame = () => {
299-
video.init(728, 410, {
298+
const createGame = async () => {
299+
const app = new Application(728, 410, {
300300
parent: "screen",
301301
// fixed internal resolution scaled to fit (keeps viewport 728×410, so
302302
// full-screen renderables cover it regardless of the container size)
@@ -308,6 +308,7 @@ const createGame = () => {
308308
// instead of snapping pixel-to-pixel (default floors dx/dy to integers)
309309
subPixel: true,
310310
});
311+
await app.init();
311312

312313
// register the debug plugin (hidden by default; press S to toggle)
313314
plugin.register(DebugPanelPlugin, "debugPanel");

packages/examples/src/examples/aseprite/ExampleAseprite.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import { paladin } from "./play";
1010

1111
export const ExampleAseprite = () => {
1212
useEffect(() => {
13-
if (!me.game.isInitialized) {
14-
createGame();
13+
// `game` is undefined until the first Application finishes init()
14+
if (!me.game?.isInitialized) {
15+
void createGame();
1516
}
1617
}, []);
1718

packages/examples/src/examples/aseprite/game.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* See `packages/examples/LICENSE.md` for full license + asset credits.
55
*/
66
import { DebugPanelPlugin } from "@melonjs/debug-plugin";
7-
import { loader, plugin, state, video } from "melonjs";
7+
import { Application, loader, plugin, state } from "melonjs";
88
import { PlayScreen } from "./play";
99

1010
const base = `${import.meta.env.BASE_URL}assets/aseprite/`;
@@ -13,9 +13,10 @@ const resources = [
1313
{ name: "paladin", type: "image", src: `${base}paladin.png` },
1414
];
1515

16-
export const createGame = () => {
16+
export const createGame = async () => {
1717
// `parent: "screen"` — see ExampleBenchmark for the rationale.
18-
video.init(640, 480, { parent: "screen" });
18+
const app = new Application(640, 480, { parent: "screen" });
19+
await app.init();
1920

2021
// register the debug plugin
2122
plugin.register(DebugPanelPlugin, "debugPanel");

packages/examples/src/examples/benchmark/ExampleBenchmark.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import { DebugPanelPlugin } from "@melonjs/debug-plugin";
77
import {
8+
Application,
89
device,
910
event,
1011
game,
@@ -29,10 +30,10 @@ function addFruits(numberOfFruits: number, fruitType?: string) {
2930
}
3031
}
3132

32-
const createGame = () => {
33+
const createGame = async () => {
3334
// Initialize the video.
34-
if (
35-
!video.init(1024, 768, {
35+
try {
36+
const app = new Application(1024, 768, {
3637
// `parent: "screen"` — without this the engine falls back to
3738
// `document.body`, and on this page body has no flow content
3839
// (#screen is `position: fixed`, so it's pulled out of the
@@ -44,8 +45,9 @@ const createGame = () => {
4445
scaleMethod: ScaleMethods.Flex,
4546
renderer: video.AUTO,
4647
transparent: false,
47-
})
48-
) {
48+
});
49+
await app.init();
50+
} catch {
4951
alert("Your browser does not support HTML5 canvas.");
5052
return;
5153
}

packages/examples/src/examples/billboard/ExampleBillboard.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ class SkyBackdrop extends Renderable {
129129
}
130130
}
131131

132-
const createGame = () => {
132+
const createGame = async () => {
133133
let app: Application;
134134
try {
135135
app = new Application(1024, 768, {
@@ -139,6 +139,7 @@ const createGame = () => {
139139
cameraClass: Camera3dClass,
140140
antiAlias: true,
141141
});
142+
await app.init();
142143
} catch (err) {
143144
const reason = err instanceof Error ? err.message : String(err);
144145
globalThis.alert(

packages/examples/src/examples/blendModes/ExampleBlendModes.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License.
44
* See `packages/examples/LICENSE.md` for full license + asset credits.
55
*/
6-
import { game, Renderable, Text, video } from "melonjs";
6+
import { Application, game, Renderable, Text, video } from "melonjs";
77
import { createExampleComponent } from "../utils";
88

99
const BLEND_MODES = [
@@ -27,26 +27,27 @@ const CELL_W = 180;
2727
const CELL_H = 170;
2828
const CIRCLE_R = 45;
2929

30-
const createGame = () => {
30+
const createGame = async () => {
3131
const rows = Math.ceil(BLEND_MODES.length / COLS);
3232
const canvasW = COLS * CELL_W;
3333
const HEADER_H = 40;
3434
const canvasH = rows * CELL_H + HEADER_H;
3535

36-
if (
37-
!video.init(canvasW, canvasH, {
36+
try {
37+
const app = new Application(canvasW, canvasH, {
3838
parent: "screen",
3939
renderer: video.AUTO,
4040
preferWebGL1: false,
41-
})
42-
) {
41+
});
42+
await app.init();
43+
} catch {
4344
alert("Your browser does not support HTML5 canvas.");
4445
return;
4546
}
4647

4748
// detect which modes are actually supported by probing setBlendMode
4849
// biome-ignore lint/suspicious/noExplicitAny: renderer type varies between WebGL/Canvas
49-
const renderer = video.renderer as any;
50+
const renderer = game.renderer as any;
5051
const supported: Record<string, boolean> = {};
5152
for (const mode of BLEND_MODES) {
5253
const applied = renderer.setBlendMode(mode);

packages/examples/src/examples/camera3d/ExampleCamera3d.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
import monsterImg from "../shaderEffects/assets/monster.png";
3535
import { createExampleComponent } from "../utils";
3636

37-
const createGame = () => {
37+
const createGame = async () => {
3838
// Stash teardown work assembled inside the loader callback so the
3939
// outer `createGame` can return a single cleanup function from the
4040
// async preload completion.
@@ -61,6 +61,7 @@ const createGame = () => {
6161
scale: "auto",
6262
cameraClass: Camera3dClass,
6363
});
64+
await app.init();
6465

6566
app.world.backgroundColor.parseCSS("#0a0a14");
6667
plugin.register(DebugPanelPlugin, "debugPanel");

packages/examples/src/examples/clipping/ExampleClipping.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* See `packages/examples/LICENSE.md` for full license + asset credits.
55
*/
66
import {
7+
Application,
78
Color,
89
ColorLayer,
910
Container,
@@ -204,12 +205,13 @@ class PlayScreen extends Stage {
204205
}
205206
}
206207

207-
const createGame = () => {
208-
video.init(760, 320, {
208+
const createGame = async () => {
209+
const app = new Application(760, 320, {
209210
parent: "screen",
210211
scaleMethod: "flex",
211212
renderer: video.AUTO,
212213
});
214+
await app.init();
213215

214216
state.set(state.PLAY, new PlayScreen());
215217
state.change(state.PLAY);

0 commit comments

Comments
 (0)