Add Docker WASM VICE program runner

This commit is contained in:
Christian Johannesen 2026-10-07 20:39:47 +02:00
parent d92ea59d85
commit 57d30e38d4
10 changed files with 297 additions and 77 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
.git
.env.example
node_modules
npm-debug.log

4
.env Normal file
View file

@ -0,0 +1,4 @@
HOST_PORT=8134
PROGRAM_REPOSITORY=https://git.p20.dev/christian/c64-basic
PROGRAM_BRANCH=main

12
Dockerfile Normal file
View file

@ -0,0 +1,12 @@
FROM node:22-alpine
WORKDIR /app
COPY server.mjs ./server.mjs
COPY public ./public
ENV PORT=8080
EXPOSE 8080
USER node
CMD ["node", "server.mjs"]

View file

@ -7,26 +7,36 @@ machine-language payload is required.
## Programs
| Program | Type | Controls | Description | Play online |
| Program | Type | Controls | Description | Run locally |
| --- | --- | --- | --- | --- |
| [Meteor Dodge](meteor-dodge/meteor-dodge.bas) | Arcade | `A`, `D`, `Q` | Dodge falling meteors and build your score. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#meteor-dodge) |
| [Code Breaker](code-breaker/code-breaker.bas) | Logic | Digits, `Q` | Deduce a four-digit secret in ten guesses. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#code-breaker) |
| [Submarine Hunt](submarine-hunt/submarine-hunt.bas) | Strategy | Coordinates, `Q` | Find three hidden submarines with limited sonar shots. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#submarine-hunt) |
| [Lunar Lander](lunar-lander/lunar-lander.bas) | Simulation | `0`-`9`, `Q` | Choose engine burns and touch down gently. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#lunar-lander) |
| [Memory Matrix](memory-matrix/memory-matrix.bas) | Memory | `1`-`4`, `Q` | Repeat a growing sequence of flashing quadrants. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#memory-matrix) |
| [Robot Maze](robot-maze/robot-maze.bas) | Puzzle | `W`, `A`, `S`, `D`, `Q` | Navigate a freshly generated maze to its exit. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#robot-maze) |
| [Music Box](music-box/music-box.bas) | Music | `A`-`K`, `1`-`3`, `Q` | Play the SID chip as a small computer keyboard. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#music-box) |
| [Pixel Painter](pixel-painter/pixel-painter.bas) | Creative | Cursor keys, Space, `C`, `Q` | Draw character-cell pictures on the screen. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#pixel-painter) |
| [Stock Trader](stock-trader/stock-trader.bas) | Simulation | `B`, `S`, `W`, `Q` | Trade a volatile stock over twenty market days. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#stock-trader) |
| [Starfield](starfield/starfield.bas) | Demo | `1`-`3`, `Q` | Watch and control a layered animated starfield. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#starfield) |
| [Meteor Dodge](meteor-dodge/meteor-dodge.bas) | Arcade | `A`, `D`, `Q` | Dodge falling meteors and build your score. | [Run program](http://localhost:8134/?program=meteor-dodge%2Fmeteor-dodge.prg) |
| [Code Breaker](code-breaker/code-breaker.bas) | Logic | Digits, `Q` | Deduce a four-digit secret in ten guesses. | [Run program](http://localhost:8134/?program=code-breaker%2Fcode-breaker.prg) |
| [Submarine Hunt](submarine-hunt/submarine-hunt.bas) | Strategy | Coordinates, `Q` | Find three hidden submarines with limited sonar shots. | [Run program](http://localhost:8134/?program=submarine-hunt%2Fsubmarine-hunt.prg) |
| [Lunar Lander](lunar-lander/lunar-lander.bas) | Simulation | `0`-`9`, `Q` | Choose engine burns and touch down gently. | [Run program](http://localhost:8134/?program=lunar-lander%2Flunar-lander.prg) |
| [Memory Matrix](memory-matrix/memory-matrix.bas) | Memory | `1`-`4`, `Q` | Repeat a growing sequence of flashing quadrants. | [Run program](http://localhost:8134/?program=memory-matrix%2Fmemory-matrix.prg) |
| [Robot Maze](robot-maze/robot-maze.bas) | Puzzle | `W`, `A`, `S`, `D`, `Q` | Navigate a freshly generated maze to its exit. | [Run program](http://localhost:8134/?program=robot-maze%2Frobot-maze.prg) |
| [Music Box](music-box/music-box.bas) | Music | `A`-`K`, `1`-`3`, `Q` | Play the SID chip as a small computer keyboard. | [Run program](http://localhost:8134/?program=music-box%2Fmusic-box.prg) |
| [Pixel Painter](pixel-painter/pixel-painter.bas) | Creative | Cursor keys, Space, `C`, `Q` | Draw character-cell pictures on the screen. | [Run program](http://localhost:8134/?program=pixel-painter%2Fpixel-painter.prg) |
| [Stock Trader](stock-trader/stock-trader.bas) | Simulation | `B`, `S`, `W`, `Q` | Trade a volatile stock over twenty market days. | [Run program](http://localhost:8134/?program=stock-trader%2Fstock-trader.prg) |
| [Starfield](starfield/starfield.bas) | Demo | `1`-`3`, `Q` | Watch and control a layered animated starfield. | [Run program](http://localhost:8134/?program=starfield%2Fstarfield.prg) |
## Running the programs
Click a **Run program** link in the table to launch that program directly in a
browser-based VICE x64sc emulator powered by WebAssembly. The runner downloads
the emulator from the EmulatorJS CDN; the program itself is embedded in the
repository's runner, so no file picker or manual loading is required. Click the
emulator once to give it keyboard focus and enable audio.
Start the local browser app with Docker Compose:
```text
docker compose up --build -d
```
Open [http://localhost:8134](http://localhost:8134), or click a **Run program**
link in the table. The app runs the VICE x64sc core through WebAssembly and
downloads the selected PRG through its own backend, avoiding browser CORS
restrictions. Click inside the emulator to enable keyboard input and sound.
The app reads the public repository tree at runtime. New `.prg` files pushed to
the configured branch appear after clicking **Refresh list**; rebuilding the
Docker image is not required. Repository, branch, and host port settings live in
`.env`, where the default host port is `8134`.
Each directory contains an editable, line-numbered `.bas` listing and a matching
tokenized `.prg` file. The PRGs use the normal C64 BASIC load address, `$0801`.

10
compose.yaml Normal file
View file

@ -0,0 +1,10 @@
services:
c64-basic:
build: .
ports:
- "${HOST_PORT:-8134}:8080"
environment:
PROGRAM_REPOSITORY: "${PROGRAM_REPOSITORY:-https://git.p20.dev/christian/c64-basic}"
PROGRAM_BRANCH: "${PROGRAM_BRANCH:-main}"
restart: unless-stopped

71
public/app.js Normal file
View file

@ -0,0 +1,71 @@
const library = document.querySelector('#library');
const player = document.querySelector('#player');
const status = document.querySelector('#status');
const programsElement = document.querySelector('#programs');
const refreshButton = document.querySelector('#refresh');
function titleFromPath(path) {
const filename = path.split('/').at(-1).replace(/\.prg$/i, '');
return filename.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
}
async function loadPrograms() {
status.hidden = false;
status.className = 'status';
status.textContent = 'Loading programs…';
programsElement.replaceChildren();
refreshButton.disabled = true;
try {
const response = await fetch('/api/programs', { cache: 'no-store' });
if (!response.ok) throw new Error('The program list could not be loaded.');
const { programs } = await response.json();
for (const program of programs) {
const link = document.createElement('a');
link.className = 'program-card';
link.href = `/?program=${encodeURIComponent(program.path)}`;
const title = document.createElement('strong');
title.textContent = titleFromPath(program.path);
const action = document.createElement('span');
action.textContent = 'Run program →';
link.append(title, action);
programsElement.append(link);
}
status.textContent = programs.length ? `${programs.length} programs available` : 'No PRG files found.';
} catch (error) {
status.className = 'status error';
status.textContent = error.message;
} finally {
refreshButton.disabled = false;
}
}
function startEmulator(programPath) {
library.hidden = true;
player.hidden = false;
const title = titleFromPath(programPath);
document.title = `${title} — C64 BASIC Collection`;
document.querySelector('#program-title').textContent = title;
window.EJS_player = '#game';
window.EJS_core = 'vice_x64sc';
window.EJS_gameUrl = `/api/program?path=${encodeURIComponent(programPath)}`;
window.EJS_gameName = programPath.split('/').at(-1);
window.EJS_pathtodata = 'https://cdn.emulatorjs.org/stable/data/';
window.EJS_startOnLoaded = true;
window.EJS_language = 'en-US';
const loader = document.createElement('script');
loader.src = `${window.EJS_pathtodata}loader.js`;
loader.onerror = () => {
const message = document.querySelector('#emulator-error');
message.hidden = false;
message.textContent = 'The WASM VICE emulator could not be downloaded. Check your connection and try again.';
};
document.body.append(loader);
}
refreshButton.addEventListener('click', loadPrograms);
const programPath = new URLSearchParams(location.search).get('program');
if (programPath) startEmulator(programPath);
else loadPrograms();

40
public/index.html Normal file
View file

@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Run the C64 BASIC Collection in a browser-based WASM VICE emulator.">
<title>C64 BASIC Collection</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/">C64 BASIC Collection</a>
<span>VICE x64sc · WebAssembly</span>
</header>
<main>
<section id="library" class="panel">
<p class="eyebrow">Program library</p>
<h1>Choose a program</h1>
<p class="lede">Programs are read directly from the public repository each time this page loads.</p>
<p id="status" class="status">Loading programs…</p>
<div id="programs" class="program-grid" aria-live="polite"></div>
<button id="refresh" class="secondary" type="button">Refresh list</button>
</section>
<section id="player" class="player" hidden>
<div class="player-heading">
<div>
<p class="eyebrow">Now running</p>
<h1 id="program-title"></h1>
</div>
<a class="secondary button" href="/">Back to library</a>
</div>
<p class="hint">Click inside the emulator to enable keyboard input and sound.</p>
<div id="game"></div>
<p id="emulator-error" class="error" hidden></p>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

32
public/styles.css Normal file
View file

@ -0,0 +1,32 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #121024;
color: #f7f3ff;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top, #33265d, #121024 42rem); }
.site-header { display: flex; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1rem, 5vw, 4rem); border-bottom: 1px solid #ffffff1c; color: #bdb5dd; }
.brand { color: #fff; font-weight: 800; text-decoration: none; }
main { width: min(1100px, calc(100% - 2rem)); margin: 0 auto; padding: clamp(2rem, 6vw, 5rem) 0; }
.panel { max-width: 900px; margin: auto; }
.eyebrow { margin: 0 0 .5rem; color: #c9a7ff; font-size: .75rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(2rem, 7vw, 4rem); line-height: 1; }
.lede, .hint { color: #c8c1df; }
.status { margin: 2rem 0 1rem; color: #9ce6c2; }
.program-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1rem; }
.program-card { display: flex; min-height: 125px; flex-direction: column; justify-content: space-between; padding: 1.25rem; border: 1px solid #ffffff24; border-radius: 14px; background: #211c3bcc; color: #fff; text-decoration: none; transition: transform .15s, border-color .15s; }
.program-card:hover { transform: translateY(-3px); border-color: #b58cff; }
.program-card span { color: #bca8f1; font-size: .9rem; }
.button, button { display: inline-block; margin-top: 1.5rem; padding: .7rem 1rem; border: 1px solid #ffffff2b; border-radius: 8px; background: #302653; color: #fff; font: inherit; text-decoration: none; cursor: pointer; }
button:disabled { opacity: .5; cursor: wait; }
.player-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; }
.player-heading h1 { font-size: clamp(1.8rem, 5vw, 3rem); }
#game { width: min(100%, 960px); height: min(75vw, 720px); min-height: 480px; margin: 1.5rem auto 0; background: #000; }
.error { color: #ffb4ab; }
@media (max-width: 600px) {
.site-header span { display: none; }
.player-heading { align-items: start; flex-direction: column; }
#game { min-height: 60vh; }
}

File diff suppressed because one or more lines are too long

97
server.mjs Normal file
View file

@ -0,0 +1,97 @@
import { createReadStream, existsSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const port = Number(process.env.PORT || 8080);
const repository = (process.env.PROGRAM_REPOSITORY || 'https://git.p20.dev/christian/c64-basic').replace(/\/$/, '');
const branch = process.env.PROGRAM_BRANCH || 'main';
const publicDirectory = fileURLToPath(new URL('./public/', import.meta.url));
const repositoryMatch = repository.match(/^https:\/\/([^/]+)\/([^/]+)\/([^/]+)$/);
if (!repositoryMatch) {
throw new Error('PROGRAM_REPOSITORY must look like https://host/owner/repository');
}
const [, repositoryHost, repositoryOwner, repositoryName] = repositoryMatch;
const apiBase = `https://${repositoryHost}/api/v1/repos/${encodeURIComponent(repositoryOwner)}/${encodeURIComponent(repositoryName)}`;
function sendJson(response, status, value) {
const body = JSON.stringify(value);
response.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store'
});
response.end(body);
}
async function fetchPrograms() {
const treeUrl = `${apiBase}/git/trees/${encodeURIComponent(branch)}?recursive=true`;
const upstream = await fetch(treeUrl, { signal: AbortSignal.timeout(10000) });
if (!upstream.ok) throw new Error(`Repository returned HTTP ${upstream.status}`);
const tree = await upstream.json();
return tree.tree
.filter(entry => entry.type === 'blob' && entry.path.toLowerCase().endsWith('.prg'))
.map(entry => ({ path: entry.path, size: entry.size }))
.sort((left, right) => left.path.localeCompare(right.path));
}
async function proxyProgram(response, programPath) {
const programs = await fetchPrograms();
if (!programs.some(program => program.path === programPath)) {
sendJson(response, 404, { error: 'Program not found' });
return;
}
const encodedPath = programPath.split('/').map(encodeURIComponent).join('/');
const rawUrl = `${repository}/raw/branch/${encodeURIComponent(branch)}/${encodedPath}`;
const upstream = await fetch(rawUrl, { signal: AbortSignal.timeout(10000) });
if (!upstream.ok) throw new Error(`Program download returned HTTP ${upstream.status}`);
const bytes = Buffer.from(await upstream.arrayBuffer());
response.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': bytes.length,
'Content-Disposition': `inline; filename="${programPath.split('/').at(-1)}"`,
'Cache-Control': 'no-store'
});
response.end(bytes);
}
function serveStatic(response, pathname) {
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
const filePath = normalize(join(publicDirectory, requested));
if (!filePath.startsWith(publicDirectory) || !existsSync(filePath) || !statSync(filePath).isFile()) {
return false;
}
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
response.writeHead(200, {
'Content-Type': types[extname(filePath)] || 'application/octet-stream',
'Content-Length': statSync(filePath).size,
'Cache-Control': 'no-cache'
});
createReadStream(filePath).pipe(response);
return true;
}
createServer(async (request, response) => {
try {
const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
if (request.method !== 'GET') {
sendJson(response, 405, { error: 'Method not allowed' });
} else if (url.pathname === '/api/programs') {
sendJson(response, 200, { repository, branch, programs: await fetchPrograms() });
} else if (url.pathname === '/api/program') {
await proxyProgram(response, url.searchParams.get('path') || '');
} else if (!serveStatic(response, url.pathname)) {
sendJson(response, 404, { error: 'Not found' });
}
} catch (error) {
console.error(error);
if (!response.headersSent) sendJson(response, 502, { error: 'Unable to reach the program repository' });
else response.end();
}
}).listen(port, '0.0.0.0', () => {
console.log(`C64 BASIC runner listening on port ${port}`);
});