TLDR
Intro
There are multiple (or at least a few) online ports of Space Cadet Pinball (i.e., the legendary Windows pinball) that play fine on a US or UK keyboard, but they are broken on a Spanish layout. The problem is simple: the right flipper is bound to the / key. On a Spanish keyboard, / is produced by pressing Shift+7, which a) is not really ergonomic and b) even doing that, the game just does not register it. In reality, the Spanish build uses the - key, which is in the equivalent position of the / in the English one.
So, since there was no working version, I decided to do my own, now live at 3D Pinball - Space Cadet.
Instead of just hacking an old fork, I decided to mix the different repos. On one side we have the original decompilation by k4zmu2a and, on the other, the port to Emscripten by Alula. The k4zmu2a one has several improvements over Alula’s, which was forked 5 years ago. Thus, my goal was to port the Emscripten changes by Alula to the new baseline and then add my fixes for the keyboard layout.
The keyboard layout fix
The bug exists because SDL2 reports two different identifiers for each key event:
event.key.keysym.sym: A keycode (or keysym), representing the actual character the key produces. This is layout-dependent.event.key.keysym.scancode: A scancode, representing the physical position of the key. This is layout-independent.
The upstream code stored keyboard bindings as keycodes (like SDLK_SLASH) and compared incoming sym events against them. Since a Spanish layout never naturally reports SDLK_SLASH without the shift modifier, the match never fired (and even pressing shift, it wouldn’t fire). The fix was to make the binding system speak in scancodes instead, using SDL_SCANCODE_SLASH to denote the physical key located immediately to the left of the right shift key, regardless of what character it prints.
However, this introduced a subtlety. Two parts of the codebase legitimately want typed letters (which should be layout-aware so you actually type what you want). This is the case for cheat codes (like typing hidden test) and the cheat-mode switch (like pressing b to spawn a ball). If we switched everything to scancodes, the game would compare scancodes (SDL_SCANCODE_B == 5) against ASCII characters ('b' == 0x62) and break every cheat. This is, generally, not very much of an issue, but in order to preserve the pinball as it was, it was needed to extend the pipeline to take an extra parameter:
static void InputDown(GameInput input, int keyboardKeycode = 0);
The scancode handles the actual flipper/plunger game actions, while the layout-aware keyboardKeycode is passed forward safely to the cheat code switches.
Physics Issues
Upon merging both repos and fixing the keyboard, I noticed an issue. Sometimes the ball would stutter in place, not really coming to a standstill nor fully moving. This issue made it so the plunger to launch the ball would sometimes miss it. This was reported on the upstream repo but never solved. The Alula version did not have this, so it was something introduced in a quite large physics rewrite in the upstream. My kludge fix was to create a resting-contact velocity threshold inside the collision code (maths::basic_collision):
constexpr float RestThreshold = 0.25f;
if (reboundSpeed < RestThreshold) {
ball->Speed -= elasticity * reboundSpeed;
}
If the ball’s incoming rebound velocity drops below the RestThreshold, the residual energy is bled off entirely. This damping forces the ball to stay still on surfaces without altering normal gameplay collisions, which happen at much higher energy levels. Nonetheless, this fix is not without issues, as I seem to have noticed some small slowdowns when the ball grinds against the flippers slowly (though this may have been a bias, as I know the fix is not very proper).
In addition to this, I also added a small safety cap in the physics accumulator to avoid issues on devices with varying refresh rates (something now common on phones and PCs alike). Just to note, this has not been extensively tested nor found to do harm (or do much good really).
Touch
Once the core engine was stabilized and the scancodes done, the feature creep started to creep in. The next goal was to make this somewhat playable on mobile phones. However, that brought some issues.
Coordinate Translation
One of the first issues with touch inputs on the web canvas is the aspect ratio. Touch events read coordinate offsets relative to the element box, not the actual game view. For example, a touch landing visually near the bottom of the playfield reads as ~67% down the element, but corresponds to ~95% down the actual game layout. This is an issue with the menus and options included in the port.
To fix this, a custom calculation step (gameCoords()) was added inside the HTML shell. It automatically undoes the scaling and offsets introduced by the letterboxing, clamping data to a uniform [0, 1] game-space matrix.
Independent flippers and mapping
After correctly mapping the inputs to game-space, the touch canvas is parsed into three distinct regions per finger:
- Bottom-Center Band: Maps to the plunger pull; upon finger release, it fires the launch mechanics.
- Left Half (remaining space): Triggers the left flipper.
- Right Half (remaining space): Triggers the right flipper.
Each active zone is monitored independently, allowing for two-thumb play—allowing, for instance, a player to keep a flipper raised with one thumb while also being able to use the opposite flipper with the other thumb.
Game Actions vs. UI Touches
Because touching the canvas defaults to activating the flippers, the menus and ImGui overlays (high scores, layout selection, etc.) were originally unreachable on mobile after adding the regions mapped to flippers and plungers. To solve this, the JavaScript event handler polls two C++ export getters on every single touch event:
-
web_menu_bar_height(): Detects if the top ImGui menu row is visible. -
web_modal_open(): Queries if any popup or modal window is actively drawn on the UI stack.
If a touch lands in the menu bar boundary, or if a modal is open, the engine bypasses flipper mapping entirely. Instead, it creates a synthetic SDL_MOUSEMOTION and standard click actions to let users interact with menu items, type high-score initials (this is kind of buggy to be fair), or change settings.
Onboarding: First-Run Touch Overlay
To ensure the game helps mobile users a bit, I added a first-run translucent touch hint overlay. This way, mobile users know where to tap and are reassured that the pinball works on mobile (somewhat).
Using an Emscripten-backed CSS media check ((pointer: coarse)), the system detects mobile devices and draws three labeled rectangles directly onto the ImGui foreground layer. The coordinates match the percentages utilized by the input handler. Once a player triggers their first touch command, or after a 7-second timeout, the overlay fades out and flips a persistent ShowTouchHints boolean flag to disk so returning players never see it again (this may need to be changed or altered, I’ll have to think about it).
Persistence (High Scores)
Another goal of the project was to handle save states on the web, options and high scores so they actually survive page reloads. This was done using IndexedDB. On a native desktop build, settings and scores are written to disk during the teardown process when the player cleanly exits the game. However, on the web, players usually just kill the tab, meaning those teardown paths never execute. To fix this, the IDBFS file system is mounted inside the Emscripten shell at /libsdl/SpaceCadetPinball. Then, a C++ function (WebFlushPersistence) forces the in-memory ImGui settings and high score tables into local storage. This function is hooked to the browser’s visibilitychange, pagehide, and beforeunload events and also runs periodically every 5 seconds, ensuring that high scores are never lost. This may be overkill but it is not really that hardware-taxing.
Background Pausing
Another web-centered optimization was handling background tabs. When a browser tab is hidden, modern browsers throttle requestAnimationFrame to save resources. If the game engine kept stepping physics during this throttle, it would accumulate a massive delta-time (dt) and cause a huge physics spike the moment the users switches back. To solve this, the game now polls document.hidden directly on every frame. While hidden, it resets the physics accumulator and returns early, effectively pausing the game loop completely so it does not send the ball flying into the void upon the user returning to the tab.
Optimizing Asset Payload & Dual-Table Support
The standard desktop asset compilation footprint sits at roughly ~6.9 MB. Because WebAssembly assets are served over static networks, minimizing the initial data footprint is critical for fast page load times, especially on mobile phones and metered data connections.
The primary contributor to this bloat was the audio engine’s General MIDI SoundFont (gm.sf2), weighing in at 3.2 MB on its own. Because Space Cadet Pinball’s audio tracks utilize only a subset of instruments, I wrote a dedicated Python script (trim_gm_sf2.py) to scrape the tracks for program-change commands and rebuild the internal RIFF tables using only the needed instruments.
Furthermore, the web environment now packages asset data for both the original Windows 3D Pinball (PINBALL.DAT) and the higher-resolution Full Tilt Pinball variant (CADET.DAT). You can jump back and forth directly from a top-level custom menu layout or force a specific table at boot time using a URL query parameter (e.g., ?table=ft or ?table=3dpb). For those who don’t know, Full Tilt Pinball is the commercial version of the game. Compared to the standard Windows 3D Pinball port, Full Tilt features higher-resolution sprites, a larger sound set including multi-ball stingers and engine voices, and longer, richer MIDI music tracks. However, for this port, both tables are functionally equal, the only things that change are the assets (higher quality in the Full Tilt version) as well as the music (longer, more varied in Full Tilt).
Even with both tables, the final asset size for the game is reduced to ~4.1 MB with no sacrifices.