diff --git a/CLAUDE.md b/CLAUDE.md
index 49adfa2..6e58c21 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,7 +70,7 @@ Physics FFI (~110 of the ~230) is generated by the `define_physics_ffi!` macro i
The web target uses a two-module WASM architecture:
- **Perry WASM** (game logic) imports bloom_* functions under the `"ffi"` namespace
- **bloom_web.wasm** (rendering engine) compiled from `native/web/` via wasm-pack
-- **JS glue** (`index.html`) bridges both modules, handles DOM events, string conversion, asset fetching, and Web Audio
+- **JS glue** (`bloom_glue.js`) bridges both modules, handles DOM events, asset fetching, Web Audio, and the rAF game loop. For game builds, `build.sh` runs `splice_game.py` to inject this bootstrap into Perry's self-contained WASM HTML (which carries the `rt` runtime + NaN-boxing) and gate the game's `bootPerryWasm()` on engine readiness. Perry's runtime already decodes FFI args to plain JS values (`wrapFfiForI64`), so the bridge passes plain values straight through — no manual NaN-boxing.
Key features flags in `native/shared/Cargo.toml`:
- `default = ["mp3", "jolt"]` — includes minimp3 (C dep, not WASM-compatible) + Jolt physics
@@ -92,5 +92,7 @@ The web crate exposes `_str` variants (accepting `&str`) and `_bytes` variants (
| `native/third_party/bloom_jolt/` | C++ shim wrapping JoltPhysics behind a C ABI |
| `native/web/src/lib.rs` | Web platform: all FFI functions via wasm-bindgen |
| `native/web/jolt_bridge.js` | Web physics: JoltPhysics.js implementation of FFI |
-| `native/web/index.html` | JS glue: FFI bridge, input, asset loading, Web Audio |
-| `native/web/build.sh` | Build script: wasm-pack + wasm-opt + assembly |
+| `native/web/bloom_glue.js` | Engine bootstrap + FFI bridge: loads bloom_web, builds `__ffiImports`, input, asset loading, Web Audio, rAF game loop |
+| `native/web/index.html` | Engine-only standalone page (no game); creates `__bloomReady` + loads bloom_glue.js |
+| `native/web/splice_game.py` | Splices the Bloom bootstrap into Perry's self-contained WASM HTML, gating `bootPerryWasm()` on `__bloomReady` |
+| `native/web/build.sh` | Build script: wasm-pack + wasm-opt + Perry compile + splice/assembly |
diff --git a/native/web/bloom_glue.js b/native/web/bloom_glue.js
index e0c3b58..d4aa60c 100644
--- a/native/web/bloom_glue.js
+++ b/native/web/bloom_glue.js
@@ -1,218 +1,339 @@
/**
- * Bloom Engine — Web Glue Layer
+ * Bloom Engine — Web Bootstrap & FFI Bridge
*
- * Orchestrates loading of both WASM modules:
- * 1. bloom_web.wasm (Bloom rendering engine, compiled from Rust via wasm-pack)
- * 2. game.wasm (Perry-compiled game logic)
+ * Loaded as a deferred ES module by both the standalone template (`index.html`,
+ * engine-only / no game) and the build-assembled page that `build.sh` produces
+ * by splicing this bootstrap into Perry's self-contained WASM HTML.
*
- * Bridges Perry's FFI calls to Bloom's wasm-bindgen exports, handles the
- * requestAnimationFrame loop, and manages cross-module callback invocation.
+ * Responsibilities:
+ * 1. Load the Bloom rendering engine WASM (`pkg/bloom_web.js`, wasm-pack).
+ * 2. Initialise JoltPhysics.js (best-effort).
+ * 3. Publish `globalThis.__ffiImports` — the `bloom_*` functions the Perry
+ * game WASM imports under its `"ffi"` namespace.
+ * 4. Wire DOM input, HiDPI canvas sizing, Web Audio, and the rAF game loop.
+ * 5. Resolve `window.__bloomReady` so the gated `bootPerryWasm(...)` call in
+ * the page can instantiate the game WASM *after* the engine + FFI are live.
*
- * Usage:
- *
- *
- * Or for Perry's self-contained HTML output, set window.__bloomWasmUrl
- * before bootPerryWasm is called.
+ * --- The FFI value contract (important) ---
+ * Perry's WASM runtime (`wasm_runtime.js`, embedded in the page) wraps the
+ * entire `ffi` namespace with `wrapFfiForI64`: it DECODES each NaN-boxed i64
+ * argument to a plain JS value (number / string / handle) before calling us,
+ * and RE-ENCODES our plain return value. So every function below receives and
+ * returns ordinary JS values — there is no manual NaN-boxing to do here. A
+ * `bloom_draw_text(text, ...)` import arrives with `text` already a JS string.
*/
-// State
-let bloomModule = null; // wasm-bindgen exports from bloom_web
-let perryInstance = null; // Perry WASM instance
-let perryMemory = null; // Perry WASM memory
-let rafId = null; // requestAnimationFrame handle
-let gameRunning = false;
+import init, * as bloom from './pkg/bloom_web.js';
-// Game loop state
-let gameCallbackHandle = null; // Perry closure handle for runGame callback
-let perryClosureCall1 = null; // Reference to Perry's closure_call_1 function
+let bloomModule = null;
+let booted = false;
+
+// --- Game loop state ---
+let gameCallback = null; // Perry closure handle captured from bloom_run_game
+let gameRunning = false;
+let rafId = null;
/**
- * Boot a Bloom game from two WASM modules.
- *
- * @param {string} bloomPkgUrl - URL to bloom_web.js (wasm-pack output)
- * @param {string} gameWasmUrl - URL to the Perry-compiled game.wasm
+ * Idempotent engine bootstrap. Safe to call multiple times; only the first
+ * call performs work. Returns the resolved Bloom wasm-bindgen module.
*/
-export async function bootBloomGame(bloomPkgUrl, gameWasmUrl) {
- // 1. Load Bloom engine WASM
- bloomModule = await import(bloomPkgUrl);
- await bloomModule.default(); // Initialize wasm-bindgen
-
- // 2. Build FFI import object mapping bloom_* names to wasm-bindgen exports.
- // Perry WASM imports these under the "ffi" namespace.
- const ffiImports = buildFfiImports();
-
- // 3. Fetch and instantiate Perry game WASM.
- // Perry's runtime JS (wasm_runtime.js) is embedded in the HTML.
- // We provide our FFI imports to it.
+export async function bootBloomGame() {
+ if (booted) return bloomModule;
+ booted = true;
+
+ // 1. Load Bloom engine WASM.
+ await init();
+ bloomModule = bloom;
+
+ // 2. Initialise Jolt physics (WASM build of Jolt). The jolt_bridge.js module
+ // wasm-bindgen bundled into pkg/snippets/ owns all physics state; we reach
+ // it via the Rust-exported helper so bloom_physics_* talk to one instance.
+ // Override globalThis.__joltFactory to self-host instead of the CDN.
+ try {
+ const factory = globalThis.__joltFactory
+ ?? (await import('https://cdn.jsdelivr.net/npm/jolt-physics@1.0.0/+esm')).default;
+ await bloom.bloom_physics_init_jolt(factory);
+ console.log('[bloom] Jolt physics ready');
+ } catch (e) {
+ console.warn('[bloom] Jolt init failed:', e, '- bloom_physics_* calls will be no-ops');
+ }
+
+ // 3. Publish the FFI surface for the Perry game WASM.
+ const ffi = buildFfiImports();
if (typeof globalThis.__ffiImports === 'undefined') {
- globalThis.__ffiImports = ffiImports;
+ globalThis.__ffiImports = ffi;
} else {
- Object.assign(globalThis.__ffiImports, ffiImports);
+ Object.assign(globalThis.__ffiImports, ffi);
}
- // If Perry's bootPerryWasm is available (self-contained HTML mode),
- // it will use __ffiImports. Otherwise, load game WASM directly.
- if (gameWasmUrl && typeof globalThis.bootPerryWasm === 'undefined') {
- console.warn('bloom_glue: Perry runtime not found. Load game via Perry HTML output.');
- }
+ // 4. DOM wiring (input + HiDPI canvas sizing).
+ setupDomBridge();
+
+ // 5. Hide the loading indicator, if present.
+ const loading = document.getElementById('loading');
+ if (loading) loading.style.display = 'none';
+
+ console.log('[bloom] engine + FFI ready');
+ return bloomModule;
}
/**
- * Build the FFI imports object from Bloom wasm-bindgen exports.
- * All bloom_* functions are mapped directly, plus bloom_run_game
- * is intercepted to set up the rAF loop.
+ * Build the `bloom_*` FFI import object. Defaults pass straight through to the
+ * matching wasm-bindgen export (correct for all numeric-in/out and string-out
+ * functions); the overrides below handle string-in, file loading, the host
+ * surfaces (window/title/audio/fullscreen/cursor/storage), and the game loop.
*/
function buildFfiImports() {
const imports = {};
- // Map all bloom_* exports
- for (const [name, fn] of Object.entries(bloomModule)) {
+ // Default: every bloom_* export, passed plain values by Perry's wrapFfiForI64.
+ for (const [name, fn] of Object.entries(bloom)) {
if (typeof fn === 'function' && name.startsWith('bloom_')) {
imports[name] = fn;
}
}
- // Intercept bloom_run_game to set up the rAF loop
- imports['bloom_run_game'] = (callbackHandle) => {
- gameCallbackHandle = callbackHandle;
- gameRunning = true;
- startRafLoop();
- };
+ // --- Text: string params route to the _str variants ---
+ imports.bloom_draw_text = (text, x, y, size, r, g, b, a) =>
+ bloom.bloom_draw_text_str(String(text), x, y, size, r, g, b, a);
+ imports.bloom_draw_text_ex = (font, text, x, y, size, spacing, r, g, b, a) =>
+ bloom.bloom_draw_text_ex_str(font, String(text), x, y, size, spacing, r, g, b, a);
+ imports.bloom_measure_text = (text, size) =>
+ bloom.bloom_measure_text_str(String(text), size);
+ imports.bloom_measure_text_ex = (font, text, size, spacing) =>
+ bloom.bloom_measure_text_ex_str(font, String(text), size, spacing);
- // Intercept bloom_window_should_close to return 1.0 on web
- // so that while(!windowShouldClose()) loops exit immediately
- // when runGame is being used (the rAF loop takes over)
- const origShouldClose = imports['bloom_window_should_close'];
- imports['bloom_window_should_close'] = () => {
- // Once runGame is called, signal the while loop to exit
- if (gameRunning) return f64ToI64(1.0);
- return origShouldClose ? origShouldClose() : f64ToI64(0.0);
+ // --- Materials & post-FX: shader source strings route to _str variants ---
+ const materialVariants = [
+ 'bloom_compile_material', 'bloom_compile_material_refractive',
+ 'bloom_compile_material_transparent', 'bloom_compile_material_additive',
+ 'bloom_compile_material_cutout', 'bloom_compile_material_instanced',
+ 'bloom_add_post_pass', 'bloom_set_post_pass',
+ ];
+ for (const name of materialVariants) {
+ const strFn = bloom[name + '_str'];
+ if (strFn) imports[name] = (source) => strFn(String(source));
+ }
+ // compile_material_from_file: no web filesystem — fetch the source, compile it.
+ imports.bloom_compile_material_from_file = (path, _bucketKind) => {
+ const src = syncFetchText(String(path));
+ return src != null ? bloom.bloom_compile_material_str(src) : 0;
};
- return imports;
-}
-
-/**
- * Start the requestAnimationFrame game loop.
- * Each frame: begin_drawing → invoke game callback → end_drawing.
- */
-function startRafLoop() {
- function frame() {
- if (!gameRunning) return;
+ // --- Asset loading: fetch the file synchronously, hand bytes to _bytes ---
+ const byteLoaders = {
+ bloom_load_texture: 'bloom_load_texture_bytes',
+ bloom_load_font: 'bloom_load_font_bytes',
+ bloom_load_sound: 'bloom_load_sound_bytes',
+ bloom_load_music: 'bloom_load_music_bytes',
+ bloom_load_model: 'bloom_load_model_bytes',
+ bloom_load_model_animation: 'bloom_load_model_animation_bytes',
+ bloom_load_image: 'bloom_load_image_bytes',
+ };
+ for (const [name, bytesName] of Object.entries(byteLoaders)) {
+ const bytesFn = bloom[bytesName];
+ if (!bytesFn) continue;
+ imports[name] = (path) => {
+ const data = syncFetchBytes(String(path));
+ return data ? bytesFn(data) : 0;
+ };
+ }
- bloomModule.bloom_begin_drawing();
+ // --- Window / title ---
+ imports.bloom_init_window = (w, h, title, fullscreen) => {
+ document.title = String(title) || 'Bloom Engine';
+ // Web export ignores the title slot (_title: f64); pass 0 for it.
+ bloom.bloom_init_window(w, h, 0, fullscreen);
+ };
+ imports.bloom_set_window_title = (title) => { document.title = String(title); };
- // Invoke the game's callback with delta time
- if (gameCallbackHandle !== null) {
- const dt = bloomModule.bloom_get_delta_time();
- invokePerryCallback(gameCallbackHandle, dt);
+ // --- Web Audio ---
+ let audioContext = null;
+ let audioProcessor = null;
+ imports.bloom_init_audio = () => {
+ try {
+ audioContext = new AudioContext({ sampleRate: 44100 });
+ const bufSize = 4096;
+ audioProcessor = audioContext.createScriptProcessor(bufSize, 0, 2);
+ audioProcessor.onaudioprocess = (e) => {
+ const left = e.outputBuffer.getChannelData(0);
+ const right = e.outputBuffer.getChannelData(1);
+ const interleaved = new Float32Array(left.length * 2);
+ bloom.bloom_audio_mix(interleaved);
+ for (let i = 0; i < left.length; i++) {
+ left[i] = interleaved[i * 2];
+ right[i] = interleaved[i * 2 + 1];
+ }
+ };
+ audioProcessor.connect(audioContext.destination);
+ } catch (e) {
+ console.warn('[bloom] Web Audio init failed:', e);
}
+ };
+ imports.bloom_close_audio = () => {
+ if (audioProcessor) { audioProcessor.disconnect(); audioProcessor = null; }
+ if (audioContext) { audioContext.close(); audioContext = null; }
+ };
- bloomModule.bloom_end_drawing();
+ // --- Fullscreen / cursor ---
+ imports.bloom_toggle_fullscreen = () => {
+ const canvas = document.getElementById('bloom-canvas');
+ if (!document.fullscreenElement) canvas?.requestFullscreen?.().catch(() => {});
+ else document.exitFullscreen().catch(() => {});
+ };
+ imports.bloom_disable_cursor = () => {
+ document.getElementById('bloom-canvas')?.requestPointerLock?.();
+ bloom.bloom_disable_cursor();
+ };
+ imports.bloom_enable_cursor = () => {
+ document.exitPointerLock?.();
+ bloom.bloom_enable_cursor();
+ };
- rafId = requestAnimationFrame(frame);
- }
+ // --- File I/O via localStorage (no real filesystem on web) ---
+ const LS_PREFIX = 'bloom_fs:';
+ imports.bloom_write_file = (path, data) => {
+ try { localStorage.setItem(LS_PREFIX + String(path), String(data)); return 1; }
+ catch { return 0; }
+ };
+ imports.bloom_file_exists = (path) =>
+ localStorage.getItem(LS_PREFIX + String(path)) !== null ? 1 : 0;
+ imports.bloom_read_file = (path) => {
+ const v = localStorage.getItem(LS_PREFIX + String(path));
+ return v === null ? '' : v; // plain string; Perry re-encodes via wrapFfiForI64
+ };
- rafId = requestAnimationFrame(frame);
+ // --- Game loop ---
+ // runGame() on web (bloom_get_platform() === 7) just hands its update closure
+ // to bloom_run_game and returns; the blocking native loop is never entered.
+ // We capture the closure and drive it from requestAnimationFrame.
+ imports.bloom_run_game = (callback) => {
+ gameCallback = callback;
+ if (!gameRunning) {
+ gameRunning = true;
+ startRafLoop();
+ }
+ };
+
+ return imports;
}
/**
- * Invoke a Perry closure/function handle.
+ * Drive the captured Perry game closure once per animation frame:
+ * begin_drawing → callback(dt) → end_drawing.
*
- * Perry closures are stored in the JS handle store and invoked via
- * the indirect function table. We use the mem_dispatch closure_call_1
- * function from Perry's runtime.
+ * The closure is invoked through Perry's `callWasmClosure`, a global helper its
+ * runtime exposes that resolves the closure's function-table index + captures
+ * against the live game WASM instance. By the time the first frame runs, the
+ * runtime classic
+