Build scripts, CMake/source patches, npm packaging, and a Gitea CI workflow to compile the real Visual Pinball engine (SDL3 + WebGL2 + libwinevbs for real VBScript) to WebAssembly. The patches are validated end-to-end: the patched engine boots in a real browser, loads a real .vpx table, compiles its real GLSL shaders, computes environment map radiance, initializes physics, and starts the VBScript engine, before hanging on the one deliberately-deferred piece of work (game loop rewrite around emscripten_set_main_loop), documented in the README's roadmap.
vpinball-wasm
Visual Pinball's real engine, compiled to WebAssembly — real .vpx tables, real VBScript table logic, real WebGL2 rendering, in the browser.
This project ports Visual Pinball (the C++ pinball table simulator) to WebAssembly via Emscripten. It is a source-level port of the actual engine — not a reimplementation — so table compatibility, physics behavior, and scripting semantics come from the real codebase real tables already run against today.
Status: engine boots and renders in-browser; main loop rewrite is the one remaining blocker
This is further along than a typical "does it compile" milestone. As of this writing, the patched engine, running in Chrome via this project's build:
- Loads a real
.vpxfile (the bundled default table) and parses it completely (OLE/BIFF container, all game items, images, sounds metadata). - Initializes SDL3's Emscripten audio backend and creates a real window via SDL3's Emscripten video driver.
- Compiles all of vpinball's real, unmodified
.glfxshaders (UI, Basic, Ball, DMD, Flasher, Light, Framebuffer — 80 shaders total) against WebGL2/GLES3. - Computes environment map radiance (HDR/IBL, via FreeImage decoding the table's
.exrasset) and runs the static pre-render pass (including reflection probes). - Initializes the real physics engine (octree construction) and starts the real VBScript scripting engine (libwinevbs).
- Reaches
Player::Player@832 Startup done/Unpausing Game— i.e., initialization completes successfully end to end.
It then hangs: the classic desktop game loop (FramePacingGameLoop/GPUQueueStuffingGameLoop in player.cpp) is a blocking native while loop, which cannot run on a single-threaded WASM main thread without yielding control back to the browser. This is the one deliberately-deferred piece of engineering work — see Roadmap below. Everything upstream of it (file loading, rendering setup, shader compilation, physics init, script engine startup) is proven working, in the real engine, in a real browser.
Why a source-level port, not a reimplementation
Projects like vpx-js reimplement Visual Pinball's physics and a VBScript-to-JavaScript transpiler from scratch in TypeScript. That approach has to independently re-derive correct behavior for every physics quirk and every VBScript language feature — and in vpx-js's case, its own test suite documents that it never achieved compatibility with core.vbs, the shared script library nearly all real tables depend on.
This project instead compiles the actual C++ engine and the actual VBScript interpreter (libwinevbs, extracted from Wine, the same interpreter real tables already run against on macOS/Linux/iOS/Android today) to WebAssembly. Table compatibility and scripting correctness come from code that's already correct, not from independently re-deriving it.
Architecture
.vpx file (OLE/BIFF) → vpinball's own loader (POLE-based, unmodified)
│
┌──────────────────────┴───────────────────────┐
│ │
Physics engine Rendering
(unmodified C++) SDL3 → WebGL2/GLES3 (glad)
│ real .glfx shaders
│ │
VBScript table logic ←────────────────────── libwinevbs
(real Wine-derived interpreter, compiled to wasm32)
│
emscripten_set_main_loop
(frame stepping - in progress, see Roadmap)
This project's own contribution is glue, not a rewrite: a new PLATFORM=emscripten CMake target modeled on vpinball's existing Linux build, wasm32 builds of its SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs dependencies, and a handful of small source patches (~160 lines total across 8 files) fixing genuine first-32-bit-target and first-wasm-target issues — see patches/ for the exact diffs, each with an explanatory comment.
Full research and two isolated feasibility spikes (proving real VBScript execution and real WebGL2 rendering work under Emscripten, independently, before this project existed) live in the companion research repository:
- VBScript-in-WASM spike: proves
libwinevbscompiles and correctly runs real VBScript (classes,Scripting.Dictionary, error handling) under wasm32. - SDL3/WebGL2 spike: proves SDL3's Emscripten backend creates a real WebGL2 context and GLSL ES 3.00 rendering works.
- Feasibility reports covering the native engine,
vpx-js, andlibwinevbsspecifically.
Roadmap
Proven working (independent spikes, before this project's own build existed):
- Real VBScript execution under wasm32 (libwinevbs).
- Real WebGL2/GLES3 rendering under Emscripten (SDL3).
Proven working (in this project's own build, in a real browser):
.vpxfile loading and parsing.- SDL3 audio/video/window initialization under Emscripten.
- Compilation of all of vpinball's real, unmodified
.glfxshaders against WebGL2. - Environment map / HDR radiance computation.
- Physics engine initialization (octree).
- VBScript engine startup.
- Static scene pre-render pass, including reflection probes.
The current blocker (next milestone):
- Rewrite
player.cpp's game loop dispatch to add an__EMSCRIPTEN__branch driven byemscripten_set_main_loop(), based on the existing (currently BGFX-only)CallbackSteppedGameLoop()"step one frame" function, instead of the blockingFramePacingGameLoop()/GPUQueueStuffingGameLoop()loops the non-BGFX/GL path uses today. This is real engineering work, not a flag flip —WinMain's control flow needs to reach "loop registered, return now" without blocking first.
Explicitly out of scope for v1 (browser-sandbox constraints, not a technical dead end — could be revisited later):
- All plugins (PinMAME, DOF, B2S, FlexDMD, Inspector, FFmpeg-dependent features, PUP, AltSound). Excluded via a single CMake guard; zero core-engine impact.
- BGFX renderer path (multi-threaded design doesn't fit a single-threaded wasm32 v1). This project uses vpinball's existing
RENDERER=GL(glad/GLES) path instead. - Raw HID device input (
OpenPinDevHandler/ hidapi) — no browser equivalent for real-hardware nudge/plunger boards. - Multi-threading (no
-pthread/SharedArrayBufferin v1 —ThreadPool's one-off parallel work, like parallel.vpxitem deserialization, runs synchronously instead; seepatches/vpinball/0002-*).
Further follow-up work once the main loop is fixed:
- Browser file-loading UX (drag-and-drop / file picker for user-supplied
.vpxfiles beyond the bundled demo table). - Input mapping (keyboard/gamepad through SDL3's Emscripten backend).
- Binary size / performance tuning (this build is unoptimized
-O1-equivalent; a real-O2/-Osrelease pass, dead-code stripping, and only-needed-SDL3-subsystem linking are all still ahead). - A browser-based (Puppeteer) CI smoke test, replacing today's compile-only CI check.
Build
Requires: bison ≥ 3.8.2, curl, git, cmake ≥ 3.25, python3 (for the dev server), a POSIX shell. Emscripten itself is installed automatically by setup.sh. Expect a multi-gigabyte emsdk/ + build/ footprint and a first build in the tens of minutes (subsequent builds are much faster, especially with CI caching).
./scripts/setup.sh # installs emsdk + bison check, fetches vpinball + libwinevbs at pinned commits, applies patches/
source emsdk/emsdk_env.sh
./scripts/build-deps.sh # builds SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs for wasm32
./scripts/build.sh # configures + builds vpinball itself -> dist/vpinball.{js,wasm,data}
./scripts/dev-server.sh # serves dist/ locally for manual testing
scripts/build.sh --debug produces a build with assertions and runtime-exit-on-return enabled, useful for diagnosing engine startup issues (this is exactly how the current game-loop blocker above was diagnosed).
npm usage
import { loadPinball } from '@valknar/vpinball-wasm';
const pinball = await loadPinball({ canvas: document.querySelector('canvas') });
pinball.start();
The published package name/registry (@valknar/vpinball-wasm on this project's Gitea npm registry) is a placeholder pending the first real release — see package.json. Runtime lifecycle control (start/stop/dispose) depends on the main-loop rewrite above being completed; today the module boots and initializes but doesn't yet expose a controllable running loop from JS.
Known limitations
- Single-threaded only (no pthreads/SharedArrayBuffer) — see Roadmap.
- No plugin ecosystem (PinMAME/DOF/B2S/DMD/etc.) — see Roadmap.
- No raw hardware input (real cabinet nudge/plunger boards).
- Not yet performance/size-tuned — current unoptimized build produces a ~51MB asset bundle (mostly vpinball's own
src/assets/textures/EXR files) and a ~1.2MB.wasm.
Licensing
Visual Pinball itself is under a mixed license: the project has been migrating file-by-file from a legacy "old MAME"-like license to GPLv3+ since October 2020; each GPLv3+ file is marked // license:GPLv3+ at its top, and any file without that marking remains under the legacy license. See vendor/vpinball/LICENSE (fetched by setup.sh) for the authoritative text — do not treat this README as a substitute for reading it before redistributing built artifacts. libwinevbs is LGPL-2.1 (Wine-derived) with a handful of small ATL header stubs of less certain provenance (see its own README.md). This project's own glue code (CMake integration, patches, npm wrapper) has no license conflict with either, but the combined built artifact's distribution terms are governed by vpinball's and libwinevbs's licenses, not just this repository's.
Attribution
- Visual Pinball — the engine this project ports.
- libwinevbs — the real VBScript interpreter (Wine-derived), compiled here to wasm32.
- Wine — original source of the VBScript/OLE Automation engine libwinevbs extracts and packages.