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 (CPython compiled to WebAssembly) and renders via WebGPU. Both 2D and 3D games are supported; 3D node usage is auto-detected.
Quick Start¶
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:
JavaScript calls
WebApp.tick(dt)via PyodidePython runs the scene tree:
on_physics_process(dt)→on_process(dt)→on_draw(renderer)Draw2Dcommands are serialized to a compact binary formatJavaScript passes the binary data to the WebGPU renderer
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 for single-file scripts:
# /// 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:
[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:
uv run simvx export web my_game.py \
--packages requests aiohttp
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 <game.py> [options]
Option |
Default |
Description |
|---|---|---|
|
|
Output HTML file path (defaults to the input file’s stem) |
|
|
Engine viewport width |
|
|
Engine viewport height |
|
|
Browser page title |
|
auto-detect |
Root |
|
|
Physics tick rate |
|
= |
Display frame cap |
|
responsive on |
Disable responsive viewport sizing (the CLI adapts the viewport to the browser window by default) |
|
|
Pyodide CDN version |
|
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¶
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 |
|---|---|---|---|
|
|
required |
Path to the game’s Python module |
|
|
|
Output HTML file path |
|
|
|
Engine viewport width |
|
|
|
Engine viewport height |
|
|
|
Browser page title |
|
|
|
Root Node subclass (auto-detected if None) |
|
|
|
Physics tick rate |
|
|
|
Display frame cap (defaults to |
|
|
|
Characters to pre-bake in the MSDF atlas |
|
|
|
Adapt viewport to browser window size |
|
|
|
Pyodide CDN version (canonical default from |
|
|
|
Additional Pyodide packages to load |
Example: Tic Tac Toe¶
The tictactoe example exports to a 256 KB HTML file (before Pyodide CDN):
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:
Finds a system font on the export machine
Scans your game’s string literals to determine which characters are needed
Pre-renders the MSDF atlas at export time
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 viaGainNodeandStereoPannerNode. No HRTF: playback matches desktop sample-for-sample.Buses: each
AudioBusbecomes aGainNodeparented to itssend_totarget. Bus volume / mute changes propagate live to playing channels, matching the desktopMiniaudioBackendand the pure-Python fallback mixer. All three backends pick upAudioBusLayoutchanges within the next frame / audio period: desktop viasync_bus_layout()driven fromSceneTreeeach frame, web via a per-drain bus diff that emitsbuscalls to the JS bridge.User-gesture gate: browsers require a user interaction before audio plays (autoplay policy). The first
keydown/mousedown/touchstartresumes theAudioContext. Sounds triggered before the first gesture are queued (bounded buffer of 32) and replayed on resume.Streaming:
AudioStreamPlayerwithstream_mode = "streaming"plays via anAudioWorkletNodethat consumes 16-bit PCM chunks fed at the source sample rate (44.1 kHz). The bridge falls back toScriptProcessorNodewhenAudioWorkletis unavailable (legacy Safari < 14.1).Sample rate:
AudioContext.sampleRateis browser-controlled (typically 48 kHz). StaticAudioBuffers 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 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 |
✓ |
|
Distance fog |
✓ |
|
Height fog |
✓ |
|
Volumetric fog |
✓ |
raymarched, low sample count for browser cost |
Tonemap operators |
✓ |
ACES, Neutral, Reinhard, Uchimura |
|
✓ |
scalar + white-point inputs |
Vignette |
✓ |
|
Chromatic aberration |
✓ |
|
Film grain |
✓ |
|
3D LUT colour grading |
✓ |
|
FXAA |
✓ |
|
Motion blur |
✓ |
camera + per-object velocity buffer |
SSAO |
✓ |
|
Depth of field |
✓ |
|
Camera exposure composition |
✓ |
|
TAA (temporal anti-aliasing) |
✓ |
|
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 |
✓ |
|
GPU particles |
✓ |
|
Tilemaps (orthogonal) |
✓ |
|
Tilemaps (isometric) |
✓ |
shared with desktop renderer |
glTF loader |
✓ |
meshes, materials, textures |
Frame capture |
✓ |
|
GPU timestamp profiler |
✓ |
exposed through |
Debug draw line overlay |
✓ |
|
Custom shading¶
Feature |
Web |
Notes |
|---|---|---|
|
✓ |
numpy texture upload: used by Q1K3 + HexGL for procedural ramps |
|
✗ |
Vulkan-only ( |
Audio¶
The full audio stack lives in the 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. Pure-Python packages work; C-extension packages need Pyodide-specific builds.