Tested pinball.loadRom() against a correctly-matched ROM zip for a real community table (Heavy Metal Meltdown, Bally 1987): PinMAME loads the ROM, and its own video/audio subsystems come up and run with no errors (osd_create_display: 60.00 fps; real INT16/44100Hz audio format), not just Controller object creation succeeding as before. Updates the README to move this from "not yet validated" to confirmed, narrowing the remaining gap to on-screen DMD/backglass output and hands-on switch/solenoid/scoring behavior during actual play, which nobody has watched yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoSarxLgY33Kax5UNXcafZ
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: a real, playable table, live in the browser — keyboard and audio confirmed
The patched engine, running in a real browser via this project's build, renders and plays a real, interactive, physically-simulated pinball table from a real .vpx file — lit flippers that respond to Left/Right Shift, slingshots, bumpers, targets, a plunger, drain detection, and audible sound effects — all driven by a real per-frame game loop, not a static screenshot. This has been confirmed by hands-on manual testing in Chrome, not just code review. 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. - Runs the real physics engine and the real VBScript scripting engine (
libwinevbs) — confirmed by pressing a physical key and watching a table-script-driven flipper (Table1_KeyDown→LeftFlipper.RotateToEnd) actually move. - Runs a real per-frame game loop (
Player::EmscriptenStepFrame(), driven byemscripten_set_main_loop) stepping input, physics, and rendering every frame, with a cleanstop()→~Player()teardown mid-session and no crash or hang. - Keyboard input and audio, confirmed by ear and by hand against the bundled default table (see below) — the full DOM → SDL3 →
InputManager→ VBScript-dispatch pipeline, and the full audio pipeline, both work. - Ships the bundled default table (
exampleTable.vpx, a real playable demo table with working gameplay logic) at ~34MB total (vpinball.wasm+vpinball.data+vpinball.js) after trimming ~52MB of editor-only content never used by the player runtime (bundled native-editor templates and a Monaco code editor) — see Roadmap.
Getting here required finding and fixing four real, previously-unknown bugs in the upstream engine (not just adding a new CMake target) — see patches/vpinball/0003-*, 0004-* and 0005-* 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/dispose/eval_scriptinsrc/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. - The window's OpenGL back buffer was created with the window's logical (CSS) pixel size instead of its device pixel size — on any browser tab with
devicePixelRatio != 1(essentially all HiDPI displays), the GL viewport only covered a fraction of the canvas's actual backing buffer, rendering anchored to one corner (GL's viewport origin) instead of filling the canvas. Window::OnResized()(called on every SDL resize event, including entering/leaving browser fullscreen) updated the window's own tracked pixel size but never propagated it to the window's back buffer render target — so toggling fullscreen resized the canvas but rendering stayed pinned to the pre-resize viewport size.
Status: real PinMAME (VPinMAME.Controller) integration — real ROM-driven emulation confirmed running
Real-hardware ("SS"/ROM-based) tables CreateObject("VPinMAME.Controller") against the actual PinMAME emulation core (libpinmame, from the real vpinball/pinmame project — not a stub, not a mock), compiled to wasm32 and statically linked in. Confirmed by hands-on testing against a real community ROM-based table with its real, correctly-matched ROM zip supplied via pinball.loadRom(): the Controller identifies the requested game from PinMAME's actual ~2,900-entry built-in driver database (PinMAME::Controller::SetGameName → Game found: name=hvymetal, description=Heavy Metal Meltdown, manufacturer=Bally, year=1987), loads the ROM successfully, and PinMAME's own video and audio subsystems come up and run with no errors (osd_create_display: 60.00 fps; OnAudioAvailable: format=INT16, channels=1, sampleRate=44100.00, framesPerSecond=60.00) — this is real emulation actually executing, not just object creation succeeding. A missing/invalid ROM (verified separately, with two different non-matching ROM zips) fails cleanly instead — a logged error, the game just doesn't start, no page crash.
Getting a real OS-thread-based emulator core running inside a single-threaded WASM build required the same class of fix as the vsync/game-loop bugs above, just one level deeper — this time inside PinMAME's own CPU-execution loop, not vpinball's:
PinmameRun()spawned PinMAME's actual emulation main loop (run_game()→run_machine()→run_machine_core()→cpu_run(), MAME's real CPU-cycle scheduler) on a realstd::thread— the same hard-abort-under-Emscripten bug asRenderDevice::WaitForVSync()above, just for an entire emulation session instead of one vsync wait. Fixed by splitting that whole call chain into a one-shot init, a step bounded to roughly one host video frame of emulated time (timer_get_time()-bounded, called once per frame fromPlayer::EmscriptenStepFrame()itself), and a one-shot teardown — seepatches/pinmame/0004-emscripten-cooperative-scheduling.patch.- Two small wasm32 portability bugs in libpinmame itself, unrelated to threading: an x86-only compiler intrinsic (
__rolq/__rorq) reached by a portability guard that didn't exclude wasm32, and a bundled-zlib include path only wired up for the Windows build — seepatches/pinmame/0001-*and0003-*. - An optional external-clock-sync feature (
time_fence, mirrored byController.TimeFencein table scripts) used POSIX semaphores unavailable in this build; it now reports itself as unsupported instead, the same way every other optional sync/timing feature becomes a no-op under this build's single-threaded model — seepatches/pinmame/0002-*.
A ROM zip can be supplied via pinball.loadRom(gameName, romZipBytes), written to /tables/pinmame/roms/<gameName>.zip — the same table-relative convention vpinball's own PinMAME plugin already checks before any global settings path, so no extra configuration is needed. Still not visually/interactively confirmed: actual on-screen DMD/backglass output and hands-on switch/solenoid/scoring behavior during play — video/audio subsystem startup is confirmed (see above), but no one has yet watched a full game played against this integration. Table script compatibility for tables that don't need real ROM emulation — most tables, including the bundled default — is unaffected either way.
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, by hands-on manual testing):
.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. - The full input pipeline: a real physical keypress → SDL3 →
InputManager::PushButtonEvent→PinTable::FireDispID(DISPID_GameEvents_KeyDown)→ the table's own VBScriptKeyDownhandler →LeftFlipper.RotateToEnd— confirmed by watching a flipper actually respond to Left/Right Shift. - Audio, confirmed audible by ear.
- 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. - Correct rendering at any canvas size/DPI, including entering/leaving fullscreen — two real GL back-buffer sizing bugs found and fixed (see Status).
- A bundled default table (
exampleTable.vpx) with real, working gameplay logic (flippers, slingshots, bumpers, targets, a plunger, drain detection) — swapped in from an earlier choice (test000-default-table.vpx) that turned out to be upstream's own rendering/component regression-test fixture with no gameplay script at all (confirmed by diffing a screenshot against vpinball's own official reference image for that table). - Editor-only content (bundled native-editor example/template tables, a Monaco code editor) excluded from the asset payload — never used by the player runtime — see
patches/vpinball/0001-*. - A full loading → play flow in a real page (
examples/basic/index.html): a byte-level download progress bar, a user-gesture "Start" button (required for both audio autoplay and fullscreen to work), a working fullscreen button, and responsive canvas sizing that fills its container correctly. - Browser file-loading for self-contained tables: picking a
.vpxfile, mounting it viaFS.writeFile, and starting the engine against it — verified end-to-end (PinTable::LoadGameFromFilename /tables/uploaded.vpxin the boot log, followed by a normal render). - A JS-callable
evalScript()debug/utility hook (pinball.evalScript(script)) that runs arbitrary VBScript against the live table via the same entry point the interpreter's own debug console uses. - Real PinMAME (
VPinMAME.Controller) ROM-driven emulation actually running, not just object creation — see Status above for the exact confirmation (real video/audio subsystem startup against a real, correctly-matched ROM zip supplied viapinball.loadRom()).
Not yet validated (should work, per code review and by analogy to already-proven mechanisms, but unconfirmed end-to-end):
- PinMAME on-screen DMD/backglass output and hands-on gameplay — the emulation session itself is confirmed running (see Status above), but no one has yet watched real DMD frames render or played a full game against it to confirm switch/solenoid/scoring behavior end-to-end.
- Gamepad/joystick input during real gameplay (keyboard input uses the identical
InputManagerpipeline and is confirmed working; gamepad support is code-complete but untested on real hardware). - Touch-control overlay (
attachTouchControls) actually flipping a flipper on a real touch device — implemented and included in the example (synthesizes the real default keyboard scancodes onwindow), not yet confirmed on physical touch hardware. - DMD rendering via a script-driven
Flasher/ScriptGlobalTable::put_DMDPixels. This is native, core-engine functionality requiring no new code (src/core/ScriptGlobalTable.cpp:886-937,src/parts/flasher.cpp:1312-1341), and rides the exact sameScriptInterpreter::Evaluate()VBScript-dispatch path already confirmed working by the flipper test above — but the bundled default table has no DMD-configuredFlasher, so a full visual confirmation needs a real DMD-equipped table (uploadable via the file picker) or authoring one, neither done yet.
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 rest of the plugin ecosystem beyond PinMAME (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. (PinMAME itself is no longer in this category — see Status above.) - The modern
plugins/b2sbackglass plugin — still not wired in, but meaningfully cheaper now than when this was last assessed: PinMAME's own static-linking plumbing (patches/vpinball/0006-*, see Status above) already established the exact pattern B2S would need — registering a statically-linked plugin viaMsgPluginManager::RegisterPlugininstead of desktop VP's dynamic/pluginsfolder scan — so this is now mostly "repeat the same wiring for a second plugin" rather than new infrastructure. Still no.directb2s-equipped test table available to confirm it actually renders anything against. Revisit if/when one is available. - Raw HID device input (
OpenPinDevHandler/hidapi) — no browser equivalent for real-hardware nudge/plunger boards. Permanent, not a "for now."
Further follow-up work:
- Real-device gamepad and touch-control validation (see above).
- Visual confirmation of DMD rendering against a real DMD-equipped table (see above).
- Pointer-lock UI wiring (fullscreen is done; pointer-lock isn't needed for this game's input model but noted here in case a future feature wants it).
- Real-world table support beyond self-contained single-file uploads: tables with an external
.vbsscript override or aMusic/folder are a documented, known-unsupported gap. - Further binary-size tuning (the
.wasmitself is still ~13MB with-O2;--closure 1was tried but silently strippedFS.writeFile/readFile/mkdirdown to low-level node ops - a real Emscripten/Closure interaction bug worth root-causing before re-enabling, since it broke runtime table loading with no compile-time warning.-sFORCE_FILESYSTEM=1/broadEXPORTED_RUNTIME_METHODSalso pull in more than strictly needed. The default table itself is now ~18MB of the ~34MB total, since a real playable demo table is larger than the rendering-test fixture used before). - 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 + pinmame at pinned commits, applies patches/
source emsdk/emsdk_env.sh
./scripts/build-deps.sh # builds SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs/libpinmame 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, attachTouchControls } from '@valknar/vpinball-wasm';
const canvas = document.querySelector('canvas');
const pinball = await loadPinball({
canvas,
onProgress: (fraction) => updateMyLoadingBar(fraction),
});
// start() (and requestFullscreen()) must be called from within a user
// gesture, e.g. a click handler - browsers block audio autoplay and
// fullscreen requests otherwise.
startButton.addEventListener('click', () => {
pinball.start();
if ('ontouchstart' in window) attachTouchControls({ container: canvas.parentElement });
});
// Optional debug/utility hook: runs arbitrary VBScript against the live
// table, via the same entry point the interpreter's own debug console uses.
pinball.evalScript('DMDWidth = 128 : DMDHeight = 32');
// Optional: supply a ROM zip for a real-hardware ("SS") table's
// CreateObject("VPinMAME.Controller")/Controller.Run() before start() -
// ROM files are copyrighted, so bring your own legally-obtained one.
// gameName is the short ROM name the table's script passes to LoadVPM
// (e.g. "hvymetal"), not the table's display title.
pinball.loadRom('hvymetal', romZipArrayBuffer);
See examples/basic/index.html for a complete working page (loading progress, file upload, touch controls, fullscreen). Published as @valknar/vpinball-wasm on this project's Gitea npm registry — see package.json.
Known limitations
- Single-threaded only (no pthreads/SharedArrayBuffer) — a deliberate tradeoff, not a gap; see Roadmap.
- Real PinMAME (
VPinMAME.Controller) integration for ROM-based tables — confirmed actually running real ROM-driven emulation (video/audio subsystem startup against a real ROM), but on-screen DMD/backglass output and hands-on switch/solenoid/scoring behavior during play are not yet confirmed — see Status and Roadmap. The rest of the plugin ecosystem (DOF/FlexDMD/etc., and the modernplugins/b2sbackglass plugin) remains unwired — see Roadmap. - No raw hardware input (real cabinet nudge/plunger boards) — permanent, no browser equivalent.
- Keyboard input, audio, and VBScript-driven gameplay are confirmed working end-to-end by hands-on manual testing (not just code review). Gamepad input and the touch-control overlay are code-complete and use the identical input pipeline, but are not yet confirmed on real gamepad/touch hardware — see Roadmap.
- DMD rendering is native, code-complete functionality riding the same proven VBScript-dispatch path, but not yet visually confirmed since the bundled default table has no DMD-configured
Flasher— see Roadmap. .wasm/asset size is ~34MB total (a real playable default table is larger than the rendering-test fixture used earlier in development) and 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). PinMAME is under a similar mixed license to vpinball's own — migrating file-by-file from the same inherited "old MAME" license to 3-Clause BSD, with each migrated file marked // license:BSD-3-Clause; see vendor/pinmame/LICENSE for the authoritative text. Separately, and unrelated to source licensing: PinMAME requires ROM images dumped from the real arcade/pinball hardware to actually run a game, which this project does not and cannot bundle — see pinball.loadRom() above. This project's own glue code (CMake integration, patches, npm wrapper) has no license conflict with any of the above, but the combined built artifact's distribution terms are governed by vpinball's, libwinevbs's, and PinMAME'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.
- PinMAME — the real ROM/hardware emulation core
VPinMAME.Controllerwraps, compiled here to wasm32.