# Web Export Export any SimVX game as a standalone HTML file that runs entirely in the browser. No server required: the game logic runs in [Pyodide](https://pyodide.org/) (CPython compiled to WebAssembly) and renders via WebGPU. Both 2D and 3D games are supported; 3D node usage is auto-detected. ## Quick Start ```bash uv run simvx export web my_game.py \ --output game.html --width 800 --height 600 \ --title "My Game" --root MyGameNode ``` Open `game.html` in Chrome or Edge. The file is fully self-contained: host it on any static site. ## How It Works The export tool bundles everything into a single HTML file: ``` HTML file ├─ Pyodide runtime (loaded from CDN, ~15 MB, cached by browser) ├─ simvx.core engine (Python source, bundled inline) ├─ Game code (Python source, bundled inline) ├─ MSDF font atlas (pre-baked PNG, base64-encoded) ├─ WebGPU renderer (renderer2d.js + WGSL shaders, inlined) └─ Input capture (keyboard, mouse, touch → Python Input singleton) ``` Each frame: 1. JavaScript calls `WebApp.tick(dt)` via Pyodide 2. Python runs the scene tree: `on_physics_process(dt)` → `on_process(dt)` → `on_draw(renderer)` 3. `Draw2D` commands are serialized to a compact binary format 4. JavaScript passes the binary data to the WebGPU renderer 5. Four GPU pipelines render: filled shapes, lines, MSDF text, textured quads ## Requirements **Export machine:** Standard SimVX dev environment (freetype-py for atlas generation). **Browser:** WebGPU support required: Chrome 113+, Edge 113+, or Firefox Nightly with `dom.webgpu.enabled`. ## Declaring Dependencies Games that import packages beyond `numpy` (which is always loaded) need to declare them so the export tool includes them in the Pyodide bundle. There are three ways, checked in priority order: ### PEP 723 Inline Script Metadata (single-file games) Add a `# /// script` block at the top of your game file. This is the [standard Python mechanism](https://peps.python.org/pep-0723/) for single-file scripts: ```python # /// script # dependencies = ["pillow>=10.0", "scipy"] # /// from simvx.core import Node class MyGame(Node): ... ``` ### pyproject.toml (project-based games) For games organised as a project with a `pyproject.toml`, declare dependencies in the standard `[project]` table: ```toml [project] name = "my-game" dependencies = [ "pillow>=10.0", "scipy", ] ``` The export tool reads `pyproject.toml` from the same directory as the game file. PEP 723 metadata takes precedence if both are present. ### CLI / API Override Pass additional packages directly, regardless of what the game declares: ```bash uv run simvx export web my_game.py \ --packages requests aiohttp ``` ```python export_web("my_game.py", "game.html", extra_packages=["requests", "aiohttp"]) ``` These are merged with any declared dependencies. Version specifiers and `simvx-*` packages are automatically stripped. ## Command-Line Reference ``` uv run simvx export web [options] ``` | Option | Default | Description | |--------|---------|-------------| | `--output`, `-o` | `.html` | Output HTML file path (defaults to the input file's stem) | | `--width` | `800` | Engine viewport width | | `--height` | `600` | Engine viewport height | | `--title` | `SimVX` | Browser page title | | `--root` | auto-detect | Root `Node` subclass name | | `--physics-fps` | `60` | Physics tick rate | | `--target-fps` | = `--physics-fps` | Display frame cap | | `--no-responsive` | responsive on | Disable responsive viewport sizing (the CLI adapts the viewport to the browser window by default) | | `--pyodide-version` | `DEFAULT_PYODIDE_VERSION` | Pyodide CDN version | | `--packages` | none | Additional Pyodide packages to load | Root class is auto-detected from the first class definition in the game module. Specify `--root` if the file contains multiple classes. ## Python API ```python from simvx.web.export import export_web export_web( "my_game.py", "game.html", width=800, height=600, title="My Game", root_class="MyGameNode", physics_fps=60, extra_packages=["pillow"], ) ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `game_path` | `str \| Path` | required | Path to the game's Python module | | `output` | `str \| Path` | `"game.html"` | Output HTML file path | | `width` | `int` | `800` | Engine viewport width | | `height` | `int` | `600` | Engine viewport height | | `title` | `str` | `"SimVX"` | Browser page title | | `root_class` | `str \| None` | `None` | Root Node subclass (auto-detected if None) | | `physics_fps` | `int` | `60` | Physics tick rate | | `target_fps` | `int \| None` | `None` | Display frame cap (defaults to `physics_fps`) | | `charset` | `str \| None` | `None` | Characters to pre-bake in the MSDF atlas | | `responsive` | `bool` | `True` | Adapt viewport to browser window size | | `pyodide_version` | `str` | `DEFAULT_PYODIDE_VERSION` | Pyodide CDN version (canonical default from `simvx.web.export`) | | `extra_packages` | `list[str] \| None` | `None` | Additional Pyodide packages to load | ## Example: Tic Tac Toe The tictactoe example exports to a 256 KB HTML file (before Pyodide CDN): ```bash uv run simvx export web \ packages/graphics/examples/game_tictactoe/game.py \ --output tictactoe.html --width 400 --height 550 \ --title "Tic Tac Toe" --root TicTacToeGame ``` The game renders identically to the desktop version: same UI widgets, same layout, same input handling. ## Font Atlas Text rendering uses MSDF (Multi-channel Signed Distance Field) font atlases. The export tool: 1. Finds a system font on the export machine 2. Scans your game's string literals to determine which characters are needed 3. Pre-renders the MSDF atlas at export time 4. Embeds the atlas as a base64 PNG in the HTML This eliminates the freetype-py dependency at runtime. If your game generates text dynamically (e.g., user input), ensure the charset covers the expected characters. ## Audio Web exports get full audio via the Web Audio API. `AudioStreamPlayer`, `AudioStreamPlayer2D`, and `AudioStreamPlayer3D` work unchanged: the same code that runs on Vulkan plays in the browser. The engine swaps `MiniaudioBackend` for `WebAudioBackend` (`packages/web/src/simvx/web/audio/web_backend.py`), which bridges the duck-typed backend interface to a JS-side `AudioBridge`. Behaviour notes: - **Source formats**: procedurally synthesised numpy buffers ship as float32 PCM through the resource channel. File-backed streams (WAV / OGG / MP3 / FLAC) ship as raw bytes and are decoded by the browser's `AudioContext.decodeAudioData`. - **Spatialisation**: distance attenuation, pan, and Doppler run in the player nodes (the same code as desktop). The bridge applies the resulting `(gain, pan, pitch)` per channel via `GainNode` and `StereoPannerNode`. No HRTF: playback matches desktop sample-for-sample. - **Buses**: each `AudioBus` becomes a `GainNode` parented to its `send_to` target. Bus volume / mute changes propagate live to playing channels, matching the desktop `MiniaudioBackend` and the pure-Python fallback mixer. All three backends pick up `AudioBusLayout` changes within the next frame / audio period: desktop via `sync_bus_layout()` driven from `SceneTree` each frame, web via a per-drain bus diff that emits `bus` calls to the JS bridge. - **User-gesture gate**: browsers require a user interaction before audio plays (autoplay policy). The first `keydown` / `mousedown` / `touchstart` resumes the `AudioContext`. Sounds triggered before the first gesture are queued (bounded buffer of 32) and replayed on resume. - **Streaming**: `AudioStreamPlayer` with `stream_mode = "streaming"` plays via an `AudioWorkletNode` that consumes 16-bit PCM chunks fed at the source sample rate (44.1 kHz). The bridge falls back to `ScriptProcessorNode` when `AudioWorklet` is unavailable (legacy Safari < 14.1). - **Sample rate**: `AudioContext.sampleRate` is browser-controlled (typically 48 kHz). Static `AudioBuffer`s are auto-resampled at playback. ## Internals: resource channel protocol The browser-side renderer receives two streams from the Pyodide runtime each frame: the **scene binary** (viewports, materials, lights, draw groups) and the **resource channel** (texture / mesh / audio uploads). The resource channel uses a typed TLV wire format so new resource kinds slot in without protocol surgery. See [Resource channel wire format](resource-channel.md) for the full spec, kind registry, Python and JS entry points, and rationale for what stays in-frame vs on the channel. ## Renderer feature coverage The WebGPU renderer aims for visual parity with the Vulkan forward renderer. The table below catalogues every effect plumbed through `WorldEnvironment` and the camera, plus the systems exposed on `simvx.core`. Use it to budget visual features when targeting the web. ### Post-processing & environment | Feature | Web | Notes | |-------------------------------|-----|-------| | Bloom | ✓ | `bloom_enabled`, `bloom_threshold`, `bloom_intensity` | | Distance fog | ✓ | `fog_enabled`, exponential / linear modes | | Height fog | ✓ | `height_fog_enabled`, `height_fog_min/max` | | Volumetric fog | ✓ | raymarched, low sample count for browser cost | | Tonemap operators | ✓ | ACES, Neutral, Reinhard, Uchimura | | `tonemap_exposure` / `tonemap_white` | ✓ | scalar + white-point inputs | | Vignette | ✓ | `vignette_enabled`, `vignette_strength`, `vignette_softness` | | Chromatic aberration | ✓ | `chromatic_aberration_*` | | Film grain | ✓ | `film_grain_enabled`, `film_grain_amount` | | 3D LUT colour grading | ✓ | `lut_path` (PNG strip), `lut_amount` | | FXAA | ✓ | `fxaa_enabled` | | Motion blur | ✓ | camera + per-object velocity buffer | | SSAO | ✓ | `ssao_enabled`, `ssao_radius`, `ssao_strength` | | Depth of field | ✓ | `dof_enabled`, focal distance, aperture | | Camera exposure composition | ✓ | `Camera3D.exposure` multiplies the tonemap input | | TAA (temporal anti-aliasing) | ✓ | `WorldEnvironment.taa_enabled`; Halton-jittered, web-only (no desktop counterpart) | ### Lighting & shadows | Feature | Web | Notes | |-------------------------------|-----|-------| | Directional CSM shadows | ✓ | cascaded shadow maps for sun-like lights | | Point-light cube shadows | ✓ | omnidirectional depth cubes | | Spot-light 2D shadows | ✓ | perspective-projected depth map | | Split-sum IBL | ✓ | irradiance + prefiltered specular + BRDF LUT | | Skybox (gradient) | ✓ | procedural gradient | | Skybox (cubemap) | ✓ | 6-face cubemap upload | | Skybox (equirect HDR) | ✓ | runtime equirect→cube projection | ### Scene & rendering systems | Feature | Web | Notes | |-------------------------------|-----|-------| | CPU particles | ✓ | `Particles2D`, `Particles3D` | | GPU particles | ✓ | `GPUParticles2D`/`GPUParticles3D`: compute-driven SSBO path (`gpu_particle_pass.js` + `particle_sim.wgsl`), per-emitter state mirroring desktop `ParticleCompute` | | Tilemaps (orthogonal) | ✓ | | | Tilemaps (isometric) | ✓ | shared with desktop renderer | | glTF loader | ✓ | meshes, materials, textures | | Frame capture | ✓ | `simvx.graphics.save_png(...)` analogue via WebGPU readback | | GPU timestamp profiler | ✓ | exposed through `App.last_telemetry` | | Debug draw line overlay | ✓ | `renderer.debug_*` calls | ### Custom shading | Feature | Web | Notes | |-------------------------------|-----|-------| | `Material(albedo_map=ndarray)` | ✓ | numpy texture upload: used by Q1K3 + HexGL for procedural ramps | | `ShaderMaterial` | ✗ | Vulkan-only (`simvx.graphics.materials.custom_shader`); no GLSL→WGSL translator on web. Web ports must bake a texture (`albedo_map=ndarray`) or route around custom per-pixel shading | ### Audio The full audio stack lives in the [Audio](#audio) section above. All `AudioStreamPlayer*` nodes work unchanged; the engine swaps the miniaudio backend for `WebAudioBackend`, which bridges to the Web Audio API. ### Input `Input` + `InputMap` ride the same code path as desktop. Touch surfaces as `MouseButton.LEFT` so existing pointer code is mobile-playable out of the box. Gamepad polling goes through the browser Gamepad API. ## Limitations - **WebGPU required**: no Canvas2D or WebGL fallback. - **First load**: Pyodide runtime (~15 MB) is downloaded from CDN on first visit. Subsequent visits use the browser cache. - **Performance**: Python in WebAssembly is ~2-5x slower than native. UI-widget games run at 60fps easily; compute-heavy games may need optimisation. - **No filesystem**: `open()`, `subprocess`, and filesystem operations are not available. - **Pyodide packages only**: declared dependencies must be [available in Pyodide](https://pyodide.org/en/stable/usage/packages-in-pyodide.html). Pure-Python packages work; C-extension packages need Pyodide-specific builds.