Compare commits

...
9 Commits
Author SHA1 Message Date
valknar 5423ca70f4 Fix stale/mismatched engine cache causing "ASM_CONSTS[...] is not a function" live
The service worker's engine cache key relied on a manual CACHE_VERSION
bump that was forgotten across the last two @valknar/vpinball-wasm
version bumps, so returning visitors kept getting served old cached
vpinball.js/.wasm/.data via cache-first with no revalidation — the origin
server itself was serving a correct, matching 0.3.4 set the whole time
(verified via headers/sizes against the local build).

Split CACHE_VERSION into a manually-bumped SHELL_VERSION and an
ENGINE_VERSION that copy-engine-assets.mjs now writes automatically from
the installed engine's package.json on every build, so the engine cache
always busts in lockstep with the dependency version — no step to forget.
2026-08-24 14:42:38 +02:00
valknar 89d4d9b071 Bump @valknar/vpinball-wasm to 0.3.4
0.3.4 skips SDL_SetWindowIcon on the Emscripten target, so the engine no
longer hijacks the browser tab's favicon (and title) via a blob: URL
when a table loads — verified live, no app-side workaround needed.
2026-08-24 14:08:19 +02:00
valknar a2b929dbec Revert "Stop the pinball engine from hijacking the browser tab's favicon"
This reverts commit 4dd6d60457.
2026-08-24 13:52:38 +02:00
valknar 4dd6d60457 Stop the pinball engine from hijacking the browser tab's favicon
vpinball.js sets a table-supplied image as the page favicon via a blob:
URL as a table loads. Revert any blob: href on the icon <link> the
moment it appears, via a MutationObserver, instead of patching the
compiled engine.
2026-08-24 13:46:26 +02:00
valknar 0afa61cc5c Use proper SVG icons in the touch control bar, center the middle cluster absolutely
Swaps the unicode glyphs (◀ ▶ ⬆) for hand-drawn stroke/fill SVG icons
(chevrons, an eject-style plunger glyph, a coin glyph, a play triangle),
and positions the coin/start/plunger cluster with absolute + translate
so it sits on the bar's true center regardless of the flipper buttons.
2026-08-24 13:28:45 +02:00
valknar 9e8f08ba16 Replace mobile touch overlay with a coin-door-styled bottom control bar
The engine's default attachTouchControls() overlay was generic floating
circles; this swaps in an app-themed bottom bar (left/right flipper,
plunger, insert coin, start game) anchored to the screen edges instead.
2026-08-24 13:24:47 +02:00
valknar a193bdb3da fix(sw): stop precaching /404.html, it 403s and breaks the whole addAll
nginx serves /404.html as internal-only (see docker/nginx.conf) so it
preserves the real 404 status via error_page - any direct client fetch,
including the service worker's install-time addAll, gets a 403. Since
addAll is atomic, that one bad URL was failing precache of the entire
app shell.
2026-08-24 12:42:49 +02:00
valknarandClaude Sonnet 5 44504c718d Fix eject hanging the tab on ROM-based tables
Bump @valknar/vpinball-wasm to 0.3.3, which fixes a genuine infinite
busy-wait in the PinMAME plugin's Controller::Stop() under Emscripten
(no OS thread ever clears the flag it spins on) — this fired from
dispose()'s synchronous script Exit event on any ROM-based table,
hanging the tab permanently.

Sequence unmount cleanup as stop() then two animation frames then
dispose(), instead of dispose() alone, matching the consumer pattern
the engine fix expects (stop() only takes effect on its next internal
step, so disposing immediately after can still race it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 10:27:35 +02:00
valknarandClaude Sonnet 5 53c8ea03ac Show a spinner while the table is starting
instance.start() runs synchronously and blocks the main thread for a
noticeable moment on heavy WASM init, so pressing "Tap to Start" would
freeze on the old screen with no feedback until the HUD suddenly
appeared. Add a "starting" status between "ready" and "running", and
defer the actual start() call by a nested rAF so the browser gets a
chance to paint the spinner before the blocking call runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 09:52:30 +02:00
11 changed files with 241 additions and 73 deletions
+2 -5
View File
@@ -64,11 +64,8 @@ export default function CoinDoorHud({ instanceRef, tableInfo, loadTimeMs }: Coin
};
const handleEject = () => {
// dispose() (called by usePinballInstance's unmount cleanup, triggered by
// this navigation) tears the instance down immediately. Calling stop()
// here too raced it — stop() defers teardown to the engine's next
// internal step, and dispose() freeing WASM memory before that step runs
// hung the tab.
// usePinballInstance's unmount cleanup (triggered by this navigation)
// handles stop()+dispose() itself, sequenced to avoid hanging the tab.
router.push("/");
};
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useCallback, useState } from "react";
interface KeyBinding {
key: string;
code: string;
}
const LEFT_FLIPPER: KeyBinding = { key: "Shift", code: "ShiftLeft" };
const RIGHT_FLIPPER: KeyBinding = { key: "Shift", code: "ShiftRight" };
const PLUNGER: KeyBinding = { key: "Enter", code: "Enter" };
const INSERT_COIN: KeyBinding = { key: "4", code: "Digit4" };
const START_GAME: KeyBinding = { key: "1", code: "Digit1" };
function dispatchKey(type: "keydown" | "keyup", binding: KeyBinding) {
// SDL3's Emscripten video backend listens on the default "#window" target,
// so a real KeyboardEvent dispatched on window reaches the engine the same
// way a physical keypress would (see @valknar/vpinball-wasm's own
// touch-controls.js, which this bar replaces with app-styled UI).
window.dispatchEvent(new KeyboardEvent(type, { key: binding.key, code: binding.code, bubbles: true }));
}
function isTouchDevice(): boolean {
return typeof window !== "undefined" && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
}
function FlipperLeftIcon() {
return (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="15 5 8 12 15 19" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function FlipperRightIcon() {
return (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="9 5 16 12 9 19" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** Eject-style glyph (triangle over a bar) for the plunger/launch control. */
function PlungerIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 4.5 19 15H5l7-10.5Z" />
<rect x="5" y="17.5" width="14" height="2.5" rx="1" />
</svg>
);
}
function CoinIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.75">
<circle cx="12" cy="12" r="8.5" />
<path
d="M14.5 9.3c-.4-.8-1.3-1.3-2.5-1.3-1.7 0-3 .9-3 2.1 0 2.9 5.5 1.3 5.5 4.1 0 1.2-1.3 2.1-3 2.1-1.2 0-2.1-.5-2.5-1.3M12 6.8v1.2M12 16v1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function PlayIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M7 4.5v15l13-7.5-13-7.5Z" />
</svg>
);
}
function ControlButton({
binding,
label,
icon,
size,
}: {
binding: KeyBinding;
label: string;
icon: React.ReactNode;
size: "lg" | "md" | "sm";
}) {
const [pressed, setPressed] = useState(false);
const onDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
setPressed(true);
dispatchKey("keydown", binding);
},
[binding],
);
const onUp = useCallback(() => {
setPressed(false);
dispatchKey("keyup", binding);
}, [binding]);
const sizeClasses = size === "lg" ? "h-20 w-20" : size === "md" ? "h-14 w-14" : "h-12 w-12";
return (
<button
type="button"
aria-label={label}
onPointerDown={onDown}
onPointerUp={onUp}
onPointerCancel={onUp}
onContextMenu={(e) => e.preventDefault()}
className={`flex touch-none select-none items-center justify-center rounded-full border-2 transition ${sizeClasses} ${
pressed
? "border-marquee bg-marquee/30 text-marquee shadow-[0_0_16px_rgba(255,122,41,0.5)]"
: "border-chrome/40 bg-black/40 text-chrome"
}`}
>
{icon}
</button>
);
}
export interface TouchControlBarProps {
active: boolean;
}
/**
* Coin-door-styled bottom control bar for mobile play. Replaces
* @valknar/vpinball-wasm's default floating overlay buttons with UI that
* matches the cabinet look and puts the flippers at the screen's bottom
* corners, in thumb reach when holding the device landscape.
*/
export default function TouchControlBar({ active }: TouchControlBarProps) {
// Computed once at mount (matches usePinballInstance's hasWebGL2 pattern) —
// touch support doesn't change over a session, so this doesn't need an
// effect/listener, just a lazy initial state.
const [isTouch] = useState(isTouchDevice);
if (!active || !isTouch) return null;
return (
<div
data-hud
className="absolute inset-x-0 bottom-0 z-10 flex items-center justify-between border-t border-chrome/30 bg-ash/90 px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur sm:hidden"
>
<ControlButton binding={LEFT_FLIPPER} label="Left flipper" icon={<FlipperLeftIcon />} size="lg" />
{/* Positioned absolutely so it sits on the bar's true center, independent
of the (equal-width, so already symmetric) flipper buttons on either side. */}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-3">
<ControlButton binding={INSERT_COIN} label="Insert coin" icon={<CoinIcon />} size="sm" />
<ControlButton binding={START_GAME} label="Start game" icon={<PlayIcon />} size="sm" />
<ControlButton binding={PLUNGER} label="Plunger" icon={<PlungerIcon />} size="md" />
</div>
<ControlButton binding={RIGHT_FLIPPER} label="Right flipper" icon={<FlipperRightIcon />} size="lg" />
</div>
);
}
+11 -4
View File
@@ -2,8 +2,8 @@
import { useRef } from "react";
import { usePinballInstance, type RomSelection } from "@/lib/pinball/usePinballInstance";
import { useTouchControls } from "@/lib/pinball/useTouchControls";
import CoinDoorHud from "@/components/hud/CoinDoorHud";
import TouchControlBar from "@/components/hud/TouchControlBar";
import type { TableInfo } from "@/lib/pinball/types";
export interface PinballCanvasImplProps {
@@ -14,16 +14,14 @@ export interface PinballCanvasImplProps {
export default function PinballCanvasImpl({ tableData, rom, tableInfo }: PinballCanvasImplProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const { status, progress, error, loadTimeMs, instanceRef, start } = usePinballInstance(
canvasRef,
tableData,
rom,
);
useTouchControls(containerRef, status === "running");
return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden bg-black">
<div className="relative h-full w-full overflow-hidden bg-black">
{/* SDL3's Emscripten backend resolves its own window/GL-context target via
document.querySelector("#canvas") independently of Module.canvas — the
id is required or context creation silently no-ops (findCanvasEventTarget
@@ -35,6 +33,8 @@ export default function PinballCanvasImpl({ tableData, rom, tableInfo }: Pinball
<CoinDoorHud instanceRef={instanceRef} tableInfo={tableInfo} loadTimeMs={loadTimeMs} />
)}
<TouchControlBar active={status === "running"} />
{status === "loading" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-ink/90 text-paper">
<div className="font-display text-2xl tracking-wide text-marquee">LOADING TABLE</div>
@@ -74,6 +74,13 @@ export default function PinballCanvasImpl({ tableData, rom, tableInfo }: Pinball
</p>
</div>
)}
{status === "starting" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-ink/90 text-paper">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-chrome/20 border-t-marquee" />
<div className="font-display text-2xl tracking-wide text-marquee">STARTING TABLE</div>
</div>
)}
</div>
);
}
+26 -4
View File
@@ -10,7 +10,7 @@ import { hasWebGL2 } from "./webglSupport";
// outside webpack's module graph (see scripts/copy-engine-assets.mjs).
const ENGINE_BASE_URL = "/vendor/vpinball-wasm";
export type PinballStatus = "idle" | "loading" | "ready" | "running" | "error";
export type PinballStatus = "idle" | "loading" | "ready" | "starting" | "running" | "error";
interface EngineModule {
loadPinball(options: LoadPinballOptions): Promise<PinballInstance>;
@@ -104,8 +104,20 @@ export function usePinballInstance(
return () => {
disposedRef.current = true;
instanceRef.current?.dispose();
const instance = instanceRef.current;
instanceRef.current = null;
if (instance) {
// stop() halts the engine's render loop, but only takes effect on
// its next internal step rather than immediately. Calling dispose()
// synchronously right after — freeing WASM memory before that step
// runs — is what hangs the tab. Give it a couple of frames first.
instance.stop();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
instance.dispose();
});
});
}
};
// Intentionally run once: PlayView mounts a fresh PinballCanvasImpl
// (via `key`) per table rather than swapping tableData in place.
@@ -114,8 +126,18 @@ export function usePinballInstance(
const start = useCallback(() => {
if (!instanceRef.current) return;
instanceRef.current.start();
setStatus("running");
setStatus("starting");
// instance.start() runs synchronously and blocks the main thread for a
// noticeable moment (heavy WASM init) — a nested rAF lets the browser
// paint the "starting" spinner from the state update above before that
// block hits, instead of the UI freezing on the old screen mid-click.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (disposedRef.current || !instanceRef.current) return;
instanceRef.current.start();
setStatus("running");
});
});
}, []);
return { status, progress, error, loadTimeMs, instanceRef, start };
-38
View File
@@ -1,38 +0,0 @@
"use client";
import { useEffect } from "react";
import type { attachTouchControls as AttachTouchControls } from "@valknar/vpinball-wasm";
const ENGINE_BASE_URL = "/vendor/vpinball-wasm";
function isTouchDevice(): boolean {
return typeof window !== "undefined" && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
}
/** Attaches the engine's on-screen touch overlay to `containerRef` while `active` is true. */
export function useTouchControls(
containerRef: React.RefObject<HTMLElement | null>,
active: boolean,
): void {
useEffect(() => {
if (!active || !isTouchDevice()) return;
const container = containerRef.current;
if (!container) return;
let handle: { detach: () => void } | undefined;
let cancelled = false;
(async () => {
const { attachTouchControls } = (await import(
/* webpackIgnore: true */ `${ENGINE_BASE_URL}/index.js`
)) as { attachTouchControls: typeof AttachTouchControls };
if (cancelled) return;
handle = attachTouchControls({ container });
})();
return () => {
cancelled = true;
handle?.detach();
};
}, [containerRef, active]);
}
+1 -1
View File
@@ -12,7 +12,7 @@
"lint": "eslint"
},
"dependencies": {
"@valknar/vpinball-wasm": "0.3.2",
"@valknar/vpinball-wasm": "0.3.4",
"next": "16.3.2",
"react": "19.2.8",
"react-dom": "19.2.8"
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@valknar/vpinball-wasm':
specifier: 0.3.2
version: 0.3.2
specifier: 0.3.4
version: 0.3.4
next:
specifier: 16.3.2
version: 16.3.2(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -754,8 +754,8 @@ packages:
cpu: [x64]
os: [win32]
'@valknar/vpinball-wasm@0.3.2':
resolution: {integrity: sha512-52vkjVzev92khiO8a50N2afYIviX1hHCMzTilN24Pe/7pu5m9LlSQVS3o11rRdItSF5h1RVmTLpoh1ENYrLQJw==, tarball: https://dev.pivoine.art/api/packages/valknar/npm/%40valknar%2Fvpinball-wasm/-/0.3.2/vpinball-wasm-0.3.2.tgz}
'@valknar/vpinball-wasm@0.3.4':
resolution: {integrity: sha512-ULJ8jF3Q8sxmkkSdC70SSKzkvaNHORPZEFuJzQbmfH9aeqh4kYmqj59KX7bNywmAxLz4yLtr8r4i/oWEo8bbSw==, tarball: https://dev.pivoine.art/api/packages/valknar/npm/%40valknar%2Fvpinball-wasm/-/0.3.4/vpinball-wasm-0.3.4.tgz}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
@@ -2645,7 +2645,7 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@valknar/vpinball-wasm@0.3.2': {}
'@valknar/vpinball-wasm@0.3.4': {}
acorn-jsx@5.3.2(acorn@8.18.0):
dependencies:
+1 -1
View File
@@ -2,5 +2,5 @@ allowBuilds:
sharp: false
unrs-resolver: false
minimumReleaseAgeExclude:
- '@valknar/vpinball-wasm@0.2.0 || 0.3.1 || 0.3.2'
- '@valknar/vpinball-wasm@0.2.0 || 0.3.1 || 0.3.2 || 0.3.3 || 0.3.4'
updateNotifier: false
+12 -6
View File
@@ -1,14 +1,20 @@
// Bump this on every release that changes the app shell or the vendored
// engine build — it's the only thing that invalidates old caches, since
// Bump manually (via `pnpm sw:bump`) whenever SHELL_ASSETS or the shell's
// caching behavior changes — nothing else invalidates the shell cache, since
// none of the cached URLs below are content-hashed by us.
const CACHE_VERSION = "v1";
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const ENGINE_CACHE = `engine-${CACHE_VERSION}`;
const SHELL_VERSION = "v2";
// Written automatically by scripts/copy-engine-assets.mjs from the installed
// @valknar/vpinball-wasm version — do not edit by hand. Ties the engine
// cache key to the actual engine build, so a version bump always busts
// stale caches instead of relying on a human to remember a separate step
// (which is exactly how returning visitors ended up on a stale, and
// possibly internally inconsistent, engine bundle before this existed).
const ENGINE_VERSION = "0.3.4";
const SHELL_CACHE = `shell-${SHELL_VERSION}`;
const ENGINE_CACHE = `engine-${ENGINE_VERSION}`;
const SHELL_ASSETS = [
"/",
"/play/",
"/404.html",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
+8 -7
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
// Bumps public/sw.js's CACHE_VERSION so a deploy invalidates old caches —
// run this before cutting a release that changes the app shell or updates
// the vendored vpinball-wasm engine build.
// Bumps public/sw.js's SHELL_VERSION so a deploy invalidates old shell
// caches — run this before cutting a release that changes SHELL_ASSETS or
// the shell's caching behavior. (The engine cache busts itself automatically
// from the installed vpinball-wasm version — see copy-engine-assets.mjs.)
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -10,12 +11,12 @@ const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const swPath = path.join(rootDir, "public", "sw.js");
const contents = await readFile(swPath, "utf8");
const match = contents.match(/CACHE_VERSION = "v(\d+)"/);
const match = contents.match(/SHELL_VERSION = "v(\d+)"/);
if (!match) {
console.error(`Couldn't find CACHE_VERSION in ${swPath}`);
console.error(`Couldn't find SHELL_VERSION in ${swPath}`);
process.exit(1);
}
const next = Number(match[1]) + 1;
const updated = contents.replace(/CACHE_VERSION = "v\d+"/, `CACHE_VERSION = "v${next}"`);
const updated = contents.replace(/SHELL_VERSION = "v\d+"/, `SHELL_VERSION = "v${next}"`);
await writeFile(swPath, updated);
console.log(`Bumped service worker CACHE_VERSION to v${next}`);
console.log(`Bumped service worker SHELL_VERSION to v${next}`);
+16 -2
View File
@@ -3,13 +3,14 @@
// so it's served as plain static files (see lib/pinball/usePinballInstance.ts for why:
// the package's own loadPinball() does a dynamic import() of its glue script that must
// never pass through webpack's module graph).
import { cp, mkdir, rm } from "node:fs/promises";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const pkgDistDir = path.join(rootDir, "node_modules", "@valknar", "vpinball-wasm", "dist");
const pkgDir = path.join(rootDir, "node_modules", "@valknar", "vpinball-wasm");
const pkgDistDir = path.join(pkgDir, "dist");
const targetDir = path.join(rootDir, "public", "vendor", "vpinball-wasm");
if (!existsSync(pkgDistDir)) {
@@ -24,3 +25,16 @@ await mkdir(targetDir, { recursive: true });
await cp(pkgDistDir, targetDir, { recursive: true });
console.log(`Copied vpinball-wasm engine assets to ${path.relative(rootDir, targetDir)}/`);
// Keep the service worker's engine cache key in lockstep with the installed
// engine version, so a version bump always busts stale caches for returning
// visitors — see sw.js's ENGINE_VERSION comment for why this must not be a
// manual step.
const { version: engineVersion } = JSON.parse(await readFile(path.join(pkgDir, "package.json"), "utf8"));
const swPath = path.join(rootDir, "public", "sw.js");
const swContents = await readFile(swPath, "utf8");
const updatedSw = swContents.replace(/ENGINE_VERSION = "[^"]*"/, `ENGINE_VERSION = "${engineVersion}"`);
if (updatedSw !== swContents) {
await writeFile(swPath, updatedSw);
console.log(`Set sw.js ENGINE_VERSION to ${engineVersion}`);
}