Phase A (the hard blocker): the engine now plays a real table live in the browser via a real per-frame game loop, not just a static render. Two real, previously-unknown upstream bugs found and fixed along the way: - RenderDevice::WaitForVSync() unconditionally spawned a real std::thread every frame, even on __STANDALONE__ builds - a hard crash under Emscripten's single-threaded runtime (0003). - The desktop game loop is a blocking native while loop with manual uSleep throttling, incompatible with a single-threaded WASM main thread. Adds Player::EmscriptenStepFrame() (one frame, no internal loop) driven by emscripten_set_main_loop, plus new JS-callable lifecycle entry points (vpinball_wasm_start/stop/dispose in the new src/core/EmscriptenBridge.cpp) that bypass the desktop main()/WinMain() chain entirely, since that chain assumes the process runs exactly one table to completion then exits (0004). Verified end-to-end in Chrome: real .vpx load, real shader compilation, real physics/script engine init, a real running frame loop (observed advancing), and a clean stop() -> ~Player() teardown mid-session with no crash or hang. Phase B: trims the preloaded asset payload from ~51MB to ~11MB by excluding editor-only bundled example tables and a Monaco code editor never used by the player runtime, and adds real link-time optimization (-O2 --closure 1) and --use-preload-cache for repeat visits.
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: the engine plays a real table, live, in the browser
The patched engine, running in Chrome via this project's build, renders a real, interactive, physically-simulated pinball table from a real .vpx file — lit flippers, a ball, lane guides, wood-grain playfield, all via WebGL2 — driven by a real per-frame game loop, not a static screenshot. Concretely, verified end-to-end:
- Loads a real
.vpxfile and parses it completely (OLE/BIFF container, all game items, images, sounds metadata). - Compiles all of vpinball's real, unmodified
.glfxshaders (80 shaders across UI/Basic/Ball/DMD/Flasher/Light/Framebuffer) against WebGL2/GLES3, computes environment map radiance (HDR/IBL), and runs the static pre-render pass. - Initializes the real physics engine and the real VBScript scripting engine (
libwinevbs). - Runs a real per-frame game loop (
Player::EmscriptenStepFrame(), driven byemscripten_set_main_loop) stepping input, physics, and rendering every frame — verified by observing the frame counter advance and by a cleanstop()→~Player()teardown mid-session, with no crash or hang. - Ships a trimmed ~11MB asset payload (down from an initial ~51MB — see Roadmap).
Getting here required finding and fixing two real, previously-unknown bugs in the upstream engine (not just adding a new CMake target) — see patches/vpinball/0003-* and 0004-* for the exact fixes:
RenderDevice::WaitForVSync()unconditionally spawned a realstd::threadevery frame, even on__STANDALONE__builds — a hard crash under Emscripten's single-threaded runtime, independent of and encountered before the main-loop-yielding problem below.- The desktop game loop (
FramePacingGameLoop/GPUQueueStuffingGameLoop) is a blocking nativewhileloop with manualuSleepthrottling — incompatible with a single-threaded WASM main thread, which must yield control back to the browser every frame. This project adds a newPlayer::EmscriptenStepFrame()(one frame, no internal loop) driven byemscripten_set_main_loop, plus new JS-callable lifecycle entry points (vpinball_wasm_start/stop/disposeinsrc/core/EmscriptenBridge.cpp) that don't route through the desktopmain()/WinMain()chain at all, since that chain assumes the whole process runs exactly one table to completion then exits.
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
(Player::EmscriptenStepFrame, one frame per callback)
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, a small number of source patches fixing genuine first-32-bit-target and first-wasm-target issues, and the new game-loop/lifecycle bridge described above — 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.- Compilation of all of vpinball's real, unmodified
.glfxshaders against WebGL2; environment map/HDR radiance computation; static pre-render pass. - Physics engine initialization and the VBScript engine starting up.
- A real, running per-frame game loop (
emscripten_set_main_loop-driven), with clean JS-controllablestart()/stop()lifecycle and normal C++ teardown (~Player()) on stop — see Status above. - A trimmed ~11MB asset payload (~78% smaller than the initial unoptimized ~51MB), by excluding editor-only bundled example tables and a Monaco code editor never used by the player runtime — see
patches/vpinball/0001-*.
Not yet validated (should work, per code review, but unconfirmed end-to-end):
- Keyboard/gamepad input actually reaching a flipper/plunger during real gameplay (the input pipeline is code-complete and validated as wired correctly, but a definitive "pressed a key, ball moved" test is still open — see
src/input/SDLInputHandler.h/InputManager.cpp, unmodified). - Audio actually audible (the pipeline is fully wired per code review; needs a real speaker/headphone test plus a user-gesture gate for browser autoplay policy — see below).
- DMD rendering via a script-driven
Flasher/ScriptGlobalTable::put_DMDPixels(this is native, core-engine functionality requiring no new code — seesrc/core/ScriptGlobalTable.cpp:886-937,src/parts/flasher.cpp:1312-1341— just needs a test table that exercises it).
Explicitly out of scope for now (browser-sandbox constraints or a real infrastructure-cost tradeoff, not a technical dead end — could be revisited):
- Multi-threading (pthreads/SharedArrayBuffer): would require mandatory
Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policyheaders on every page hosting this widget, acoi-serviceworker-style workaround for static hosts that can't set custom headers, and real risk of breaking unrelated cross-origin embeds on host pages — a poor tradeoff for something meant to be embeddable in arbitrary third-party pages.ThreadPool's one-off parallel work (e.g. parallel.vpxitem deserialization) already runs synchronously instead (patches/vpinball/0002-*). Revisit only if real-world profiling shows single-threaded performance is genuinely insufficient. - The heavy plugin ecosystem (PinMAME real ROM emulation — copyrighted ROMs, can't legally bundle; DOF/AltSound/PUP/FlexDMD/Serum — real-cabinet-hardware or FFmpeg-dependent;
b2slegacy— a ~11,000-line legacy VB6-COM compatibility layer). Excluded via a single CMake guard; zero core-engine impact. The modernplugins/b2sbackglass plugin (self-contained, no FFmpeg) is a reasonable future addition via the same static-plugin-linking mode iOS/Android already use (__LIBVPINBALL__/VPinballLib::SetupStaticPlugins()) — not yet done. - Raw HID device input (
OpenPinDevHandler/hidapi) — no browser equivalent for real-hardware nudge/plunger boards. Permanent, not a "for now."
Further follow-up work:
- A definitive input/audio validation pass (see above).
- Touch controls (on-screen virtual flipper/plunger buttons synthesizing keyboard events) and fullscreen/pointer-lock UI wiring for mobile and kiosk-style embedding.
- Browser file-loading UX (drag-and-drop/file picker for user-supplied, self-contained
.vpxfiles beyond the bundled demo table; real-world tables with an external.vbsscript override or aMusic/folder are a documented, known-unsupported gap for single-file uploads). - Further binary-size tuning (the
.wasmitself is still ~13MB even with-O2 --closure 1;-sFORCE_FILESYSTEM=1/broadEXPORTED_RUNTIME_METHODSpull in more than strictly needed and are candidates to narrow). - A browser-based (Puppeteer) CI smoke test that actually loads a table and checks for a rendered frame, 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. start()/stop()/dispose() now call real, working exported C functions (vpinball_wasm_start/stop/dispose) that drive the actual per-frame game loop — see Status.
Known limitations
- Single-threaded only (no pthreads/SharedArrayBuffer) — a deliberate tradeoff, not a gap; see Roadmap.
- No heavy plugin ecosystem (PinMAME/DOF/FlexDMD/etc.) — see Roadmap. Native DMD rendering and a lightweight modern backglass plugin remain possible without it.
- No raw hardware input (real cabinet nudge/plunger boards) — permanent, no browser equivalent.
- Keyboard/gamepad input and audio are code-complete and wired correctly but not yet validated end-to-end in real gameplay (see Roadmap) — no touch controls or fullscreen/pointer-lock UI yet.
.wasm/asset size is reduced (~11MB total, down from ~51MB) but not fully tuned — see Roadmap.
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.