Web Export¶
Export any SimVX game to a static HTML page that runs entirely in the browser: no backend, no build step. 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.
An export is the .html plus one sibling .png, the pre-baked font atlas the page fetches at boot. Deploy both. --single-file folds the atlas into the markup instead, for a page that has to travel alone or open straight off disk.
Quick Start¶
uv run simvx export web my_game.py \
--output game.html --width 800 --height 600 \
--title "My Game" --root MyGameNode
That writes game.html and game.atlas.png. Upload both to any static host and open the page over
http(s). The atlas is fetched as a sibling, and a browser refuses that fetch on a page opened from
file://, so to double-click a local export use --single-file:
uv run simvx export web my_game.py --output game.html --single-file
Your game code, engine code and assets are in the page either way. The only external fetch is the Pyodide runtime, loaded from a CDN on first visit and cached by the browser after that.
How It Works¶
The export tool bundles everything into one page plus its font atlas:
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 glyph regions + metrics (inline JSON)
├─ WebGPU renderer (renderer2d.js + WGSL shaders, inlined)
└─ Input capture (keyboard, mouse, touch → Python Input singleton)
game.atlas.png ← pre-baked MSDF font atlas, fetched at boot
(base64 in the HTML instead, under --single-file)
Each frame:
JavaScript calls
WebApp.tick(dt)via PyodidePython runs the scene tree:
on_fixed_update(dt)→on_update(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.
Toolchain requirements¶
Build-time native tools the export machine needs beyond the Python environment:
C compiler (gcc/clang): required once to build the optional basis_universal texture transcoder extension via
simvx build-textures.glslcon PATH: GLSL→SPIR-V compilation (same requirement as the desktop Vulkan backend).naga(naga-cli), recommended 29.0.3: transpilesShaderMaterialGLSL→WGSL at export time. Install with:cargo install --version 29.0.3 naga-cli
Any 29.0.x patch is accepted (output is byte-identical across the series for our shaders, and the committed golden WGSL fixtures catch any actual drift); a new major/minor series requires re-baselining those fixtures.
simvx build-textures: builds the basis_universal transcoder used for compressed textures (KTX2/Basis). Optional; exports degrade gracefully without it.
Exports involving custom shaders or compressed textures can degrade silently when a
tool is missing or a shader falls back: pass --strict (see the
custom shading coverage table below) to fail the export instead.
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.
Bundling assets¶
Binary assets (textures, audio, glTF meshes, …) are embedded in the HTML so Path("assets/foo.png").read_bytes() resolves in the browser exactly as on disk. Font files are the exception: a face swept out of a directory is dropped, because the engine rasterises no face in the browser and so a bundled one cannot be the typeface your text is drawn in. Name the face you want with --font and it is baked into the glyph atlas instead (see Font Atlas). A font file named on its own (--asset fonts/MyFace.woff2) does ship, for a page that registers it with the browser itself.
Automatic (default): assets kept in a conventional asset directory next to your game ship with zero config. The exporter bundles, recursively, any of
assets/,resources/,data/,fonts/,audio/,music/,sfx/,sounds/,images/,textures/,sprites/, ormodels/sitting directly beside the game file. A single-file procedural demo with no such folder ships no assets, and a sibling demo’sscreenshots/is never swept (it isn’t a named asset dir). Loose files at the game root are not auto-bundled, move them into an asset dir or list them explicitly.Explicit: pass
--asset PATH(repeatable) to bundle an exact set. Each entry resolves relative to the game file’s directory (or may be absolute); a file ships as-is, a directory ships recursively. When any--assetis given, only those paths are bundled (no auto-discovery). A font file named on its own ships as-is like any other file; one merely sitting inside a directory you named is dropped, the same as a swept one.
# Ship exactly these, nothing else:
uv run simvx export web my_game.py --asset levels/ --asset hero.png
export_web("my_game.py", "game.html", assets=["levels/", "hero.png"])
.py / .toml / .json files are never bundled as raw assets, they are collected as source and as virtual data files (readable via tomllib / json.load) instead.
What is never bundled¶
The source, data and asset walks all run over your game’s directory, and share one exclusion rule so a folder can never be half-bundled. A sub-directory is skipped when it is:
hidden (a leading dot). Nothing importable can live there, since a module name cannot start with a dot. This covers
.venv/,.git/,.tox/,.mypy_cache/,.pytest_cache/,.ruff_cache/and whatever cache the next tool invents.named
venv,site-packages,node_modules,__pypackages__,__pycache__, or*.egg-info, at any depth (site-packagesnever sits at the top).a virtualenv, identified by a
pyvenv.cfgwhatever the directory is called.a nested checkout or worktree, identified by a
.git. Your game’s own directory is never tested this way, only its sub-directories, so a game living at the root of a checkout still bundles itself.a build backend’s output: a
build/holdinglib*/bdist.*/temp.*/scripts-*, or adist/holding a.whl/.tar.gz/.egg. The name alone is not enough, so your ownbuildpackage and yourassets/build/icons still ship.
Everything skipped is named in the export log. mygame/.venv/ is the default layout of python -m venv, uv venv and poetry; bundling one used to produce a page of hundreds of megabytes that the browser could not load, with nothing on the console to diagnose it from.
Bundle size¶
The exporter measures everything the page carries (engine and game sources, data files, base64 assets), warns past 24 MB, and refuses past --max-bundle-mb (64 MB by default), naming the largest contributors:
Bundle payload is 78.2 MB across 9099 entries (Python sources 74.6 MB, assets 3.6 MB). Largest contributors:
55.66 MB vendor
4.43 MB simvx
For scale, the largest page published today carries 11.4 MB. A page of tens of megabytes fails silently: the browser parses the literal for minutes or gives up, with nothing on the console. Raise the limit to ship a large game deliberately, or pass 0 to lift the check:
uv run simvx export web my_game.py --max-bundle-mb 128
uv run simvx export web my_game.py --max-bundle-mb 0 # no check at all
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 |
|
auto (asset dirs) |
Explicit asset file/dir to bundle (repeatable). When given, ONLY these ship |
|
|
Typeface baked into the page’s glyph atlas, resolved against the game file’s directory. Give it a |
|
|
Refuse the export when the bundled payload passes this many MB. |
|
off |
Refuse the export when the game uses anything the browser cannot run (see What the export tells you) instead of naming it and shipping the drop |
|
auto |
Force the physics backend. |
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 |
|
|
|
Typeface to bake into the atlas, relative to the game file’s directory ( |
|
|
|
Adapt viewport to browser window size |
|
|
|
Pyodide CDN version (canonical default from |
|
|
|
Additional Pyodide packages to load |
|
|
|
Embed the atlas in the markup instead of shipping it beside the page ( |
|
|
|
Atlas pixel storage: |
|
|
|
Refuse a bundled payload larger than this many MB; |
|
|
|
Refuse the export when the game uses a feature the browser cannot run ( |
|
|
|
Force the physics backend instead of reading it from the game ( |
Example: Tic Tac Toe¶
The tictactoe example exports to a 256 KB HTML file (before Pyodide CDN):
uv run simvx export web \
examples/demos/tictactoe/main.py \
--output tictactoe.html --width 400 --height 550 \
--title "Tic Tac Toe" --root TicTacToeApp
The game renders identically to the desktop version: same UI widgets, same layout, same input handling.
Testing a web export¶
A web export runs your game in Pyodide + WebGPU, a different runtime from the desktop
backend. A passing desktop run does not prove the export works: web-only code paths
(the EngineStub, the WebGPU renderer, the JS bundle) can break independently. Smoketest
the actual .html headlessly with the bundled runner:
# First run only: fetch the headless browser.
uv run --with playwright python -m playwright install chromium
# Headless Chromium + WebGPU. xvfb-run is required on Linux -- canvas WebGPU needs a
# real display, so a virtual one (Xvfb) is mandatory for pixel capture.
xvfb-run -a uv run --with playwright python tools/web_smoketest.py out.html \
--frames 120 --out shot.png
It serves the export, waits for the first rendered frame (window.__simvx_ready), advances
--frames, screenshots the canvas, and asserts it is not blank. Crucially it captures the
browser console, so a Python exception in your per-frame on_update/draw path surfaces as
a traceback in the output instead of a silently blank page. For static checks (no browser),
tools/validate_web_runtime.sh runs node --check over the runtime JS plus naga WGSL
validation.
Font Atlas¶
Text rendering uses MSDF (Multi-channel Signed Distance Field) font atlases. The export tool:
Bakes the font the engine ships with, so the same game exported on two machines gets the same typeface. To set a page in your own face, name it:
--font fonts/MyFont.ttf,export_web(font=...), or once insimvx.tomlunder[export.web] font.--fontandexport_web(font=...)resolve their path against the game file’s directory;[export.web] fontresolves against the directory holdingsimvx.toml, as every other path in that file does. A path that names no file refuses the export rather than shipping a page in the wrong typeface. Failing all of those the[rendering]font keys of the exported game’s ownsimvx.tomldecide, wherever you run the export from:fontnames the face to bake, andprefer_system_fonts = truebakes the export machine’s own font insteadScans your game’s string literals, and the translation catalogues in its asset directories, to determine which characters are needed (
[rendering] localesnarrows that to the languages you ship)Pre-renders the MSDF atlas at export time
Writes it as
<page>.atlas.pngbeside the page, which fetches it at boot
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.
A character the baked face has no glyph for is borrowed from the fallback faces that are the same on every machine: the ones the project named in [rendering] fallback_fonts, or failing that the "fallback" face the engine ships. They are packed into the same atlas at the same pixel size, so the page draws them exactly as the desktop does.
The rest of the desktop fallback chain is not baked. On the desktop, CJK and nerd fonts installed on the machine are found automatically; baking those would put one developer’s system font into the page and leave the next developer’s build without it. So a game that renders Japanese on a developer’s desktop with no configuration at all bakes no Japanese unless it names the face in fallback_fonts. Ship the fonts you need.
The atlas is the only font data an export needs to carry: a face swept out of a fonts/ directory, or out of a directory you passed to --asset, is dropped from the bundle. FreeType does not run in the browser, so the engine draws your text from the baked atlas and from nothing else, and a face riding along in the bundle could not change that while base64 charged a third over its own size to carry it. (A font file named on its own as an --asset still ships: not to set the game’s type, which the bake settles, but for a page that registers the face with the browser itself for the characters the atlas could not hold.) The export says how many it dropped and names the face it baked in their place, which is what a game whose [rendering] font or [export.web] font already picks its own face will see. It says so as a warning only when nothing named a face at all, since a page that ships a typeface and is set in the fallback one is about to render in the wrong face. Anything left out, and anything the scan could not predict, is rasterised in the browser at runtime from the player’s own fonts. See Fonts and Languages for choosing the typeface, shipping fallback faces for other scripts, and controlling what the unbaked characters are drawn in.
One face per page: the atlas holds a single typeface, so a game that draws headings in one face and body in another gets the named one throughout. Baking several is a separate piece of work.
Delivery and storage¶
The atlas is the largest single payload an export carries, so it ships as a PNG the page fetches at boot. Base64 in the markup would cost a third more bytes and make the browser parse the whole literal before it runs a line of script. The fetch overlaps the multi-second Pyodide download, so it costs no wall-clock time, and no text can draw before it lands: the renderer is not published until the atlas is on the GPU. The URL carries a digest of the PNG’s contents, so a re-export can never be served a stale atlas against fresh glyph regions. --single-file inlines the same bytes instead, which is what a page opened over file:// needs.
Past half a megabyte the atlas is stored single-channel. The fragment shaders decode a glyph as median(r, g, b), and a greyscale PNG decodes back to r = g = b, so storing that median is what the shader was going to compute anyway, at roughly a quarter of the bytes. What is given up is sub-texel corner reconstruction, which only becomes visible when a glyph is magnified well past the 48px it was baked at. A game that sets headlines that large can keep the full field with --atlas-channels rgba.
Audio¶
Web exports get full audio via the Web Audio API. AudioPlayer, AudioPlayer2D, and AudioPlayer3D 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. Files (WAV / OGG / MP3 / FLAC) ship as raw bytes and are decoded by the browser’s
AudioContext.decodeAudioData, which is thestream_mode = "memory"path and is what the browser is good at.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, and what it does not cover: a chunk-fed PCM channel plays via an
AudioWorkletNodethat consumes 16-bit PCM at the source sample rate (44.1 kHz), falling back toScriptProcessorNodewhenAudioWorkletis unavailable (legacy Safari < 14.1). Anything that renders its own PCM and feeds it,AudioSynth.attach_toabove all, reaches the browser through that path unchanged. Streaming a file does not work in any container: both routes to it need a decoder in the Python process, and the browser’s decoders belong to the browser rather than toWebAudioBackend.stream_mode = "streaming"on a file therefore warns, emitsstream_open_failed, and plays nothing. Usestream_mode = "memory", which hands the bytes toAudioContext.decodeAudioDataand decodes off the main thread. See SimVX Audio System, “Streaming a file on web”.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) |
✓ |
|
Custom |
✓ |
the effect’s GLSL body is transpiled to WGSL at export (build-time |
Lighting & shadows¶
Feature |
Web |
Notes |
|---|---|---|
Directional CSM shadows |
✓ |
cascaded shadow maps for sun-like lights |
Point-light cube shadows |
✓ |
omnidirectional distance atlas, six array layers per caster. |
Spot-light 2D shadows |
✓ |
perspective-projected depth atlas, one layer per caster. Same budget and same device rule as the row above |
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 |
|
✓ |
Fullscreen user shaders. |
|
✓ |
Transpiled GLSL→WGSL at export (build-time |
Audio¶
The full audio stack lives in the Audio section above. All
AudioPlayer* 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.
Where to register input actions¶
The page instantiates your root class directly. It never calls main() and
never runs the if __name__ == "__main__": block, so an
InputMap.add_action call that lives only there never executes in the browser.
The export names the calls it finds inside def main(), with their line
numbers; a call in the __main__ block is not reported, so a silent export is
not proof your bindings survived.
Register from the root’s input_actions class attribute, or from a node
lifecycle callback such as on_ready: both run in the browser exactly as they
do on desktop. The entry module itself is imported (it ships as __game__.py),
so module-level statements do run, but the root’s own declaration keeps the
actions with the scene that uses them.
What the export tells you¶
Parity is the default: a feature works in the browser unless the platform genuinely
cannot do it. Where it cannot, the export says so at build time, naming the feature
and the source line that used it, rather than shipping a page that boots and is
quietly wrong. --strict turns the whole set into a refusal.
Named at export |
Why the browser cannot run it |
What to do instead |
|---|---|---|
|
a tab cannot spawn a process |
keep the shell out of the exported build |
|
there is no tty |
as above |
|
FreeType cannot run in the browser |
name the face with |
direct |
the page owns the canvas and renders through WebGPU |
drive the window through |
direct |
the browser decodes and mixes audio itself |
play through the |
a |
see custom shading |
fix the shader, or accept the fallback |
a |
the same GLSL-to-WGSL step, applied to the effect’s fragment body |
fix the effect, or accept a page that renders without it |
Detection walks the module’s syntax tree, so a feature named in a docstring, a
comment or an unrelated string literal is not reported. It does not follow control
flow, though: an import guarded by a platform check
(if sys.platform != "emscripten": import subprocess) is still named, and under
--strict still refused. Keep the desktop-only path in a module the exported entry
point does not import.
Physics backend¶
The Jolt runtime is 3.7 MB of wasm and glue, so it ships only when the game asks for
it. The export decides that the way the runtime does: an explicit
PhysicsRoot(backend=...) in the sources that ship, then the project’s
physics_backend setting. Every spelling counts (either quote, a module constant, a
settings dict, a direct simvx.physics.jolt import). A game that picks its backend
from an expression the export cannot evaluate is warned about by name and line; pass
--physics-backend jolt to settle it. The warning stands even when the project
config names another backend, because an explicit PhysicsRoot(backend=...)
outranks that setting at runtime.
Bundling the runtime is not the same as selecting it: the web Jolt adapter is
registered but never auto-selected, so a page that carries the wasm still runs the
builtin backend unless something names Jolt. A game that names it in its own source
already does. When the choice came from somewhere the browser cannot see – the
project’s physics_backend setting, or --physics-backend – the export writes that
one setting into the bundle as .simvx/config.json, which the page reads exactly as
the desktop build reads the project config. Nothing else from the developer’s config
ships.
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.