Skip to content

Commit 21ff900

Browse files
committed
feat: add web/WASM target — run Bloom games in the browser
Add web as the 7th platform target. Games compile to WebAssembly and render via WebGPU/WebGL in the browser, with Web Audio for sound. Architecture: Perry compiles game TS to WASM (with FFI imports under "ffi" namespace), Bloom's Rust backend compiles to a second WASM module via wasm-pack, and a JS glue layer bridges both modules. Shared code changes: - Feature-flag minimp3 (C dep, not WASM-compatible) behind "mp3" feature - Add "web" feature using web-time crate for Instant - Gate std::thread::sleep and screenshot readback for WASM New native/web/ crate (1380 lines): - All ~130 FFI functions via #[wasm_bindgen] - Async WebGPU init (canvas + wgpu BROWSER_WEBGPU | GL) - _str variants for text functions (wasm-bindgen &str) - _bytes variants for asset loading (wasm-bindgen &[u8]) - bloom_audio_mix bridge for Web Audio ScriptProcessorNode JS glue layer (index.html): - NaN-box conversion via __perryToJsValue for string params - Sync XHR asset fetching (textures, fonts, sounds, models) - Web Audio API with shared Rust AudioMixer - requestAnimationFrame game loop (Emscripten-style) - localStorage file I/O, Fullscreen API, Pointer Lock API - DOM event listeners for keyboard/mouse/touch input injection New APIs: - runGame(update) — cross-platform game loop (rAF on web, while loop on native) - Platform.WEB (7) — platform detection constant - bloom_run_game FFI function added to all 7 platforms Build tooling: - native/web/build.sh — wasm-pack + wasm-opt + perry compile + assembly - "web" target in package.json nativeLibrary config
1 parent 48c0832 commit 21ff900

24 files changed

Lines changed: 3959 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code when working with the Bloom Engine codebase.
4+
5+
## Project Overview
6+
7+
Bloom is a native TypeScript game engine compiled by [Perry](../../perry/perry) (a TypeScript AOT compiler). It provides a simple, function-based API for 2D/3D games that compiles to Metal, DirectX 12, Vulkan, OpenGL, and WebGPU.
8+
9+
## Build Commands
10+
11+
```bash
12+
# Native (macOS example)
13+
cd native/macos && cargo build --release
14+
15+
# Web/WASM
16+
cd native/web && cargo check --target wasm32-unknown-unknown
17+
./native/web/build.sh [game.ts] # Full web build pipeline
18+
19+
# Check shared code compiles for all targets
20+
cd native/shared && cargo check # native (default features)
21+
cd native/shared && cargo check --target wasm32-unknown-unknown --no-default-features --features web # WASM
22+
```
23+
24+
## Architecture
25+
26+
```
27+
src/ TypeScript API (compiled by Perry)
28+
core/ Window, input, game loop, runGame()
29+
shapes/ 2D shapes + collision
30+
textures/ Image loading, sprites
31+
text/ Font rendering
32+
audio/ Sound + music
33+
models/ 3D models, skeletal animation
34+
math/ Vectors, matrices, easing
35+
scene/ Scene graph, frame callbacks, lighting
36+
37+
native/ Rust implementations (one crate per platform)
38+
shared/ Cross-platform core (~7000 lines)
39+
- renderer.rs: wgpu + WGSL shaders (2D/3D, shadows, post-FX)
40+
- audio.rs: platform-agnostic mixer
41+
- text_renderer.rs: fontdue-based text
42+
- textures.rs, models.rs, scene.rs, etc.
43+
macos/ Metal + AppKit + Core Audio
44+
ios/ Metal + UIKit + Core Audio
45+
tvos/ Metal + UIKit + GCController
46+
windows/ DirectX 12 + Win32 + WASAPI
47+
linux/ Vulkan/OpenGL + X11 + PulseAudio
48+
android/ Vulkan/OpenGL ES + NativeActivity + AAudio
49+
web/ WebGPU/WebGL + Canvas + Web Audio API (WASM via wasm-pack)
50+
```
51+
52+
## FFI Pattern
53+
54+
Each platform implements ~130 `bloom_*` FFI functions declared in `package.json` under `perry.nativeLibrary.functions`. Native platforms use `#[no_mangle] extern "C"`, web uses `#[wasm_bindgen]`.
55+
56+
String parameters are `i64` on native (Perry StringHeader pointers) and NaN-boxed string IDs on web (converted by JS glue layer).
57+
58+
## Web/WASM Target
59+
60+
The web target uses a two-module WASM architecture:
61+
- **Perry WASM** (game logic) imports bloom_* functions under the `"ffi"` namespace
62+
- **bloom_web.wasm** (rendering engine) compiled from `native/web/` via wasm-pack
63+
- **JS glue** (`index.html`) bridges both modules, handles DOM events, string conversion, asset fetching, and Web Audio
64+
65+
Key features flags in `native/shared/Cargo.toml`:
66+
- `default = ["mp3"]` — includes minimp3 (C dep, not WASM-compatible)
67+
- `web` — uses web-time instead of std::time::Instant
68+
69+
The web crate exposes `_str` variants (accepting `&str`) and `_bytes` variants (accepting `&[u8]`) for functions that take strings or file data. The JS glue converts Perry NaN-boxed values via `__perryToJsValue` and fetches assets via sync XHR.
70+
71+
## Key Files
72+
73+
| File | Purpose |
74+
|------|---------|
75+
| `package.json` | FFI function manifest + per-platform build config |
76+
| `src/core/index.ts` | Core API: window, input, drawing, `runGame()` |
77+
| `native/shared/src/renderer.rs` | wgpu renderer (2D/3D, ~2600 lines) |
78+
| `native/shared/src/engine.rs` | EngineState with timing, frame callbacks |
79+
| `native/web/src/lib.rs` | Web platform: all FFI functions via wasm-bindgen |
80+
| `native/web/index.html` | JS glue: FFI bridge, input, asset loading, Web Audio |
81+
| `native/web/build.sh` | Build script: wasm-pack + wasm-opt + assembly |

README.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
**Native games from TypeScript.**
44

5-
Write TypeScript. Ship native games. No browser, no C++.
6-
Bloom compiles your game to Metal, DirectX 12, Vulkan, and OpenGL — one codebase for every platform.
5+
Write TypeScript. Ship native games — and now the web too.
6+
Bloom compiles your game to Metal, DirectX 12, Vulkan, OpenGL, and WebGPU — one codebase for every platform.
77

88
## Quick Start
99

@@ -21,11 +21,33 @@ while (!windowShouldClose()) {
2121
}
2222
```
2323

24+
### Web-Compatible Pattern
25+
26+
Use `runGame()` for code that works on both native and web:
27+
28+
```typescript
29+
import { initWindow, runGame, clearBackground, drawText, Colors } from "bloom";
30+
31+
initWindow(800, 450, "My Game");
32+
33+
runGame((dt) => {
34+
clearBackground(Colors.RAYWHITE);
35+
drawText("Hello, Bloom!", 190, 200, 20, Colors.DARKGRAY);
36+
});
37+
```
38+
39+
Build for web:
40+
41+
```bash
42+
./native/web/build.sh main.ts
43+
cd dist/web && python3 -m http.server 8080
44+
```
45+
2446
## Features
2547

2648
- **Simple API** — Functions, not classes. The entire API fits on a cheatsheet.
27-
- **True native** — Compiles to Metal, DirectX 12, Vulkan, and OpenGL via wgpu. No browser, no runtime.
28-
- **Ship everywhere** — macOS, Windows, Linux, iOS, tvOS, Android from one codebase.
49+
- **True native** — Compiles to Metal, DirectX 12, Vulkan, OpenGL, and WebGPU via wgpu.
50+
- **Ship everywhere** — macOS, Windows, Linux, iOS, tvOS, Android, and Web from one codebase.
2951
- **Unified 2D/3D** — Shapes, textures, text, 3D models, and audio in one engine.
3052
- **Zero magic** — Explicit game loops, no hidden framework overhead.
3153

@@ -51,6 +73,7 @@ while (!windowShouldClose()) {
5173
| iOS | Metal | Touch + gamepad |
5274
| tvOS | Metal | Siri Remote + gamepad |
5375
| Android | Vulkan / OpenGL ES | Touch + gamepad |
76+
| **Web** | **WebGPU / WebGL** | **Keyboard + mouse + touch + gamepad** |
5477

5578
## Architecture
5679

@@ -72,6 +95,7 @@ native/ Rust implementations
7295
windows/ DirectX 12 + Win32 + XAudio2
7396
linux/ Vulkan/OpenGL + X11/Wayland + PulseAudio
7497
android/ Vulkan/OpenGL ES + NativeActivity + AAudio
98+
web/ WebGPU/WebGL + Canvas + Web Audio (WASM)
7599
76100
examples/
77101
pong/ Complete working example (~170 lines)

docs/web-target.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Web/WASM Target
2+
3+
Bloom games can run in the browser via WebAssembly. The web target uses WebGPU (with WebGL fallback) for rendering and Web Audio API for sound.
4+
5+
## Architecture
6+
7+
```
8+
Game.ts ─(perry --target wasm)──> game.wasm (game logic in WASM)
9+
10+
│ FFI imports ("ffi" namespace)
11+
12+
index.html / JS glue (bridges both WASM modules)
13+
14+
│ wasm-bindgen calls
15+
16+
bloom_web.wasm (Bloom rendering in WASM)
17+
18+
19+
Browser: <canvas> + WebGPU + Web Audio + DOM Events
20+
```
21+
22+
Both game logic and rendering run in WebAssembly. A thin JS glue layer bridges the two modules, handles DOM events, string conversion, asset fetching, and audio output.
23+
24+
## Building
25+
26+
### Prerequisites
27+
28+
- [wasm-pack](https://rustwasm.github.io/wasm-pack/installer/): `cargo install wasm-pack`
29+
- [Perry compiler](../../perry/perry): built from source
30+
- wasm-opt (optional): `cargo install wasm-opt`
31+
32+
### Quick Build
33+
34+
```bash
35+
./native/web/build.sh path/to/game/main.ts
36+
```
37+
38+
This runs:
39+
1. `wasm-pack build` to compile `native/web/``bloom_web.wasm` + JS bindings
40+
2. `wasm-opt -Oz` for binary size optimization (if installed)
41+
3. `perry main.ts --target wasm` to compile game TypeScript → WASM
42+
4. Assembles output directory at `dist/web/`
43+
44+
### Serve Locally
45+
46+
```bash
47+
cd dist/web
48+
python3 -m http.server 8080
49+
# Open http://localhost:8080
50+
```
51+
52+
## Game Loop
53+
54+
Browsers cannot run blocking `while` loops. Use `runGame()` instead:
55+
56+
```typescript
57+
import { initWindow, runGame, clearBackground, drawRect, Colors } from "bloom";
58+
59+
initWindow(800, 600, "My Game");
60+
61+
runGame((dt) => {
62+
clearBackground(Colors.BLACK);
63+
drawRect(100, 100, 50, 50, Colors.RED);
64+
});
65+
```
66+
67+
On native, `runGame()` enters a blocking loop. On web, it passes the callback to the JS runtime which drives it via `requestAnimationFrame`.
68+
69+
The traditional `while (!windowShouldClose())` pattern still works on native but is not supported on web.
70+
71+
## Asset Loading
72+
73+
Assets are loaded via synchronous HTTP requests from the game's served directory:
74+
75+
```typescript
76+
const tex = loadTexture("assets/player.png"); // sync fetch from server
77+
const snd = loadSound("assets/jump.wav"); // WAV or OGG
78+
const model = loadModel("assets/scene.glb"); // glTF/GLB
79+
const font = loadFont("assets/font.ttf", 20); // TTF/OTF
80+
```
81+
82+
Place asset files in your game's `assets/` directory. The build script copies them to the output.
83+
84+
Supported formats:
85+
- **Images**: PNG, JPEG, BMP, TGA
86+
- **Audio**: WAV, OGG (MP3 not supported on web)
87+
- **Models**: glTF, GLB
88+
- **Fonts**: TTF, OTF
89+
90+
## Audio
91+
92+
Audio uses the Web Audio API with the shared Rust AudioMixer:
93+
94+
```typescript
95+
initAudio();
96+
const sound = loadSound("assets/click.wav");
97+
playSound(sound);
98+
```
99+
100+
The JS glue creates an `AudioContext` with a `ScriptProcessorNode` that calls `bloom_audio_mix()` each audio frame. The Rust AudioMixer handles mixing, volume, and spatial audio identically to native.
101+
102+
## File I/O
103+
104+
`writeFile` / `readFile` / `fileExists` use `localStorage` on web (prefixed with `bloom_fs:`):
105+
106+
```typescript
107+
writeFile("save.json", JSON.stringify(gameState));
108+
if (fileExists("save.json")) {
109+
const data = readFile("save.json");
110+
}
111+
```
112+
113+
## Platform Detection
114+
115+
```typescript
116+
import { getPlatform, Platform } from "bloom";
117+
118+
if (getPlatform() === Platform.WEB) {
119+
// web-specific code
120+
}
121+
```
122+
123+
## Browser Support
124+
125+
- **Chrome 113+**: WebGPU (best performance)
126+
- **Firefox 141+**: WebGPU
127+
- **Safari**: WebGPU in Technology Preview; WebGL fallback available
128+
- **Edge 113+**: WebGPU
129+
130+
The wgpu backend supports both WebGPU and WebGL. WebGL is used automatically as a fallback on browsers without WebGPU support.
131+
132+
## How It Works
133+
134+
### String Handling
135+
136+
Perry WASM uses NaN-boxed string IDs. The JS glue converts these to actual JS strings via `__perryToJsValue` (exposed by Perry's runtime), then passes them to Bloom's `_str` variants via wasm-bindgen.
137+
138+
### Two-Module WASM
139+
140+
Perry compiles game TypeScript to one WASM module. Bloom's Rust backend compiles to a second WASM module via wasm-pack. The JS glue:
141+
1. Loads bloom_web.wasm and extracts all `bloom_*` exports
142+
2. Wraps them as FFI imports (converting i64 BigInt args to f64)
143+
3. Provides string-param functions with NaN-box → string conversion
144+
4. Boots Perry WASM with these imports under the `"ffi"` namespace
145+
146+
### Shared Code
147+
148+
67% of Bloom's Rust code is in `native/shared/` — the renderer, audio mixer, text renderer, model loader, scene graph. This code compiles identically for native and WASM. Only the platform layer (~1300 lines in `native/web/src/lib.rs`) is web-specific.

native/android/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,3 +1120,8 @@ pub extern "C" fn bloom_commit_music(staging_handle: f64) -> f64 {
11201120
None => 0.0,
11211121
}
11221122
}
1123+
1124+
#[no_mangle]
1125+
pub extern "C" fn bloom_run_game(_callback: extern "C" fn(f64)) {
1126+
// No-op on native. The TypeScript runGame() helper provides the while loop.
1127+
}

native/ios/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,3 +1633,8 @@ pub extern "C" fn bloom_commit_music(staging_handle: f64) -> f64 {
16331633
None => 0.0,
16341634
}
16351635
}
1636+
1637+
#[no_mangle]
1638+
pub extern "C" fn bloom_run_game(_callback: extern "C" fn(f64)) {
1639+
// No-op on native. The TypeScript runGame() helper provides the while loop.
1640+
}

native/linux/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,11 @@ pub extern "C" fn bloom_unregister_frame_callback(id: f64) {
10261026
engine().frame_callbacks.unregister(id as u64);
10271027
}
10281028

1029+
#[no_mangle]
1030+
pub extern "C" fn bloom_run_game(_callback: extern "C" fn(f64)) {
1031+
// No-op on native. The TypeScript runGame() helper provides the while loop.
1032+
}
1033+
10291034
// ============================================================
10301035
// Multiple lights
10311036
// ============================================================

native/macos/src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,6 +1371,15 @@ pub extern "C" fn bloom_unregister_frame_callback(id: f64) {
13711371
engine().frame_callbacks.unregister(id as u64);
13721372
}
13731373

1374+
/// Emscripten-style game loop. On native, this is a no-op — the game uses
1375+
/// a while(!windowShouldClose()) loop in TypeScript instead. The TS-level
1376+
/// runGame() helper handles the native loop. Only used on web where the
1377+
/// JS glue intercepts this and drives requestAnimationFrame.
1378+
#[no_mangle]
1379+
pub extern "C" fn bloom_run_game(_callback: extern "C" fn(f64)) {
1380+
// No-op on native. The TypeScript runGame() helper provides the while loop.
1381+
}
1382+
13741383
// ============================================================
13751384
// Multiple lights
13761385
// ============================================================

0 commit comments

Comments
 (0)