Add machine-code programs and category pages

This commit is contained in:
Christian Johannesen 2026-10-16 01:58:41 +02:00
parent 06c16f80f0
commit b247b859e3
15 changed files with 856 additions and 199 deletions

112
README.md
View file

@ -1,34 +1,38 @@
# C64 BASIC Collection # C64 Program Collection
Twenty small, polished programs for the Commodore 64, written entirely in stock Twenty-three small, polished programs for the Commodore 64, written in stock
Commodore BASIC V2. The collection mixes arcade action, puzzles, simulations, Commodore BASIC V2 and 6502 machine code. The collection mixes arcade action,
creative tools, sound, and visual effects. No cartridge, BASIC extension, or puzzles, simulations, creative tools, sound, and visual effects. Every entry is
machine-language payload is required. self-contained and runs on an ordinary PAL or NTSC C64 without a cartridge or
BASIC extension.
## Programs ## Programs
| Program | Type | Controls | Description | Run locally | | Program | Category | Language | Controls | Description | Source | Run locally |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| [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) | | Meteor Dodge | Arcade | BASIC | `A`, `D`, `Q` | Dodge falling meteors and build your score. | [BAS](meteor-dodge/meteor-dodge.bas) | [Run](http://localhost:8134/program/meteor-dodge) |
| [Code Breaker](code-breaker/code-breaker.bas) | Logic | `0`-`9`, `Q` | Deduce a four-digit secret in ten guesses. | [Run program](http://localhost:8134/program/code-breaker) | | Code Breaker | Logic | BASIC | `0`-`9`, Return, `Q` | Deduce a four-digit secret in ten guesses. | [BAS](code-breaker/code-breaker.bas) | [Run](http://localhost:8134/program/code-breaker) |
| [Submarine Hunt](submarine-hunt/submarine-hunt.bas) | Strategy | Row, Column, `Q` | Find three hidden submarines with limited sonar shots. | [Run program](http://localhost:8134/program/submarine-hunt) | | Submarine Hunt | Strategy | BASIC | Row, Column, Return | Find three hidden submarines with limited sonar shots. | [BAS](submarine-hunt/submarine-hunt.bas) | [Run](http://localhost:8134/program/submarine-hunt) |
| [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) | | Lunar Lander | Simulation | BASIC | `0`-`9`, Return, `-1` | Choose engine burns and touch down gently. | [BAS](lunar-lander/lunar-lander.bas) | [Run](http://localhost:8134/program/lunar-lander) |
| [Memory Matrix](memory-matrix/memory-matrix.bas) | Memory | `1`, `2`, `3`, `4`, `Q` | Repeat a growing sequence of flashing quadrants. | [Run program](http://localhost:8134/program/memory-matrix) | | Memory Matrix | Memory | BASIC | `1`, `2`, `3`, `4`, `Q` | Repeat a growing sequence of flashing quadrants. | [BAS](memory-matrix/memory-matrix.bas) | [Run](http://localhost:8134/program/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](http://localhost:8134/program/robot-maze) | | Robot Maze | Puzzle | BASIC | `W`, `A`, `S`, `D`, `Q` | Navigate a freshly generated maze to its exit. | [BAS](robot-maze/robot-maze.bas) | [Run](http://localhost:8134/program/robot-maze) |
| [Music Box](music-box/music-box.bas) | Music | `A`-`K`, `1`, `2`, `3`, `Q` | Play the SID chip as a small computer keyboard. | [Run program](http://localhost:8134/program/music-box) | | Music Box | Music | BASIC | `A`-`K`, `1`, `2`, `3`, `Q` | Play the SID chip as a small computer keyboard. | [BAS](music-box/music-box.bas) | [Run](http://localhost:8134/program/music-box) |
| [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) | | Pixel Painter | Creative | BASIC | Cursor keys, Space, `C`, `Q` | Draw character-cell pictures on the screen. | [BAS](pixel-painter/pixel-painter.bas) | [Run](http://localhost:8134/program/pixel-painter) |
| [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) | | Stock Trader | Simulation | BASIC | `B`, `S`, `W`, `Q` | Trade a volatile stock over twenty market days. | [BAS](stock-trader/stock-trader.bas) | [Run](http://localhost:8134/program/stock-trader) |
| [Starfield](starfield/starfield.bas) | Demo | `1`, `2`, `3`, `Q` | Watch and control a layered animated starfield. | [Run program](http://localhost:8134/program/starfield) | | Starfield | Demo | BASIC | `1`, `2`, `3`, `Q` | Watch and control a layered animated starfield. | [BAS](starfield/starfield.bas) | [Run](http://localhost:8134/program/starfield) |
| [Snake Trail](snake-trail/snake-trail.bas) | Arcade | `W`, `A`, `S`, `D`, `Q` | Guide a growing snake through a field of food and danger. | [Run program](http://localhost:8134/program/snake-trail) | | Snake Trail | Arcade | BASIC | `W`, `A`, `S`, `D`, `Q` | Guide a growing snake through a field of food and danger. | [BAS](snake-trail/snake-trail.bas) | [Run](http://localhost:8134/program/snake-trail) |
| [Dice Poker](dice-poker/dice-poker.bas) | Dice | `1`-`5`, Return, `Q` | Keep your best dice and chase a winning poker combination. | [Run program](http://localhost:8134/program/dice-poker) | | Dice Poker | Dice | BASIC | `1`-`5`, Return, `Q` | Keep your best dice and chase a winning poker combination. | [BAS](dice-poker/dice-poker.bas) | [Run](http://localhost:8134/program/dice-poker) |
| [Word Scramble](word-scramble/word-scramble.bas) | Word | `A`-`Z`, Return, `Q` | Unscramble five computer-themed words from helpful clues. | [Run program](http://localhost:8134/program/word-scramble) | | Word Scramble | Word | BASIC | `A`-`Z`, Return, `Q` | Unscramble five computer-themed words from helpful clues. | [BAS](word-scramble/word-scramble.bas) | [Run](http://localhost:8134/program/word-scramble) |
| [Nim Challenge](nim-challenge/nim-challenge.bas) | Strategy | `1`, `2`, `3`, `0` | Outthink the computer in the classic game of twenty-one sticks. | [Run program](http://localhost:8134/program/nim-challenge) | | Nim Challenge | Strategy | BASIC | `1`, `2`, `3`, `0` | Outthink the computer in the classic game of twenty-one sticks. | [BAS](nim-challenge/nim-challenge.bas) | [Run](http://localhost:8134/program/nim-challenge) |
| [Reaction Timer](reaction-timer/reaction-timer.bas) | Reflex | Any key, `Q` | Test your reactions across five unpredictable timed rounds. | [Run program](http://localhost:8134/program/reaction-timer) | | Reaction Timer | Reflex | BASIC | Any key, `Q` | Test your reactions across five unpredictable timed rounds. | [BAS](reaction-timer/reaction-timer.bas) | [Run](http://localhost:8134/program/reaction-timer) |
| [Morse Trainer](morse-trainer/morse-trainer.bas) | Educational | `A`-`Z`, Return, `Q` | Learn Morse letters by reading and hearing their signals. | [Run program](http://localhost:8134/program/morse-trainer) | | Morse Trainer | Educational | BASIC | `A`-`Z`, Return, `Q` | Learn Morse letters by reading and hearing their signals. | [BAS](morse-trainer/morse-trainer.bas) | [Run](http://localhost:8134/program/morse-trainer) |
| [Kaleidoscope](kaleidoscope/kaleidoscope.bas) | Demo | Space, `C`, `Q` | Grow colorful symmetrical patterns across the character screen. | [Run program](http://localhost:8134/program/kaleidoscope) | | Kaleidoscope | Demo | BASIC | Space, `C`, `Q` | Grow colorful symmetrical patterns across the character screen. | [BAS](kaleidoscope/kaleidoscope.bas) | [Run](http://localhost:8134/program/kaleidoscope) |
| [World Quiz](world-quiz/world-quiz.bas) | Trivia | `1`, `2`, `3`, `Q` | Take a seven-stop tour of world geography and landmarks. | [Run program](http://localhost:8134/program/world-quiz) | | World Quiz | Trivia | BASIC | `1`, `2`, `3`, `Q` | Take a seven-stop tour of world geography and landmarks. | [BAS](world-quiz/world-quiz.bas) | [Run](http://localhost:8134/program/world-quiz) |
| [Life Lab](life-lab/life-lab.bas) | Simulation | Space, `R`, `Q` | Observe generations of Conway's cellular automaton evolve. | [Run program](http://localhost:8134/program/life-lab) | | Life Lab | Simulation | BASIC | Space, `R`, `Q` | Observe generations of Conway's cellular automaton evolve. | [BAS](life-lab/life-lab.bas) | [Run](http://localhost:8134/program/life-lab) |
| [Cipher Wheel](cipher-wheel/cipher-wheel.bas) | Utility | `E`, `D`, `N`, `Q` | Encode and decode secret messages with a Caesar shift. | [Run program](http://localhost:8134/program/cipher-wheel) | | Cipher Wheel | Utility | BASIC | `E`, `D`, `N`, `Q` | Encode and decode secret messages with a Caesar shift. | [BAS](cipher-wheel/cipher-wheel.bas) | [Run](http://localhost:8134/program/cipher-wheel) |
| Sprite Playground | Creative | Machine Code | `W`, `A`, `S`, `D`, `C`, `Q` | Move and recolor a hardware sprite in a responsive VIC-II playground. | [ASM](sprite-playground/sprite-playground.asm) | [Run](http://localhost:8134/program/sprite-playground) |
| Raster Bars | Demo | Machine Code | `1`, `2`, `3`, `C`, `Q` | Paint animated bands of color with cycle-aware VIC-II raster timing. | [ASM](raster-bars/raster-bars.asm) | [Run](http://localhost:8134/program/raster-bars) |
| SID Arpeggiator | Music | Machine Code | `A`-`K`, `1`-`3`, `+`, `-`, Space, `Q` | Shape a looping SID arpeggio with notes, waveforms, and tempo controls. | [ASM](sid-arpeggiator/sid-arpeggiator.asm) | [Run](http://localhost:8134/program/sid-arpeggiator) |
## Running the programs ## Running the programs
@ -38,22 +42,16 @@ Start the local browser app with Docker Compose:
docker compose up --build -d docker compose up --build -d
``` ```
Open [http://localhost:8134](http://localhost:8134), or click a **Run program** Open [http://localhost:8134](http://localhost:8134), or use a **Run** link in
link in the table. The app runs the VICE x64sc core through WebAssembly and the table. The app runs the VICE x64sc core through WebAssembly and downloads
downloads the selected PRG through its own backend, avoiding browser CORS the selected PRG through its own backend. Click inside the emulator to enable
restrictions. Click inside the emulator to enable keyboard input and sound. keyboard input and sound. Direct keyboard input is enabled by default, mouse
Direct keyboard input is enabled by default, mouse locking is disabled, and locking is disabled, and moving the pointer anywhere over the emulator reveals
moving the pointer anywhere over the emulator reveals its control bar. its control bar.
Docker Compose mounts this repository read-only at `/programs`, and the app The repository is mounted read-only at `/programs`. Add a manifest entry and
serves PRG files from that local mount. There is no runtime dependency on the its source and PRG files, then click **Refresh**; the app does not need to be
Git server and no browser CORS boundary. Add a program to `programs.json` and rebuilt. The default host port is configured as `8134` in `.env`.
place its PRG in the referenced path; it appears after clicking **Refresh list**
without rebuilding the image. The default host port is configured as `8134` in
`.env`.
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`.
On a Commodore 64, transfer a PRG to disk or another supported medium, then use: On a Commodore 64, transfer a PRG to disk or another supported medium, then use:
@ -62,17 +60,29 @@ LOAD"METEOR-DODGE.PRG",8
RUN RUN
``` ```
Substitute the filename and device number as needed. In VICE or another C64 Substitute the filename and device number as needed. The machine-code PRGs
emulator, drag a PRG into the emulator window or use its autostart command. include a small BASIC `SYS` launcher, so the same `RUN` command starts them.
## Building machine-code programs
The assembly sources use ACME syntax. Build every machine-code PRG with the
isolated tools profile; no local assembler installation is required:
```text
docker compose --profile tools run --rm assembler
```
The tools profile is opt-in and is not started by the normal app command.
## Source format ## Source format
The `.bas` files are plain ASCII for comfortable reading and editing on a modern Each program directory contains a loadable `.prg` and its readable source:
computer. They use uppercase BASIC V2 keywords and avoid embedded control-code
glyphs. Their corresponding `.prg` files contain the tokenized PETSCII program
that the C64 loads directly.
All programs are self-contained and designed for an ordinary PAL or NTSC - BASIC programs use line-numbered, uppercase `.bas` files compatible with
Commodore 64. Randomized programs seed BASIC's random-number generator from the stock Commodore BASIC V2. Their PRGs are tokenized at the normal `$0801`
machine clock, so every session is different. BASIC load address.
- Machine-code programs use commented ACME `.asm` files. Their PRGs also load
at `$0801`, where a short BASIC line starts the 6502 code with `SYS`.
All programs are self-contained and designed for ordinary PAL and NTSC C64
hardware as well as VICE-compatible emulators.

View file

@ -9,3 +9,12 @@ services:
- .:/programs:ro - .:/programs:ro
restart: unless-stopped restart: unless-stopped
assembler:
profiles: ["tools"]
build:
context: .
dockerfile: tools/acme/Dockerfile
working_dir: /programs
volumes:
- .:/programs

View file

@ -1,143 +1,140 @@
[ [
{ {
"title": "Meteor Dodge", "title": "Meteor Dodge", "category": "Arcade", "language": "basic",
"type": "Arcade",
"description": "Dodge falling meteors and build your score.", "description": "Dodge falling meteors and build your score.",
"controls": ["A", "D", "Q"], "controls": [{"keys":["A"],"description":"Move left"},{"keys":["D"],"description":"Move right"},{"keys":["Q"],"description":"Quit"}],
"path": "meteor-dodge/meteor-dodge.prg" "path": "meteor-dodge/meteor-dodge.prg", "source": "meteor-dodge/meteor-dodge.bas"
}, },
{ {
"title": "Code Breaker", "title": "Code Breaker", "category": "Logic", "language": "basic",
"type": "Logic",
"description": "Deduce a four-digit secret in ten guesses.", "description": "Deduce a four-digit secret in ten guesses.",
"controls": ["0-9", "Q"], "controls": [{"keys":["0-9"],"description":"Enter a four-digit guess"},{"keys":["RETURN"],"description":"Submit the guess"},{"keys":["Q"],"description":"Quit"}],
"path": "code-breaker/code-breaker.prg" "path": "code-breaker/code-breaker.prg", "source": "code-breaker/code-breaker.bas"
}, },
{ {
"title": "Submarine Hunt", "title": "Submarine Hunt", "category": "Strategy", "language": "basic",
"type": "Strategy",
"description": "Find three hidden submarines with limited sonar shots.", "description": "Find three hidden submarines with limited sonar shots.",
"controls": ["ROW", "COLUMN", "Q"], "controls": [{"keys":["ROW","COLUMN"],"description":"Enter sonar coordinates"},{"keys":["RETURN"],"description":"Fire a sonar shot"},{"keys":["0","0"],"description":"Quit"}],
"path": "submarine-hunt/submarine-hunt.prg" "path": "submarine-hunt/submarine-hunt.prg", "source": "submarine-hunt/submarine-hunt.bas"
}, },
{ {
"title": "Lunar Lander", "title": "Lunar Lander", "category": "Simulation", "language": "basic",
"type": "Simulation",
"description": "Choose engine burns and touch down gently.", "description": "Choose engine burns and touch down gently.",
"controls": ["0-9", "Q"], "controls": [{"keys":["0-9"],"description":"Choose engine burn strength"},{"keys":["RETURN"],"description":"Commit the burn"},{"keys":["-1"],"description":"Quit"}],
"path": "lunar-lander/lunar-lander.prg" "path": "lunar-lander/lunar-lander.prg", "source": "lunar-lander/lunar-lander.bas"
}, },
{ {
"title": "Memory Matrix", "title": "Memory Matrix", "category": "Memory", "language": "basic",
"type": "Memory",
"description": "Repeat a growing sequence of flashing quadrants.", "description": "Repeat a growing sequence of flashing quadrants.",
"controls": ["1", "2", "3", "4", "Q"], "controls": [{"keys":["1","2","3","4"],"description":"Repeat the flashed quadrant"},{"keys":["Q"],"description":"Quit"}],
"path": "memory-matrix/memory-matrix.prg" "path": "memory-matrix/memory-matrix.prg", "source": "memory-matrix/memory-matrix.bas"
}, },
{ {
"title": "Robot Maze", "title": "Robot Maze", "category": "Puzzle", "language": "basic",
"type": "Puzzle",
"description": "Navigate a freshly generated maze to its exit.", "description": "Navigate a freshly generated maze to its exit.",
"controls": ["W", "A", "S", "D", "Q"], "controls": [{"keys":["W","A","S","D"],"description":"Move the robot"},{"keys":["Q"],"description":"Quit"}],
"path": "robot-maze/robot-maze.prg" "path": "robot-maze/robot-maze.prg", "source": "robot-maze/robot-maze.bas"
}, },
{ {
"title": "Music Box", "title": "Music Box", "category": "Music", "language": "basic",
"type": "Music",
"description": "Play the SID chip as a small computer keyboard.", "description": "Play the SID chip as a small computer keyboard.",
"controls": ["A-K", "1", "2", "3", "Q"], "controls": [{"keys":["A-K"],"description":"Play notes"},{"keys":["1","2","3"],"description":"Select square, saw, or triangle wave"},{"keys":["Q"],"description":"Quit"}],
"path": "music-box/music-box.prg" "path": "music-box/music-box.prg", "source": "music-box/music-box.bas"
}, },
{ {
"title": "Pixel Painter", "title": "Pixel Painter", "category": "Creative", "language": "basic",
"type": "Creative",
"description": "Draw character-cell pictures on the screen.", "description": "Draw character-cell pictures on the screen.",
"controls": ["CURSOR KEYS", "SPACE", "C", "Q"], "controls": [{"keys":["CURSOR KEYS"],"description":"Move the drawing cursor"},{"keys":["SPACE"],"description":"Draw a pixel"},{"keys":["C"],"description":"Clear the canvas"},{"keys":["Q"],"description":"Quit"}],
"path": "pixel-painter/pixel-painter.prg" "path": "pixel-painter/pixel-painter.prg", "source": "pixel-painter/pixel-painter.bas"
}, },
{ {
"title": "Stock Trader", "title": "Stock Trader", "category": "Simulation", "language": "basic",
"type": "Simulation",
"description": "Trade a volatile stock over twenty market days.", "description": "Trade a volatile stock over twenty market days.",
"controls": ["B", "S", "W", "Q"], "controls": [{"keys":["B"],"description":"Buy shares"},{"keys":["S"],"description":"Sell shares"},{"keys":["W"],"description":"Wait for the next day"},{"keys":["Q"],"description":"Quit"}],
"path": "stock-trader/stock-trader.prg" "path": "stock-trader/stock-trader.prg", "source": "stock-trader/stock-trader.bas"
}, },
{ {
"title": "Starfield", "title": "Starfield", "category": "Demo", "language": "basic",
"type": "Demo",
"description": "Watch and control a layered animated starfield.", "description": "Watch and control a layered animated starfield.",
"controls": ["1", "2", "3", "Q"], "controls": [{"keys":["1","2","3"],"description":"Select slow, medium, or fast speed"},{"keys":["Q"],"description":"Quit"}],
"path": "starfield/starfield.prg" "path": "starfield/starfield.prg", "source": "starfield/starfield.bas"
}, },
{ {
"title": "Snake Trail", "title": "Snake Trail", "category": "Arcade", "language": "basic",
"type": "Arcade",
"description": "Guide a growing snake through a field of food and danger.", "description": "Guide a growing snake through a field of food and danger.",
"controls": ["W", "A", "S", "D", "Q"], "controls": [{"keys":["W","A","S","D"],"description":"Steer the snake"},{"keys":["Q"],"description":"Quit"}],
"path": "snake-trail/snake-trail.prg" "path": "snake-trail/snake-trail.prg", "source": "snake-trail/snake-trail.bas"
}, },
{ {
"title": "Dice Poker", "title": "Dice Poker", "category": "Dice", "language": "basic",
"type": "Dice",
"description": "Keep your best dice and chase a winning poker combination.", "description": "Keep your best dice and chase a winning poker combination.",
"controls": ["1-5", "RETURN", "Q"], "controls": [{"keys":["1-5"],"description":"Choose dice positions to keep"},{"keys":["RETURN"],"description":"Roll the remaining dice"},{"keys":["Q"],"description":"Quit"}],
"path": "dice-poker/dice-poker.prg" "path": "dice-poker/dice-poker.prg", "source": "dice-poker/dice-poker.bas"
}, },
{ {
"title": "Word Scramble", "title": "Word Scramble", "category": "Word", "language": "basic",
"type": "Word",
"description": "Unscramble five computer-themed words from helpful clues.", "description": "Unscramble five computer-themed words from helpful clues.",
"controls": ["A-Z", "RETURN", "Q"], "controls": [{"keys":["A-Z"],"description":"Type your answer"},{"keys":["RETURN"],"description":"Submit the answer"},{"keys":["Q"],"description":"Quit"}],
"path": "word-scramble/word-scramble.prg" "path": "word-scramble/word-scramble.prg", "source": "word-scramble/word-scramble.bas"
}, },
{ {
"title": "Nim Challenge", "title": "Nim Challenge", "category": "Strategy", "language": "basic",
"type": "Strategy",
"description": "Outthink the computer in the classic game of twenty-one sticks.", "description": "Outthink the computer in the classic game of twenty-one sticks.",
"controls": ["1", "2", "3", "0"], "controls": [{"keys":["1","2","3"],"description":"Choose how many sticks to take"},{"keys":["0"],"description":"Quit"}],
"path": "nim-challenge/nim-challenge.prg" "path": "nim-challenge/nim-challenge.prg", "source": "nim-challenge/nim-challenge.bas"
}, },
{ {
"title": "Reaction Timer", "title": "Reaction Timer", "category": "Reflex", "language": "basic",
"type": "Reflex",
"description": "Test your reactions across five unpredictable timed rounds.", "description": "Test your reactions across five unpredictable timed rounds.",
"controls": ["ANY KEY", "Q"], "controls": [{"keys":["ANY KEY"],"description":"React when NOW appears"},{"keys":["Q"],"description":"Quit"}],
"path": "reaction-timer/reaction-timer.prg" "path": "reaction-timer/reaction-timer.prg", "source": "reaction-timer/reaction-timer.bas"
}, },
{ {
"title": "Morse Trainer", "title": "Morse Trainer", "category": "Educational", "language": "basic",
"type": "Educational",
"description": "Learn Morse letters by reading and hearing their signals.", "description": "Learn Morse letters by reading and hearing their signals.",
"controls": ["A-Z", "RETURN", "Q"], "controls": [{"keys":["A-Z"],"description":"Enter the decoded letter"},{"keys":["RETURN"],"description":"Submit the answer"},{"keys":["Q"],"description":"Quit"}],
"path": "morse-trainer/morse-trainer.prg" "path": "morse-trainer/morse-trainer.prg", "source": "morse-trainer/morse-trainer.bas"
}, },
{ {
"title": "Kaleidoscope", "title": "Kaleidoscope", "category": "Demo", "language": "basic",
"type": "Demo",
"description": "Grow colorful symmetrical patterns across the character screen.", "description": "Grow colorful symmetrical patterns across the character screen.",
"controls": ["SPACE", "C", "Q"], "controls": [{"keys":["SPACE"],"description":"Start a new pattern"},{"keys":["C"],"description":"Change color"},{"keys":["Q"],"description":"Quit"}],
"path": "kaleidoscope/kaleidoscope.prg" "path": "kaleidoscope/kaleidoscope.prg", "source": "kaleidoscope/kaleidoscope.bas"
}, },
{ {
"title": "World Quiz", "title": "World Quiz", "category": "Trivia", "language": "basic",
"type": "Trivia",
"description": "Take a seven-stop tour of world geography and landmarks.", "description": "Take a seven-stop tour of world geography and landmarks.",
"controls": ["1", "2", "3", "Q"], "controls": [{"keys":["1","2","3"],"description":"Choose an answer"},{"keys":["Q"],"description":"Quit"}],
"path": "world-quiz/world-quiz.prg" "path": "world-quiz/world-quiz.prg", "source": "world-quiz/world-quiz.bas"
}, },
{ {
"title": "Life Lab", "title": "Life Lab", "category": "Simulation", "language": "basic",
"type": "Simulation",
"description": "Observe generations of Conway's cellular automaton evolve.", "description": "Observe generations of Conway's cellular automaton evolve.",
"controls": ["SPACE", "R", "Q"], "controls": [{"keys":["SPACE"],"description":"Pause or continue"},{"keys":["R"],"description":"Create a new random colony"},{"keys":["Q"],"description":"Quit"}],
"path": "life-lab/life-lab.prg" "path": "life-lab/life-lab.prg", "source": "life-lab/life-lab.bas"
}, },
{ {
"title": "Cipher Wheel", "title": "Cipher Wheel", "category": "Utility", "language": "basic",
"type": "Utility",
"description": "Encode and decode secret messages with a Caesar shift.", "description": "Encode and decode secret messages with a Caesar shift.",
"controls": ["E", "D", "N", "Q"], "controls": [{"keys":["E","D"],"description":"Choose encode or decode"},{"keys":["N"],"description":"Enter another message"},{"keys":["Q"],"description":"Quit"}],
"path": "cipher-wheel/cipher-wheel.prg" "path": "cipher-wheel/cipher-wheel.prg", "source": "cipher-wheel/cipher-wheel.bas"
},
{
"title": "Sprite Playground", "category": "Creative", "language": "machine-code",
"description": "Move and recolor a hardware sprite in a responsive VIC-II playground.",
"controls": [{"keys":["W","A","S","D"],"description":"Move the sprite"},{"keys":["C"],"description":"Change sprite color"},{"keys":["Q"],"description":"Return to BASIC"}],
"path": "sprite-playground/sprite-playground.prg", "source": "sprite-playground/sprite-playground.asm"
},
{
"title": "Raster Bars", "category": "Demo", "language": "machine-code",
"description": "Paint animated bands of color with cycle-aware VIC-II raster timing.",
"controls": [{"keys":["1","2","3"],"description":"Select slow, medium, or fast movement"},{"keys":["C"],"description":"Change color palette"},{"keys":["Q"],"description":"Return to BASIC"}],
"path": "raster-bars/raster-bars.prg", "source": "raster-bars/raster-bars.asm"
},
{
"title": "SID Arpeggiator", "category": "Music", "language": "machine-code",
"description": "Shape a looping SID arpeggio with notes, waveforms, and tempo controls.",
"controls": [{"keys":["A-K"],"description":"Select the root note"},{"keys":["1","2","3"],"description":"Select triangle, saw, or pulse wave"},{"keys":["+","-"],"description":"Increase or decrease tempo"},{"keys":["SPACE"],"description":"Pause or resume playback"},{"keys":["Q"],"description":"Return to BASIC"}],
"path": "sid-arpeggiator/sid-arpeggiator.prg", "source": "sid-arpeggiator/sid-arpeggiator.asm"
} }
] ]

View file

@ -4,47 +4,102 @@ const status = document.querySelector('#status');
const programsElement = document.querySelector('#programs'); const programsElement = document.querySelector('#programs');
const refreshButton = document.querySelector('#refresh'); const refreshButton = document.querySelector('#refresh');
const searchInput = document.querySelector('#search'); const searchInput = document.querySelector('#search');
const typeFilter = document.querySelector('#type-filter'); const categoryFilter = document.querySelector('#category-filter');
const languageFilter = document.querySelector('#language-filter');
const sortOrder = document.querySelector('#sort-order'); const sortOrder = document.querySelector('#sort-order');
const gridViewButton = document.querySelector('#grid-view'); const gridViewButton = document.querySelector('#grid-view');
const listViewButton = document.querySelector('#list-view'); const listViewButton = document.querySelector('#list-view');
const sourceModal = document.querySelector('#source-modal'); const sourceModal = document.querySelector('#source-modal');
const sourceTitle = document.querySelector('#source-title'); const sourceTitle = document.querySelector('#source-title');
const sourceKind = document.querySelector('#source-kind');
const sourceStatus = document.querySelector('#source-status'); const sourceStatus = document.querySelector('#source-status');
const sourceCode = document.querySelector('#source-code'); const sourceCode = document.querySelector('#source-code');
const programBadges = document.querySelector('#program-badges');
const programDescription = document.querySelector('#program-description');
const programControls = document.querySelector('#program-controls');
let allPrograms = []; let allPrograms = [];
function titleFromPath(path) { const categoryMeta = {
const filename = path.split('/').at(-1).replace(/\.prg$/i, ''); Arcade: { icon: '🎮', color: '#d65f5f' },
return filename.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); Creative: { icon: '✎', color: '#65b96d' },
Demo: { icon: '✦', color: '#75a9dc' },
Dice: { icon: '⚄', color: '#d8d4c4' },
Educational: { icon: '⌁', color: '#b98a64' },
Logic: { icon: '◆', color: '#67b6bd' },
Memory: { icon: '◫', color: '#9b6bd6' },
Music: { icon: '♪', color: '#d668a4' },
Puzzle: { icon: '⌘', color: '#d6c85c' },
Reflex: { icon: '⚡', color: '#ef725f' },
Simulation: { icon: '⚙', color: '#6d8ed6' },
Strategy: { icon: '♟', color: '#d98f45' },
Trivia: { icon: '?', color: '#dfaa45' },
Utility: { icon: '⚒', color: '#a5acb8' },
Word: { icon: 'Aa', color: '#55b7a4' }
};
const languageLabels = {
basic: 'BASIC',
'machine-code': 'Machine Code'
};
function slugify(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
} }
const typeIcons = { function categoryFromRoute() {
Arcade: '🎮', const match = location.pathname.match(/^\/category\/([a-z0-9]+(?:-[a-z0-9]+)*)\/?$/);
Logic: '◆', return match?.[1] || '';
Strategy: '♟', }
Simulation: '⚙',
Memory: '◫', function createCategoryPill(category) {
Puzzle: '⌘', const meta = categoryMeta[category] || { icon: '●', color: '#a5acb8' };
Music: '♪', const pill = document.createElement('a');
Creative: '✎', pill.className = 'category-pill';
Demo: '✦', pill.href = `/category/${slugify(category)}`;
Dice: '⚄', pill.style.setProperty('--category-color', meta.color);
Word: 'Aa', const icon = document.createElement('span');
Reflex: '⚡', icon.setAttribute('aria-hidden', 'true');
Educational: '⌁', icon.textContent = meta.icon;
Trivia: '?', const label = document.createElement('span');
Utility: '⚒' label.textContent = category;
}; pill.append(icon, label);
pill.setAttribute('aria-label', `Show all ${category} programs`);
return pill;
}
function createLanguageBadge(language) {
const badge = document.createElement('span');
badge.className = `language-badge language-${language}`;
badge.textContent = languageLabels[language] || language;
return badge;
}
function createKeyGroup(keys) {
const group = document.createElement('span');
group.className = 'key-group';
for (const keyName of keys) {
const key = document.createElement('kbd');
key.textContent = keyName;
group.append(key);
}
return group;
}
function searchText(program) {
const controls = program.controls.flatMap(control => [...control.keys, control.description]);
return `${program.title} ${program.description} ${program.category} ${languageLabels[program.language]} ${controls.join(' ')}`.toLowerCase();
}
function renderPrograms() { function renderPrograms() {
const query = searchInput.value.trim().toLowerCase(); const query = searchInput.value.trim().toLowerCase();
const selectedType = typeFilter.value; const selectedCategory = categoryFilter.value;
const selectedLanguage = languageFilter.value;
const programs = allPrograms const programs = allPrograms
.filter(program => !selectedType || program.type === selectedType) .filter(program => !selectedCategory || program.category === selectedCategory)
.filter(program => !query || `${program.title} ${program.description} ${program.type} ${program.controls.join(' ')}`.toLowerCase().includes(query)) .filter(program => !selectedLanguage || program.language === selectedLanguage)
.filter(program => !query || searchText(program).includes(query))
.sort((left, right) => { .sort((left, right) => {
if (sortOrder.value === 'type') return left.type.localeCompare(right.type) || left.title.localeCompare(right.title); if (sortOrder.value === 'category') return left.category.localeCompare(right.category) || left.title.localeCompare(right.title);
return left.title.localeCompare(right.title); return left.title.localeCompare(right.title);
}); });
@ -57,9 +112,9 @@ function renderPrograms() {
if (!event.target.closest('a, button')) location.href = programUrl; if (!event.target.closest('a, button')) location.href = programUrl;
}); });
const pill = document.createElement('span'); const badges = document.createElement('div');
pill.className = 'type-pill'; badges.className = 'card-badges';
pill.textContent = `${typeIcons[program.type] || '●'} ${program.type}`; badges.append(createCategoryPill(program.category), createLanguageBadge(program.language));
const title = document.createElement('h2'); const title = document.createElement('h2');
title.className = 'program-title'; title.className = 'program-title';
@ -69,15 +124,6 @@ function renderPrograms() {
description.className = 'program-description'; description.className = 'program-description';
description.textContent = program.description; description.textContent = program.description;
const controls = document.createElement('div');
controls.className = 'controls';
controls.setAttribute('aria-label', `Controls: ${program.controls.join(', ')}`);
for (const control of program.controls) {
const key = document.createElement('kbd');
key.textContent = control;
controls.append(key);
}
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'program-actions'; actions.className = 'program-actions';
const run = document.createElement('a'); const run = document.createElement('a');
@ -91,14 +137,16 @@ function renderPrograms() {
source.disabled = !program.source; source.disabled = !program.source;
source.addEventListener('click', () => showSource(program)); source.addEventListener('click', () => showSource(program));
actions.append(run, source); actions.append(run, source);
card.append(pill, title, description, controls, actions); card.append(badges, title, description, actions);
programsElement.append(card); programsElement.append(card);
} }
status.className = 'status';
status.textContent = `${programs.length} of ${allPrograms.length} programs shown`; status.textContent = `${programs.length} of ${allPrograms.length} programs shown`;
} }
async function showSource(program) { async function showSource(program) {
sourceTitle.textContent = program.title; sourceTitle.textContent = program.title;
sourceKind.textContent = program.language === 'machine-code' ? 'Assembly source' : 'BASIC V2 source';
sourceStatus.hidden = false; sourceStatus.hidden = false;
sourceStatus.className = 'status'; sourceStatus.className = 'status';
sourceStatus.textContent = 'Loading source…'; sourceStatus.textContent = 'Loading source…';
@ -117,6 +165,15 @@ async function showSource(program) {
} }
} }
function populateFilters(programs) {
const categories = [...new Set(programs.map(program => program.category))].sort();
categoryFilter.replaceChildren(new Option('All categories', ''), ...categories.map(category => new Option(category, category)));
const routeSlug = categoryFromRoute();
const routeCategory = categories.find(category => slugify(category) === routeSlug);
if (routeCategory) categoryFilter.value = routeCategory;
else categoryFilter.value = '';
}
async function loadPrograms() { async function loadPrograms() {
status.hidden = false; status.hidden = false;
status.className = 'status'; status.className = 'status';
@ -127,10 +184,7 @@ async function loadPrograms() {
if (!response.ok) throw new Error('The program list could not be loaded.'); if (!response.ok) throw new Error('The program list could not be loaded.');
const { programs } = await response.json(); const { programs } = await response.json();
allPrograms = programs; allPrograms = programs;
const currentType = typeFilter.value; populateFilters(programs);
const types = [...new Set(programs.map(program => program.type))].sort();
typeFilter.replaceChildren(new Option('All types', ''), ...types.map(type => new Option(type, type)));
if (types.includes(currentType)) typeFilter.value = currentType;
renderPrograms(); renderPrograms();
} catch (error) { } catch (error) {
status.className = 'status error'; status.className = 'status error';
@ -140,18 +194,31 @@ async function loadPrograms() {
} }
} }
function renderProgramDetails(program) {
programBadges.replaceChildren(createCategoryPill(program.category), createLanguageBadge(program.language));
programDescription.textContent = program.description;
programControls.replaceChildren();
for (const control of program.controls) {
const row = document.createElement('div');
row.className = 'control-row';
const description = document.createElement('span');
description.textContent = control.description;
row.append(createKeyGroup(control.keys), description);
programControls.append(row);
}
}
function startEmulator(program) { function startEmulator(program) {
const programPath = program.path;
library.hidden = true; library.hidden = true;
player.hidden = false; player.hidden = false;
const title = program.title || titleFromPath(programPath); document.title = `${program.title} — C64 Program Collection`;
document.title = `${title} — C64 BASIC Collection`; document.querySelector('#program-title').textContent = program.title;
document.querySelector('#program-title').textContent = title; renderProgramDetails(program);
window.EJS_player = '#game'; window.EJS_player = '#game';
window.EJS_core = 'vice_x64sc'; window.EJS_core = 'vice_x64sc';
window.EJS_gameUrl = `/api/program?path=${encodeURIComponent(programPath)}`; window.EJS_gameUrl = `/api/program?path=${encodeURIComponent(program.path)}`;
window.EJS_gameName = programPath.split('/').at(-1); window.EJS_gameName = program.path.split('/').at(-1);
window.EJS_pathtodata = 'https://cdn.emulatorjs.org/stable/data/'; window.EJS_pathtodata = 'https://cdn.emulatorjs.org/stable/data/';
window.EJS_startOnLoaded = true; window.EJS_startOnLoaded = true;
window.EJS_language = 'en-US'; window.EJS_language = 'en-US';
@ -188,9 +255,17 @@ async function loadProgramPage(slug) {
} }
} }
function setCategory(category) {
categoryFilter.value = category;
const url = category ? `/category/${slugify(category)}` : '/';
history.pushState({}, '', url);
renderPrograms();
}
refreshButton.addEventListener('click', loadPrograms); refreshButton.addEventListener('click', loadPrograms);
searchInput.addEventListener('input', renderPrograms); searchInput.addEventListener('input', renderPrograms);
typeFilter.addEventListener('change', renderPrograms); categoryFilter.addEventListener('change', () => setCategory(categoryFilter.value));
languageFilter.addEventListener('change', renderPrograms);
sortOrder.addEventListener('change', renderPrograms); sortOrder.addEventListener('change', renderPrograms);
gridViewButton.addEventListener('click', () => setView('grid')); gridViewButton.addEventListener('click', () => setView('grid'));
listViewButton.addEventListener('click', () => setView('list')); listViewButton.addEventListener('click', () => setView('list'));
@ -198,6 +273,12 @@ document.querySelector('#close-source').addEventListener('click', () => sourceMo
sourceModal.addEventListener('click', event => { sourceModal.addEventListener('click', event => {
if (event.target === sourceModal) sourceModal.close(); if (event.target === sourceModal) sourceModal.close();
}); });
window.addEventListener('popstate', () => {
if (!library.hidden) {
populateFilters(allPrograms);
renderPrograms();
}
});
function setView(view) { function setView(view) {
const list = view === 'list'; const list = view === 'list';
@ -217,4 +298,3 @@ if (legacyProgramPath) {
location.replace(`/program/${encodeURIComponent(slug)}`); location.replace(`/program/${encodeURIComponent(slug)}`);
} else if (programPage) loadProgramPage(decodeURIComponent(programPage[1])); } else if (programPage) loadProgramPage(decodeURIComponent(programPage[1]));
else loadPrograms(); else loadPrograms();

View file

@ -3,34 +3,42 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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."> <meta name="description" content="Explore BASIC and machine-code programs for the Commodore 64 in a browser-based WASM VICE emulator.">
<title>C64 BASIC Collection</title> <title>C64 Program Collection</title>
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">
</head> </head>
<body> <body>
<header class="site-header"> <header class="site-header">
<a class="brand" href="/">C64 BASIC Collection</a> <a class="brand" href="/">C64 Program Collection</a>
<span>VICE x64sc · WebAssembly</span> <span>VICE x64sc · WebAssembly</span>
</header> </header>
<main> <main>
<section id="library" class="panel"> <section id="library" class="panel">
<p class="eyebrow">Program library</p> <p class="eyebrow">Program library</p>
<h1>Small programs. Pure 8-bit fun.</h1> <h1>Small programs. Pure 8-bit fun.</h1>
<p class="lede">Explore a growing collection of games, puzzles, creative tools, and audiovisual experiments written in Commodore 64 BASIC. Pick something that catches your eye, run it instantly, or open the source to see how every line works.</p> <p class="lede">Explore a growing collection of games, puzzles, creative tools, and audiovisual experiments written in Commodore 64 BASIC and 6502 machine code. Pick something that catches your eye, run it instantly, or open the source to see how it works.</p>
<div class="library-toolbar" aria-label="Program library controls"> <div class="library-toolbar" aria-label="Program library controls">
<label class="search-field"> <label class="search-field">
<span class="sr-only">Search programs</span> <span class="sr-only">Search programs</span>
<input id="search" type="search" placeholder="Search programs…" autocomplete="off"> <input id="search" type="search" placeholder="Search programs…" autocomplete="off">
</label> </label>
<label> <label>
<span class="sr-only">Filter by type</span> <span class="sr-only">Filter by category</span>
<select id="type-filter"><option value="">All types</option></select> <select id="category-filter"><option value="">All categories</option></select>
</label>
<label>
<span class="sr-only">Filter by language</span>
<select id="language-filter">
<option value="">All languages</option>
<option value="basic">BASIC</option>
<option value="machine-code">Machine Code</option>
</select>
</label> </label>
<label> <label>
<span class="sr-only">Sort programs</span> <span class="sr-only">Sort programs</span>
<select id="sort-order"> <select id="sort-order">
<option value="title">Sort: Title</option> <option value="title">Sort: Title</option>
<option value="type">Sort: Type</option> <option value="category">Sort: Category</option>
</select> </select>
</label> </label>
<div class="view-switcher" role="group" aria-label="View style"> <div class="view-switcher" role="group" aria-label="View style">
@ -47,9 +55,15 @@
<div> <div>
<p class="eyebrow">Now running</p> <p class="eyebrow">Now running</p>
<h1 id="program-title"></h1> <h1 id="program-title"></h1>
<div id="program-badges" class="program-badges"></div>
</div> </div>
<a class="secondary button" href="/">Back to library</a> <a class="secondary button" href="/">Back to library</a>
</div> </div>
<p id="program-description" class="program-page-description"></p>
<section class="instructions-panel" aria-labelledby="instructions-title">
<h2 id="instructions-title">Controls</h2>
<div id="program-controls" class="control-list"></div>
</section>
<p class="hint">Click inside the emulator to enable keyboard input and sound.</p> <p class="hint">Click inside the emulator to enable keyboard input and sound.</p>
<div id="game"></div> <div id="game"></div>
<p id="emulator-error" class="error" hidden></p> <p id="emulator-error" class="error" hidden></p>
@ -58,7 +72,7 @@
<dialog id="source-modal" class="source-modal"> <dialog id="source-modal" class="source-modal">
<div class="modal-heading"> <div class="modal-heading">
<div> <div>
<p class="eyebrow">BASIC V2 source</p> <p id="source-kind" class="eyebrow">Program source</p>
<h2 id="source-title">Program source</h2> <h2 id="source-title">Program source</h2>
</div> </div>
<button id="close-source" class="icon-button" type="button" aria-label="Close source">×</button> <button id="close-source" class="icon-button" type="button" aria-label="Close source">×</button>

View file

@ -33,11 +33,17 @@ h1 { margin: 0; font-size: clamp(2rem, 7vw, 4rem); line-height: 1; }
.program-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1rem; } .program-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1rem; }
.program-card { display: flex; min-height: 270px; flex-direction: column; align-items: flex-start; padding: 1.25rem; border: 1px solid #b8b1e733; border-radius: 14px; background: #211a59d9; color: #fff; cursor: pointer; transition: transform .15s, border-color .15s, background .15s; } .program-card { display: flex; min-height: 270px; flex-direction: column; align-items: flex-start; padding: 1.25rem; border: 1px solid #b8b1e733; border-radius: 14px; background: #211a59d9; color: #fff; cursor: pointer; transition: transform .15s, border-color .15s, background .15s; }
.program-card:hover { transform: translateY(-3px); border-color: var(--c64-light-blue); } .program-card:hover { transform: translateY(-3px); border-color: var(--c64-light-blue); }
.program-card:has(.card-action:hover) { transform: none; background: #29216bd9; } .program-card:has(.card-action:hover), .program-card:has(.category-pill:hover) { transform: none; background: #29216bd9; }
.type-pill { display: inline-flex; gap: .4rem; align-items: center; padding: .3rem .65rem; border: 1px solid #b8b1e766; border-radius: 999px; background: #7869c433; color: #d8d3f4; font-size: .75rem; font-weight: 750; letter-spacing: .04em; text-transform: uppercase; } .card-badges, .program-badges { display: flex; flex-wrap: wrap; gap: .45rem; align-items: center; }
.category-pill, .language-badge { display: inline-flex; gap: .4rem; align-items: center; min-height: 28px; padding: .3rem .65rem; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .04em; line-height: 1; text-transform: uppercase; }
.category-pill { border: 1px solid color-mix(in srgb, var(--category-color) 72%, white); background: color-mix(in srgb, var(--category-color) 24%, transparent); color: color-mix(in srgb, var(--category-color) 42%, white); text-decoration: none; transition: background .15s, color .15s, transform .15s; }
.category-pill:hover { background: color-mix(in srgb, var(--category-color) 58%, #17113b); color: #fff; transform: translateY(-1px); }
.category-pill:focus-visible { outline: 3px solid color-mix(in srgb, var(--category-color) 55%, white); outline-offset: 2px; }
.language-badge { border: 1px solid #b8b1e755; background: #17113b99; color: #d8d3f4; }
.language-machine-code { border-color: #d98f4588; background: #d98f4526; color: #f4c997; }
.language-basic { border-color: #75a9dc77; background: #75a9dc22; color: #c8e1f7; }
.program-title { margin: 1rem 0 0; font-size: 1.2rem; } .program-title { margin: 1rem 0 0; font-size: 1.2rem; }
.program-description { margin: .55rem 0 0; color: #d2cef0; font-size: .92rem; line-height: 1.45; } .program-description { margin: .55rem 0 0; color: #d2cef0; font-size: .92rem; line-height: 1.45; }
.controls { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: 1rem; }
kbd { min-width: 1.8rem; padding: .25rem .45rem; border: 1px solid var(--c64-light-blue); border-bottom-width: 3px; border-radius: 5px; background: #e9e7f7; color: #211a59; font: 700 .72rem ui-monospace, monospace; text-align: center; } kbd { min-width: 1.8rem; padding: .25rem .45rem; border: 1px solid var(--c64-light-blue); border-bottom-width: 3px; border-radius: 5px; background: #e9e7f7; color: #211a59; font: 700 .72rem ui-monospace, monospace; text-align: center; }
.program-actions { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 1rem; margin-top: auto; padding-top: 1.25rem; } .program-actions { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 1rem; margin-top: auto; padding-top: 1.25rem; }
.card-action { display: inline-flex; align-items: center; justify-content: center; gap: .45rem; min-width: 94px; min-height: 38px; margin: 0; padding: .5rem .75rem; border: 1px solid #b8b1e766; border-radius: 8px; background: var(--c64-blue); color: #eeecfa; font: inherit; font-size: .84rem; font-weight: 700; line-height: 1; text-decoration: none; cursor: pointer; transition: color .15s, border-color .15s, background .15s; } .card-action { display: inline-flex; align-items: center; justify-content: center; gap: .45rem; min-width: 94px; min-height: 38px; margin: 0; padding: .5rem .75rem; border: 1px solid #b8b1e766; border-radius: 8px; background: var(--c64-blue); color: #eeecfa; font: inherit; font-size: .84rem; font-weight: 700; line-height: 1; text-decoration: none; cursor: pointer; transition: color .15s, border-color .15s, background .15s; }
@ -47,8 +53,8 @@ kbd { min-width: 1.8rem; padding: .25rem .45rem; border: 1px solid var(--c64-lig
.play-icon { font-size: .78rem; } .play-icon { font-size: .78rem; }
.code-icon { font: 800 .8rem ui-monospace, monospace; } .code-icon { font: 800 .8rem ui-monospace, monospace; }
.program-grid.list-view { grid-template-columns: 1fr; } .program-grid.list-view { grid-template-columns: 1fr; }
.program-grid.list-view .program-card { min-height: 0; display: grid; grid-template-columns: auto minmax(150px, .8fr) minmax(220px, 1.5fr) minmax(180px, 1fr); align-items: center; gap: 1rem; } .program-grid.list-view .program-card { min-height: 0; display: grid; grid-template-columns: minmax(180px, auto) minmax(150px, .8fr) minmax(220px, 1.5fr) minmax(180px, 1fr); align-items: center; gap: 1rem; }
.program-grid.list-view .program-title, .program-grid.list-view .program-description, .program-grid.list-view .controls, .program-grid.list-view .program-actions { margin: 0; padding: 0; } .program-grid.list-view .program-title, .program-grid.list-view .program-description, .program-grid.list-view .program-actions { margin: 0; padding: 0; }
.source-modal { width: min(900px, calc(100% - 2rem)); height: min(80vh, 760px); padding: 0; border: 1px solid #b8b1e744; border-radius: 14px; background: var(--c64-deep-blue); color: #fff; box-shadow: 0 30px 80px #000a; } .source-modal { width: min(900px, calc(100% - 2rem)); height: min(80vh, 760px); padding: 0; border: 1px solid #b8b1e744; border-radius: 14px; background: var(--c64-deep-blue); color: #fff; box-shadow: 0 30px 80px #000a; }
.source-modal::backdrop { background: #090621d9; backdrop-filter: blur(5px); } .source-modal::backdrop { background: #090621d9; backdrop-filter: blur(5px); }
.modal-heading { position: sticky; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; border-bottom: 1px solid #b8b1e733; background: #17113bf5; } .modal-heading { position: sticky; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; border-bottom: 1px solid #b8b1e733; background: #17113bf5; }
@ -61,6 +67,13 @@ kbd { min-width: 1.8rem; padding: .25rem .45rem; border: 1px solid var(--c64-lig
button:disabled { opacity: .5; cursor: wait; } button:disabled { opacity: .5; cursor: wait; }
.player-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; } .player-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; }
.player-heading h1 { font-size: clamp(1.8rem, 5vw, 3rem); } .player-heading h1 { font-size: clamp(1.8rem, 5vw, 3rem); }
.program-badges { margin-top: .85rem; }
.program-page-description { max-width: 760px; margin: 1.5rem 0 0; color: #d2cef0; font-size: 1.08rem; line-height: 1.6; }
.instructions-panel { max-width: 760px; margin-top: 1.5rem; padding: 1.1rem 1.25rem; border: 1px solid #b8b1e738; border-radius: 12px; background: #211a59d9; }
.instructions-panel h2 { margin: 0 0 .85rem; font-size: 1rem; letter-spacing: .08em; text-transform: uppercase; }
.control-list { display: grid; gap: .7rem; }
.control-row { display: grid; grid-template-columns: minmax(150px, auto) 1fr; gap: 1rem; align-items: center; color: #d2cef0; }
.key-group { display: flex; flex-wrap: wrap; gap: .35rem; }
#game { width: min(100%, 960px); height: min(75vw, 720px); min-height: 480px; margin: 1.5rem auto 0; background: #000; } #game { width: min(100%, 960px); height: min(75vw, 720px); min-height: 480px; margin: 1.5rem auto 0; background: #000; }
.error { color: #ffb4ab; } .error { color: #ffb4ab; }
@media (max-width: 600px) { @media (max-width: 600px) {
@ -69,5 +82,6 @@ button:disabled { opacity: .5; cursor: wait; }
.library-toolbar { top: .5rem; } .library-toolbar { top: .5rem; }
.library-toolbar label, .library-toolbar select { flex: 1 1 100%; width: 100%; } .library-toolbar label, .library-toolbar select { flex: 1 1 100%; width: 100%; }
.program-grid.list-view .program-card { display: flex; } .program-grid.list-view .program-card { display: flex; }
.control-row { grid-template-columns: 1fr; gap: .45rem; }
#game { min-height: 60vh; } #game { min-height: 60vh; }
} }

124
raster-bars/raster-bars.asm Normal file
View file

@ -0,0 +1,124 @@
!cpu 6510
; Self-starting BASIC line: 10 SYS2061
* = $0801
!word basic_end
!word 10
!byte $9e
!text "2061"
!byte 0
basic_end:
!word 0
* = $080d
start:
lda $d020
sta old_border
lda $d021
sta old_background
lda #147
jsr $ffd2
ldx #0
print_title:
lda title,x
beq initialize
jsr $ffd2
inx
bne print_title
initialize:
lda #4
sta speed
lda #0
sta phase
sta tick
sta palette
main_loop:
ldx #0
draw_bar:
lda raster_lines,x
wait_line:
cmp $d012
bne wait_line
txa
clc
adc phase
and #7
tay
lda palette
beq use_color
tya
clc
adc #8
tay
use_color:
lda colors,y
sta $d020
sta $d021
inx
cpx #8
bne draw_bar
lda old_background
sta $d021
lda old_border
sta $d020
inc tick
lda tick
cmp speed
bcc read_key
lda #0
sta tick
inc phase
lda phase
and #7
sta phase
read_key:
jsr $ffe4
beq main_loop
cmp #'Q'
beq exit
cmp #'C'
beq change_palette
cmp #'1'
beq slow
cmp #'2'
beq medium
cmp #'3'
beq fast
jmp main_loop
slow:
lda #8
bne set_speed
medium:
lda #4
bne set_speed
fast:
lda #1
set_speed:
sta speed
jmp main_loop
change_palette:
lda palette
eor #1
sta palette
jmp main_loop
exit:
lda old_border
sta $d020
lda old_background
sta $d021
lda #147
jsr $ffd2
rts
title:
!text " RASTER BARS",13,13
!text "1 2 3 CHANGE SPEED",13
!text "C CHANGE PALETTE",13
!text "Q RETURN TO BASIC",13,0
raster_lines: !byte 72,84,96,108,120,132,144,156
colors: !byte 2,8,10,7,1,7,10,8, 6,14,3,13,1,13,3,14
speed: !byte 4
phase: !byte 0
tick: !byte 0
palette: !byte 0
old_border: !byte 0
old_background: !byte 0

BIN
raster-bars/raster-bars.prg Normal file

Binary file not shown.

View file

@ -0,0 +1,12 @@
#!/bin/sh
set -eu
for source in \
sprite-playground/sprite-playground.asm \
raster-bars/raster-bars.asm \
sid-arpeggiator/sid-arpeggiator.asm
do
output="${source%.asm}.prg"
echo "Assembling $source -> $output"
acme --format cbm --outfile "$output" "$source"
done

View file

@ -21,18 +21,31 @@ function sendJson(response, status, value, headOnly = false) {
function fetchPrograms() { function fetchPrograms() {
const programs = JSON.parse(readFileSync(manifestPath, 'utf8')); const programs = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (!Array.isArray(programs)) throw new Error('programs.json must contain an array'); if (!Array.isArray(programs)) throw new Error('programs.json must contain an array');
const slugs = new Set();
return programs.map(program => { return programs.map(program => {
if (!program.title || !program.category || !program.description) throw new Error('Every program must include title, category, and description');
if (!['basic', 'machine-code'].includes(program.language)) throw new Error(`Invalid language for ${program.title}`);
if (!Array.isArray(program.controls) || !program.controls.length || program.controls.some(control => !Array.isArray(control.keys) || !control.keys.length || !control.description)) {
throw new Error(`Invalid controls for ${program.title}`);
}
if (!program.path?.toLowerCase().endsWith('.prg')) throw new Error('Every program must reference a PRG file'); if (!program.path?.toLowerCase().endsWith('.prg')) throw new Error('Every program must reference a PRG file');
const filePath = resolve(programDirectory, program.path); const filePath = resolve(programDirectory, program.path);
if (!filePath.startsWith(programDirectory + sep) || !existsSync(filePath) || !statSync(filePath).isFile()) { if (!filePath.startsWith(programDirectory + sep) || !existsSync(filePath) || !statSync(filePath).isFile()) {
throw new Error(`Program file is missing: ${program.path}`); throw new Error(`Program file is missing: ${program.path}`);
} }
const sourcePath = program.source || program.path.replace(/\.prg$/i, '.bas'); const sourcePath = program.source;
const expectedExtension = program.language === 'basic' ? '.bas' : '.asm';
if (!sourcePath?.toLowerCase().endsWith(expectedExtension)) throw new Error(`Invalid source file for ${program.title}`);
const sourceFilePath = resolve(programDirectory, sourcePath); const sourceFilePath = resolve(programDirectory, sourcePath);
if (!sourceFilePath.startsWith(programDirectory + sep) || !existsSync(sourceFilePath) || !statSync(sourceFilePath).isFile()) {
throw new Error(`Source file is missing: ${sourcePath}`);
}
const slug = program.path.split('/')[0];
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slugs.has(slug)) throw new Error(`Invalid or duplicate program slug: ${slug}`);
slugs.add(slug);
return { return {
...program, ...program,
slug: program.path.split('/')[0], slug,
source: sourceFilePath.startsWith(programDirectory + sep) && existsSync(sourceFilePath) ? sourcePath : null,
size: statSync(filePath).size size: statSync(filePath).size
}; };
}); });
@ -74,7 +87,8 @@ function sendSource(response, programPath, headOnly = false) {
function serveStatic(response, pathname, headOnly = false) { function serveStatic(response, pathname, headOnly = false) {
const isProgramPage = /^\/program\/[a-z0-9]+(?:-[a-z0-9]+)*\/?$/.test(pathname); const isProgramPage = /^\/program\/[a-z0-9]+(?:-[a-z0-9]+)*\/?$/.test(pathname);
const requested = pathname === '/' || isProgramPage ? 'index.html' : pathname.slice(1); const isCategoryPage = /^\/category\/[a-z0-9]+(?:-[a-z0-9]+)*\/?$/.test(pathname);
const requested = pathname === '/' || isProgramPage || isCategoryPage ? 'index.html' : pathname.slice(1);
const filePath = normalize(join(publicDirectory, requested)); const filePath = normalize(join(publicDirectory, requested));
if (!filePath.startsWith(publicDirectory) || !existsSync(filePath) || !statSync(filePath).isFile()) { if (!filePath.startsWith(publicDirectory) || !existsSync(filePath) || !statSync(filePath).isFile()) {
return false; return false;

View file

@ -0,0 +1,198 @@
!cpu 6510
; Self-starting BASIC line: 10 SYS2061
* = $0801
!word basic_end
!word 10
!byte $9e
!text "2061"
!byte 0
basic_end:
!word 0
* = $080d
start:
ldx #0
save_sid:
lda $d400,x
sta old_sid,x
inx
cpx #25
bne save_sid
lda #147
jsr $ffd2
ldx #0
print_title:
lda title,x
beq initialize
jsr $ffd2
inx
bne print_title
initialize:
ldx #0
clear_sid:
lda #0
sta $d400,x
inx
cpx #25
bne clear_sid
lda #9
sta $d405
lda #$f0
sta $d406
lda #$0f
sta $d418
lda #0
sta root
sta step
sta tick
sta paused
lda #6
sta tempo
lda #$21
sta waveform
jsr play_note
main_loop:
jsr wait_frame
jsr read_key
lda paused
bne main_loop
inc tick
lda tick
cmp tempo
bcc main_loop
lda #0
sta tick
inc step
lda step
and #3
sta step
jsr play_note
jmp main_loop
read_key:
jsr $ffe4
beq key_done
cmp #'Q'
bne not_quit
jmp exit
not_quit:
cmp #' '
beq toggle_pause
cmp #'1'
beq triangle
cmp #'2'
beq saw
cmp #'3'
beq pulse
cmp #'+'
beq faster
cmp #'-'
beq slower
ldx #0
find_root:
cmp key_codes,x
beq set_root
inx
cpx #8
bne find_root
key_done:
rts
set_root:
stx root
jsr play_note
rts
triangle:
lda #$11
bne set_waveform
saw:
lda #$21
bne set_waveform
pulse:
lda #$41
set_waveform:
sta waveform
sta $d404
rts
faster:
lda tempo
cmp #2
beq key_done
dec tempo
rts
slower:
lda tempo
cmp #12
beq key_done
inc tempo
rts
toggle_pause:
lda paused
eor #1
sta paused
beq resume
lda waveform
and #$fe
sta $d404
rts
resume:
jsr play_note
rts
play_note:
ldx step
lda arp_offsets,x
clc
adc root
tay
lda frequencies_low,y
sta $d400
lda frequencies_high,y
sta $d401
lda waveform
sta $d404
rts
wait_frame:
wait_bottom:
lda $d012
cmp #250
bne wait_bottom
wait_top:
lda $d012
cmp #10
bne wait_top
rts
exit:
ldx #0
restore_sid:
lda old_sid,x
sta $d400,x
inx
cpx #25
bne restore_sid
lda #147
jsr $ffd2
; Discard the JSR read_key return address, then return from SYS.
pla
pla
rts
title:
!text " SID ARPEGGIATOR",13,13
!text "A-K SELECT ROOT NOTE",13
!text "1 2 3 TRIANGLE SAW PULSE",13
!text "+ - CHANGE TEMPO",13
!text "SPACE PAUSE OR RESUME",13
!text "Q RETURN TO BASIC",13,0
key_codes: !text "ASDFGHJK"
arp_offsets: !byte 0,4,7,12
frequencies_low:
!byte <4389,<4650,<4927,<5220,<5530,<5859,<6207,<6577,<6968,<7382
!byte <7821,<8286,<8779,<9301,<9854,<10440,<11060,<11718,<12414,<13154
frequencies_high:
!byte >4389,>4650,>4927,>5220,>5530,>5859,>6207,>6577,>6968,>7382
!byte >7821,>8286,>8779,>9301,>9854,>10440,>11060,>11718,>12414,>13154
root: !byte 0
step: !byte 0
tick: !byte 0
tempo: !byte 6
waveform: !byte $21
paused: !byte 0
old_sid: !fill 25,0

Binary file not shown.

View file

@ -0,0 +1,177 @@
!cpu 6510
; Self-starting BASIC line: 10 SYS2061
* = $0801
!word basic_end
!word 10
!byte $9e
!text "2061"
!byte 0
basic_end:
!word 0
* = $080d
start:
lda $d020
sta old_border
lda $d021
sta old_background
lda $d015
sta old_sprite_enable
lda $d000
sta old_sprite_x
lda $d001
sta old_sprite_y
lda $d010
sta old_sprite_x_msb
lda $d027
sta old_sprite_color
lda $07f8
sta old_sprite_pointer
lda #147
jsr $ffd2
ldx #0
print_title:
lda title,x
beq setup_sprite
jsr $ffd2
inx
bne print_title
setup_sprite:
lda #$30
sta $07f8
lda #120
sta sprite_x
sta $d000
sta sprite_y
sta $d001
lda #0
sta $d010
lda #1
sta sprite_color
sta $d027
lda $d015
ora #1
sta $d015
main_loop:
jsr wait_frame
jsr $ffe4
beq main_loop
cmp #'Q'
bne not_quit
jmp exit
not_quit:
cmp #'C'
beq change_color
cmp #'W'
beq move_up
cmp #'S'
beq move_down
cmp #'A'
beq move_left
cmp #'D'
beq move_right
jmp main_loop
move_up:
lda sprite_y
cmp #58
bcc main_loop
sec
sbc #2
sta sprite_y
sta $d001
jmp main_loop
move_down:
lda sprite_y
cmp #228
bcs main_loop
clc
adc #2
sta sprite_y
sta $d001
jmp main_loop
move_left:
lda sprite_x
cmp #26
bcc main_loop
sec
sbc #2
sta sprite_x
sta $d000
jmp main_loop
move_right:
lda sprite_x
cmp #238
bcs main_loop
clc
adc #2
sta sprite_x
sta $d000
jmp main_loop
change_color:
inc sprite_color
lda sprite_color
and #15
bne set_color
lda #1
sta sprite_color
set_color:
sta $d027
jmp main_loop
wait_frame:
wait_bottom:
lda $d012
cmp #250
bne wait_bottom
wait_top:
lda $d012
cmp #10
bne wait_top
rts
exit:
lda old_sprite_enable
sta $d015
lda old_sprite_x
sta $d000
lda old_sprite_y
sta $d001
lda old_sprite_x_msb
sta $d010
lda old_sprite_color
sta $d027
lda old_sprite_pointer
sta $07f8
lda old_border
sta $d020
lda old_background
sta $d021
lda #147
jsr $ffd2
rts
title:
!text " SPRITE PLAYGROUND",13,13
!text "W A S D MOVE THE HARDWARE SPRITE",13
!text "C CHANGE COLOR",13
!text "Q RETURN TO BASIC",13,0
sprite_x: !byte 120
sprite_y: !byte 120
sprite_color: !byte 1
old_border: !byte 0
old_background: !byte 0
old_sprite_enable: !byte 0
old_sprite_x: !byte 0
old_sprite_y: !byte 0
old_sprite_x_msb: !byte 0
old_sprite_color: !byte 0
old_sprite_pointer: !byte 0
* = $0c00
sprite_data:
!byte $00,$18,$00, $00,$3c,$00, $00,$7e,$00
!byte $00,$ff,$00, $01,$ff,$80, $03,$ff,$c0
!byte $07,$ff,$e0, $0f,$ff,$f0, $1f,$ff,$f8
!byte $3f,$ff,$fc, $7f,$ff,$fe, $ff,$ff,$ff
!byte $7f,$ff,$fe, $3f,$ff,$fc, $1f,$ff,$f8
!byte $0f,$ff,$f0, $07,$ff,$e0, $03,$ff,$c0
!byte $01,$ff,$80, $00,$ff,$00, $00,$7e,$00
!byte 0

Binary file not shown.

8
tools/acme/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends acme \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /programs
CMD ["sh", "scripts/build-machine-code.sh"]