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:

  1. JavaScript calls WebApp.tick(dt) via Pyodide

  2. Python runs the scene tree: on_fixed_update(dt)on_update(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.

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.

  • glslc on PATH: GLSL→SPIR-V compilation (same requirement as the desktop Vulkan backend).

  • naga (naga-cli), recommended 29.0.3: transpiles ShaderMaterial GLSL→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, fonts, glTF meshes, …) are embedded in the HTML so Path("assets/foo.png").read_bytes() resolves in the browser exactly as on disk.

  • 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/, or models/ sitting directly beside the game file. A single-file procedural demo with no such folder ships no assets, and a sibling demo’s screenshots/ 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 --asset is given, only those paths are bundled (no auto-discovery).

# 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.

Command-Line Reference

uv run simvx export web <game.py> [options]

Option

Default

Description

--output, -o

<input>.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

--asset

auto (asset dirs)

Explicit asset file/dir to bundle (repeatable). When given, ONLY these ship

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

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):

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.

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:

  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. 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. 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: AudioPlayer 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 AudioBuffers 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

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

Opaque, UBO-only shaders are transpiled GLSL→WGSL at export (build-time naga) and render via a per-material pipeline. Separated textures (group(2) binding 1+) and transparent variants warn and fall back to the underlying Material. Use --strict to fail the export on any fallback instead of degrading silently

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.

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.