Web export¶
Export a SimVX game to a static page that runs in the browser with no server-side code. The game runs in Pyodide (CPython compiled to WebAssembly) and renders through WebGPU. 2D and 3D games are both supported; the export detects 3D use on its own.
Quick start¶
simvx export web my_game.py -o game.html --title "My Game"
That writes game.html and game.atlas.png, the font atlas the page fetches at boot. Upload both
to any static host and open the page over http(s). A browser refuses that fetch on a page opened
from file://, so to open an export straight off disk, make it one file:
simvx export web my_game.py -o game.html --single-file
Your game code, the engine and your assets are in the page either way. The one external fetch is the Pyodide runtime, loaded from a CDN on first visit and cached by the browser after that.
The page runs your module as __main__, the way python my_game.py does, so the
if __name__ == "__main__": block is what starts the game in the browser too. A module that starts
no game when it runs is refused at export.
Requirements¶
Browser: WebGPU is required. Current Chrome and Edge ship it on the desktop; Firefox and Safari ship it on some platforms (support table).
Export machine: a normal SimVX install. A game with GLSL of its own (
ShaderMaterial,PostProcessEffect) also needsglslconPATHandnaga29.0.x, which transpiles the GLSL to WGSL:cargo install --version 29.0.3 naga-cli.
An export that cannot carry everything the game needs fails rather than shipping a degraded page:
a shader that will not transpile, a feature the browser cannot run (see
What the export tells you), a missing asset you listed, a
# /// simvx web table that does not parse, or a physics backend the game names that is not
installed.
Declaring dependencies¶
numpy is always loaded. Declare anything else so the export loads it into Pyodide, in one of
three ways (a script block wins over pyproject.toml):
# /// script
# dependencies = ["pillow>=10.0", "scipy"]
# ///
# pyproject.toml beside the game file
[project]
name = "my-game"
dependencies = ["pillow>=10.0", "scipy"]
simvx export web my_game.py --packages requests aiohttp
They are merged. Version specifiers and simvx-* packages are stripped. Each package must be
available in Pyodide: pure-Python
packages work, and C extensions need a Pyodide build.
Bundling assets¶
Assets are embedded in the page, so Path("assets/foo.png").read_bytes() works in the browser as
it does on disk.
Automatic: any of
assets/,resources/,data/,fonts/,audio/,music/,sfx/,sounds/,images/,textures/,sprites/ormodels/directly beside the game file ships, recursively. Loose files beside the game do not; move them into one of those directories or list them.Explicit:
--asset PATH(repeatable) bundles exactly the files and directories named, relative to the game file, and turns the automatic sweep off.
simvx export web my_game.py --asset levels/ --asset hero.png
.py, .toml and .json files ship as source and data files, readable with tomllib and
json.load. Font files swept from a directory are dropped, because the browser draws text from
the baked atlas only (see Font atlas); a font file you name with --asset ships.
The walk skips hidden directories, virtualenvs (any directory holding pyvenv.cfg), nested
checkouts (a .git), venv, site-packages, node_modules, __pypackages__, __pycache__,
*.egg-info, and build output (a build/ holding lib* or bdist.*, a dist/ holding wheels
or sdists). The export log names everything it skipped.
Bundle size¶
The export warns past 24 MB of bundled payload and refuses past --max-bundle-mb (64 by default),
naming the largest contributors. A page that large never finishes loading and reports nothing to
the console. Raise the limit to ship a large game deliberately, or pass 0 to lift the check.
Command-line reference¶
simvx export web <game.py> [options]
Option |
Default |
Description |
|---|---|---|
|
|
Output page |
|
|
Browser page title |
|
the game’s |
Engine viewport size |
|
responsive |
Keep the viewport at that size instead of following the browser window |
|
|
Physics tick rate |
|
|
Frame cap the page starts on; a game’s own |
|
none |
Extra Pyodide packages |
|
the asset directories |
Bundle exactly this file or directory (repeatable) |
|
|
Typeface baked into the atlas; give a |
|
off |
Embed the atlas, for a page that opens from disk |
|
|
|
|
|
What the GPU device does in a background tab: |
|
read from the game |
Force the physics backend; |
|
|
Refuse a larger payload; |
|
the engine’s pinned version |
Pyodide CDN version |
|
off |
Export an example whose web table says |
Python API¶
from simvx.web.export import export_web
export_web("my_game.py", "game.html", title="My Game", extra_packages=["pillow"])
export_web takes the same settings as the command line, as keyword arguments: width,
height, title, physics_fps, target_fps, responsive, extra_packages, assets, font,
charset, locales, single_file, atlas_channels, background_policy, max_bundle_mb,
physics_backend and pyodide_version. It returns the path of the page.
Testing a web export¶
A passing desktop run does not prove the export works: the browser runs a different renderer and
runtime. From a source checkout, tools/web_smoketest.py serves an export in headless Chromium,
waits for the first frame, advances the frames you ask for, screenshots the canvas, asserts it is
not blank, and prints the browser console, so a Python exception in your game shows as a traceback
rather than a blank page:
uv run --with playwright python -m playwright install chromium # once
xvfb-run -a uv run --with playwright python tools/web_smoketest.py game.html --frames 120 --out shot.png
On Linux xvfb-run is required: canvas WebGPU needs a display, and Xvfb provides a virtual one.
Font atlas¶
Text in the browser is drawn from an MSDF (multi-channel signed distance field) atlas baked at export time:
The face. The engine’s own face, so one game exported on two machines looks the same. Name another with
--font fonts/MyFont.ttf,export_web(font=...)(both relative to the game file) or[export.web] fontinsimvx.toml(relative to that file). Failing those, the game’s[rendering] fontdecides, andprefer_system_fonts = truebakes the export machine’s font. A path that names no file refuses the export.The characters. The export scans your string literals and the translation catalogues in your asset directories;
[rendering] localesnarrows that to the languages you ship. Passexport_web(charset=...)for text the scan cannot predict, such as player input.Fallbacks. A character the face lacks is taken from
[rendering] fallback_fonts, or the engine’s"fallback"face, baked into the same atlas. System fonts the desktop would find (CJK, nerd fonts) are not baked, so name every face you need infallback_fonts. Anything the atlas still lacks is rasterised in the browser from the player’s own fonts.
The atlas holds one typeface, so a game that uses two faces is drawn in the named one throughout. Fonts and languages covers choosing faces and fallbacks.
The atlas ships as a PNG beside the page, fetched while Pyodide downloads; its URL carries a
digest of its contents, so a re-export is never served a stale atlas. Past half a megabyte it is
stored single-channel, at about a quarter of the bytes; that gives up sharp corners on glyphs
magnified well past the 48 px bake size, which --atlas-channels rgba keeps.
Audio¶
AudioPlayer, AudioPlayer2D and AudioPlayer3D work unchanged; in the browser they play
through the Web Audio API.
Formats: WAV, OGG, MP3 and FLAC files are decoded by the browser; synthesised NumPy buffers ship as PCM.
Spatialisation: attenuation, pan and Doppler are computed by the player nodes as on the desktop. There is no HRTF.
Buses: each
AudioBusbecomes a Web Audio gain node, and volume and mute changes apply live.First gesture: browsers allow audio only after the player interacts with the page. Sounds triggered before the first key, click or touch are queued and play then.
Streaming: chunk-fed PCM (
AudioSynth.attach_to, for example) streams through anAudioWorkletNode. Streaming a file does not work in the browser:stream_mode = "streaming"on a file warns, emitsstream_open_failedand plays nothing. Usestream_mode = "memory". See Audio.Sample rate: the browser chooses it (usually 48 kHz) and resamples buffers.
Renderer feature coverage¶
The WebGPU renderer aims for visual parity with the Vulkan renderer. Use these tables to budget visual features for the web.
Post-processing and 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 and 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 and 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 |
|
✓ |
Fullscreen user shaders. |
|
✓ |
Transpiled GLSL→WGSL at export (build-time |
Input¶
Input, actions and @on_input handlers run the same code as on the desktop. The primary finger
also presses Mouse.LEFT while Input.emulate_mouse is on, so pointer code works on a phone.
For gameplay controls, add a TouchController rather than drawing buttons by hand: it is a
CanvasLayer that publishes a pad, so self.add_child(TouchController()) makes a game that binds
Controller.A and Controller.LEFT_STICK playable with two thumbs.
The page reports what the browser knows: coarse_pointer and pointer_lock in
Input.capabilities, and the edges a soft keyboard, notch or home indicator covers as
tree.screen_insets, read live. Keep anything the player must see or touch out of the insets,
and read them every frame.
Controllers are polled through the Gamepad API before each frame. Two browser rules apply:
A pad is invisible to the page until the player has interacted with the page and pressed something on the pad, so a title screen cannot assume one is there.
A pad the browser cannot map as a standard controller arrives with
ControllerDevice.recognisedFalse and only numbered controls (Controller.button(i),Controller.axis(i)).
ControllerDevice.rumble works where the browser supports Gamepad haptics. ControllerDevice.guid
is the vendor and product the browser reports, such as "045e:028e", so a saved rebinding
survives a replug; it is empty when the browser reports neither.
Declare input actions in a node’s input_context (the root’s covers the whole scene) or in
[input] in simvx.toml. InputMap.add_action before the tree exists raises in the browser as
it does on the desktop.
What the export tells you¶
A feature works in the browser unless the platform cannot do it. Where it cannot, the export refuses, naming the feature and the source line that used it:
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 terminal |
as above |
|
FreeType does not run in the browser |
name the face with |
direct |
the page owns the canvas and renders through WebGPU |
use |
direct |
the browser decodes and mixes audio itself |
play through the |
a |
see custom shading |
fix the shader |
Detection reads the module’s syntax tree, so a name in a docstring, comment or string is not
reported. It does not follow control flow: an import behind
if sys.platform != "emscripten": is still refused. Keep desktop-only code in a module the
exported entry point does not import.
Physics backend¶
The Jolt runtime ships only when the game asks for it, decided as the runtime decides: an explicit
PhysicsRoot(backend=...) in the shipped sources, then the project’s physics_backend setting.
A backend chosen by an expression the export cannot evaluate is warned about by name and line;
pass --physics-backend jolt to settle it.
Bundling Jolt does not select it: a page carrying it still runs the built-in backend unless
something names Jolt. When the choice came from the project setting or --physics-backend,
rather than from the game’s source, the export writes that one setting into the bundle as
.simvx/config.json. Nothing else from your config ships.
Limitations¶
WebGPU only: there is no WebGL or Canvas2D fallback.
First load: Pyodide downloads from a CDN on the first visit; later visits use the browser cache.
Speed: Python in WebAssembly runs slower than native CPython; UI and 2D games run comfortably, compute-heavy games may need optimising.
Files: bundled files are readable, but the page cannot reach the player’s disk and nothing it writes outlasts the tab. No processes can be spawned.
Pyodide packages only: see Declaring dependencies.