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:

  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, 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/, 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). 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-packages never sits at the top).

  • a virtualenv, identified by a pyvenv.cfg whatever 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/ holding lib*/bdist.*/temp.*/scripts-*, or a dist/ holding a .whl/.tar.gz/.egg. The name alone is not enough, so your own build package and your assets/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, -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

--font

[export.web] font, then [rendering] font, then the bundled face

Typeface baked into the page’s glyph atlas, resolved against the game file’s directory. Give it a .ttf / .otf: the bake runs on the host through FreeType, which usually cannot read a .woff2. Naming a file that is not there refuses the export. The font file itself does not have to ship

--max-bundle-mb

64

Refuse the export when the bundled payload passes this many MB. 0 lifts the check

--strict

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

--physics-backend

auto

Force the physics backend. jolt bundles the Jolt runtime (3.7 MB) and ships the setting that selects it; any other name leaves it out. Omit to read it from the game

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

font

str | Path | None

None

Typeface to bake into the atlas, relative to the game file’s directory (--font); None reads [export.web] font (relative to simvx.toml), then the game’s own [rendering] keys

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

single_file

bool

False

Embed the atlas in the markup instead of shipping it beside the page (--single-file)

atlas_channels

str

"auto"

Atlas pixel storage: "auto", "rgba", "r" (--atlas-channels)

max_bundle_mb

float

DEFAULT_MAX_BUNDLE_MB (64)

Refuse a bundled payload larger than this many MB; 0 lifts the check (--max-bundle-mb)

strict

bool

False

Refuse the export when the game uses a feature the browser cannot run (--strict)

physics_backend

str | None

None

Force the physics backend instead of reading it from the game (--physics-backend)

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:

  1. 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 in simvx.toml under [export.web] font. --font and export_web(font=...) resolve their path against the game file’s directory; [export.web] font resolves against the directory holding simvx.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 own simvx.toml decide, wherever you run the export from: font names the face to bake, and prefer_system_fonts = true bakes the export machine’s own font instead

  2. Scans your game’s string literals, and the translation catalogues in its asset directories, to determine which characters are needed ([rendering] locales narrows that to the languages you ship)

  3. Pre-renders the MSDF atlas at export time

  4. Writes it as <page>.atlas.png beside 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 the stream_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 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, and what it does not cover: a chunk-fed PCM channel plays via an AudioWorkletNode that consumes 16-bit PCM at the source sample rate (44.1 kHz), falling back to ScriptProcessorNode when AudioWorklet is unavailable (legacy Safari < 14.1). Anything that renders its own PCM and feeds it, AudioSynth.attach_to above 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 to WebAudioBackend. stream_mode = "streaming" on a file therefore warns, emits stream_open_failed, and plays nothing. Use stream_mode = "memory", which hands the bytes to AudioContext.decodeAudioData and decodes off the main thread. See SimVX Audio System, “Streaming a file on web”.

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

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 & 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 & 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

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 is named at export and dropped (--strict refuses instead)

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 warns and the object falls back to the underlying Material, while --strict refuses instead

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

ShellNode, subprocess

a tab cannot spawn a process

keep the shell out of the exported build

fcntl, pty, termios

there is no tty

as above

simvx.core.text.Font, freetype

FreeType cannot run in the browser

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

direct glfw, vulkan, simvx.graphics.engine

the page owns the canvas and renders through WebGPU

drive the window through App and rendering through the node tree

direct miniaudio

the browser decodes and mixes audio itself

play through the AudioStreamPlayer* nodes

a ShaderMaterial that will not transpile

see custom shading

fix the shader, or accept the fallback Material

a PostProcessEffect that will not transpile

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.