Add vpinball: a browser Visual Pinball player

Next.js app that plays real .vpx tables via @valknar/vpinball-wasm
(WebAssembly Visual Pinball). Demo mode with the bundled default
table, drag-and-drop custom table upload, a cabinet-styled UI with a
coin-door HUD (fullscreen/stats/info/eject), a custom 404 page, PWA
support (hand-rolled service worker), and a static Docker Compose
deployment behind nginx.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hQoM3jJT1Lx7CMTciMzvD
This commit is contained in:
2026-08-23 05:25:15 +02:00
co-authored by Claude Sonnet 5
commit e493a12e68
51 changed files with 5570 additions and 0 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+42
View File
@@ -0,0 +1,42 @@
@import "tailwindcss";
@theme {
/* Cabinet palette — grounded in real pinball-machine parts, not a generic dark theme. */
--color-ink: #0b0b0f; /* cabinet body / page background */
--color-ash: #1b1c22; /* panel surface */
--color-chrome: #a9aebb; /* metal bezel, borders, muted text */
--color-marquee: #ff7a29; /* GI lamp amber — primary accent, CTAs */
--color-arc: #4ce0d2; /* fluorescent-tube cyan — focus rings, secondary */
--color-paper: #f3efe6; /* aged-backglass cream — high-contrast body text */
--font-display: var(--font-big-shoulders), "Arial Narrow", sans-serif;
--font-body: var(--font-space-grotesk), system-ui, sans-serif;
--font-mono: var(--font-plex-mono), "IBM Plex Mono", ui-monospace, monospace;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
body {
background: var(--color-ink);
color: var(--color-paper);
font-family: var(--font-body);
}
::selection {
background: var(--color-marquee);
color: var(--color-ink);
}
:focus-visible {
outline: 2px solid var(--color-arc);
outline-offset: 2px;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+45
View File
@@ -0,0 +1,45 @@
import type { Metadata, Viewport } from "next";
import { Big_Shoulders, Space_Grotesk, IBM_Plex_Mono } from "next/font/google";
import RegisterServiceWorker from "@/components/RegisterServiceWorker";
import "./globals.css";
const bigShoulders = Big_Shoulders({
variable: "--font-big-shoulders",
weight: ["700", "900"],
subsets: ["latin"],
});
const spaceGrotesk = Space_Grotesk({
variable: "--font-space-grotesk",
subsets: ["latin"],
});
const plexMono = IBM_Plex_Mono({
variable: "--font-plex-mono",
weight: ["400", "500", "600"],
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "vpinball — Visual Pinball in your browser",
description:
"Play real .vpx pinball tables in the browser, powered by a WebAssembly build of Visual Pinball. Drop in your own table or play the bundled demo.",
};
export const viewport: Viewport = {
themeColor: "#0b0b0f",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${bigShoulders.variable} ${spaceGrotesk.variable} ${plexMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-ink text-paper font-body">
{children}
<RegisterServiceWorker />
</body>
</html>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { MetadataRoute } from "next";
// Required under output: "export" — a manifest route has no per-request
// dynamic behavior anyway, so this just makes that explicit to the build.
export const dynamic = "force-static";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "vpinball — Visual Pinball in your browser",
short_name: "vpinball",
description: "Play real .vpx pinball tables in the browser, powered by WebAssembly.",
start_url: "/",
display: "fullscreen",
background_color: "#0b0b0f",
theme_color: "#0b0b0f",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
{
src: "/icons/icon-512-maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
{ src: "/icons/icon.svg", sizes: "any", type: "image/svg+xml" },
],
};
}
+19
View File
@@ -0,0 +1,19 @@
import Link from "next/link";
import { buttonClassName } from "@/components/ui/Button";
export default function NotFound() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-6 px-4 py-24 text-center">
<p className="font-mono text-sm tracking-[0.3em] text-arc">TILT</p>
<h1 className="font-display text-5xl font-black tracking-wide text-marquee sm:text-7xl">
TABLE NOT FOUND
</h1>
<p className="max-w-md font-body text-chrome">
That page drained down the outlane. Head back to the cabinet and pick a table.
</p>
<Link href="/" className={buttonClassName("primary")}>
Back to Start
</Link>
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
import AttractHero from "@/components/landing/AttractHero";
export default function Home() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-8 px-4 py-16">
<AttractHero />
<p className="max-w-lg text-center font-mono text-xs text-chrome sm:hidden">
Pinball plays best in landscape or fullscreen rotate your device once a table loads.
</p>
<footer className="font-mono text-xs text-chrome/70">
Powered by{" "}
<a
href="https://github.com/vpinball/vpinball"
className="underline decoration-dotted underline-offset-2 hover:text-arc"
>
Visual Pinball
</a>
, compiled to WebAssembly.
</footer>
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import PlayView from "@/components/pinball/PlayView";
export default function PlayPage() {
return (
<main className="h-dvh w-full">
<PlayView />
</main>
);
}