A role-playing game for the Fairchild Channel F (1976), written from scratch in F8 assembly. 4 KiB cartridge, 64 bytes of RAM, no libraries.
Green is you. Blue wanders. Red is waiting, and will not fight you yet.
A JRPG reduced to the only things this machine can actually say. There is no text, no font, no sprite, no melody — so the game is made of coloured rectangles and the grammar between them.
You wander a single-screen map. Four blue foes wander it too; touch one and the screen turns pale blue and you are in a fight. Attack, or run. Win three fights and the red block at the top will finally take you seriously. Beat it and the whole map is re-rolled — new positions for everything, one level harder.
The foe closing in. Its hit points are the red bar, yours the green one.
The cast is not a design decision, it is the hardware. Each row of video RAM carries two palette bits, and in every palette but one the three non-zero pixel values are blue, red and green, always. You do not choose four colours: you choose a background, and those three are what you get. Green player, blue foes, red boss is the only assignment that reads on every background the game uses — which is why the cover above is not taking a liberty. That palette is the machine's, not the artist's.
Bodies are twice as wide as they are high for the same kind of reason: the framebuffer is 128×64 on a 4:3 screen, so a video pixel is twice as tall as it is wide, and anything square in VRAM comes out as a vertical bar.
| Input | Map | Battle |
|---|---|---|
| Right controller, 4 directions | Walk, one pixel per frame | Switch between ATTACK and FLEE |
| Twist or pull/push the stick | — | Confirm |
| START (console front panel) | — | Restart after a game over |
The map reads the stick as a level — hold a direction and you keep walking, diagonals included. The battle menu reads it as an edge, so holding a direction does not scroll the choice.
The first button you press does one more thing: it seeds the random number generator. The machine has no clock and no readable timer, so how long you take to start is the only entropy there is. Until you press something, the green block just blinks at you.
- Everything hits for 1 or 2 points, you for 2 or 3.
- A wanderer has 4 to 7 hit points; the boss has 12, plus 2 for every boss you have already beaten.
- Winning a fight restores 3 points. Without that the three fights the gate asks for cost you the whole bar, and you would reach the boss already dead.
- Fleeing works three times out of four. Fail and the foe attacks for free.
- Touching the boss with fewer than three kills throws you back to the start, and the boss flashes blue as it rejects you.
- Lose all ten points and the field goes black. START begins again.
You need dasm (the F8 backend), Python 3 with Pillow, and MAME to run it.
$env:MAME = "C:\path\to\mame.exe" # or pass -Mame on each call
./build.ps1 # assemble src/quest.asm -> build/quest.bin
./build.ps1 -Run # assemble and launch MAME
./build.ps1 -Shot # assemble, run 4 s headless, analyse a snapshot
./build.ps1 -Src test4k # the 4 KiB cartridge test ROM
./build.ps1 -Play "100:UP:8,460:PULL:6" -Shots "430,500"
The build fails if any relative branch is out of range — see
tools/brcheck.py. This is not optional politeness: dasm
truncates those silently, and it is the single most expensive class of bug on
this toolchain.
MAME needs the Channel F BIOS (sl31253.rom, sl31254.rom, sl90025.rom) in
emu/roms/channelf/. Those are copyrighted Fairchild images and are not
included here — supply your own dump. A plain directory works; no zip needed.
src/quest.asm the game
src/ves.inc Channel F hardware definitions
src/test4k.asm proves a 4 KiB cartridge really is mapped
build.ps1 assemble, verify branches, run or drive MAME
tools/brcheck.py catches branch displacements dasm truncated silently
tools/vcheck.py reads MAME snapshots back in VRAM coordinates
tools/regs.lua dumps the 64-byte scratchpad, i.e. all the RAM there is
tools/play.lua drives the controller from a script
tools/measure.lua snapshots at exact frames
tools/probe.lua PC0 histogram, to see where the CPU actually is
docs/ hardware notes
DEVLOG.md what went wrong, and what it cost
Two KiB was not enough. The previous game on this machine used 1896 bytes
of its 2048 for something far simpler, so QUEST is a 4 KiB cartridge. MAME's
cart slot offers no PCB options for a plain image and maps it linearly from
$0800, so 4 KiB should reach $17FF — "should" is not a measurement, and
src/test4k.asm is the measurement. It draws one square
from code below $1000 and three more, of three different heights, from code
at $1000 reading a table at $1200. Four squares of the right sizes means
code, data and cross-region calls are all really there.
A JRPG is cheaper than a Frogger here. Video RAM cannot be read back, so
every erase has to recompute what was underneath. In a scrolling game that
means re-deriving the background from the same formula that drew it; here the
background is flat, so erasing a body is just drawing the same rectangle in
the background colour. draw_rect is the only drawing primitive in the game.
The screen transition is the palette. The palette is chosen per row, in two pixels that live outside the visible area. The map's field is light grey, the battle's is pale blue, and a game over is black. Switching costs a hundred pixel writes, and on a machine with no text nothing else says "you are somewhere else" half as loudly.
Erase and redraw one body at a time. Erasing everything and then redrawing everything is the obvious structure and it is wrong: at the ~50 Hz this loop runs at, every object is missing for most of every frame and the screen reads as empty. Doing it per body cuts the gap to the 32 pixel writes between the two calls. In the battle screen the blocks only ever move horizontally, so what a moving one vacates is exactly a two-pixel strip — erase that, then draw the block whole, and nothing is ever absent at all.
One return level shapes the call graph. The 3850 has a single hardware
return register. Depth 2 costs K, and a routine that saves K cannot call
another that does. So every drawing primitive is a leaf, workers sit at level
1, and the main loop dispatches at level 0 where each pi is independent.
Getting this wrong does not crash: it wanders off into the ROM and freezes.
The tools in tools/ are not incidental. On this machine almost every wrong
diagnosis comes from inferring state instead of reading it.
python tools/vcheck.py shot.png --map # every visible pixel, as ASCII
REG_FRAMES=150,300 PLAY_LIST=100:UP:8 mame ... -autoboot_script tools/regs.lua
regs.lua reads the 64-byte scratchpad out of MAME's :maincpu:regs share and
prints it with the game's variable names attached. The CPU's own state entries
only cover R0–R8 — above that the F8 calls the registers J, H, K and Q — so the
share is the only way to see the game's variables at all. Every real bug in
this project was found by looking at those 64 bytes or at
vcheck.py --map, and every false alarm came from a snapshot that happened to
land in the middle of a redraw.
Two bits give silence plus three fixed tones (roughly 1 kHz, 500 Hz, 120 Hz), with no divider. A melody in a key is not possible, so what the game has is two short jingles built from the alternation itself: one when a battle opens, one when a boss goes down.
Tones are disabled by default (SOUND_ON = 0). Any non-zero tone crashes
MAME 0.287 inside its own channelf_sound_device; the ROM's use of port 5 is
spec-conformant. That crash is also the test oracle: build with SOUND_ON = 1
and if MAME dies in sound_w, the jingles really are reaching the port. They
have been verified exactly that way, and never heard.
MIT — see LICENSE. The Channel F BIOS images are not covered by it and are not distributed here.


