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 needs glslc on PATH and naga 29.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/ or models/ 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

-o, --output

<input stem>.html

Output page

--title

SimVX

Browser page title

--width, --height

the game’s # /// simvx web table, else 800 and 600

Engine viewport size

--no-responsive

responsive

Keep the viewport at that size instead of following the browser window

--physics-fps

60

Physics tick rate

--target-fps

--physics-fps

Frame cap the page starts on; a game’s own App(target_fps=...) replaces it

--packages PKG...

none

Extra Pyodide packages

--asset PATH

the asset directories

Bundle exactly this file or directory (repeatable)

--font PATH

[export.web] font, then [rendering] font, then the engine’s face

Typeface baked into the atlas; give a .ttf or .otf

--single-file

off

Embed the atlas, for a page that opens from disk

--atlas-channels

auto

auto, rgba or r; see Font atlas

--background-policy

keep

What the GPU device does in a background tab: keep, pause, or release to free GPU memory and rebuild on return

--physics-backend NAME

read from the game

Force the physics backend; jolt bundles the Jolt runtime

--max-bundle-mb MB

64

Refuse a larger payload; 0 lifts the check

--pyodide-version

the engine’s pinned version

Pyodide CDN version

--force

off

Export an example whose web table says disabled = true

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:

  1. 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] font in simvx.toml (relative to that file). Failing those, the game’s [rendering] font decides, and prefer_system_fonts = true bakes the export machine’s font. A path that names no file refuses the export.

  2. The characters. The export scans your string literals and the translation catalogues in your asset directories; [rendering] locales narrows that to the languages you ship. Pass export_web(charset=...) for text the scan cannot predict, such as player input.

  3. 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 in fallback_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 AudioBus becomes 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 an AudioWorkletNode. Streaming a file does not work in the browser: stream_mode = "streaming" on a file warns, emits stream_open_failed and plays nothing. Use stream_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

✓

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)

Custom PostProcessEffect

✓

the effect’s GLSL body is transpiled to WGSL at export (build-time naga) and runs between the TAA resolve and tonemap, exactly where the desktop chain runs it

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. WorldEnvironment.shadow_caster_count sets how many lights of each kind get a map, up to four here against eight on the desktop, and the same rule ranks them on both. A device granting fewer than 18 sampled textures per fragment stage renders positional lights unshadowed rather than failing to start

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

✓

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

PostProcessEffect

✓

Fullscreen user shaders. u_colour_tex, u_depth_tex, u_resolution, u_time, v_uv and your own uniforms behave as on desktop, including wrap="repeat"/"mirror"; uniforms are repacked every frame so animation crosses to the browser. An effect that will not transpile refuses the export

ShaderMaterial

✓

Transpiled GLSL→WGSL at export (build-time naga) and rendered via a per-material pipeline, textures and transparent=True included: the exporter publishes the group(2) texture/sampler bindings it read from the emitted WGSL, so the browser builds the same layout Vulkan does. A declaration neither backend can bind (combined sampler, storage image, cube or array texture) is named at export, quoting the shader line, and is treated like a shader that will not transpile at all: the export refuses

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.recognised False 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

ShellNode, subprocess

a tab cannot spawn a process

keep the shell out of the exported build

fcntl, pty, termios

there is no terminal

as above

simvx.core.text.Font, freetype

FreeType does not run in the browser

name the face with --font or [export.web] font so it is baked into the atlas

direct glfw, vulkan, simvx.graphics.engine

the page owns the canvas and renders through WebGPU

use App and the node tree

direct miniaudio

the browser decodes and mixes audio itself

play through the AudioPlayer nodes

a ShaderMaterial or PostProcessEffect that will not transpile

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.