A workbench for writing C — with a little 6502 assembly where it matters — for the Commodore Plus/4, built with cc65 and run in VICE.
This is a playground, not a library: each subfolder is a self-contained program, from a three-line "hello world" to a full arcade game. The point is to find out how far plain C gets you on a 1984 machine with 64 KB, no sprites, and a 1.76 MHz 7501.
Ideas, design and direction come from decades of programming experience; the implementation made intensive use of AI (Claude by Anthropic). That is part of the experiment: how far a 1984 machine can be pushed when an experienced programmer puts an AI to work on it.
| Folder | What it is |
|---|---|
| demonattack/ | A conversion of the Atari 2600 Demon Attack that behaves like the original frame for frame, checked automatically against it. A colour on every line through a raster interrupt, collisions worked out pixel by pixel as the 2600's video chip sees them, and a page at the end of each game for hall of fame screenshots. Also as a 32 KB cartridge that runs on the C16. |
| main/ | The starting point — clrscr(), printf(), cgetc(). Useful as a template and as a sanity check that the toolchain is wired up correctly. |
| paradroid/ | Paradroid (Andrew Braybrook, 1985) after the C64 original, with its decks, droids, waypoints and lifts taken out of its memory: an eight-way scrolling deck under a fixed status panel, shooting, the transfer game, lifts with the side view of the ship, the deck plan at consoles, the original's briefing. One .prg: nothing loads after the start. |
| pacman/ | A complete Pac-Man: full-screen 40×24 maze, four ghosts with distinct AI, power pills, levels, lives, TED sound. ~1500 lines of C plus two assembly routines. |
| phoenix/ | A rebuild of the Atari 2600 Phoenix: all five waves, the force field, the mothership, two-voice sound with the arcade melodies, and a starfield that scrolls pixel by pixel. |
| stardew/ | Stardew Pond, a small farming game modelled on Stardew Valley by ConcernedApe: a farm, a village with a store and a smith, three villagers, a mine with thirty floors. Runs from a .d64 and loads its rooms and tile sets as it goes; figures walk see-through over any background; the game is saved to the disk. |
| pokerth/ | Work in progress: a PokerTH client. TLS, protobuf and authentication are handled by a proxy on a PC, which hands the Plus/4 a protocol small enough to parse in 6502. Logging in and watching a lobby works; the Plus/4 end is next. |
![]() |
![]() |
| Phoenix — all five waves of the 2600 game, the force field, the mothership, a starfield scrolling a pixel at a time. One 2600 pixel is two Plus/4 pixels, so 160×192 lands exactly on 40×24 cells. The hardware fine scroll was tried first and had to go; the README says why. | Pac-Man — the whole maze on one screen, four ghosts with their own ways of hunting, pixel-by-pixel movement. |
![]() |
![]() |
| Demon Attack — frame for frame the 2600 game, compared automatically with the original running in an emulator. What the Phoenix clone could leave out, this one cannot: a colour on every line, pixel-exact collisions, the original's exact timing. The README is about how. | Stardew Pond — after Stardew Valley by ConcernedApe: farming, a village and a mine, one screen per room, each room a file on the disk that is loaded when the farmer walks in. Figures with a mask over whatever is behind them, a toolbar with a character set of its own, and a save file. |
![]() |
![]() |
| Paradroid — the C64 classic, deck by deck from the original's data. The TED cannot change its vertical scroll under a status panel the way the C64 does; setting back two of its internal counters in the middle of the picture does it instead. The droids switch the cells they cover to multicolour over the hires deck. | The transfer game: twelve wires a side, pulses, a column of lights to win. |
The Plus/4 has no sprites at all, so all the games build their figures out of characters that are rewritten as the figures move — and each does it its own way, which is the interesting part. Pac-Man works the cells out on every frame. Phoenix cannot afford that with a dozen figures on screen, so it works out all 32 ways a figure can sit inside its cells when a wave starts and then only copies finished blocks. That is the difference between 4000 cycles per figure and a few hundred; a frame has 17784. Demon Attack keeps a pool of characters that are handed out afresh for every picture and taken back before the next, draws into a second, hidden screen, and changes two colour registers on every line while the picture is shown.
Each folder has a README of its own with the details.
| Piece | Role |
|---|---|
| cc65 | C compiler, assembler and linker for 6502 targets (cl65 -t plus4) |
VICE (xplus4) |
Plus/4 emulator, also used for source-level debugging |
| VS64 | VS Code extension that drives cc65 and VICE and provides breakpoints |
| plus4emu | Another Plus/4 emulator to check against, see below |
Both cc65 and VICE are expected under ~/.local/share/cc65-vs64/bin. On this machine
VS Code runs as a Flatpak while cc65 and VICE are host packages, so that directory
holds small shim scripts that reach the host tools (flatpak-spawn --host,
CC65_HOME, …) and fall back to the plain binaries when there is no sandbox. VS64
also expects the classic cc65 layout (<dir>/bin, <dir>/include), which the shim
directory provides. If you clone this elsewhere, point
vs64.cc65InstallDir / vs64.viceExecutable in the .vscode/settings.json files at
your own installation, or just call cl65 directly (see below).
There are two ways to work, and which one you get depends on which folder you open in VS Code.
F5 (or the Plus/4: Build & Run (aktive Datei) task) builds the .c file that is
currently active and starts it in VICE. Ctrl+Shift+B builds it without launching.
No breakpoints, but you can hop between programs freely.
This runs .vscode/run-current.sh, which you can also call from a shell:
.vscode/run-current.sh pacman/pacman.cOpen main/ or pacman/ directly and F5 uses VS64's own VICE debugger with
source-level breakpoints. It always loads the program named in that folder's
project-config.json, which is exactly the one program in there.
The split exists because VS64 supports one project-config.json per workspace:
its debugger always resolves the first workspace folder and the program field in
launch.json is ignored. One folder per program is the way around that.
BIN=~/.local/share/cc65-vs64/bin
mkdir -p pacman/build
$BIN/cl65 -t plus4 -O -g -c -o pacman/build/pacman.o pacman/pacman.c
$BIN/cl65 -t plus4 -o pacman/build/pacman.prg pacman/build/pacman.o
$BIN/xplus4 -autostartprgmode 1 pacman/build/pacman.prgTwo steps on purpose: cl65 otherwise drops the object file next to the source.
VICE is not the last word on the TED, so a program can be checked in
plus4emu too (a PAL Plus/4 with 64 KB).
There is no package for it; the prebuilt Linux release goes to ~/.cache/plus4emu:
mkdir -p ~/.cache/plus4emu && cd ~/.cache/plus4emu
curl -LO https://github.com/istvan-v/plus4emu/releases/download/1.2.11-beta_20190320/plus4emu-1.2.11-beta_20190320-x86_64.tar.xz
tar xf plus4emu-*.tar.xz
mkdir -p ~/.plus4emu/roms && cp plus4emu-*/roms/*.rom ~/.plus4emu/roms/
plus4emu-*/p4makecfg ~/.plus4emuThen, for any .prg:
~/.cache/plus4emu/plus4emu-1.2.11-beta_20190320/plus4emu \
-cfg ~/.plus4emu/config/P4_64k_PAL.cfg paradroid/build/paradroid.prgor, for Paradroid, paradroid/run-plus4emu.sh, which also works from VS Code's Flatpak. The joystick is the keypad (8/2/4/6, 0 fires) or a gamepad.
mkdir foo- Copy
main/.vscode/into it — those three files are deliberately program-agnostic (they use${workspaceFolderBasename}), so they work as a template unchanged. - Copy
project-config.jsonand setnameandmainto the new name. - Write
foo/foo.c.
If a program needs more than an emulator - the PokerTH client needs a proxy
running beside it - put a run.sh in its folder. F5 builds as usual, notices
it, and hands the finished .prg over to it instead of starting VICE.
If it needs its own compiler switches, put them in a cflags file next to the
source. Phoenix uses that for -Cl: cc65 otherwise keeps local variables on
its software stack, which costs a sixth of the running time in a game that is
busy every frame.
Convention: the folder, the source file and the output all share one name.
foo/foo.c builds to foo/build/foo.prg. Build output never goes into git.
Plus4/
├── .vscode/ F5 = build & run the active .c file
├── main/
│ ├── main.c
│ ├── project-config.json
│ ├── .vscode/ F5 = VS64 build & run with debugger
│ └── build/ main.prg (ignored)
└── pacman/
└── …same shape
Collected here because every one of them compiles cleanly and only shows up in the emulator.
-
The ROM is banked out. cc65 hides it to use RAM up to
$FD00, so copying the character generator from$D000yields garbage. Page it in via$FF3E/$FF3Fwith interrupts off — and touch no C stack in that window (it lives at$F500–$FCFF, underneath the ROM): globals only, no locals, no calls. Check withcc65 -Sthat nospappears between the switches. -
Character literals are translated to PETSCII.
'A'is 193, not 65;'a'is 65. Socase 'w'matches unshifted W andcase 'W'the shifted one — handle both. When converting to screen codes, compute relative to'A'/'a'instead of subtracting 64/96, or everything lands 128 too high and the TED renders all text inverted, which looks deliberate and is easy to miss. -
VS64 2.6.2 always links
c64.lib, even for-t plus4— it is hardcoded and thelibrariesfield inproject-config.jsonis read but never used. The build succeeds and the binary is wrong (conio writes to$0400instead of$0C00). Theld65shim rewrites it to<target>.lib; verify with a map file (ld65 -m map.txt) that the modules come fromplus4.lib. -
Colors are
brightness * 16 + hue. High brightness washes out to white on the Plus/4; the strong colors live at brightness 3–6. -
Avoid
%and*in per-frame code. cc65 calls a full division routine for a modulo; a single conditional subtraction or a small lookup table is far cheaper. -
Comparing a byte against a plain constant costs a 16-bit compare.
if (zeile >= ZEILEN)withZEILENanintconstant makes cc65 widen both sides and push them through its software stack. Casting the constant tounsigned charturns four such comparisons in the drawing routine back into byte compares — in Phoenix that alone was worth more than the assembly around it. -
-Clis worth a sixth of the running time in a program that is busy every frame: cc65 otherwise keeps every local on its software stack, and each access is an indexed load through a zero-page pointer. There is no recursion in a game loop, so static locals cost nothing. -
Either everything on screen is hardware-scrolled, or nothing is. The TED's fine scroll moves the whole picture in the instant the register is written, but a figure built out of characters only follows on its next redraw — and a redraw takes longer than a frame. Every step leaves the figures a pixel behind, and when the register wraps they all jump seven pixels and crawl back one at a time. It cannot be synchronised; it has to be avoided. Moving a sparse background by hand is cheaper than compensating for a scroll anyway, because the compensation touches everything on screen.
-
An absolute array is cheaper than a pointer, and a loop iteration is expensive. Keeping ready-made bytes in a table can be slower than recomputing them, because cc65 reads
TAB[x]in one instruction andp[x]in several; and eight rounds of two bytes beat sixteen rounds of one. Both were measured, both are the opposite of what the source suggests. -
Measure with an autopilot and an immortality switch, and hold the load still. A game left alone dies in seconds, and then the numbers describe BASIC sitting at its prompt rather than the game. An early Phoenix measurement said 0.8 passes per second for exactly that reason; the truth was twenty times better.
-
Music must not hang off the game loop. A pass is however long the drawing takes, so a tune advanced once per pass changes tempo with the number of objects on screen — obvious the moment somebody with an ear for it listens. Sound belongs on an interrupt: save the vector at
$FFFE(cc65 leaves the ROM banked out and has its own handler there), chain back to it, and save cc65's zero page around the call because the interrupted C code is in the middle of using it. About five per cent of the drawing, and the tempo is then exact. -
Hang on to the interrupt the machine already runs — do not acknowledge it. Setting up a raster interrupt of one's own and clearing
$FF09before handing on looks right and is not: the KERNAL hangs off the same interrupt, finds the cause already cleared, decides there is nothing to do and stops counting its clock. Everything timed off that clock then runs at a fraction of its speed while the music sounds perfect — which is a confusing thing to debug, because the symptom is nowhere near the cause. -
The joystick hangs on the keyboard's lines, and
$FF08has to be read twice. The row goes to$FD30, and writing a value with bit 2 low to$FF08puts joystick 1 on those lines instead. The write leaves its own value on the data bus and the TED samples a cycle later, so a read in the next instruction hands back what was just written — which looks exactly like a closed contact. It costs an evening if the test program happens to have a few instructions in between and therefore works. Both games read it the same way;phoenix/README.mdhas the details. -
VICE looks for joysticks once, at startup, and only in
/dev/input/by-id. A wireless pad that is asleep when VICE starts is not picked up, and a Bluetooth pad has noby-identry at all unless a udev rule makes one — which is why a perfectly working gamepad can look like a broken game.
VICE can be driven remotely, which makes Plus/4 programs verifiable in an automated way:
# Screenshot after a fixed number of cycles (boot + autostart alone cost ~28M)
xplus4 -default -warp -autostart-warp -autostartprgmode 1 \
-limitcycles 45000000 -exitscreenshot "$HOME/shot.png" \
-autostart "$HOME/path/to/program.prg"
# Or the binary monitor, which is far more powerful
xplus4 -remotemonitor -remotemonitoraddress ip4://127.0.0.1:6510 …Over the monitor socket, m 0c00 0fe7 dumps screen memory — reconstructing the
display as text is more precise than diffing screenshots — and writing to $0527
(keyboard buffer) plus $00EF (count) injects keystrokes, so a game can be played
remotely. x resumes emulation. Note that -keybuf does not work together with
-autostart: autostart consumes the buffer itself.
Each program lists its own; see the README in its folder.
| Demon Attack | joystick in port 1 or 2, or cursor keys and space; fire, space or F1 starts, F2 picks one of the ten games, F3 / Help the difficulty |
| Pac-Man | joystick in port 1, or W A S D, or the cursor keys; Q quits, fire or space starts |
| Paradroid | joystick in either port, or cursor keys; fire (or space, CTRL, C=) with a direction shoots, held without one transfers, held on a lift or before a console opens it; Run/Stop pauses |
| Phoenix | joystick in port 1, or cursor keys and space; stick down raises the force field |
| Stardew Pond | joystick in either port, or cursor keys; fire or space uses what is in your hand; , . pick an item; I the backpack |





