c64-ready
v2.4.0
Published
Commodore 64 emulator for the browser and Node.js — browser player, headless streaming CLI, and TypeScript API
Downloads
122
Maintainers
Readme
c64-ready
c64-ready is a TypeScript/Vite frontend prototype for running and rendering a Commodore 64 emulator in the browser.
It is based on c64.js (from lvllvl.com by James) from the original project source: https://github.com/jaammees/lvllvl
Goal
Build a clean, testable C64 emulator for the web, with a focus on:
- low-level WASM access,
- emulator control/state,
- canvas-based rendering
- node based headless rendering
- framework agnostic integration
Live URL
https://hayesmaker.github.io/c64-ready/
Install and run locally
- Prerequisites: Node.js 18+ and npm (see https://nodejs.org/)
Install dependencies:
npm installStart the dev server:
npm run devCreate a production build:
npm run buildUnit tests
This project uses Vitest with a jsdom environment (Jest-like API, faster integration with Vite/TypeScript).
Run tests:
npm testRun tests in watch mode:
npm run test:watchHeadless streaming (Docker)
The headless player streams the C64 output over WebRTC — a single container serves a self-contained browser player page with sub-100ms latency and a built-in keyboard/ joystick input channel. No separate media server is required.
Prerequisites: Docker and Docker Compose v2.
Quick start
With NPM
npx c64-ready```This starts a static server that serves the browser player on http://localhost:5173/c64-ready/ by default.
With Docker
# 1. Copy the env file and edit to taste (defaults: BASIC prompt, WebRTC on :9002)
cp docker/.env.example docker/.env
# 2. Build and start
docker compose up --build
# 3. Open the player in a browser
open http://localhost:9002The player page is self-contained — video, audio, and keyboard/joystick input are all handled in the browser with no extra software needed.
Load a cartridge
Games are bind-mounted from public/games/ — no rebuild needed:
# Set GAME_PATH in docker/.env, then restart
docker compose restart headless
# Or override inline for a one-off run
GAME_PATH=/app/public/games/cartridges/legend-of-wilf.crt docker compose upYou can also drag-and-drop game files onto the player page at any time to load without
restarting the container. Supported browser-side formats are .crt, .prg, .d64,
and native snapshots (.c64, .snapshot, .s64).
Note: VICE .vsf snapshots are currently disabled in the browser runtime.
Keyboard limitations (current WASM build)
- The C64
RESTOREkey is currently not functional in this build. - We verified browser key mapping for
Page Upis received, but the underlying WASM core does not appear to trigger restore behavior from key input. - Treat this as a runtime limitation of the shipped binary for now.
Environment variables
All options live in docker/.env (copy from docker/.env.example). See that file for
the full annotated list.
| Variable | Default | Description |
| ------------------------- | ---------------------- | ---------------------------------------------------------------------- |
| WASM_PATH | /app/public/c64.wasm | Path to the WASM binary inside the container |
| GAME_PATH | (empty) | Cartridge to load on startup — leave blank to boot to BASIC |
| FPS | 50 | Target frame rate (50 = PAL, 60 = NTSC) |
| AUDIO | 1 | Set to 1 to include SID audio in the WebRTC stream |
| DURATION | (empty = forever) | Stop after this many seconds |
| VERBOSE | (empty) | Set to 1 for per-frame diagnostics in container logs |
| LOG_EVENTS | 1 | Log player joins/leaves, cart loads, input events |
| WEBRTC_ENABLED | 1 | Must be 1 — WebRTC is the only supported streaming mode |
| WEBRTC_PORT | 9002 | Port inside the container for the WebRTC server |
| WEBRTC_HOST_PORT | 9002 | Host-side port mapping for the WebRTC server |
| MAX_SPECTATORS | 10 | Max concurrent spectator connections (players are separate, see below) |
| WEBRTC_MIN_BITRATE_KBPS | 200 | VP8 SDP x-google-min-bitrate hint in kbps |
| WEBRTC_MAX_BITRATE_KBPS | 600 | VP8 SDP x-google-max-bitrate hint in kbps |
| WEBRTC_OUTPUT_FPS | 40 | Cap outgoing WebRTC video FPS (0 disables cap) |
| C64_ADMIN_TOKEN | (empty) | Shared token required by c64-admin (status, kick) |
| WS_PORT | 9001 | WebSocket input server port inside the container |
| WS_HOST_PORT | 9001 | Host-side port mapping for the input WebSocket |
Admin CLI
Use c64-admin to inspect active players/spectators and run admin actions over the input WebSocket:
# show current room/client state
c64-admin --token "$C64_ADMIN_TOKEN" status
# kick a specific player slot
c64-admin --token "$C64_ADMIN_TOKEN" kick --player host
# kick all clients and disconnect all WebRTC peers
c64-admin --token "$C64_ADMIN_TOKEN" kick --allSpectator limit
Up to 2 player slots (host + P2) are always reserved. MAX_SPECTATORS controls how
many additional viewers can connect simultaneously. Total WebRTC peers = MAX_SPECTATORS + 2.
Connections beyond the limit receive a capacity-full message and are rejected immediately.
FFmpeg recording / capture
FFmpeg can be used alongside the WebRTC stream for local recording or debugging. Pass
--record to the CLI to enable it — both modes run concurrently:
# Record a 60-second session to a file (no Docker needed)
node bin/headless.mjs --wasm public/c64.wasm --no-game \
--record --output out.mp4 --duration 60
# WebRTC stream + simultaneous local recording
node bin/headless.mjs --wasm public/c64.wasm --game public/games/cartridges/game.crt \
--webrtc --webrtc-port 9002 \
--record --output recording.mp4 \
--input --ws-port 9001 --fps 50
# Push to an RTMP endpoint (e.g. for OBS / Twitch ingest)
node bin/headless.mjs --wasm public/c64.wasm --no-game \
--record --output rtmp://localhost:1935/live/c64ffmpeg must be on PATH for --record to work.
Stop
docker compose downHeadless Input API
When the headless emulator is started with --input (or INPUT_ENABLED=1 in Docker), it opens a WebSocket server on port 9001 (configurable via --ws-port / WS_PORT).
Any client — browser, Node script, or bot — can connect and send JSON messages to control the emulator in real-time.
Connection & handshake
On connect the server immediately sends a hello frame:
{
"type": "hello",
"protocol": "c64-input",
"version": 1,
"joystickBitmask": { "up": 1, "down": 2, "left": 4, "right": 8, "fire": 16 }
}Wire protocol (client → server)
Joystick — the emulator holds the direction for exactly as long as the client holds it.
A release must be sent explicitly when the physical (or virtual) button is lifted.
Never infer release from a timer — if the release message is not sent the direction will stick indefinitely.
{ "type": "joystick", "action": "push", "joystickPort": 2, "direction": "up" }
{ "type": "joystick", "action": "release", "joystickPort": 2, "direction": "up" }
{ "type": "joystick", "action": "push", "joystickPort": 2, "fire": true }
{ "type": "joystick", "action": "release", "joystickPort": 2, "fire": true }Keyboard — use the C64 key code (integer):
{ "type": "key", "action": "down", "key": 65 }
{ "type": "key", "action": "up", "key": 65 }Client example — Node.js
A minimal Node.js client that connects, waits for the handshake, then mirrors keydown / keyup-style events from the calling code as explicit push / release pairs:
import { WebSocket } from 'ws'; // npm install ws
const ws = new WebSocket('ws://localhost:9001');
let ready = false;
// --- helpers ---------------------------------------------------------
function joystickPush(port, direction, fire) {
if (!ready) return;
ws.send(
JSON.stringify({ type: 'joystick', action: 'push', joystickPort: port, direction, fire }),
);
}
function joystickRelease(port, direction, fire) {
if (!ready) return;
ws.send(
JSON.stringify({ type: 'joystick', action: 'release', joystickPort: port, direction, fire }),
);
}
// --- lifecycle -------------------------------------------------------
ws.on('open', () => console.log('connected'));
ws.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.type !== 'hello') return;
console.log('server ready, protocol version', msg.version);
ready = true;
});
ws.on('close', () => console.log('disconnected'));
ws.on('error', (err) => console.error('ws error:', err.message));
// --- usage -----------------------------------------------------------
// Drive push/release from your own input events, e.g.:
//
// gamepad 'buttondown' event fires → joystickPush(2, 'up')
// gamepad 'buttonup' event fires → joystickRelease(2, 'up')
//
// Example: a simple readline-driven test sequence
import readline from 'readline';
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => {
const [action, direction] = line.trim().split(' ');
if (action === 'push') joystickPush(2, direction);
if (action === 'release') joystickRelease(2, direction);
if (action === 'quit') ws.close();
});Run it and type push up / release up to move the joystick while the emulator is streaming.
Client example — Browser
The browser's built-in WebSocket works the same way — useful for a frontend that streams video via flv.js and sends keyboard/joystick input back:
const ws = new WebSocket('ws://localhost:9001');
ws.addEventListener('message', (evt) => {
const msg = JSON.parse(evt.data);
if (msg.type !== 'hello') return;
console.log('c64-input server ready');
});
// Map keyboard events → C64 key codes and send them
document.addEventListener('keydown', (evt) => {
ws.send(JSON.stringify({ type: 'key', action: 'down', key: evt.keyCode }));
});
document.addEventListener('keyup', (evt) => {
ws.send(JSON.stringify({ type: 'key', action: 'up', key: evt.keyCode }));
});Using InputBridge helpers (TypeScript / ESM)
InputBridge ships static encoder helpers so you don't have to hand-roll JSON strings:
import { InputBridge } from 'c64-ready/src/headless/input-bridge';
// Encode a joystick push and release
const push = InputBridge.encodeJoystick(2, 'push', 'right');
const release = InputBridge.encodeJoystick(2, 'release', 'right');
// Encode fire button
const firePush = InputBridge.encodeJoystick(2, 'push', undefined, true);
const fireRelease = InputBridge.encodeJoystick(2, 'release', undefined, true);
// Encode a keypress
const keyDown = InputBridge.encodeKeypress(65, 'down'); // 'A'
const keyUp = InputBridge.encodeKeypress(65, 'up');
// Send push on button-down, release on button-up — never infer release from a timer
gamepad.on('buttondown', (btn) => ws.send(InputBridge.encodeJoystick(2, 'push', btn.direction)));
gamepad.on('buttonup', (btn) => ws.send(InputBridge.encodeJoystick(2, 'release', btn.direction)));Docker — enabling input
Expose the WebSocket port and set INPUT_ENABLED=1 in docker/.env (or inline):
INPUT_ENABLED=1 WS_PORT=9001 docker compose upThen connect your client to ws://localhost:9001.
Using c64-ready as an npm package
c64-ready can be installed as a dependency and used in three ways:
| Use-case | How |
| ------------------------------ | -------------------------------------------------------- |
| Run the browser player locally | npx c64-ready (zero config) |
| TypeScript / Node API | import from sub-path exports |
| Vite browser app | Copy or re-use src/player/* with your own Vite project |
Prerequisites
The TypeScript compiled outputs (dist-ts/) must be present in the package. They are
generated by npm run package:build (npm run build && npm run headless:build) before
publishing. A published release on npm will always contain them.
Running the browser player
After installing the package globally, or via npx, the c64-ready command starts a
lightweight static HTTP server that serves the pre-built browser player:
# Run without installing (npx caches the package automatically)
npx c64-ready
# Or install globally and run
npm install -g c64-ready
c64-readyOpen the URL printed to the terminal in any modern browser:
C64 Ready player is running.
➜ Local: http://localhost:5173/c64-ready/Options:
| Flag | Default | Description |
| ------------ | --------- | ----------------------------------------------------------------- |
| --port <n> | 5173 | HTTP port to listen on |
| --host | localhost | Bind to 0.0.0.0 so the player is reachable on the local network |
| --help | | Print usage |
# Different port, accessible on the LAN
c64-ready --port 8080 --hostNote:
c64-readyserves the compileddist/directory. If you are working from a cloned repo rather than a published package, runnpm run buildfirst.
TypeScript / Node.js API
After installing as a local dependency:
npm install c64-readyThe following sub-path exports are available:
Shared types — c64-ready/types
import type {
C64Config,
FrameBuffer,
AudioBuffer,
InputEvent,
GameLoadOptions,
} from 'c64-ready/types';Low-level emulator — c64-ready/emulator
C64Emulator is the single class all higher-level consumers build on. It owns the WASM
lifecycle and fires onFrame / onAudio callbacks each tick.
import { C64Emulator } from 'c64-ready/emulator';
// Load and initialise the WASM binary
const emulator = await C64Emulator.load('/path/to/c64.wasm');
// React to each rendered frame (RGBA pixels, 384 × 272)
emulator.onFrame = (frame) => {
console.log(`frame ${frame.timestamp}: ${frame.width}×${frame.height}`);
};
// Load a cartridge (.crt file bytes)
const crtBytes = new Uint8Array(await fetch('/games/mygame.crt').then((r) => r.arrayBuffer()));
emulator.loadGame({ type: 'crt', data: crtBytes });
// Start the emulation loop
emulator.start();Headless emulator — c64-ready/headless
C64Headless wraps C64Emulator for server-side use (Node.js / Deno). It wires up
FrameCapture, AudioCapture, and InputBridge out of the box.
import { C64Headless } from 'c64-ready/headless';
const headless = new C64Headless('/path/to/c64.wasm');
await headless.init();
// Optional: load a game
const data = new Uint8Array(fs.readFileSync('/path/to/game.crt'));
await headless.loadGame({ type: 'crt', data });
// Step the emulator and capture frames
const { frame, audio } = headless.stepAndCapture(20); // 20 ms step
if (frame) {
// frame is a Uint8Array of raw RGBA pixels (384 × 272)
}
// Forward remote input from an external source (e.g. WebSocket message)
headless.inputBridge.receiveRemoteInput(
JSON.stringify({ type: 'joystick', action: 'push', joystickPort: 2, direction: 'up' }),
);Remote input encoding — c64-ready/input-bridge
InputBridge provides static helpers so you don't hand-roll input JSON:
import { InputBridge } from 'c64-ready/input-bridge';
// Joystick
const push = InputBridge.encodeJoystick(2, 'push', 'right');
const release = InputBridge.encodeJoystick(2, 'release', 'right');
const fire = InputBridge.encodeJoystick(2, 'push', undefined, true);
// Keyboard (C64 key code)
const keyDown = InputBridge.encodeKeypress(65, 'down'); // key code 65 = 'A'
const keyUp = InputBridge.encodeKeypress(65, 'up');
// Send via WebSocket — push on button-down, release on button-up
gamepad.on('buttondown', (btn) => ws.send(InputBridge.encodeJoystick(2, 'push', btn.direction)));
gamepad.on('buttonup', (btn) => ws.send(InputBridge.encodeJoystick(2, 'release', btn.direction)));Using the browser player in your own app
The browser player is framework and build-engine agnostic. Import the player API from the package entrypoint in any browser app that can serve static assets:
import { C64Player, CanvasRenderer } from 'c64-ready';
const renderer = new CanvasRenderer('c64-canvas');
const player = new C64Player({
wasmUrl: '/c64.wasm',
gameUrl: '/games/mygame.crt',
renderer,
});
await player.start();The package entrypoint also exports AudioEngine, InputHandler, UIController, and
shared TypeScript types for custom integrations. Serve c64.wasm and
audio-worklet-processor.js from your app's static asset directory so the browser can
load the emulator core and audio worklet at runtime. If those assets are not served from
the web root, pass audio.assetBaseUrl or audio.workletUrl to C64Player.
Building before publish
To regenerate both the Vite browser bundle and the TypeScript API outputs in one step:
npm run package:build
# equivalent to: npm run buildThis produces:
dist/— Vite browser bundle (served byc64-readyCLI)dist-ts/— compiled JS +.d.tsdeclarations (imported by API consumers)
npm run headless:build is reserved for the Docker/headless TypeScript build and does
not run the package ESM export rewrite step.
Deployment
The project deploys to GitHub Pages automatically via GitHub Actions.
On every push to master:
- Tests run (
npm test) - If tests pass, a production build is created (
npm run build) - The
dist/output is deployed to GitHub Pages
Work in Progress:
- Proof of Concept Implementation:
- [x] WASM module loading and initialization
- [x] Emulator control and state management
- [x] Canvas-based rendering
- [x] Node-based headless rendering
- [x] Framework agnostic integration (e.g., Vanilla HTML+JS, React, Vue, Angular etc)
- Additional features:
- [x] Audio output
- [x] Input handling (keyboard)
- [x] Loading and running .crt cartridge roms
- [x] Display settings
- [x] Docker headless streaming (RTMP / HTTP-FLV via Node Media Server)
- [ ] Gamepad support
- [ ] Touch controls
- [ ] Mobile Layout
- [x] Loading more game formats (e.g., .d64 disk images)
- [ ] Performance optimizations (e.g., offscreen canvas, audio worklets)
- [ ] Jitter creep - frame timing optimizations for smoother rendering
Changelog & Releases
See docs/CHANGELOG_RELEASES.md for the full release workflow,
changelog generator usage, tools/release.sh examples, and authentication notes.
Quick start — bump and push a patch release:
npm version patch -m "chore(release): %s"
git push origin master && git push --tagsDocs
Extended documentation lives in the docs/ folder:
| File | Description |
| ----------------------------------------------------- | ------------------------------------------------------------------ |
| AUDIO_ENGINE.md | SID audio pipeline, worklet pull-model, timing rules |
| HEADLESS_INPUT.md | Headless WebSocket input API reference |
| HEADLESS_RUNNING.md | Headless CLI usage, frame/timing rules, ffmpeg integration |
| PROJECT_OVERVIEW.md | High-level architecture and design decisions |
| CHANGELOG_RELEASES.md | Release workflow, tools/release.sh, changelog generator |
| WIKI_PUBLISHING.md | How to sync docs/ to the GitHub wiki via tools/publish_wiki.sh |
The wiki is kept in sync automatically by the .github/workflows/publish_wiki.yml CI workflow
on every push to master. To publish manually:
./tools/publish_wiki.sh [email protected]:YOUR_USER/c64-ready.wiki.git
