Status: v0.9 — Parts I–VII written (28 chapters); appendices A, E, F and G written, B/C/D/H still outline. Code examples target the ca65/cl65 toolchain and the kernel's 60-entry jump table (
$FF00–$FFB1).
A practical, example-driven manual for writing a cartridge game for the MAD-65. It assumes you can write 6502 assembly but does not assume you know the MAD-65 hardware. It explains how the machine actually behaves and how to get a game running, in plain language, with lots of small runnable examples.
In scope: the developer's mental model, the per-frame programming pattern, the OS kernel ABI (what to call and why), I/O, drawing via the GPU, audio, the 3-D / vector library, loading cartridge data, living inside the RAM budget, getting your artwork into the machine, and using the simulator to build and measure your game.
Out of scope (intentionally hidden from the developer): boot procedures, ROM layout internals, the GPU's internal frame-rendering workflow, raw PPRAM command encoding, internal status codes, GAL/CUPL logic, and PCB hardware. These live in the architecture / OS reference docs and you don't need them to ship a game. Pointers are given where useful.
This first Part is the "lay of the land." It has no register tables and almost no code — its only job is to put the right mental model in your head before the details start. If you read nothing else before opening your editor, read these two chapters. Everything later in the guide assumes you think about the machine the way these pages describe.
The MAD-65 is a small monochrome arcade console. The screen is 400×300 pixels, one bit deep — every pixel is either black or white. There is no colour, no greyscale, no alpha. What you gain in return is simplicity and speed: the whole picture is just 15,000 bytes of on/off dots, and you get a fresh one sixty times a second.
Inside the box are two WDC 65C02 processors running at about 14.3 MHz. That sounds like a lot for a 6502, but a 60 Hz frame is short: each CPU gets roughly 237,000 clock cycles per frame to do all of its work. A 16-bit multiply costs a few hundred of those cycles; drawing a long line costs the other CPU a few thousand. You will spend this guide learning to live inside that budget — and Part VI shows you how to measure it.
The two CPUs have different jobs, and that split is the single biggest thing that makes MAD-65 programming different from programming an ordinary 6502 machine. Chapter 2 is entirely about it. For now, just hold this: one CPU runs your game; the other one draws. Your code lives on the first one.
A MAD-65 game is a cartridge. Electrically, a cartridge is a chip that
appears in an 8 KB window in the first CPU's memory, at addresses
$8000–$9FFF. A cartridge can be much bigger than 8 KB — up to about 1 MB — by
banking: it swaps which 8 KB slice is currently visible in that window. You
will almost never run code straight out of the window; instead you copy the
pieces you need (level data, sprite shapes, music) into RAM and work from there.
Chapter 11 covers this in full.
The console finds your game by looking at the very start of the cartridge for a five-byte signature and two addresses:
; The first bytes of bank 0 — the console reads these at boot.
.byte "MAD65" ; signature: this is a real cartridge
.addr cart_init ; called ONCE, after the console has booted
.addr cart_frame ; called EVERY frame, forever
; ... your code and data follow ...
That is the entire contract for "being a game." You give the console two entry points:
cart_init runs one time at power-on. This is where you set up your
variables, upload your sprite and tile shapes to the drawing CPU, and paint
your starting background.cart_frame runs once per frame, forever. This is your game. Read the
controls, move things, decide what the next picture should look like, describe
it. Then the console calls you again for the next frame. And again. Sixty times
a second.If you have written for other 8-bit machines, notice what is missing: there is
no main() that loops on its own, no place where you own the top-level loop.
The console owns the loop. You just fill in cart_frame. This is not a
limitation to work around — it is the whole design, and Chapter 9 explains why it
makes your life easier.
A lot of fiddly work is already done for you by software baked into the console (the "OS kernel," covered properly in Chapter 8). Some of what you get for free:
What is yours:
Print this on the wall:
Everything renders 60 times a second — or it doesn't render at all.
There is no "draw now and leave it there." Every frame starts from a blank description and you rebuild the scene. If you finish describing the frame in time, the player sees it. If you run even slightly over budget, that frame is simply skipped — the screen blinks black for 1/60th of a second — and you try again next frame. The console will not cover for you; staying in budget is your job. Chapter 3 is devoted to this rule, because almost every "why doesn't my game do X" question traces back to it.
madsim (Chapter 7). It runs your
cartridge on a normal PC, at the true console frame rate, with sound and a live
meter that shows how much of each CPU's budget you are using. You will not need
real hardware to follow along.The most important picture in this entire guide is this one: the MAD-65 has two brains, and they do not share a desk.
┌──────────────┐ mailbox ┌──────────────┐
│ CPU1 │ ─── (shared notes) ──>│ GPU (CPU2) │
│ "the game" │ │ "the artist" │
│ │ │ │
│ logic, input │ │ turns notes │
│ audio, math │ │ into pixels │
│ 3-D, YOUR │ │ │──> screen
│ cartridge │ │ │
└──────────────┘ └──────────────┘
CPU1 runs your cartridge. Both cart_init and cart_frame execute here.
Everything you normally think of as "the game" happens on CPU1:
Crucially, CPU1 does not put pixels on the screen. It cannot. It has no direct line to the display. All it can do about graphics is write down a to-do list of drawing requests — "print this text here," "stamp this sprite there," "draw a line from A to B" — and hand that list to the other CPU.
The GPU is a second, identical 65C02 whose entire life is drawing. It reads the to-do list CPU1 produced and turns each request into actual pixels in video memory. It owns the font, the tile renderer, the sprite blitter, the line and circle routines. You will spend Chapter 15 and Part IV learning what it can do — but always at arm's length. You never program the GPU directly. You describe what you want drawn; the GPU decides how.
This is why, throughout the guide, the GPU is described "at a high level only." From your seat on CPU1, the GPU is a black box with a slot you post requests into. That is genuinely all you need.
Here is the part that trips up newcomers. The two CPUs cannot read each other's memory. CPU1 cannot peek at a GPU variable; the GPU cannot read your game state. There is no shared array of "sprite positions" that both sides edit.
What they do share is a small mailbox — a 2 KB region of memory that the guide calls PPRAM (ping-pong RAM). The flow is always the same:
You will see PPRAM's name a lot, but you will almost never touch it by hand — the
OS gives you friendly builder calls (gpu_text, gpu_sprite, gpu_line, …)
that fill the mailbox for you. Mentally, file PPRAM as "the outbox where my
drawing requests go." The exact byte format is intentionally hidden (Part V
shows the builders; the raw encoding lives in the reference docs).
Because the mailbox is handed over at the frame boundary, the GPU is always drawing the list CPU1 built on the previous frame. And there is a second hand-over downstream — the finished picture is swapped over to the video circuit one frame after the GPU draws it (Chapter 4 walks through both). The upshot is that what CPU1 decides this frame reaches the player's eyes about two frames later — roughly 33 milliseconds at 60 Hz. That is small enough not to notice in play, and it is the price of letting the work flow down an assembly line: while the GPU draws frame N, CPU1 is already building frame N+1, and nobody ever waits. Chapter 4 lays the whole pipeline out step by step; for now just know the delay exists and is designed in.
One 65C02 cannot both run a game and push 15,000 bytes of pixels every 1/60th of a second — there simply aren't enough cycles. Splitting the work means each CPU has a full frame budget to itself: CPU1 spends its ~237,000 cycles on thinking, and the GPU spends its ~237,000 cycles on drawing. They work in parallel, like an assembly line. The price of that parallelism is the two rules you now know: no shared variables (talk only through the mailbox) and one frame of delay.
For the rest of this guide, hold this model:
Every chapter that follows is a detail hanging off this skeleton. Part II zooms into the timing — the 60 Hz heartbeat and the exact journey a drawing request takes from your cartridge all the way to a lit pixel.
Part I gave you the cast of characters. Part II is about timing and flow: the rhythm the whole machine marches to, the exact path a drawing request takes from your cartridge to a lit pixel, and the hard limits you are working inside. None of this is code yet — it is the model that makes the code in Part III obvious instead of mysterious.
Everything on the MAD-65 is timed to one steady pulse: the VSYNC, the moment the video circuit finishes drawing the screen and snaps back to the top to start again. That happens 60.317 times every second — call it 60 Hz — and it is the clock the entire console dances to. One tick of that clock is one frame.
A frame is about 16.6 milliseconds long. That is the entire window your game gets to do everything: read the controls, update the world, and describe the next picture. When the VSYNC fires, time is up — whatever you have prepared is what gets used, and the next frame begins immediately.
This is the mental shift that matters most, and it is worth saying in several ways until it sticks:
Think of it like an artist who wipes the canvas clean 60 times a second and repaints it from your instructions. If you want a spaceship to stay on screen, you must ask for it to be drawn every frame. Stop asking, and it is gone the very next 1/60th of a second. There is no "draw once and leave it" — except for the special background layer, which we will get to in Chapter 4.
This sounds like a lot of repeated work, but it is actually a gift: there is no hidden state to get out of sync, no "erase the old position then draw the new one" dance, no leftover smear from last frame. Each frame is a clean slate. To move something, you simply describe it at its new position next time.
Because the heartbeat is so regular, frames are your unit of time. You will rarely think in seconds or milliseconds; you think in frames:
The OS even hands you a running frame counter (FRAME_COUNT) that ticks up once
every VSYNC, so timing an event is just "remember the frame number and compare."
Animation, movement, cooldowns, and timers are all just counting frames. There is
no need for a real-time clock, and trying to use one would only fight the grain
of the machine.
Here is the rule from Chapter 1, now with teeth. Each frame you must finish building your picture description before the next VSYNC. If you make it, the player sees your frame. If your per-frame code runs even slightly too long and the VSYNC catches you mid-build:
An occasional blink is a brief flicker; a game that overruns every frame
strobes horribly and is unplayable. Staying inside the budget is therefore your
job, every frame — and Part VI is devoted to measuring and protecting it. To
help during development, the OS sets an OVERRUN_FLAG whenever you blew the
budget on a frame, so a debug build can light a warning and tell you exactly when
your scene got too heavy.
The takeaway: a frame is a hard deadline, not a goal. Design your game so the worst-case frame — the most enemies, the longest lines, the busiest moment — still finishes on time, and you will never blink.
Let's follow a single drawing request all the way from your cartridge to a glowing pixel. Keep the two-brain model from Chapter 2 in mind; this chapter just walks the path between them in order.
┌───────────┐ copy ┌───────────┐ build ┌──────────┐ swap ┌──────────┐
│ Cartridge │ ───────> │ CPU1 RAM │ ──list─> │ PPRAM │ ─────> │ GPU │
│ (banks) │ at init │ game data │ each │ mailbox │ at │ (CPU2) │
└───────────┘ │ + logic │ frame │ (2 KB) │ VSYNC │ draws │
└───────────┘ └──────────┘ └────┬─────┘
│ pixels
v
┌───────────────────────┐
│ VRAM (double-buffered)│
└──────────┬────────────┘
swap at VSYNC │
v
video circuit ─> 800×600 screen
There are two swaps in that picture, both happening at every VSYNC: the PPRAM mailbox swaps from CPU1 to the GPU, and the VRAM buffer swaps from the GPU to the video circuit. That second swap is the part the original "one-frame" intuition misses — and the reason the true decision-to-screen delay is two frames, which we'll account for precisely below.
Step 1 — Cartridge to RAM (once, at init). Your game's assets — sprite
shapes, tile graphics, level maps, music, 3-D meshes — live out in the cartridge
banks. Code does not run comfortably from the 8 KB window (you would have to keep
swapping banks under your own feet), so during cart_init you copy what you
need into RAM with the cart_load helper, and from then on you work from RAM.
Chapter 11 covers this in detail. The point here: by the time the game is running,
everything you touch each frame is in fast, freely-addressable RAM.
Step 2 — CPU1 builds a request list (every frame). Inside cart_frame, your
logic decides what the next picture looks like and calls OS builders —
gpu_text, gpu_sprite, gpu_line, gpu_tile, and friends. Each builder
appends a few neatly-formatted bytes to a list in a 2 KB region of memory called
PPRAM (the "mailbox" from Chapter 2). You never write those bytes by hand and
never manage the write position — the builders do it. You are essentially writing
a short to-do list: "text 'SCORE' at the top, sprite #3 here, a line from A to
B."
Step 3 — The mailbox is handed to the GPU (at VSYNC). When the frame boundary arrives, the hardware swaps the mailbox over to the GPU. (It is literally two 2 KB memories taking turns — that is what "ping-pong RAM" means — but you never see the swap; it just works.) The GPU now has the list CPU1 finished, and starts reading it.
Step 4 — The GPU turns the list into pixels. The GPU walks your list, executing each request: stamping glyphs, blitting sprites, plotting lines. It writes the resulting pixels into VRAM, the video memory the screen is built from. Crucially, VRAM is double-buffered: there are two VRAM buffers, and the GPU always draws into the one the video circuit is not currently showing. So while the GPU paints the new picture, the screen keeps calmly displaying the previous finished picture — you never see a half-drawn frame. This is the GPU spending its own full frame budget, and why "pixels are the GPU's currency": a long line or a big sprite costs it real time (Chapter 23).
Step 5 — The picture is swapped to the screen (at the next VSYNC). When the GPU's frame ends, the second swap happens: the buffer it just finished becomes the one the video circuit displays, and the GPU moves on to draw into the other buffer. This is a different swap from Step 3's mailbox hand-over — same VSYNC heartbeat, different pair of memories.
Step 6 — The video circuit shows it. Independently and continuously, the video circuit reads the displayed VRAM buffer and streams it out as an 800×600 picture (your 400×300 image with each row shown twice). This part is pure hardware; no CPU is involved.
There are two hand-overs on the path — the mailbox swap (Step 3) and the VRAM swap (Step 5) — and each one costs a frame. So the journey is a little three-stage assembly line, where three frames are always in flight at once:
| Frame | CPU1 is… | GPU is… | The screen shows… |
|---|---|---|---|
| N | building list N | drawing list N−1 | picture N−2 |
| N + 1 | building list N+1 | drawing list N | picture N−1 |
| N + 2 | building list N+2 | drawing list N+1 | picture N |
Read down the bold "N": the list your cart_frame builds during frame N is
drawn by the GPU during frame N+1, and only displayed during frame N+2.
That is two frames — about 33 ms — from "CPU1 decides" to "the player sees
it." (Chapter 2 mentioned this; here is where it comes from.)
This is the price of the assembly line, and it buys a lot: both CPUs run flat-out every frame, neither ever waits on the other, and the screen never shows a torn or half-finished image. In practice you design as if your decisions appear "very soon" and never count exact frames — the only place the two-frame lag can matter is the very tightest input-timing feel, and even there 33 ms is comfortably inside the range arcade games have always lived in.
The MAD-65 hands you two conveniences that shape how you design a game:
1. The fresh list every frame. gpu_begin resets the mailbox to empty at the
start of every frame, so you always build from a clean slate (the heartbeat rule
from Chapter 3, in mechanism form). You never have to "undo" last frame's
requests — they are simply gone. Move something by describing it at its new
position; that is the entire technique.
2. The free background layer. VRAM actually has two layers: the image (everything you rebuild each frame) and the background (a persistent picture underneath). The hardware automatically copies the background back under your image every frame, at no cost to either CPU. So static scenery — a starfield, a play-field border, a dashboard — can be drawn once into the background and it reappears for free every frame forever, while your sprites and text are drawn fresh on top. This is how you get a rich-looking screen without spending your whole frame budget redrawing the parts that never change.
There is exactly one wrinkle to the background, and the OS hides most of it: a background change must reach the hardware on two consecutive frames to stick (the layer is double-buffered, so one write only lands in one of two buffers). You issue the change once through the normal builder and the OS automatically replays it on the next frame for you. The only thing you must remember: leave the source data unchanged until the frame after you draw it. Chapter 18 returns to backgrounds in practice; for now, just know the layer exists and is cheap.
It is tempting to see "rebuild everything every frame, talk only through a 2 KB mailbox, a couple of frames of lag" as a pile of restrictions. In practice it removes whole categories of bugs that plague other architectures: no torn frames (the swap is atomic), no flicker from erasing-then-redrawing (each frame is whole), no two-CPU race conditions (they share nothing but the mailbox, handed over cleanly at the boundary). The structure does the hard part for you; you just fill in the list.
We have seen the path a request takes (Chapter 4) and the rhythm it takes it to (Chapter 3). Now let's stand on CPU1 and watch a single frame go by from your code's point of view, so you know exactly where your code sits in the dance.
Here is one frame on CPU1, top to bottom:
VSYNC fires ──────────────────────────────────────────────────────┐
│ │
│ (OS) housekeeping runs: bump FRAME_COUNT, read joysticks, │
│ advance sound effects and music one tick │
│ │
▼ │
(OS) gpu_begin ── empties the mailbox, replays the background │
│ │
▼ │
►► YOUR cart_frame runs ◄◄ │
│ read the joystick state the OS latched for you │ ~16.6 ms
│ update your game world (positions, score, state machine) │ budget
│ call gpu_* builders to describe the next picture │
│ │
▼ │
(OS) gpu_end ── caps the list, marks it "ready for the GPU" │
│ │
▼ │
(OS) sleep (WAI) ── CPU1 parks until the next VSYNC ───────────────┘
│
▼
next VSYNC fires → the mailbox swaps to the GPU → repeat
A few things to read off this picture:
Your code is the middle slice. The OS owns the top (housekeeping +
gpu_begin) and the bottom (gpu_end + sleep). You own cart_frame — the part
labelled "YOUR." You do not write the loop, you do not call gpu_begin /
gpu_end yourself in the normal flow (the OS brackets your routine for you), and
you do not manage the sleep. You just describe the frame.
The joysticks were read for you, before your code ran. Notice input latching
happens up in the housekeeping step, before cart_frame. So when your code
starts, fresh controller state is already waiting in a few zero-page variables —
both "what's held down" and "what was just pressed this frame." You read those
variables; you never poll the joystick hardware yourself (Chapter 10).
Sound advances on its own. The sound-effect and music engines are ticked once per frame in that same housekeeping step. You start a sound or a tune with one call; keeping it playing frame-to-frame is automatic. You do not nurse audio in your loop.
The sleep is not wasted — it is your headroom. After gpu_end, CPU1 sleeps
until the next VSYNC. The more of the frame you spend sleeping, the more
budget you have to spare. In the simulator's meter (Chapter 7), a game using "8%
of CPU1" means it did its work in 8% of the frame and slept the other 92%. Sleep
is good; running out of frame before the sleep is the blink.
Your frame rides an assembly line. Look again at the very bottom: the mailbox
you just finished is swapped to the GPU at the next VSYNC. The GPU then draws it
during the following frame, and that picture is swapped onto the screen one frame
after that (Chapter 4's two swaps). So the list your cart_frame builds during
frame N is drawn during N+1 and seen during N+2 — the two-frame delay, now
located precisely in the timeline. In practice you ignore it — you build "the next
frame," it shows up a hair later, consistently — but it explains why you can never
read back "what the GPU drew": by the time it has drawn, you are already a frame or
two ahead building the ones after.
That is the whole life of a frame. Your entire game is this picture, repeated 60 times a second: wake, read input, think, describe, sleep.
You now know how the machine moves. This chapter is the box it moves inside — the concrete features and the hard limits. Treat it as a reference to come back to; the "things that will bite you" list at the end is the short version.
The GPU works in two different pixel grids, depending on the instruction, and mixing them up is a classic first-week bug:
| You're drawing… | Coordinate space | X range | Y range |
|---|---|---|---|
Individual pixels (pixel) |
Full-res | 0–399 | 0–299 |
| Lines, dotted lines, circles, dot-pixels | Half-res (doubled internally) | 0–199 | 0–149 |
| Text / tiles | Cell grid (8×8 cells) | 0–49 (column) | 0–35 (row) |
| Sprites | Full-res, signed (top-left pixel, (0,0) = screen origin) |
may run off any edge | may run off any edge |
The half-res space is the one that surprises people. Lines and circles are
drawn on a 200 × 150 grid and each unit is automatically doubled to cover the
400 × 300 screen. This makes line drawing twice as fast (half as many pixels to
plot) and is plenty precise for vector graphics and HUDs — but it means a line
from (0,0) to (199,149) spans the whole screen, not the top-left quarter.
When you mix primitives and individual pixels, keep straight which grid you are
in. (Most games live almost entirely in half-res for shapes and the cell grid for
text, and rarely touch full-res pixels at all.)
The text/tile grid is 50 columns by 36 rows of 8 × 8 cells. There is a single built-in font — you cannot define your own font (Chapter 16); you can define 256 of your own tiles, which is how you make custom block graphics.
Sprites are the friendly exception to all the range-juggling. A sprite's
position is a signed full-res pixel giving its top-left corner, with
(0,0) at the screen's top-left — the same origin as everything else, no offset
to remember. The coordinate may be negative or past the right/bottom edge, and
the GPU simply clips the sprite against all four screen edges (a sprite lying
entirely off-screen draws nothing). So you can slide a sprite smoothly on and off
any edge without special-casing it — just give it where you want its corner.
This is the single most important limit on the drawing side. The GPU does not check coordinates. If you hand it an off-screen coordinate, it does not clip or reject it — it computes an address from your numbers and writes there, whatever "there" is. Push Y far enough past the bottom of the screen and the address walks right off VRAM and into the video hardware registers, where a stray write can blank the screen or flip the display into a weird mode until something corrects it.
The good news: when you use the OS builders (which you always should), they validate and clamp coordinates for you before anything reaches the GPU — out-of range pixels are clamped to the screen edge, off-screen circles are skipped, bad characters are replaced with spaces. So "the GPU doesn't bounds-check" really means "keep your own game logic's coordinates sane, and let the builders be the safety net." The danger only appears if you bypass the OS and write PPRAM yourself — which this guide tells you not to do.
The mailbox (PPRAM) that holds one frame's request list is 2 KB — about
2047 bytes of commands. Every request you make spends some of that:
a sprite is a few bytes, a line is a few bytes, a string of text is one byte per
character, and so on. If a single frame's requests would overflow 2 KB, the
builder drops the overflowing command and sets a PP_OVERFLOW flag (which a
debug build can watch). The frame still finishes cleanly — you just silently lose
whatever didn't fit.
In practice 2 KB is a lot of drawing, and you will usually hit the cycle budget (the GPU running out of frame time to draw it all) before you hit the byte budget. But it is a real ceiling: a scene with hundreds of individual line segments can run out of mailbox space, and the fix is the same as for running out of time — thin the scene.
$0400–$77FF) plus about 7.75 KB
of upper RAM ($A000–$BEFF). If your game runs with the cartridge window turned
off, the lower and upper join into one ~15.75 KB contiguous upper block — but
most cart games keep the cartridge enabled for banking. Either way you have tens
of kilobytes for game state, buffers, and tables.$80–$FF (128 bytes) is yours; $00–$7F belongs to the OS.
Zero page is precious 6502 real estate — spend it on your hottest variables.$8000–$9FFF, banked, up to ~1 MB total. Copy out
of it into RAM before using; don't run code from a bank you're about to swap
(Chapter 11).It's worth stepping back and asking what this machine is for, because its whole character comes from one design decision: there is no video chip — just a second CPU. Classic 8-bit machines hand graphics to dedicated silicon with fixed, baked-in features (so many sprites, so many colours, a hardware tile scroller). The MAD-65 has none of that. Every pixel is software-rendered by the GPU and shipped to you, hidden behind the PPRAM mailbox.
That trade has a liberating side and a demanding side.
The liberating side: the GPU doesn't care what you draw. There is no "8 sprites per scanline" rule, no fixed object limit, no hardware that says "no." The only thing standing between you and the screen is the GPU's frame budget. Whatever fits in ~237,000 cycles, you can have. In round numbers, one frame buys you about any one of these:
The key word is one. These all draw from the same budget, so a real scene is a mix — say 30 sprites and a dozen vectors and a couple of text rows — and the mix has to add up to one frame. You're not juggling a dozen hardware limits; you're spending a single budget however you like (Part VI shows you how to measure it).
The demanding side: the staple of most 8-bit games — a full screen of dynamic tiles that scrolls and animates — does not fit. Redrawing all 36 rows of an 8×8 tilemap every frame would need roughly three frames' worth of cycles; there simply isn't time. If your design depends on a constantly-redrawn tiled playfield (think Boulder Dash, a scrolling platformer's tile world), the MAD-65 will fight you. You have to think differently.
The way out is the free background layer (Chapter 4). A static background costs the GPU nothing per frame — the hardware copies it under your scene for free. The only price is up front: loading it once. And that price isn't small — a full 400×300 bitmap is ~15 KB, far more than fits in one frame's budget or the 2 KB mailbox, so it streams in over several frames (and at least two, because a background must be written twice to land in both buffers — Chapter 18). You pay it at a loading screen or level start, then it's free forever.
So the MAD-65's sweet spot is clear: dynamics on top, static or minimal background underneath. Designs that shine:
What to let go of: the scrolling tile-maze mindset. Everything else — fast action, big characters, vector spectacle, particle-ish swarms of small sprites — the machine does happily. Design around movement against a fixed backdrop and the MAD-65 is in its element.
This is where we stop describing and start writing code. Everything here runs on CPU1 — your home turf from Chapter 2. The examples are real ca65/cl65 assembly: the same toolchain the console's own ROMs are built with, so you can paste them into a file, build a cartridge image, and run it in the simulator.
Which is why this Part opens with the simulator rather than with the kernel. Every chapter from here on has code in it, and code you can't run is code you can't learn from — so Chapter 7 gets you building and running first, in about ten minutes, and everything after it is something you can try immediately. (The simulator also has a live budget meter. Ignore it for now; Part VI is where that becomes the most important number on your screen.)
By the end of Part III you will have a complete, buildable cartridge that boots, runs at 60 Hz, reads the controls, moves something on screen, loads data out of its own banks, and is organised the way a full-size game is organised — the skeleton every MAD-65 game grows from.
madsim is the MAD-65 simulator — a cross-platform Rust program that runs your cartridge on a normal PC. It simulates both CPUs cycle-accurately, renders the 400×300 screen in a window, plays the audio (both PSGs + the FM chip), and — the part you'll live by — shows a live per-CPU utilization meter. Crucially, it's paced to the true MAD-65 frame rate (60.317 Hz), decoupled from your monitor, so timing and tempo match real hardware regardless of your display.
From the madsim repo root, build once (the first build takes a few minutes — it compiles the GUI and the audio core; later builds are fast):
cargo build --release
Then run your cartridge. madsim auto-discovers the system ROMs (cpu_os.bin,
gpu_os.bin) from the neighbouring MAD-65/roms, so you usually only point it at
your cart:
cargo run --release -- --cart game.bin
# or run the built binary directly:
.\target\release\madsim.exe --cart game.bin
Useful flags:
| Flag | Does |
|---|---|
--cart <file> |
load your cartridge image at $8000 |
--cpu1 <file> / --gpu <file> |
override the system ROMs (normally auto-found) |
--scale <n> |
window opens at 400·n × 300·n (default 2) |
--pause |
start paused (resume with Space/P) |
--audio off |
start silent |
--no-meter |
start with the meter hidden |
| Key | Action |
|---|---|
Esc |
quit |
Space / P |
pause / resume |
F2 |
dump full machine state to a file (for inspection) |
F3 |
toggle the on-screen meter |
M |
mute / unmute audio |
| Arrows / Right Ctrl | joystick port 1 (directions / fire) |
W A S D / Left Shift |
joystick port 2 |
So you actually play your game with the arrow keys, and watch the meter while you do.
Your day-to-day rhythm is short:
cl65 -t none -C cart.cfg -o game.bin game.s.madsim --cart game.bin.P and dump with F2 when you
want to inspect a frozen frame.)That's the whole cycle, and it's fast enough to tweak-and-see many times a minute.
Don't have a game.s and a cart.cfg yet? Chapter 9 has a complete, buildable
one — twenty lines that print a greeting. Copy it, run it, and you'll have closed
this loop once before you learn what any of it means. That's the right order.
madsim takes a per-frame snapshot of the screen at VSYNC — which is exactly
right for normal game development, because that's what a frame is. It does
not model mid-frame raster timing. If you're ever doing something exotic that
depends on what the video circuit is doing mid-scanline (raster register tricks,
cycle-exact video experiments), reach for the Verilator reference simulator in
MAD-65/sim instead — it's the gate-level model. For building a game, madsim is
the tool.
Burned into the console's 16 KB system ROM is a small operating system — the kernel. It boots the machine, runs the frame loop, reads the joysticks, ticks the sound, and offers you a library of ready-made services: draw text, blit a sprite, multiply two 16-bit numbers, play music, rotate a 3-D point. You reach all of it the same way: by calling a fixed table of entry points near the top of memory.
Every service lives at a frozen address in a jump table that starts at
$FF00. gpu_text is always at $FF2D. joy_read is always at $FF6F.
mul16 is always at $FF72. These addresses never move. To call a service,
you just JSR to its address:
jsr API_GPU_TEXT ; = jsr $FF2D — draw some text
In your own source you give those addresses friendly names with equates, exactly as the console's ROMs do. You only need the handful your game actually uses, but here is the convention (names match the system ROM, so they are easy to cross-reference):
; ── MAD-65 OS kernel — jump table (ABI v1), the subset this guide uses ──
API_CART_BANK = $FF03 ; A = bit7 enable | bank → select cart bank
API_CART_LOAD = $FF06 ; copy cart data → RAM (OS_ARG; bank-crossing)
API_GPU_BEGIN = $FF09 ; start a frame's command list (OS does this for you)
API_GPU_END = $FF0C ; terminate the list (OS does this for you)
API_GPU_LINE = $FF15 ; OS_ARG: X1,Y1,X2,Y2 (half-res)
API_GPU_DOTCIRCLE = $FF27 ; OS_ARG: CX,CY,R (half-res)
API_GPU_SPRITE = $FF2A ; OS_ARG: SPR_ID, X16, Y16
API_GPU_TEXT = $FF2D ; OS_ARG: col,row,scroll, ptr→string
API_GPU_TILE = $FF33 ; OS_ARG: col,row,scroll, ptr→tile ids
API_SFX_PLAY = $FF57 ; A = sfx id
API_JOY_READ = $FF6F ; latch joysticks (OS does this for you each frame)
API_MUL16 = $FF72 ; 16×16→32 unsigned (OS_ARG)
API_RNG = $FF78 ; next random byte → A
API_SIN = $FF7B ; sin(A brad) → A, signed Q0.7
You might wonder why you don't just write the video or sound chips directly. Three reasons, and they all protect you:
1. The kernel does the dangerous, fiddly part correctly. Drawing text means looking up glyphs, formatting PPRAM command bytes, advancing the write pointer, and — critically — validating your coordinates so a stray number can't walk off into the hardware registers and blank the screen (Chapter 6). The builders do all of that every time. Hand-rolling it is how you get garbage on screen and heisenbugs.
2. The addresses are a frozen contract — your cartridge keeps working. This
is the "ABI" part (see the sidebar). Because every service sits at a fixed
address with a fixed way of passing arguments, a cartridge you compile today is a
binary that hardcodes jsr $FF2D. The kernel promises those addresses will
never move: new OS versions may only append new entries to the end of the
table, never shift an existing one. So your old cartridge runs unchanged on a
newer console. Poke the hardware directly and you forfeit that guarantee — the
next board revision could move things and break you.
3. It is simply less code. mul16, sin, a 3-D mesh transform, a music
player — these already exist, debugged, in ROM. Re-implementing them in your 8 KB
cartridge is wasted space and wasted effort.
Sidebar — "API" vs "ABI." The API is the friendly contract: "there's a
gpu_textservice, it draws a string at a cell." The ABI is the same contract nailed down to the metal: "it lives at$FF2D, you pass the column inOS_ARG+0, you reach it withJSR." Because your cartridge is a compiled binary that bakes in those addresses, the binary-level promise — fixed addresses, fixed argument block, append-only growth — is what keeps your game running across OS revisions. That promise is why this guide insists you go through the table and never touch hardware yourself.
Every service follows one of two simple patterns:
A, X, Y. For example sfx_play
takes a sound-effect id in A; sin takes an angle in A and returns the
result in A.OS_ARG block. OS_ARG is a
16-byte scratch area in zero page at $20–$2F. You write your arguments
there before the JSR. 16-bit values are little-endian (low byte first).
Pointers (to a string, a tile list, a block of data) are passed as a 16-bit
address in OS_ARG — the actual data stays in your RAM and the builder copies
what it needs.A couple of standing rules to internalise now (they prevent real bugs):
OS_ARG = $20 ; API argument block, $20–$2F (little-endian)
$80–$FF of zero page is yours; $00–$7F belongs to the OS. Keep your
variables in $80–$FF.OS_SCRATCH ($12–$1F) is destroyed by every kernel call. Never leave
anything you care about there across a JSR.X, save it (phx/plx) around the call.Here is the whole pattern end to end — fill OS_ARG, point at your data, JSR:
; Draw "HELLO" at column 4, row 2.
lda #4
sta OS_ARG+0 ; column (0–49)
lda #2
sta OS_ARG+1 ; row (0–35)
stz OS_ARG+2 ; scroll = 0 (stz = store zero, a 65C02 instruction)
lda #<msg
sta OS_ARG+3 ; pointer low byte
lda #>msg
sta OS_ARG+4 ; pointer high byte
jsr API_GPU_TEXT ; the builder formats + queues the command
; ... on return, the request is in the mailbox; the GPU draws it next frame
rts
msg: .byte "HELLO", 0 ; NUL-terminated string in your RAM/cart
That is the shape of almost every drawing call you'll make: set up OS_ARG,
jsr API_…. The next chapter shows where this call belongs — inside your
per-frame routine — and gives you a complete cartridge to drop it into.
In Chapter 5 you saw the life of a frame as a timeline. Now let's see it as code, and pin down exactly the two routines you have to write.
You do not write a main loop. The kernel owns it. Stripped to its essence, the OS loop does this, forever:
(VSYNC wakes the CPU; the OS reads joysticks + ticks audio)
jsr API_GPU_BEGIN ; empty the mailbox, replay the background
jsr (your frame routine) ; ◄── YOUR CODE
jsr API_GPU_END ; cap the list, mark it ready for the GPU
(sleep until the next VSYNC, then repeat)
Notice gpu_begin and gpu_end are called for you, bracketing your routine.
So in normal use you never call them yourself — you just fill the list in
between. Your whole job each frame is the middle line.
A cartridge hands the OS two entry points through its header (Chapter 1):
cart_init — called once, at boot, before the frame loop starts. Set up
your variables, upload sprite/tile graphics, paint the initial background.cart_frame — called once per frame, forever, between gpu_begin and
gpu_end. Read input, update the world, emit drawing commands. It must
return (rts) well before the next VSYNC — that's the frame budget from
Chapter 3. (A do-nothing frame uses a few hundred cycles; you have ~237,000.)What goes where is usually obvious once you ask "does this change every frame?" The starfield's shape: once, in init. The starfield's positions: every frame. Loading a level's tile graphics: once (or once per level). Drawing the player at its current spot: every frame.
Here is a full, buildable cartridge. It boots, runs at 60 Hz, and prints one line
of text. This is your starting template — every later example is a change to
cart_frame or an addition to cart_init.
; =========================================================================
; hello.s — minimal MAD-65 cartridge. Build:
; cl65 -t none -C cart.cfg -o hello.bin hello.s
; Run:
; madsim --cart hello.bin
; =========================================================================
; ---- OS kernel entry points we use --------------------------------------
API_GPU_TEXT = $FF2D
; ---- published OS zero-page locations -----------------------------------
OS_ARG = $20 ; API argument block ($20–$2F)
; ---- the cartridge header (must be the very first bytes of bank 0) -------
.segment "HEADER"
.byte "MAD65" ; signature — "this is a cartridge"
.addr cart_init ; one-time setup entry
.addr cart_frame ; per-frame entry
; ---- code ---------------------------------------------------------------
.segment "CODE"
cart_init:
; Nothing to set up yet.
rts
cart_frame:
; Print a greeting at column 16, row 14, every frame.
lda #16
sta OS_ARG+0 ; column
lda #14
sta OS_ARG+1 ; row
stz OS_ARG+2 ; scroll = 0
lda #<message
sta OS_ARG+3 ; string pointer, low byte
lda #>message
sta OS_ARG+4 ; string pointer, high byte
jsr API_GPU_TEXT
rts ; ◄── return before the next VSYNC
.segment "RODATA"
message: .byte "HELLO, MAD-65", 0
A few things worth noticing:
cart_init
instead, the text would flash for one frame and vanish — remember the heartbeat
rule: persistent means redrawn, not drawn once (Chapter 3).cart_frame ends with rts. The OS called you with jsr, so you return to it,
and it carries on with gpu_end and the sleep.The cartridge needs to land at $8000 (the cart window) with the header first.
This cart.cfg produces a single 8 KB bank-0 image:
# cart.cfg — a single-bank (8 KB) MAD-65 cartridge
MEMORY {
ZP: start=$0080, size=$0080, type=rw, define=yes; # your ZP: $80–$FF
CART: start=$8000, size=$2000, type=ro, fill=yes, fillval=$00, file=%O;
}
SEGMENTS {
ZEROPAGE: load=ZP, type=zp;
HEADER: load=CART, type=ro, start=$8000; # signature + vectors, first
CODE: load=CART, type=ro;
RODATA: load=CART, type=ro;
}
Build it, point the simulator at it, and you have a running MAD-65 program. From
here on we only add to cart_init and cart_frame.
Multi-bank note. This template is one 8 KB bank — plenty for a small game's code. Bigger games put data (graphics, levels, music) in higher banks and pull it into RAM with
cart_load(Chapter 11). When your code outgrows 8 KB you have two clean options, both in Chapter 11: keep the code in bank 0 and only page data, or copy your code into RAM and run it from there — which makes switching the cartridge window completely safe. Start single-bank; grow into whichever model fits.
Your game has two ways to touch the outside world: reading the joysticks and writing a few control registers. Both go through the kernel, and the first one is almost entirely done for you.
There are two joystick ports. Each has four directions and two fire buttons —
the primary fire and an Amiga-style second fire on DE-9 pin 9. You do
not poll the hardware — the kernel's joy_read does that once per frame,
during the housekeeping step before cart_frame runs (Chapter 5). By the time
your code executes, fresh input is already sitting in six zero-page variables:
; ---- published joystick state (read these; never write them) ----
JOY1 = $0A ; port 1, held this frame
JOY1_PREV = $0B ; port 1, held last frame
JOY1_PRESS = $0C ; port 1, newly pressed THIS frame (edge)
JOY2 = $0D ; port 2, held this frame
JOY2_PREV = $0E ; port 2, held last frame
JOY2_PRESS = $0F ; port 2, newly pressed THIS frame (edge)
Every one of these is a bitmask with the same layout:
JOY_UP = %00000001 ; b0
JOY_DOWN = %00000010 ; b1
JOY_LEFT = %00000100 ; b2
JOY_RIGHT = %00001000 ; b3
JOY_FIRE = %00010000 ; b4
JOY_FIRE2 = %00100000 ; b5 — second fire (Amiga DE-9 pin 9)
A set bit means "pressed." (The hardware is active-low, but joy_read already
inverted it for you, so 1 = pressed, the intuitive way.) JOY_FIRE2 is the
second fire button; controllers with only one fire simply never set it, and
older games that ignore b5 keep working unchanged.
This trips up every newcomer once, so let's be explicit. There are two questions you can ask about a button, and they want different variables:
JOY1. True every frame the button is held.
Use it for continuous actions: walking while the stick is pushed, holding to
charge.JOY1_PRESS. True only on the
single frame the press began. Use it for one-shot actions: jump, fire one
shot, confirm a menu choice, toggle pause.If you fire a bullet on JOY1 instead of JOY1_PRESS, holding the button spews
a bullet every frame — 60 a second. On JOY1_PRESS, one tap = one bullet. That
one variable swap is the entire fix.
; Continuous: move right while the stick is held right.
lda JOY1
and #JOY_RIGHT
beq :+ ; bit clear → not held → skip
inc player_x ; held → move
:
; One-shot: fire exactly once per press.
lda JOY1_PRESS
and #JOY_FIRE
beq :+ ; not a fresh press this frame → skip
jsr fire_bullet
:
(:+ / : are ca65 anonymous labels — beq :+ jumps forward to the next bare
:. Handy for short skips without naming every branch target.)
The kernel gives you held and just-pressed for free. If you ever need
just-released, compute it yourself: a button released this frame is one that was
down last frame and is up now — JOY1_PREV AND NOT JOY1.
You will rarely write hardware registers directly, because the kernel wraps the ones that matter. The two you might reach for:
gpu_vreg, passing a sub-op in A (e.g. blinder on
/ off). Because it's a drawing-side effect, it travels through the mailbox like
any other command.cart_bank (or let
cart_load handle it), which also keeps the OS's shadow copy in sync. Writing
the raw register behind the OS's back desynchronises that shadow and will
bite you. Chapter 11 covers banking properly.The pattern is the rule from Chapter 8 in action: the OS owns the hardware; you ask the OS. Audio "registers" are the same story — you never write the sound chips, you call the audio API (Chapter 21).
Putting input together with drawing, here is a cart_frame that walks a sprite
around with the stick and fires on a fresh press. (Assume cart_init already
uploaded sprite #0's graphics — Chapter 16.)
; ---- game variables, in OUR zero page ($80–$FF) ----
player_x = $80 ; 0–199 (we'll keep the sprite on-screen)
player_y = $81
API_GPU_SPRITE = $FF2A
cart_frame:
; --- update: read stick, move player ---
lda JOY1
and #JOY_LEFT
beq :+
dec player_x
: lda JOY1
and #JOY_RIGHT
beq :+
inc player_x
: lda JOY1
and #JOY_UP
beq :+
dec player_y
: lda JOY1
and #JOY_DOWN
beq :+
inc player_y
:
; --- one-shot fire ---
lda JOY1_PRESS
and #JOY_FIRE
beq :+
lda #0 ; sfx id 0 = "shoot"
jsr API_SFX_PLAY ; (see Chapter 21)
:
; --- draw: queue the player sprite at its new spot ---
lda #0
sta OS_ARG+0 ; sprite id 0
lda player_x
sta OS_ARG+1 ; X low byte
stz OS_ARG+2 ; X high byte (X < 256 here)
lda player_y
sta OS_ARG+3 ; Y low byte
stz OS_ARG+4 ; Y high byte
jsr API_GPU_SPRITE
rts
This is a real game loop in miniature: read input → update state → draw. The
next chapters fill in where the sprite graphics came from (13), how to keep the
player from walking off-screen (clamp player_x/player_y to a legal range),
and how to grow this into a structured game (11).
Your cartridge can hold up to ~1 MB of graphics, levels, music, and tables — but
only an 8 KB window of it is visible to the CPU at a time, at $8000–$9FFF.
This chapter is about getting the bytes you need out of that window and into RAM,
where your game can use them freely.
Two reasons:
cart_load — the bank-crossing copy#The kernel gives you one call that handles all of this: cart_load. You tell it
which bank, where in the window, where in RAM, and how many bytes, and it
copies them — automatically crossing bank boundaries if the span is bigger than
the window, and restoring your original bank before it returns so your code
keeps running normally.
API_CART_LOAD = $FF06
; cart_load arguments, in OS_ARG:
; OS_ARG+0 source bank (0–127)
; OS_ARG+1/2 source addr (16-bit, a real window address $8000–$9FFF)
; OS_ARG+3/4 destination (16-bit RAM address)
; OS_ARG+5/6 length (16-bit byte count)
The source address is the literal window address as it appears in the memory map
— so "bank 3, offset 0" is bank 3, address $8000; there's no offset math.
LEVEL_RAM = $4000 ; somewhere in free RAM ($0400–$77FF)
load_level1:
lda #3
sta OS_ARG+0 ; from bank 3
lda #<$8000
sta OS_ARG+1
lda #>$8000
sta OS_ARG+2 ; ...starting at window offset $8000
lda #<LEVEL_RAM
sta OS_ARG+3
lda #>LEVEL_RAM
sta OS_ARG+4 ; ...into RAM at $4000
lda #<2048
sta OS_ARG+5
lda #>2048
sta OS_ARG+6 ; ...2048 bytes (may span into bank 4 — fine)
jsr API_CART_LOAD
rts ; bank is back to where it was; safe to continue
To make assets easy to place in banks, your data .s files assemble into the
higher banks via your linker config, and you reference their addresses
symbolically. (We'll show a multi-bank cart.cfg when we build the full example
game.)
The healthy shape for a game is:
cart_load the level's data and graphics into RAM,
push the graphics to the GPU (Chapter 16), paint the background (Chapter 18).
This is a big, one-time cost — it belongs in cart_init or a level-load
routine, not in cart_frame. (For graphics specifically you can skip the
RAM hop entirely and stream them from the cart straight into the GPU with
gpu_load_cart / gpu_load_cart_n — see Chapter 16.)cart_load in the hot path.So far we've kept all code in bank 0 and used the other banks only for data. That's the simplest model, and it's the right place to start — but it's not the only one, and for a bigger game it's not the one you'll want. There are two valid strategies:
Model A — code stays in bank 0 (simple). Your whole program fits in the 8 KB
of bank 0 and never leaves it. The cartridge window only ever shows bank 0 while
your code runs; higher banks hold data that cart_load pulls into RAM. Nothing to
juggle. Perfect for a first game or any program whose code is under ~8 KB.
Model B — copy your code into RAM and run it from there (scalable). At boot, a
tiny bank-0 stub copies your main code out of the cartridge into RAM and jumps
to it. From then on your game executes from RAM — at a safe distance from the
window. Now the window is free real estate: because the running code isn't in
it, you can re-bank $8000–$9FFF as much as you like (cart_bank / cart_load)
to stream in data, or even page in further code overlays, without ever pulling
the floor out from under yourself. This is how you grow past 8 KB of code, and
many developers prefer it from the start precisely so bank-switching is never
dangerous.
Be clear-eyed about which of these you are actually going to use. Model A is where you learn — and where every worked example in this guide stays, so the code on the page is about the game rather than about overlay plumbing. But a full-size MAD-65 game is a Model B game. 8 KB of 6502 is perhaps two or three thousand instructions; a shooter with six bosses, a level scripter, a HUD and five songs is an order of magnitude past that. Plan on moving to Model B, and plan on it early — the migration is much cheaper before you have absolute addresses baked into a hundred call sites.
The good news is that Model B scales further than it first appears, because
cart_load walks across bank boundaries by itself. You are not limited to the
8 KB of one bank: store your main code across banks 0–3, point cart_load at the
first one, give it the full length, and it advances the source bank each time the
address crosses the end of the window. Your code growing past 8 KB is then a
non-event — no chunking, no second call, nothing to maintain.
The one thing Model B asks of you: 6502 code is full of absolute addresses, so
RAM-resident code must be assembled for the RAM address it will actually run at.
In ca65 that's a segment with a separate load and run address — it's stored
in a cartridge bank but linked to run from its RAM location. You cart_load
it to that address and jmp in. Sketch:
# in your cart.cfg — a RAM-resident code segment:
MEMORY {
MAIN_RAM: start=$0400, size=$4000, type=rw, define=yes; # where it RUNS
BANK1: start=$8000, size=$2000, type=ro, file=%O; # where it's STORED
# ... ZP, bank 0 (HEADER + stub), other data banks ...
}
SEGMENTS {
MAINCODE: load=BANK1, run=MAIN_RAM, type=ro, define=yes; # load≠run
}
; bank-0 stub (cart_init): pull the main code into RAM, then run it
; cart_load bank 1 ($8000), length = size of MAINCODE → $0400
; (fill OS_ARG as in the cart_load example above) ...
jsr API_CART_LOAD
jmp main_start ; main_start is linked at its $0400 run address
The linker gives you the symbols (__MAINCODE_LOAD__, __MAINCODE_SIZE__, the
run-address labels) so you don't hand-count bytes. The takeaway: bank 0 for
everything you're learning here; run-from-RAM as soon as you're building the real
thing — and once you do, bank juggling becomes completely safe.
Model B changes where your ceiling is, and that deserves its own chapter: once code runs from RAM, RAM is the resource you run out of, not cartridge. Chapter 12 is about living in that budget — the memory map you actually get, code overlays, and how to get space back when you run out.
In both models, two things must remain in bank 0: the "MAD65" signature
and the bootstrap (the stub in Model B, or your whole program in Model A) —
because the console reads those straight out of the window at boot, before any of
your code has had a chance to move anything into RAM.
cart_load big things every frame. It's a blocking copy; a few KB can
blow your frame budget. Load at level boundaries. If you truly must stream a
large asset during play, split it into small per-frame chunks.cart_load
is safe because it runs from ROM and restores your bank — but if you call
cart_bank while your own code is running in the window, the next instruction
fetch reads the new bank and you crash. The fix is one of the two models above:
keep code in bank 0 (so you never re-bank the window your code lives in), or run
from RAM (so the window holds no executing code and re-banking is always safe)."MAD65" header and your two vectors must
be the first bytes of bank 0; the linker config places them. Don't relocate
them into another bank.Chapter 11 was about the cartridge — a megabyte of it, more than you will plausibly fill. This chapter is about the resource you will run out of.
Here is the shape of the surprise, taken from a finished full-size MAD-65 game (a six-level shooter with five songs, six bosses and a level scripter). At the point it was feature-complete:
| Resource | Capacity | Left |
|---|---|---|
| Cartridge | 512 KB (64 banks) | 4 banks |
| Lower RAM (code + read-only data) | ~26 KB | 49 bytes |
| Variables (BSS) | 3 KB | 32 bytes |
| Upper RAM (overlay region) | ~7.9 KB | 143 bytes |
Eight times more cartridge than it needed, and 0.2% of its RAM. That ratio is not an accident of one project — it falls out of Model B. The moment your code runs from RAM, every byte of code competes with every byte of game state for the same ~29 KB, while the cartridge keeps offering banks you have no room to copy anywhere. Plan for RAM from the beginning and this is comfortable. Discover it at 90% full and you will be paying for it in awkward ways.
Chapter 6 gave the raw numbers; this is how they divide up in a Model B game:
$0000–$007F zero page — the OS's (published variables, scratch, OS_ARG, audio)
$0080–$00FF zero page — YOURS. 128 bytes, the fastest memory in the machine.
$0100–$01FF the 6502 stack
$0200–$02FF OS state (audio shadows, the loader cursor, LOAD_REM at $0240)
$0300–$03FF OS state / headroom — treat as reserved
$0400–$0FFF ← typical home for your VARIABLES (BSS)
$1000–$77FF ← typical home for your CODE + read-only tables (Model B)
$7800–$7FFF the GPU mailbox — NOT yours, never touch it
$8000–$9FFF the cartridge window
$A000–$BEFF upper RAM — a second run region (see overlays below)
$BF00–$BFFF I/O registers
$C000–$FFFF the OS
Where you put the boundary between variables and code is your choice — the linker config decides it. The split above is a reasonable default: a page-aligned BSS area low down, everything else above it.
Three things on this map bite people:
$BEFF, not $BFFF. $BF00 is the I/O page — the audio
chips, the joystick ports and CART_BANK_REG all live there. Size an upper-RAM
region to $BFFF and your last 256 bytes of code will be writing to sound chips.$7800–$7FFF belongs to the mailbox. It looks like ordinary RAM in the memory
map and it is not. Your lower region stops at $77FF.$80–$FF — 128 bytes, and they are the
cheapest bytes in the machine to access. Late in a project, when BSS is full, a
small piece of cross-cutting per-frame state is often better off in spare zero
page than in BSS anyway. Don't let it all get claimed by whatever you wrote first.Upper RAM ($A000–$BEFF) is a second place to run code from, and it has a trick
that lower RAM does not: two bodies of code can share one run address, as long as
they are never live at the same time.
Think about what is actually simultaneous in a game. A boss fight and the
level-summary screen never coexist. Neither do the title screen and the endgame
sequence. So they don't need separate memory — they need the same memory, loaded
on demand. Give both segments the same run address in your linker config, store them
in different cartridge banks, and cart_load whichever one the current stage needs.
A flag saying which overlay is currently resident lets you skip the load when it's
already there.
This is the cheapest large win available to a full game: in the case study above it was worth several kilobytes of lower RAM, on machinery that already existed.
Two safety rules make it work, and both are easy to get wrong:
1. Never swap an overlay while its code is on the stack. If routine A in the boss overlay
jsrs something that swaps in the UI overlay, A's return address now points into code that no longer exists — and you return into the middle of a different routine. The discipline that prevents this: every transition out of an overlay state is a tailjmp, and control returns to the OS viartsfrom the top level before the swap happens. Set the stage, return, swap on the way in.2. Pin anything that runs every frame. A routine polled on every frame of play must live in code that is always resident, even if it logically belongs to a file full of overlay code. In ca65 you push it into your main segment explicitly (
.pushseg/.popsegaround it). Miss this and the routine vanishes the moment the other overlay loads.
You cannot manage a budget you cannot see, and the link map is not produced
unless you ask for it. A typical build line passes no -m, so any map file sitting
in your project is a souvenir of whenever someone last generated one by hand — often
badly out of date, and quoting it will mislead you.
Make a fresh one whenever you need the truth:
cl65 -t none --cpu 65C02 -C cart.cfg -m fresh.map -o /tmp/throwaway.bin src/*.s
Get in the habit of checking it before adding anything that runs during play, not after the build starts failing. "How much room do I have?" should be a ten-second question.
While you're at it, keep your build switches in one block at the top of your main
file — debug overlays, a level-skip cheat, an on-screen profiler. They need to be
above your .includes for the conditional-assembly guards to work anyway, and that
is also where you will look for them. Every one of them is space you get back before
shipping, and knowing exactly how much (rebuild with it off, read the map) turns a
vague "I'll strip that later" into a number you can plan with.
When lower RAM does fill up, these are the levers, roughly best-first.
1. Data read once has no business in lower RAM. This is the big one, and it rests on a guarantee worth stating plainly:
The machine's only interrupt is VSync, and the OS services it at the frame boundary — it does not preempt your frame. The OS's own bank switching (VGM music streaming) happens inside the OS's half of the loop, never inside yours. So if your game does not itself write
CART_BANK_REG, the window bank is stable for your entire frame, and cartridge data at$8000–$9FFFcan be read in place with no race at all.
Which means: before you .incbin anything into a RAM-resident data segment, ask
"is this read more than once?" Level scripts, narrative text, tables consulted
occasionally — all of these can stay in the cartridge and be read through the window.
Anything uploaded to the GPU at boot and never looked at again shouldn't touch RAM
either; stream it straight from the cartridge with gpu_load_cart (Chapter 16).
Two rules when you do read through the window: if you borrow the window by switching banks, save the current bank, switch, read, and restore it before anything else reads through it — and don't call OS routines while the bank is borrowed. Hand the window back first, then call.
2. Turn off debug code. Free, already written, and it has to go before you ship regardless. Measure it once so you know what it's worth.
3. Derive tables instead of storing them. Three tables that are one curve at three amplitudes are two tables too many — a shift or an add at read time buys the space back. Worth it for cosmetic data where a rounding error costs you nothing.
4. Move code that isn't always live into an overlay. Boot and title-screen code never coexists with a boss fight; if the overlay machinery already exists, moving things into it is nearly free.
Notice what all four have in common: they are structural, not clever. There is no byte-squeezing here, no hand-optimised assembly. If you find yourself shaving instructions to fit, you have skipped a lever above.
You now have all the pieces: a per-frame entry point (Ch. 9), input (Ch. 10), data loading (Ch. 11), and the calling convention to drive the kernel (Ch. 8). This chapter assembles them into the shape of a real game.
Almost every cart_frame has the same three phases, in this order:
; 1. INPUT — read JOY1 / JOY1_PRESS (already latched for you)
; 2. UPDATE — advance the world: move things, run game rules, timers
; 3. DRAW — emit the scene with gpu_* builders
rts
Keep them in that order and keep them separate. Reading input first means update acts on this frame's controls; drawing last means you draw the world after it has moved. Interleaving "move a bit, draw a bit, move a bit" is how you get objects that lag a frame behind each other or react to stale input.
What must be there: an rts that is reached every frame within budget. That's
it. You don't have to draw anything (a black frame is valid), you don't call
gpu_begin/gpu_end (the OS brackets you), and you don't manage timing.
The one ordering rule that bites people: fill OS_ARG inside cart_frame, per
call, right before each jsr. Don't set it up in cart_init expecting it to
survive — gpu_begin (which runs before your routine every frame) can overwrite
OS_ARG when it replays a background command. Treat OS_ARG as fresh scratch you
fill immediately before each use.
There's no clock; there's the frame counter. The kernel bumps FRAME_COUNT
($01) once every VSYNC. Anything time-based is just arithmetic on frame counts:
FRAME_COUNT = $01
; Blink a "PRESS FIRE" prompt: visible 30 frames, hidden 30 (1 Hz).
lda FRAME_COUNT
and #%00100000 ; bit 5 toggles every 32 frames
bne :+ ; ...so this is on for 32, off for 32
jsr draw_prompt
:
For your own timers — an invulnerability window, a respawn delay, an animation — keep a counter in your zero page and count it down each frame:
invuln = $82
lda invuln
beq :+ ; 0 → not invulnerable, skip
dec invuln ; tick the timer down
; (e.g. flicker the player while invuln > 0)
:
Animation frames work the same way: frame_index = (FRAME_COUNT / N) mod count
gives you a walk cycle that advances every N frames. Everything is counting.
A real game isn't one loop — it's a title screen, the game itself, a pause
overlay, a game-over screen. The clean way to handle that on a 60 Hz tick is a
state variable that says which screen you're in, and a cart_frame that
dispatches on it:
game_state = $83
ST_TITLE = 0
ST_PLAY = 1
ST_PAUSE = 2
ST_OVER = 3
cart_frame:
lda game_state
asl ; ×2: table holds 2-byte addresses
tax
jmp (state_table,x) ; 65C02 indexed-indirect jump
state_table:
.addr do_title ; ST_TITLE
.addr do_play ; ST_PLAY
.addr do_pause ; ST_PAUSE
.addr do_over ; ST_OVER
do_title:
; draw the title; on FIRE press, switch to play
lda JOY1_PRESS
and #JOY_FIRE
beq :+
lda #ST_PLAY
sta game_state
: rts
do_play:
; ... the real game: input, update, draw ...
; on PAUSE press → sta ST_PAUSE; on death → sta ST_OVER
rts
do_pause:
; draw "PAUSED"; on PAUSE press → back to ST_PLAY
rts
do_over:
; draw "GAME OVER"; on FIRE press → reset and ST_TITLE
rts
Each state is its own little input→update→draw routine, and switching screens is
a single sta game_state. Because every state ends in rts back to the OS, the
frame budget and the heartbeat take care of themselves — you've just organised
which code runs this frame.
The skeleton of essentially every MAD-65 game is now in your hands:
cart_init — cart_load your first screen's assets into RAM, upload
graphics to the GPU, draw the initial background, set game_state.cart_frame — dispatch on game_state; each state reads input, updates,
and draws; everything is timed by counting frames.That's a game loop. The next chapter is about what happens to it when the game gets big — and then Part IV turns to the draw phase in depth.
Chapter 13 got you a working loop. This chapter is about the four structural decisions that separate a working loop from a game that survives being finished.
They are not optimisations and they are not advanced techniques. They are all cheap on the day you make them and expensive to retrofit, which is the only reason they get a chapter of their own. Each one exists because of something the machine actually does — none of this is generic software advice.
A finished game is not one loop; it is a dozen screens — boot, logo, title, level intro, play, boss, level-cleared, game over, high scores — that share a machine. The structure that handles this without becoming a swamp is small:
cart_frame does nothing but dispatch.
cart_frame:
jmp (frame_vec) ; that's the whole function
Each screen is then a pair of routines — <screen>_enter, run once on arrival
(start the music, arm a load, reset counters), and <screen>_update, run every
frame. Switching screens means pointing frame_vec at the new update routine and
calling the new enter routine. Screens that need no setup share one empty enter.
Two details worth copying:
$00, $10, $20, $30… rather than 0, 1, 2, 3.
You will need to insert a stage between two existing ones (a loading step
between "prepare" and "play" is the usual one), and spaced ids mean inserting it
costs nothing. Consecutive ids mean renumbering.LOAD_REM hits 0. Wrap it in the blinder and the player sees a clean transition
instead of a half-drawn screen.This also gives the overlay machinery from Chapter 12 somewhere natural to live:
the swap happens in enter, at a moment when nothing from the outgoing overlay is
on the stack.
Every object in your game will want to draw itself. Let them all do it by calling
gpu_sprite directly and you have given away something you will want back.
Route every draw through one wrapper of your own instead. Even a single-tile object should go through it. The wrapper's job is to be the one place that knows how a game object becomes a draw call: it applies your anchor convention (centre, not top-left), and — the payoff — it can apply a global screen offset to everything at once.
That offset is screen shake. Two bytes of state, added to every object's position
inside the wrapper, and an explosion can rattle the entire world without a single
object knowing it happened. Retrofitting that into fifty scattered gpu_sprite
calls is a day's work; having it from the start is free.
The same argument applies to composite objects. A "sprite" in a real game is usually several hardware tiles with fixed relative offsets — a meta-sprite. Make that the normal case and the single-tile object is just a meta-sprite with one part. One code path, no special cases, and the slot arithmetic from Chapter 17 lines up with it exactly.
The alignment footnote. Sprites drawn at an X that is a multiple of 8 skip the blitter's per-row bit-shift and are meaningfully cheaper. It's a real effect, but don't design around it: the moment your screen offset is non-zero, every sprite in the game is off-alignment anyway. Pick bullet speeds for how they feel, not to stay byte-aligned. The win is worth having only for things that never move and never shake — a HUD frame, fixed scenery.
You will write animation timing once per surface unless you decide not to. A game typically grows three or four separate places that step through frames — the enemy pool, the boss, a gallery screen, the player — and if each has its own copy of the logic, each has its own copy of the bugs.
That is not hypothetical. Here is the bug they all share:
An animation driven by a fixed-period counter breaks when the frame count doesn't divide the period. If you advance frames on
(counter / 8) mod nwith a counter that wraps at 256, then anynthat isn't a power of two gets a short cycle at the wrap — a visible stutter, once every 256 frames, always at the same point.
Chased through one implementation, that's an afternoon. Chased through four, separately, because each surface looked like its own unrelated glitch, it's a week.
So: one animation player, with per-object state (current animation, current frame, ticks remaining) held in a small block that any object type can embed. Sequences live in data — a list of (frame, duration) steps with a loop-or-hold flag — not in code.
One piece of advice that isn't structural: when an animation looks wrong, ask what the art is supposed to do before you assume the code is wrong. A sequence that reads as a stutter may be a progression the artist intended.
This is the biggest single performance decision in a MAD-65 game, and it is made in the design, not in the optimiser.
Chapter 18 introduced the background layer; here is what it means in practice for a HUD, a scoreboard, or any fixed furniture:
Draw on change, not per frame. One emit makes a background line persist — the OS replays it into both buffers for you. So the model is not "redraw the HUD every frame," it's:
The result is a HUD that costs literally nothing on the vast majority of frames, and one line's worth of work on the rare frame where the score ticks over. Compare that to a per-frame text draw and you have bought back a slice of every frame in the game for about thirty lines of code.
Redrawing the whole line rather than the changed field matters: it erases stale content for free. A boss name replaced by a shorter one, a three-digit score dropping to two — all handled, with no blanking logic.
Two supporting habits:
And the general form of the rule, worth holding onto beyond the HUD: anything on screen that isn't moving this frame should not be costing you anything this frame. Scenery, borders, frames, instructions, backgrounds — all of it belongs to the background layer. What's left on the image layer is what actually moves, and that is the only thing your frame budget should be paying for.
Part IV now turns to the draw phase in depth — what the GPU can put on screen and how to ask for it — and Part V is the full per-call cookbook for everything in that jump table.
Part III gave you a CPU1 program that runs, reads input, and keeps time. Part IV is about the picture — everything the GPU can put on screen and how you ask for it. We stay deliberately high level: you'll learn what each drawing tool is for and how to drive it, not how the GPU renders it internally. From your seat on CPU1, the GPU is still the black box from Chapter 2 — you post requests, it draws. This Part is the catalogue of requests you can post.
Everything you put on screen is one of a small set of drawing requests you
append to the frame's list with a gpu_* builder (Chapter 8). The GPU reads the
list next frame and renders each request. That's the whole model. Your job is
picking the right request for the job and knowing what it costs.
There are five families of things you can draw, plus the controls for the background layer:
| Family | Builders | What it's for |
|---|---|---|
| Text | gpu_text |
Scores, HUD, menus, dialogue — the built-in font |
| Tiles | gpu_tile |
Custom 8×8 block graphics — your own little glyph set |
| Sprites | gpu_sprite |
Moving objects: player, enemies, bullets |
| Lines | gpu_line, gpu_dotline, gpu_dotlines, gpu_dotline_clip |
Vectors, wireframes, borders, bars, HUD art |
| Circles | gpu_dotcircle |
Reticles, radar rings, gauges, bubbles |
| Pixels | gpu_pixel, gpu_dotpixel, gpu_dotpixels |
Stars, particles, plotting |
Each of these also has a meaning you already know from Part II: text/tiles work in the 50×36 cell grid, lines/circles/dot-pixels work in half-res (200×150), plain pixels and sprites work in full-res (400×300). Chapters 16–15 take each family in turn.
Recall from Chapter 4 that the screen is built from two layers:
Most builders target the image layer. A subset has a _bg twin
(gpu_text_bg, gpu_tile_bg, plus gpu_clearbg and gpu_load to a background page)
that writes the background layer instead. The rule of thumb: draw the moving things
to the image every frame; draw the static scenery to the background once. Chapter 18
shows this in practice.
Background is whole-byte only. There is no
gpu_pixel_bgorgpu_line_bg: setting a single pixel needs a read-modify-write, but the VRAM-background window is write-only (reads return ROM), so per-bit drawing there is impossible. This is a property of the memory, not a gap in the firmware — it can never be added, so don't go looking for a workaround. (If you try to fake one anyway, the symptom is stray vertical banding: each plotted pixel ORs seven junk ROM bits into its byte.) The_bgbuilders that exist (text/tile/load/clearbg) all write whole aligned bytes. Build a background picture withgpu_load; use tiles/text for the rest, and put lines and pixels on the image layer, where read-modify-write works.Background discipline — three rules, not one. The background is double-buffered, so a change must reach the hardware on two consecutive frames to stick. You don't issue it twice — the OS automatically replays your
_bgdraw next frame. But that replay needs room, and that costs you two more rules on top of the obvious one:
- Leave the source data unchanged until the frame after you issued the draw (a string, a tile list, a
gpu_loadpage) — the OS re-reads it on the replay.- At most one background op per frame. Never two
gpu_text_bglines in one frame, never agpu_clearbgplus anything else.- Leave one idle frame between background ops. A second
_bgop on the very next frame lands on top of the first one's replay slot.Break rules 2 or 3 and the first op reaches only one of the two buffers. The hardware keeps ping-ponging between them, so the half-written content blinks every other displayed frame — the classic symptoms are "my banner flickers" and "the background only half-cleared." Chapter 18 shows the sequencer pattern that keeps you honest.
Two things you might expect, and won't find:
gpu_begin resets the list; the hardware
paints the background underneath). You just draw this frame's contents. (There
is a gpu_clearbg — but that clears the background, a rare one-time act.)The GPU has the same ~237,000-cycle frame budget as CPU1, and it spends it on pixels touched. A rough ranking, cheapest to dearest:
You don't need exact numbers yet (Chapter 23 has the table and how to measure) — just the instinct: area drawn ≈ time spent. A screen of small moving sprites is cheap; a screen of long solid vectors is not. When a frame gets heavy, you thin the most expensive things first.
Before you can draw tiles and sprites, the GPU needs their bitmaps. The formats are fixed by the ROM — you fill in the pixels, but the layouts, sizes, and the single font are not yours to change. This chapter is the reference for those formats and how you get your data into the GPU.
Font glyphs, tiles, and sprite planes all use the same 1-bit bitmap format:
one byte per row, MSB = leftmost pixel, a 1 bit = white. An 8-pixel-wide
row is one byte; a 16-wide row is two bytes; a 32-wide row is four. Here's the
letter "A" as an 8×8 cell — the bytes are the picture:
.byte %00011100 ; $1C ...XXX..
.byte %00110110 ; $36 ..XX.XX.
.byte %01100011 ; $63 .XX...XX
.byte %01100011 ; $63 .XX...XX
.byte %01111111 ; $7F .XXXXXXX
.byte %01100011 ; $63 .XX...XX
.byte %01100011 ; $63 .XX...XX
.byte %00000000 ; $00 ........
Learn to read that binary-as-pixels and you can author every graphic on the
machine. (Using %-binary literals in ca65 makes your art legible in the source.)
$20–$7F, 8×8 each, stored in ROM. You cannot replace
the font — there are no user fonts on the MAD-65.$60,
$61, $64, $73, $77) are repurposed as symbols (a copyright sign and
arrow glyphs) instead of those lowercase letters — handy for HUD arrows, but
check the font source if you need exact lowercase.gpu_text (Chapter 18). If you want lettering the font can't give
you — a logo, a stylised title — make it out of tiles instead.Tiles are how you make your own block graphics: a 256-entry set of 8×8 cells you define, living in GPU RAM. Think of them as a second, editable "font" of arbitrary pictures — walls, ground, UI chrome, big-letter logos, status icons.
1–255 (tile $00 is reserved as the end-of-list
sentinel — never use it as a drawable tile).gpu_tile — same call shape as gpu_text, but the
"string" is a list of tile ids instead of characters, and every cell is
drawn (text hides its last cell; tiles don't).You author a tile bank as a flat byte table and upload it to the GPU once at init
with gpu_load (below):
; A few tiles, 8 bytes each, in cart ROM. Index = position in this table.
tilebank:
; tile $00 — leave it blank/unused (reserved sentinel)
.byte 0,0,0,0,0,0,0,0
; tile $01 — a solid brick
.byte %11111111,%10000001,%11111111,%10000001
.byte %11111111,%10000001,%11111111,%00000000
; tile $02 — a dotted ground texture
.byte %10101010,%01010101,%10101010,%01010101
.byte %10101010,%01010101,%10101010,%01010101
; ... up to 255 tiles ...
Sprites are the moving things. Up to 256 of them can be defined (ids 0–255),
each described by a small definition (size + shape) plus its pixel data.
You set these up once at init; then each frame you just call gpu_sprite with an
id and a position.
256 definitions is fewer than it sounds. A definition covers one tile, and a sprite wider than 64 pixels — or one whose width isn't 8, 16, 32 or 64 — is sliced into several tiles, each taking its own definition. So an animation's slot cost is frames × tiles-per-frame, and on a real project the definitions run out well before the pixel memory does. Chapter 17 works through the arithmetic and the (free) trick that halves it.
A sprite definition has three knobs:
8, 16, 32, or 64 pixels (1, 2, 4, or 8 bytes per row). Only
these four. A 64-px sprite costs about twice the per-row work of a 32-px one, so
for a single wide object one 64-px sprite beats stitching two 32-px sprites
together (you save the second draw's setup and clip). 128 px is not available.Transparency and the two planes. A sprite isn't a solid rectangle — it has a shape with transparent areas, so the background shows through around it. That comes from how its planes are drawn:
1 bit paints white, a 0
bit is transparent (leaves the screen untouched). This alone gives you a
white shape on any background.1
overlay bit forces black, a 0 leaves things as they are. With both planes you
get a proper two-tone sprite — white body, black outlines/eyes/shading — that
still has transparent surroundings.A no-overlay sprite is just the white silhouette (smaller data, faster). Pixel
data is stored row-interleaved: for an overlay sprite each row is W bitmap
bytes followed by W overlay bytes; for a no-overlay sprite each row is just W
bitmap bytes.
Sprite 0 is a built-in 32×32 test sprite, usable immediately (it's what the demo and the Chapter 10 example move around). You can overwrite it like any slot.
gpu_load#Your tile and sprite bitmaps start life in your cartridge. To make the GPU able to
draw them, you copy them into GPU RAM with gpu_load, which transfers one
256-byte page per call into a GPU memory page you name:
API_GPU_LOAD = $FF39
; OS_ARG+0 = destination page (high byte of address), +1/2 = pointer to 256 bytes
lda #$07 ; GPU page $0700 — the start of the tile bank
sta OS_ARG+0
lda #<tilebank
sta OS_ARG+1
lda #>tilebank
sta OS_ARG+2
jsr API_GPU_LOAD ; uploads 256 bytes (32 tiles) to $0700
; ...repeat for each 256-byte page of tiles / sprite data you need...
Because gpu_load is itself a queued command (it travels through the mailbox like
any draw), you issue your uploads from cart_init's first few frames — a big
asset set is several pages and may not all fit in one frame's mailbox, so spread
it out. Once uploaded, the data lives in GPU RAM and you never resend it (unless
it changes). The exact GPU page addresses for the tile bank and sprite tables are
a reference detail; the worked example in Part V walks a complete sprite upload
end to end.
Mental model:
gpu_loadis "install this artwork in the GPU." You do it once at startup (or at level load).gpu_tile/gpu_spriteare "draw the artwork I already installed" — cheap, every frame.
gpu_load_cart and the drain loop#gpu_load takes its 256 bytes from RAM. If your artwork lives in the cartridge
(it usually does), that means a two-step shuffle: cart_load it into RAM, then
gpu_load it onward to the GPU. The kernel gives you a shortcut that does both at
once — it streams a page straight from a cartridge bank into the GPU, skipping
the RAM copy:
API_GPU_LOAD_CART = $FF9C
; OS_ARG+0 = source bank, +1/2 = window address, +3 = destination GPU page
lda #4 : sta OS_ARG+0 ; from cart bank 4
lda #<$8000 : sta OS_ARG+1
lda #>$8000 : sta OS_ARG+2 ; at window $8000
lda #$07 : sta OS_ARG+3 ; → GPU tile bank page $0700
jsr API_GPU_LOAD_CART ; one page, cart → GPU
bcs @retry_next_frame ; CARRY SET = it did NOT fit; nothing emitted
Always check the carry — and count with a cursor, not a flag. One
LOADpage costs 258 bytes of the 2 KB mailbox, so if this frame's scene already filled the list,gpu_load_cartemits nothing and returns carry set. That is not an error; it means "no room, ask me again next frame."The trap is how you track progress across frames. If you drive a multi-page upload with a boolean ("have I sent the definitions yet?"), you will set it on the frame that was refused and silently lose that page. Keep a cursor instead — an index of how many pages have actually gone out — and only advance it when the carry is clear. A dropped page is nasty to diagnose because it doesn't look like a loading bug: a lost sprite-definition page corrupts every sprite that uses it, so you go hunting in your drawing code instead.
That's the one-page tool. But a full asset set — the 4 sprite-definition pages plus
the bitmaps — is many pages, and only ~7 LOADs fit in one frame's 2 KB mailbox
(Chapter 6). So a bulk load must spread over several frames, which means a
loading screen. The kernel makes that a tidy little loop with two more calls:
API_GPU_LOAD_CART_BEGIN = $FF9F ; arm the job (once)
API_GPU_LOAD_CART_N = $FFA2 ; drain a frame's worth (every frame)
LOAD_REM = $0240 ; published: pages still to go (0 = done)
; --- once, when you enter the "loading" state: queue the whole job ---
lda #4 : sta OS_ARG+0 ; source bank
lda #<$8000 : sta OS_ARG+1
lda #>$8000 : sta OS_ARG+2 ; first page's window address
lda #$10 : sta OS_ARG+3 ; destination START page (e.g. $1000)
lda #28 : sta OS_ARG+4 ; how many 256-byte pages
jsr API_GPU_LOAD_CART_BEGIN
; --- then EVERY frame, inside your frame handler, just before you finish ---
jsr API_GPU_LOAD_CART_N ; loads as many pages as fit this frame
lda LOAD_REM
bne still_loading ; not done → keep showing "LOADING…"
; LOAD_REM == 0 → the whole set is in the GPU; switch to gameplay
gpu_load_cart_n is non-blocking: it is an ordinary builder that appends a few
LOADs and returns, so your game keeps running and you draw your own loading
screen (a word, a bar — whatever) around it. Three things to remember:
LOAD_REM, not a guess — it's the live page count remaining. When it
hits 0 the artwork is fully installed; until then, don't draw the new sprites
(they'd reference bytes that haven't arrived yet).Use this for level loads: at a level boundary, gpu_load_cart_begin the new level's
graphics, then run the drain loop behind a "LOADING…" until LOAD_REM is 0. A whole
28 KB of GPU graphics streams in under ~16 frames (about a quarter-second).
_bg twins#The loaders above target GPU RAM pages. VRAM-background pages ($C0–$FF)
need different handling: the background layer is double-buffered, so every write
must reach the GPU on two consecutive frames or it lands in only one of the two
physical buffers and flickers. The plain gpu_load_cart* calls don't do that — so
for backgrounds use their twins, which capture a replay record so gpu_begin
automatically re-issues each page next frame into the other buffer:
API_GPU_LOAD_CART_BG = $FFA5 ; one page, cart → VRAM-bg (+ auto replay)
API_GPU_LOAD_CART_BG_N = $FFA8 ; drain an armed job into VRAM-bg (+ auto replay)
gpu_load_cart_bg takes the same OS_ARG as gpu_load_cart (bank, window
address, destination page — here a bg page $C0–$FF). Carry set on return means
the mailbox was full and nothing was emitted.gpu_load_cart_bg_n is the drain twin of gpu_load_cart_n: arm the job with
the same gpu_load_cart_begin (the begin is source-agnostic — the drain
flavour you call decides image vs. background), then call it every frame until
LOAD_REM is 0. It self-throttles: the previous frame's replay runs first and
the drain simply loads fewer new pages, so no replay is ever dropped.A full 400×300 background is 59 pages and streams in about ~20 frames behind a "LOADING…" screen — and once it's in, the hardware copies it under the image layer for free every frame (Chapter 18).
| Thing | Count | Size | Format | Notes |
|---|---|---|---|---|
| Font glyph | 96 ($20–$7F) |
8×8 | 8 bytes, MSB=left | ROM, not editable |
| Tile | 255 usable ($01–$FF) |
8×8 | 8 bytes, MSB=left | id $00 reserved sentinel |
| Sprite bitmap | up to 256 ids | W∈{8,16,32,64}px × 1–255 rows | W bytes/row, MSB=left |
1=white, 0=transparent |
| Sprite overlay | (optional plane) | same W×H | W bytes/row, interleaved after bitmap |
1=black, 0=no change |
Chapter 16 told you what the machine eats. This chapter is about the kitchen.
Nobody hand-types sprite bitmaps as hex. You draw in an image editor and something in your build turns those files into the formats Chapter 16 described. That something is yours to write — this guide can't ship it, because it depends on your tools and your art. But the contract it has to satisfy is fixed by the hardware, and so are the traps, and those are what this chapter is for. Everything here is true whatever language you write your converter in.
A workable pipeline looks like this:
assets/art/*.png ──► your converter ──► ├─ pixel blobs (.bin, .incbin'd into cart banks)
├─ sprite defs (the GPU's definition pages)
└─ generated tables (.inc, assembled with your code)
with one rule over the top: edit art → make → everything regenerates together and
stays internally consistent. Never hand-edit a generated file.
Chapter 16 said there are 256 sprite definitions and a graphics pool of about 28 KB. It is natural to assume the pool is the constraint. It usually isn't. The case-study game finished with 253 of 256 slots used and only 20.5 KB of its 28 KB pool touched — it ran out of slots with a quarter of its pixel memory unused.
That happens because of a rule that is easy to miss and pure arithmetic:
A sprite's canvas width in bytes is decomposed greedily, largest-first, into hardware tiles of 8, 4, 2 and 1 bytes — and each of those tiles costs its own definition slot. One frame costs one slot per tile, not one slot per frame.
Work through it and the consequences are strange but very useful:
| Canvas width | Bytes | Decomposes to | Slots |
|---|---|---|---|
| 24 px | 3 | 2, 1 |
2 |
| 32 px | 4 | 4 |
1 |
| 88 px | 11 | 8, 2, 1 |
3 |
| 120 px | 15 | 8, 4, 2, 1 |
4 |
| 128 px | 16 | 8, 8 |
2 |
Read that table twice. A 32-px-wide sprite is cheaper than a 24-px one, and a 128-px sprite is half the cost of a 120-px one. Now add the second half of the rule:
Padding the canvas is free. Empty rows are trimmed per tile when the blob is packed, so a transparent margin costs nothing in the pixel blob — it only moves the part offsets. And only width matters: height is free, 1 to 255 rows.
Which gives you a one-line rule with no downside:
Pad every sprite canvas to a width whose byte count is a power of two (8, 16, 32, 64 px, or 128 for a multi-tile piece). It costs no memory and it can halve your slot usage.
A six-frame animation on a 24-px canvas costs 12 slots. Padded to 32 px it costs 6. On a project with 256 slots total, that difference is a feature you get to keep.
Sprites are positioned by their centre, and that centre is the centre of the canvas — not of the art inside it. Two consequences, both of which produce bugs that look like animation problems rather than data problems.
Every frame in an animation group must share one canvas size. If your converter takes the group's largest width and height as the canvas and pads shorter frames, it has to pad them somewhere — and padding at the bottom means a short frame sits high by half the difference. On screen: the sprite jumps when the animation changes frames. The case-study game had a fish whose 7-pixel swim frames sat 5 pixels above its 17-pixel attack frame, so it appeared to drop every time it fired. Nothing was wrong with the code. Since padding is free, the fix is upstream and total: author every frame of a group on an identical canvas.
Centre the art within its canvas. Empty margin on one side shifts the sprite off its own anchor — and therefore off everything keyed to that anchor: its hitbox, the muzzle point its shots spawn from, the position your collision code believes it is at. A sprite that "shoots from slightly the wrong place" or "gets hit before you touch it" is usually an off-centre canvas, not a bug in the game logic.
It is worth making this explicit in your pipeline: there is no per-sprite anchor override. The canvas is the anchor. Alignment is fixed in the image file, which means it is fixed by the artist, in the tool where they can see it.
An opaque black background renders as a solid white rectangle. The rule for a single-plane sprite is "any non-transparent pixel is lit." Export a PNG in RGBA with a black background and every one of those black pixels is opaque — so the whole canvas is lit and your sprite is a white block. The same picture saved as an indexed image with index 0 treated as transparent comes out correct. This costs somebody an hour on most projects; put the check in your converter and report it.
The overlay plane must be opted into explicitly — per folder, not per file. You cannot detect it from the image, and it is worth understanding why: in a single-plane sprite a black pixel means "lit," while in an overlay plane a black pixel means "force black." Same pixel, opposite meanings, and no amount of inspecting the colours will tell you which the artist intended. So the decision has to be carried by something outside the image — a dedicated folder is the simplest thing that works. Within an overlay group, split by luminance (white → bitmap plane, other opaque colours → overlay plane, transparent → neither), and emit the second plane only for tiles that actually need it so plain white tiles stay cheap.
The pool holds about 28 KB, but the things competing for it are often mutually exclusive. One boss is on screen at a time. A boot logo is never visible again after the title screen. Keeping all of them resident is a self-inflicted wound.
The pattern: pick a window — one pool address, after your shared resident set — and bake every mutually-exclusive set at that same address. They overlap, which is fine, because only one is ever loaded. At the start of a level you stream that level's blob into the window and the previous occupant is simply overwritten.
Definitions are the exception that makes this cheap: keep every definition resident, for every set, uploaded once at boot. A definition is small, and the slots for a boss who isn't loaded merely point at the window at meaningless pixels — harmless, because you never draw them. So per level only the pixels swap; the definition pages never reload.
Your real budget is then:
shared set + largest single window set ≤ pool, and total tiles ≤ 256 slots.
Have your converter print both numbers on every build. They are the two things you need to know and the only two you'll forget to check.
Every rebuild can renumber tiles — add one frame near the front and every id after it
shifts. That's fine and you should not fight it, but it means your game code must
never name a tile id. What stays stable is the group name and the frame
index, so generate a per-group table and let code refer to <group> and frame n.
The one place this leaks is hand-written animation timing that indexes frames by number: adding a frame at the end is safe, but reordering or removing frames means revisiting those tables. Worth a comment where they're defined.
Every constraint in this chapter is silent when broken. A too-wide canvas, an inconsistent group, a blown slot count, an unsupported music export (Chapter 21) — none of them produce an error at runtime. You get a game that looks subtly wrong, or a song that plays nothing, and no clue where to start.
So spend the twenty lines: have your converter hard-fail the build on a group with mixed canvas sizes, a canvas wider than 64 pixels that you didn't intend to slice, a slot count over 256, a pool overflow. A build error names its own cause. A silent asset bug is an afternoon.
Now let's actually use text, tiles, and the background layer. All three share the same cell-grid call shape, so once you've printed one string you can do all of it.
gpu_text takes a cell column (0–49), row (0–35), a scroll value
(0–7, leave 0 for now), and a pointer to a NUL-terminated string. You met it in
Chapter 8; here it is as the everyday HUD tool:
lda #2
sta OS_ARG+0 ; column
lda #0
sta OS_ARG+1 ; row (top of screen)
stz OS_ARG+2 ; scroll = 0
lda #<score_str
sta OS_ARG+3
lda #>score_str
sta OS_ARG+4
jsr API_GPU_TEXT
To show a changing number (a score, a timer), convert it to digits in a small RAM
buffer first, then point gpu_text at that buffer. The conversion is plain 6502
work; the drawing is the call above. Remember the heartbeat rule — redraw the
HUD every frame, it's not persistent.
One quirk:
gpu_textdoes not draw its last character (it's reserved for smooth-scroll bookkeeping). In practice: end strings you want fully visible with a trailing space, or just be aware the final byte before the0won't show. Tiles don't have this quirk — they draw every cell.
scroll byte#The scroll value (0–7) shifts a text or tile row sideways by that many pixels.
Increment it 0→7 across frames and, when it wraps, shift your string by one cell,
and the row glides smoothly one pixel at a time — that's how you make a marquee
credits scroller or a side-scrolling tile strip. (The built-in demo's bottom-row
scroller does exactly this.)
A tile row is drawn just like text, but the "string" is tile ids:
lda #0
sta OS_ARG+0 ; column 0
lda #10
sta OS_ARG+1 ; row 10
stz OS_ARG+2 ; scroll = 0
lda #<maprow
sta OS_ARG+3
lda #>maprow
sta OS_ARG+4
jsr API_GPU_TILE
maprow: .byte 1,1,2,2,1,1,2,2,3,3,0 ; tile ids; 0 ends the row
To draw a whole screen of tiles you'd loop this over several rows — but remember the budget warning from Chapter 6: you can afford roughly a dozen full tile rows per frame, not the whole 36-row screen redrawn every frame. Which is the perfect cue for the background layer.
Static scenery belongs in the background. You draw it once with the _bg
builders, the hardware copies it under your moving image every frame at no cost,
and you never pay for it again. A typical level setup, in cart_init (or a
level-load routine):
; paint a static tiled backdrop into the BACKGROUND layer, once.
; (issued once; the OS auto-replays next frame for the two-buffer rule —
; just don't disturb maprow until the frame after.)
lda #0
sta OS_ARG+0 ; column
lda #10
sta OS_ARG+1 ; row
stz OS_ARG+2
lda #<maprow
sta OS_ARG+3
lda #>maprow
sta OS_ARG+4
jsr API_GPU_TILE_BG ; note the _BG twin → background layer
The same _bg idea works for gpu_text_bg (a fixed HUD frame label) and a
gpu_load-painted background picture (a border, a vector horizon, a starfield). And
gpu_clearbg wipes the background to black when you're changing levels. (There is no
gpu_line_bg/gpu_pixel_bg — the background takes whole-byte writes only; see the
Chapter 16 note.)
Chapter 16's background discipline said one _bg op per frame, with an idle frame
between them. That makes painting a multi-line screen — a title, a briefing, a
score table — a sequencer, not a loop. Resist the obvious for loop over your
lines: it would emit them all in one frame and only the last one would survive
intact.
The pattern is a tiny state byte, stepped once per frame from your stage's update routine:
; --- paint NUM_LINES background text lines, one every other frame ---
; bg_step = which line to draw next (0..NUM_LINES), NUM_LINES = done
; bg_wait = cooldown counter: skip this frame if non-zero
paint_tick:
lda bg_wait
beq @ready
dec bg_wait ; this is the idle frame — emit nothing
rts
@ready:
lda bg_step
cmp #NUM_LINES
beq @done ; all lines painted; nothing more to do
; ... point OS_ARG at line[bg_step], set column/row ...
jsr API_GPU_TEXT_BG ; exactly ONE bg op this frame
inc bg_step
lda #1
sta bg_wait ; force one idle frame before the next line
@done: rts
Two consequences worth internalising:
gpu_clearbg needs two quiet frames#gpu_clearbg is the heaviest single command in the machine — about 138,000 cycles,
58% of a frame (Chapter 23). Combine that with the replay rule and it becomes the
one op that most often gets this wrong: the clear has to run twice, on two
successive frames, once per buffer. If either of those frames is busy with other
GPU work, the second pass gets starved and the previous screen bleeds through the
buffer that never got cleared — it then flashes back every other frame.
So a screen transition is never "clear, then load the new picture." It is:
Never fire
gpu_clearbgand a heavy background stream or load in the same frame, or in adjacent frames. This is the single most common source of "my new screen still shows bits of the old one."
Put it together and you have the shape of most MAD-65 games:
gpu_load), paint the background once
(gpu_tile_bg / gpu_text_bg / a gpu_load background picture / …).gpu_sprite), the HUD text (gpu_text), maybe a few vectors.The background reappears for free under everything; your per-frame budget is spent only on what actually moves. This is exactly the "dynamics over a static backdrop" sweet spot from Chapter 6.
Lines, circles, and pixels are the GPU's vector tools. They're how you draw wireframes, HUD art, radar rings, particle fields — anything that isn't a glyph, tile, or sprite. They all work in the half-res 200×150 space (Chapter 6), one byte per coordinate, doubled to the screen internally.
There are five line builders, trading quality for speed:
| Builder | Look | Speed | Use it for |
|---|---|---|---|
gpu_line |
Solid | baseline (H/V lines very fast) | Borders, axes, bars, anything that must look continuous. Image only — lines can't target the background (write-only VRAM-bg has no per-bit RMW). |
gpu_dotline |
Dotted | ~1.8× faster than solid | One-off diagonal edges where dotted is fine |
gpu_dotlines |
Dotted chain (polyline) | fastest for connected edges | Wireframes and any connected path — send a strip as one chain |
gpu_dotline_clip |
Dotted, screen-clipped | like gpu_dotline + clipping |
Lines whose endpoints may go off-screen (signed coords) |
gpu_hdotline |
Dotted horizontal rule (byte-aligned) | ~10–14× faster than a horizontal gpu_dotline |
HUD separators, grid/ruler lines, dotted underlines — horizontal only |
Three things worth knowing:
gpu_dotline / gpu_dotlines — the dotted look reads fine at speed and
costs far less (Chapter 6's "~25 long vectors per frame" assumed dotted).gpu_line has a background twin. Dotted lines and circles are
image-only — they're meant to be redrawn each frame, which is what wireframes do
anyway.gpu_dotline_clip — for lines that leave the screen#Plain gpu_dotline takes one-byte half-res coordinates, so it assumes both
endpoints are on-screen. The moment a line should run off an edge — a vector
object pushed partly out of frame, a HUD element sliding away — you want
gpu_dotline_clip instead. It takes signed 16-bit endpoints (so they can be
negative or past the edge) and trims the line to the screen for you: a line fully
off-screen draws nothing, a crossing line is cut at the edge with its slope intact.
It's the right tool whenever you can't guarantee your endpoints stay in bounds —
and it's the builder the 3-D wireframe pipeline uses (Part VII).
gpu_hdotline — cheap horizontal rules#When the line you want is horizontal, there's a much faster tool than
gpu_dotline: gpu_hdotline ($FFB1). Give it OS_ARG = X1, Y, X2 (half-res,
one byte each — the builder swaps them if X1 > X2) and the GPU draws a dotted
rule on that row by filling whole VRAM bytes with a constant $AA pattern —
about 10–14× faster than the same span drawn as a dotted line, and only
3 argument bytes in the mailbox. Two things to know:
X1..X2 — fine for HUD
separators, ruler lines and dotted underlines, not for exact-endpoint geometry.gpu_dotcircle draws a dotted outline circle from a half-res centre and
radius — reticles, radar rings, gauges. The centre must be on-screen (an
off-screen centre draws nothing), and a circle fully on-screen is cheaper than
one that spills past an edge.gpu_dotpixel plots one half-res dot; gpu_dotpixels plots a whole
list of them in one call (much cheaper than many separate calls) — ideal for a
starfield or particle spray. There's also full-res gpu_pixel when you need
exact 400×300 placement.gpu_dotpixels_clip is the off-screen-safe twin of gpu_dotpixels: it takes
a list of signed-16 half-res points and silently drops any that fall off the
screen, drawing only the survivors. Use it for clouds that drift in and out of
frame (a scrolling starfield, particles) so you don't have to pre-filter them.A quick rule of thumb:
A small self-contained scene — a radar ring with a sweep line and a few blips —
showing the primitives together. (Assume blips holds half-res x,y pairs.)
API_GPU_DOTCIRCLE = $FF27
API_GPU_DOTLINE = $FF1B
API_GPU_DOTPIXELS = $FF24
draw_radar:
; --- the radar ring: centre (170,120) half-res, radius 25 ---
lda #170
sta OS_ARG+0 ; CX
lda #120
sta OS_ARG+1 ; CY
lda #25
sta OS_ARG+2 ; R
jsr API_GPU_DOTCIRCLE
; --- a sweep line from centre outward (endpoint precomputed) ---
lda #170
sta OS_ARG+0 ; X1
lda #120
sta OS_ARG+1 ; Y1
lda sweep_x
sta OS_ARG+2 ; X2
lda sweep_y
sta OS_ARG+3 ; Y2
jsr API_GPU_DOTLINE
; --- the blips: one batched call for the whole list ---
lda #<blips
sta OS_ARG+0
lda #>blips
sta OS_ARG+1
jsr API_GPU_DOTPIXELS ; blips = N, x0,y0, x1,y1, ...
rts
That ring, sweep, and blip cloud cost the GPU very little — a textbook use of the
cheap primitives. Rotating the sweep line each frame (recomputing sweep_x/y from
an angle) is the kind of thing the math helpers in the next chapter make easy.
This Part is the reference shelf. Chapter 20 is the one-page map of the whole
kernel API — every call, its arguments, and where in this guide it's taught — so
you can flip here mid-coding to remember "what goes in OS_ARG for gpu_sprite
again?" Chapters 21 and 22 then give the two subsystems we haven't yet shown with
examples — audio and math — their proper walkthrough.
Everything the kernel offers is reached through the frozen jump table at $FF00
(Chapter 8). Below is the whole table, grouped, with arguments and the chapter
that covers each call in depth. Argument conventions, in brief (full rules in
Chapter 8):
A / X / Y — small values passed in registers.OS_ARG = $20–$2F, the argument block; 16-bit values are little-endian
(low byte first). Fill it after gpu_begin, right before the jsr.A or in OS_ARG, as noted.| Addr | Name | In | Out | See |
|---|---|---|---|---|
$FF00 |
os_run |
— | (never returns) | boot only — carts don't call |
$FF03 |
cart_bank |
A=bit7 enable|bank |
— | Ch 11 |
$FF06 |
cart_load |
OS_ARG: bank, src16, dst16, len16 |
— | Ch 11 |
| Addr | Name | In | Out | See |
|---|---|---|---|---|
$FF09 |
gpu_begin |
— | resets list, replays BG | Ch 9 |
$FF0C |
gpu_end |
— | caps list, sets CPU_READY |
Ch 9 |
| Addr | Name | OS_ARG (or reg) |
See |
|---|---|---|---|
$FF0F |
gpu_pixel |
X16, Y16 | Ch 19 |
$FF12 |
(reserved) | — | removed (gpu_pixel_bg); ABI-stub no-op |
$FF15 |
gpu_line |
X1,Y1,X2,Y2 (half-res) | Ch 19 |
$FF18 |
(reserved) | — | removed (gpu_line_bg); ABI-stub no-op |
$FF1B |
gpu_dotline |
X1,Y1,X2,Y2 | Ch 19 |
$FF1E |
gpu_dotlines |
ptr→{N,x0,y0,…} | Ch 19 |
$FF21 |
gpu_dotpixel |
X,Y | Ch 19 |
$FF24 |
gpu_dotpixels |
ptr→{N,x0,y0,…} | Ch 19 |
$FF27 |
gpu_dotcircle |
CX,CY,R | Ch 19 |
$FF2A |
gpu_sprite |
SPR_ID, X16, Y16 (signed) | Ch 10, 13 |
$FF2D |
gpu_text |
col,row,scroll, ptr→str | Ch 18 |
$FF30 |
gpu_text_bg |
col,row,scroll, ptr→str | Ch 18 |
$FF33 |
gpu_tile |
col,row,scroll, ptr→ids | Ch 18 |
$FF36 |
gpu_tile_bg |
col,row,scroll, ptr→ids | Ch 18 |
$FF39 |
gpu_load |
page_MSB, ptr→256 B | Ch 16 |
$FF3C |
gpu_clearbg |
— | Ch 18 |
$FF3F |
gpu_vreg |
A=sub-op 0–5 |
Ch 10 |
$FF42 |
gpu_led |
A=pattern |
(diagnostic) |
$FF45 |
gpu_raw |
A=literal byte |
(escape hatch) |
$FFB1 |
gpu_hdotline |
X1,Y,X2 (half-res; auto byte-aligned) | Ch 19 |
| Addr | Name | In | See |
|---|---|---|---|
$FF48 |
snd_init |
— | Ch 21 |
$FF4B |
snd_tone |
A=ch, X=MIDI note, Y=vol |
Ch 21 |
$FF4E |
snd_off |
A=ch |
Ch 21 |
$FF51 |
snd_noise |
A=ch, X=mode, Y=vol |
Ch 21 |
$FF54 |
snd_beep |
A=note |
Ch 21 |
$FF57 |
sfx_play |
A=sfx id |
Ch 21 |
$FF5A |
audio_tick |
— (IRQ calls it) | Ch 21 |
$FFAB |
sfx_play_ptr |
A=ptr lo, X=ptr hi, Y=tone-voice hint (0–2 / $FF auto) |
Ch 21 |
$FFAE |
vgm_play_loop |
OS_ARG+0/1/2 start bank/addr, +3/4/5 loop-anchor bank/addr |
Ch 21 |
| Addr | Name | In | See |
|---|---|---|---|
$FF5D |
ym_inst |
A=ch 0–8, X=instr 0–15 |
Ch 21 |
$FF60 |
ym_note_on |
A=ch, X=MIDI note, Y=vol |
Ch 21 |
$FF63 |
ym_note_off |
A=ch |
Ch 21 |
$FF66 |
vgm_play |
OS_ARG: bank, addr16 (0x66 loops whole stream) |
Ch 21 |
$FFAE |
vgm_play_loop |
as vgm_play + OS_ARG+3/4/5 loop anchor (0x66 loops to it) |
Ch 21 |
$FF69 |
vgm_stop |
— | Ch 21 |
$FF6C |
vgm_tick |
— (IRQ calls it) | Ch 21 |
| Addr | Name | In | See |
|---|---|---|---|
$FF6F |
joy_read |
— (IRQ calls it; read the ZP shadows) | Ch 10 |
| Addr | Name | In → Out | See |
|---|---|---|---|
$FF72 |
mul16 |
OS_ARG: A16×B16 → 32-bit product |
Ch 22 |
$FF75 |
div16 |
OS_ARG: A16÷B16 → quotient, remainder |
Ch 22 |
$FF78 |
rng |
→ A (random byte) |
Ch 22 |
$FF7B |
sin |
A=angle brad → A signed Q0.7 |
Ch 22 |
$FF7E |
cos |
A=angle brad → A signed Q0.7 |
Ch 22 |
| Addr | Name | Purpose | See |
|---|---|---|---|
$FF81 |
rot3d |
rotate a point by 3 angles | Ch 27 |
$FF84 |
project |
world → screen byte + flag | Ch 27 |
$FF87 |
mesh_xform |
transform a vertex list (byte) | Ch 27 |
$FF8A |
face_facing |
backface test (byte points) | Ch 27 |
$FF8D |
project_raw |
world → screen signed-16 + flag | Ch 27 |
$FF90 |
mesh_xform_raw |
transform list → signed-16 | Ch 27 |
$FF93 |
gpu_dotline_clip |
screen-clipped dotted line (signed-16) | Ch 19, 21 |
$FF96 |
face_facing_raw |
backface test (signed-16 points) | Ch 27 |
$FF99 |
gpu_dotpixels_clip |
screen-clipped pixel cloud (signed-16) | Ch 19, 21 |
| Addr | Name | In | Out | See |
|---|---|---|---|---|
$FF9C |
gpu_load_cart |
OS_ARG: bank, addr16, destPage |
carry set = didn't fit | Ch 16 |
$FF9F |
gpu_load_cart_begin |
OS_ARG: bank, addr16, destStartPage, count |
arms job | Ch 16 |
$FFA2 |
gpu_load_cart_n |
— (drains armed job) | updates LOAD_REM ($0240) |
Ch 16 |
$FFA5 |
gpu_load_cart_bg |
OS_ARG: bank, addr16, dest bg page ($C0–$FF) |
carry set = didn't fit; auto two-frame replay | Ch 16 |
$FFA8 |
gpu_load_cart_bg_n |
— (drains armed job into VRAM-bg) | updates LOAD_REM; auto two-frame replay |
Ch 16 |
Common combinations worth keeping in muscle memory:
gpu_text at the buffer. (There's no "print integer" call — formatting is yours,
drawing is the kernel's.)rng → A twice, mask/scale each to the
field range, store as the new object's X/Y. (Ch 22.)sin/cos of an angle, scaled, added to a centre — a
rotating sweep, an orbiting enemy, a wobble. (Ch 22.)gpu_load the bitmaps once at init; gpu_sprite /
gpu_tile them every frame. (Ch 16.)gpu_load_cart_begin once, then
gpu_load_cart_n every frame behind a "LOADING…" screen until LOAD_REM is 0 —
no hand-managed cart_load→gpu_load shuffle. (Ch 16.)_bg builders once at init; image-layer
builders every frame. (Ch 18.)The MAD-65 has real, capable sound — and the kernel makes it genuinely easy, because the engines run themselves. You start a sound or a song with one call; the OS advances it every frame from the interrupt handler, so it keeps playing at steady tempo no matter what your game logic is doing. You never write a sound chip directly.
There are three sound chips, divided statically so music and effects can never step on each other:
| Chip(s) | Role | You drive it with |
|---|---|---|
| SN76489 #1 + YM2413 (FM) | music | the VGM player (vgm_*), or snd_*/ym_* when no song plays |
| SN76489 #2 | sound effects | sfx_play, snd_beep, snd_tone |
Because effects live on their own chip (SN76489 #2), an effect can fire at any moment without ducking or interrupting the music. This is why you rarely have to think about channel management — the split is done for you.
Both engines advance once per frame automatically (audio_tick for effects,
vgm_tick for music, both from the IRQ). The jump table exposes them only for
games running an unusual custom loop; in normal use you ignore them.
The simplest sound is a predefined effect, fired by id:
API_SFX_PLAY = $FF57
SFX_ZAP = 0 ; falling laser sweep
SFX_COIN = 1 ; two-note pickup chime
SFX_BOOM = 2 ; white-noise explosion
SFX_BLIP = 3 ; 2-frame UI tick
lda #SFX_COIN
jsr API_SFX_PLAY ; that's it — plays over the music, frees itself
That one call allocates a channel on the effects chip, plays the effect to completion, and releases the channel. Fire and forget. For a quick UI confirmation beep there's an even simpler one:
API_SND_BEEP = $FF54
lda #72 ; a MIDI note (72 = C5)
jsr API_SND_BEEP ; short fixed beep — menus, cursor moves
The four
SFX_*effects live in the system ROM, so they're always available but fixed. For your own effects, the clean path issfx_play_ptr($FFAB): write the effect as the same little step program (type, thenframes,note|mode,voltriples,$FFterminator) in your cartridge's RAM, and hand its address to the engine —A= pointer low,X= pointer high. It then runs on the IRQ-driven sequencer just like a built-in, so you get sweeps/decays for free without drivingsnd_toneframe-by-frame yourself:```asm API_SFX_PLAY_PTR = $FFAB my_zap: .byte $00 ; type: tone .byte 2, 96, 12 ; frames, MIDI note, volume .byte 3, 84, 6 .byte $FF ; terminator
lda #<my_zap ldx #>my_zap ldy #$FF ; tone-voice hint: $FF = auto-allocate (see below) jsr API_SFX_PLAY_PTR```
Because the engine reads the program from the IRQ, keep it resident (RAM is always mapped, so a RAM pointer is bank-safe). Pass
Y=0/1/2instead of$FFto pin the effect to tone voice 4/5/6 — handy for keeping, say, weapon shots and impacts on separate channels so they never cut each other (Yis ignored for noise effects, which always use voice 7). You can of course still drivesnd_tonedirectly if you want per-frame control of the chip.
For melodic blips or your own effects, snd_tone plays a note on an effects
channel:
API_SND_TONE = $FF4B
; A = channel (4–6 tone on the effects chip), X = MIDI note, Y = volume 0–15
lda #4 ; effects tone channel
ldx #69 ; MIDI 69 = A4 = 440 Hz
ldy #12 ; volume (15 = loudest, 0 = silent)
jsr API_SND_TONE
; ... later, to stop it:
lda #4
jsr API_SND_OFF ; $FF4E — silence the channel
Notes are standard MIDI numbers (60 = middle C, 69 = A4, +1 per semitone;
playable range A2–C8). Volume is the intuitive way round on both chips: 15
loudest, 0 silent. snd_noise does percussion/whitenoise on the noise channel.
The YM2413 gives you nine FM voices with 15 built-in instruments — a much richer sound than the PSG square waves. If you want to play music note-by-note (rather than via a VGM file), three calls drive it:
API_YM_INST = $FF5D
API_YM_NOTE_ON = $FF60
API_YM_NOTE_OFF = $FF63
lda #0 ; FM channel 0
ldx #1 ; instrument 1 (a ROM preset; 0 = custom patch)
jsr API_YM_INST ; assign the voice's sound
lda #0 ; channel 0
ldx #60 ; MIDI middle C
ldy #15 ; volume
jsr API_YM_NOTE_ON ; key the note on
; ... hold for however many frames you like, then:
lda #0
jsr API_YM_NOTE_OFF ; key off; the instrument's envelope releases it
There's no built-in sequencer — you decide when to key notes on and off, usually from a little frame-counted music routine in your game. (For pre-composed music, the VGM player below is far easier.)
The easy way to have music is a VGM file — a recording of sound-chip writes that the player replays on schedule. Compose or grab a SN76489 + YM2413 VGM tune, embed it in your cartridge, and play it with one call. The player drives SN76489 #1 + the YM2413, leaving the effects chip untouched.
API_VGM_PLAY = $FF66
API_VGM_STOP = $FF69
VGM_FLAT = $FF ; "flat" source: a plain CPU address, no banking
; --- in cart_init: start the title music ---
start_music:
lda #VGM_FLAT
sta OS_ARG+0 ; bank: $FF = flat (song sits at a fixed address)
lda #<song
sta OS_ARG+1
lda #>song
sta OS_ARG+2 ; address of the (header-stripped) VGM stream
jsr API_VGM_PLAY ; starts; plays + loops automatically every frame
rts
; embed the song data somewhere in your cart/RAM:
song: .incbin "title.vgm.bin" ; a stripped VGM command stream
After that, the music just plays — vgm_tick advances it every frame for you.
vgm_stop halts it and silences the music chips (e.g. on game-over).
Two practical notes:
$FF = flat means "the song is at a normal CPU address" (ROM or RAM). If
your song instead lives out in cartridge banks (big tunes), pass the bank
number 0–127 and a window address instead — the player follows the song across
bank boundaries automatically, the same way cart_load does (Ch 11).vgm_play points its cursor straight at the address you hand
it. Strip the header (and the trailing GD3 tag) in your build step before
.incbin. See the checklist below for why this matters more than it sounds.Before you drop in a song — four things that produce silence, not an error. The music path fails quietly: every mistake below sounds identical from the outside ("I added music and nothing plays"), and none of them points at its own cause. Check all four before debugging anything else.
- Export target must be SN76489 + YM2413. In a tracker that means the Sega Master System (+FM) target. A Sega Genesis export uses the YM2612 — a different, incompatible FM chip, with different commands. The player hard-stops on the first command it doesn't recognise, so a Genesis export plays nothing at all, not even its PSG layer. YM2612 ≠ YM2413.
- The header and GD3 tag must be gone. Feed the player a whole
.vgmfile and it will read the header bytes as commands, hit garbage, and stop. The loop point would be wrong too —vgm_playloops back to the address you gave it, so it would loop the header.- Author at 60 Hz. The tick pays out 735 samples per frame, so a 60 Hz song keeps exact tempo. A 50 Hz (PAL) song plays about 20% too fast.
- Any unsupported command kills the song. The player understands the SN76489 and YM2413 writes, the standard waits, and the end/loop marker — and faults to silence on anything else.
Because all four failures are silent, make your build step validate the stream and fail the build — walk the commands, reject an unsupported chip or opcode, and compute the loop anchor — rather than letting a dead song ship. That tool is a dozen lines and it turns a lost afternoon into a build error.
Tying it together — start the music once, fire an effect on an event:
cart_init:
jsr start_music ; (from above) title/level music begins
; ... upload graphics, paint background, etc ...
rts
cart_frame:
; ... game update ...
; when the player grabs a coin:
lda got_coin ; a flag your logic set this frame
beq :+
lda #SFX_COIN
jsr API_SFX_PLAY ; chimes over the music — no ducking needed
stz got_coin
: ; ... draw ...
rts
Music on its own chips, effects on theirs, both advancing themselves — that's the whole audio model.
The 65C02 has no multiply, no divide, and no trig — so the kernel provides them. These helpers are the workhorses behind movement, physics-ish motion, random events, and the entire 3-D library in Part VII. They're worth knowing well.
There are no floating-point numbers on a 6502 — and you don't need them. The MAD-65 convention is fixed-point: integers that represent fractions by a fixed scale. Two conventions you'll use constantly:
sin/cos results in Q0.7. The result is a signed byte from −127 to
+127, representing −1.0 to +1.0. To use it, multiply by your magnitude and
shift right 7 (÷128). So "move 40 units at angle A" is (40 × sin A) >> 7.sin and cos#Both take an angle in A (brad) and return the Q0.7 value in A:
API_SIN = $FF7B
API_COS = $FF7E
lda angle ; 0–255 brad
jsr API_SIN ; A = sin(angle), signed −127..127
mul16 — 16×16 → 32-bit multiply#Used whenever you scale a fixed-point value (like that 40 × sin A). Arguments and
result live in OS_ARG:
API_MUL16 = $FF72
; OS_ARG+0/1 = A (multiplicand, preserved)
; OS_ARG+2/3 = B (multiplier, consumed)
; OS_ARG+4/5/6/7 = 32-bit product (out, little-endian)
; compute speed (40) × sin(angle), keep the high-ish bytes
lda #40
sta OS_ARG+0
stz OS_ARG+1 ; A = 40
lda angle
jsr API_SIN ; A = sin, signed
sta OS_ARG+2
; (sign-extend sin into OS_ARG+3 if you need a signed multiply;
; for small positive magnitudes a careful unsigned use is common —
; the 3-D library handles signed scaling for you in Part VII)
stz OS_ARG+3
jsr API_MUL16
; product in OS_ARG+4..7; >>7 (take OS_ARG+5 bits) gives the scaled result
div16 — 16 ÷ 16 → quotient + remainder#API_DIV16 = $FF75
; OS_ARG+0/1 = dividend (preserved)
; OS_ARG+2/3 = divisor (preserved)
; OS_ARG+4/5 = quotient (out)
; OS_ARG+6/7 = remainder (out)
No divide-by-zero guard. A zero divisor yields a garbage quotient (
$FFFF), by design (no spare cycles to check). You must ensure the divisor is non-zero before calling — guard it in your code.
rng — a random byte#rng returns the next pseudo-random byte in A and advances its internal state.
Fast, no arguments:
API_RNG = $FF78
jsr API_RNG ; A = random 0–255
Smooth movement (sub-pixel velocity). To move slower than one pixel per frame, keep position in fixed-point — say 8 fractional bits — and add a fractional velocity each frame, drawing only the whole-pixel part:
; player_x is 16-bit: high byte = pixel, low byte = fraction (1/256ths)
clc
lda player_x_lo
adc vel_x_lo ; add fractional velocity
sta player_x_lo
lda player_x_hi
adc vel_x_hi
sta player_x_hi ; player_x_hi is the pixel to draw at
Circular / orbiting motion. Position an object on a circle of radius R around
a centre, by angle A — the classic sin/cos use:
; x = CX + (R × cos A) >> 7 ; y = CY + (R × sin A) >> 7
; (compute each with cos/sin + mul16 as shown above, then add the centre)
; increment A each frame to orbit; A wraps at 256 for free.
This is exactly how you'd rotate the radar sweep from Chapter 19, orbit a satellite enemy, or make something bob and weave.
Random spawns. Drop an enemy at a random on-screen position:
jsr API_RNG
; scale 0–255 down to the field — e.g. AND/compare to your X range
sta new_x
jsr API_RNG
sta new_y ; clamp/scale to 0–149 (half-res) as needed
These three patterns — fixed-point accumulation, sin/cos for circular motion,
and rng for variety — cover most of the "feel" of a game. They're also the exact
conventions the 3-D library is built on — so if you go on to Part VII, you already
speak its language.
You met the simulator back in Chapter 7 and have been building with it ever since. This Part is about the thing it can do that a real console can't: tell you how much of each frame you are using.
Everything so far has been about making the machine do what you want. This Part is about making it do so in time — reading the meter, finding which of the two CPUs is the bottleneck, and shipping a game that holds 60 Hz on its worst frame. It applies to every game, 2-D or 3-D, which is why it comes before the 3-D Part rather than after it.
Everything in this guide has pointed at one number. Here it is, made measurable.
Each CPU gets a fixed ~237,400 cycles per frame (14.318 MHz ÷ 60.317 Hz). That is the hard ceiling from Chapter 3 — finish the frame's work within it, or the frame blinks. The two CPUs have separate budgets: CPU1 spends its 237,400 on thinking, the GPU spends its own 237,400 on drawing. Either one can overrun independently.
madsim's meter turns that into a percentage. Each frame it measures what fraction
of the budget each CPU spent on real work — i.e. cycles that were not spent
asleep on WAI (waiting for VSYNC) or padding on NOP. It shows two lines,
top-left (and in the window title):
CPU1: 8% ▓░░░░░░░░░░░░
GPU : 47% ▓▓▓▓▓▓░░░░░░░
CPU1 8% means CPU1 did its work in 8% of the frame and slept the other 92%
— you have ~92% headroom on the game-logic side.Reading the meter is the single most useful habit in MAD-65 development: it tells you, live, exactly how close to the edge each half of the machine is.
The two CPUs run out of budget for different reasons, and you fix each differently.
CPU1 (your game logic) burns cycles on computation:
mesh_xform is ~2–2.5k cycles per vertex,
so a 40-vertex scene is ~100k cycles (~40% of CPU1's budget) before any game
logic.mul16/div16 in tight loops, big sort/collision passes, anything O(n²) over
many objects.The GPU burns cycles on pixels — area touched ≈ time spent. From the measured cost table, roughly:
| What you draw | ~Cycles | ~Max/frame |
|---|---|---|
| Small 8×8 sprite | 1,400 | ~165 |
| 16×16 sprite | 4,300 | ~55 |
| 32×32 sprite | 15,000 | ~15 |
| Dotted line, 50 half-res steps | 3,580 | ~66 |
| Dotted line, 150 steps (long) | 9,980 | ~24 |
| Solid diagonal line, 50 steps | 6,600 | ~36 |
| Circle, R≈50 | 10,500 | ~22 |
| Full tile row (50 cells) | 18,000 | ~13 |
| Clear the background | 138,000 | (58% of one frame!) |
Two things jump out: solid diagonal lines cost ~2× their dotted equivalent (use dotted for wireframes), and clearing the background is enormous — over half a frame — so do it rarely, at level loads, not per frame.
And note what that number implies once you remember the background is
double-buffered: gpu_clearbg has to run twice, on two successive frames, so it
really costs ~58% of each of two frames. That is why it needs both its own frame
and the next one kept quiet (Chapter 18) — there is simply no room left over to share
either frame with a stream or a load.
Per-primitive numbers are useful for reasoning, but the question you actually have is "can I afford this screen?" Here is a measured answer, taken from a finished full-size game — a scrolling shooter, built entirely from sprites over a static background:
| Scene, built up | GPU |
|---|---|
| One large boss (4 tiles, two of them 64×70) | 24% |
| …plus the player (64×40 + 16×40) | +7% |
| …plus 20 small 16×16 sprites (bubbles) | +19% |
| Total | ~50%, with CPU1 at ~6% |
That works out to roughly 1% of the GPU per 16×16 sprite, and it points at the single most useful shape-of-the-machine fact for a 2-D game:
Many small sprites are cheap; a few big ones dominate. One boss costs as much as two dozen ordinary sprites.
In that scene there was room for about fifty more 16×16 sprites alongside the boss and the player. So the intuition to carry is not "be careful with sprite counts" — it's "be careful with sprite area." A screen busy with small moving things is comfortably affordable. A screen with three large sprites may not be.
Note also where CPU1 sits: 6%. In a sprite-driven 2-D game the game-logic CPU is nowhere near its limit, and the GPU is the only budget that matters. That asymmetry flips completely once you start doing 3-D (Part VII), where the vertex math on CPU1 becomes the bottleneck and the GPU is merely drawing lines. Know which kind of game you're making, and you know which meter to watch.
The method is simple and empirical:
The OVERRUN_FLAG (Chapter 3) is the programmatic version of the red bar: in a
debug build, light something on screen when it's set, so you catch the exact moment
a frame blew its budget even if the meter's smoothing hid it.
Take the Chapter 28 scene and run it in madsim, building it up in two steps so the meter shows you where the cost lives:
mesh_xform_raw) and the signed cross-products of culling (face_facing_raw)
are real arithmetic on a chip with no multiplier.That ~6:1 split is the single most important performance fact about 3-D on the MAD-65: wireframe scenes are overwhelmingly CPU1-bound, not GPU-bound. The GPU draws the result of all that math in a tenth of a frame; CPU1 spends most of the frame producing it. Notice too how linearly the math scales — doubling the transformed points roughly doubled the CPU1 load — which is the lever you pull to fit a scene: control the point count.
Two lessons fall straight out of the meter:
You now have the instinct and the instrument. This closing chapter is the patterns that keep a game in budget, and the checklist for calling it done.
The cheapest optimization is not drawing the expensive thing in the first place. Chapter 6 already pointed the way: pick a design whose worst-case frame fits at 60 Hz. Decide early "this is a wireframe-and-sprites game" or "this is big sprites over a static bitmap," and you'll rarely fight the budget later. Retrofitting performance onto a design the machine can't afford is the hard road.
When a CPU goes red, reach for these, roughly in order:
mul16/div16/sin chains with
lookup tables built once at init. Trading ROM/RAM for cycles is almost always
worth it on the hot path.cart_load or a full
background repaint doesn't have to happen in one frame — chunk it over several, or
do it behind a "loading" blinder, so no single frame overruns.Even a well-budgeted game can hit a pathological moment. Design so an overrun is a flicker, not a crash:
cart_frame, and the decorative things — stars,
particles, background sparkle — at the bottom. On a heavy frame the GPU sheds
the eye-candy and still draws the boss, automatically, with no special-case code.
(This is a softer failure than a CPU1 overrun, which drops the whole frame:
a GPU overrun just trims the least-important tail.)OVERRUN_FLAG in development and treat any sustained overrun as a bug to
fix, not tolerate.Before you call a cartridge done:
OVERRUN_FLAG stays clear in normal play._bg source
data isn't disturbed before its replay frame; no more than one background op
per frame, with an idle frame between them, and gpu_clearbg gets two quiet
frames to itself (Ch 16, Ch 18).cart_frame.JOY*_PRESS, continuous ones use JOY*."MAD65" + init/frame vectors are correct at the
start of bank 0.--cart (the system ROMs
auto-load).Tick those and you have a MAD-65 game that boots, plays, and holds 60 Hz. That's the whole job — everything in this guide was in service of those nine lines.
This is the part that makes the MAD-65 special. A 1-bit machine with no video chip turns out to be good at exactly the thing most 8-bit machines can't do: real-time 3-D wireframe graphics. The GPU draws dotted lines fast, and the kernel ships a complete 3-D math library in ROM — rotation, perspective, hidden-line removal — so your cartridge does almost no heavy math itself. By the end of this Part you'll have a spinning, hidden-line-correct solid on screen.
It builds directly on Part V: the angles are brad, the trig is Q0.7, and
the helpers lean on mul16/div16. If those words are fuzzy, re-skim Chapter 22
first.
Skip this Part if you're making a 2-D game. Nothing in Parts I–VI depends on it, and a sprite-and-tile game never calls a single routine described here. It is last in the book for exactly that reason. Come back when you want a spinning wireframe — everything here will still be waiting.
Vector graphics here means drawing shapes as points joined by lines — wireframes — rather than as filled pixels or sprites. You define an object as a list of 3-D corners (vertices) and which corners connect; each frame the library rotates and projects those corners to the screen and you draw the connecting lines. Spin the angles a little each frame and the shape tumbles.
The MAD-65 has no filled polygons and no Z-buffer. There is no hardware (or cycle budget) to flood-fill triangles and sort them by depth. So 3-D on this machine is wireframe only — outlines, not solid surfaces.
That has one important consequence for hiding the back of a shape. With no depth buffer, the only hidden-surface tool is backface culling: don't draw the faces that point away from the camera. This is exact for convex solids (a cube, a pyramid, an octahedron — anything with no dents): cull the back faces and what's left is precisely the visible silhouette and front edges. For concave shapes (an L-shape, a torus) backface culling is only approximate — parts that should be hidden behind nearer parts of the same object will still show through. v1 is a convex-wireframe machine; design your 3-D objects accordingly.
The library comes in two flavours, and choosing the right one avoids a classic glitch:
project, mesh_xform, face_facing, drawn with
gpu_dotline/gpu_dotlines) works in the half-res byte space and is the
fastest. It assumes every projected vertex lands on-screen. If a vertex
leaves the frame, its coordinate gets clamped to the edge — which bends any
line touching it, and a point off the left/top can even fling a line across the
whole screen. Fine when the whole object always stays comfortably inside the
frame.project_raw, mesh_xform_raw,
face_facing_raw, drawn with gpu_dotline_clip) carries the true signed-16
screen coordinate and clips the geometry to the screen instead of clamping
the numbers. A line that runs off an edge is cut at the edge with its slope
intact; a line fully off-screen draws nothing. Use this whenever an object can
approach the camera or drift past an edge.The rule of thumb: prototype with the byte pipeline; ship with the raw pipeline the moment your object can get big or move off-centre. The cube demo (Chapter 28) uses the raw pipeline throughout, because pulling the cube toward the camera pushes its corners past the screen edges.
Before the math makes sense you need the world model it lives in. The MAD-65's 3-D world is deliberately simple, and that simplicity is what makes it fast.
There is one camera, fixed at the origin, looking down the +Z axis, and it never moves or rotates. This is the single biggest performance decision in the whole library: a movable/rotating camera would roughly double the per-vertex cost. You give that up, and in exchange every object is cheap.
"But I want the camera to move!" You fake it by moving the world:
So a flythrough is "everything drifts toward me in Z," not "I move forward." It looks identical and costs nothing extra.
The camera sees a frustum — a widening pyramid of space:
So you place an object by choosing its Z (how far away / how big) and its X, Y (where on screen, in world units). Move it closer each frame and it grows; slide its X and it tracks across the view.
Every 3-D object in your game is just three things:
(posX, posY, posZ), 16-bit signed. Where the object
sits in the frustum.(ax, ay, az) in brad. Its orientation.You animate by changing position and angles only — the shape data is constant.
Want it to tumble? Add to the angles each frame. Fly toward the camera? Decrease
posZ. That's the whole animation model, and because rotating about all three
axes costs the same as one, multi-axis spin is free.
A model is two tables you author by hand (or with a converter) in your cart:
A vertex array — the corners, as signed bytes x, y, z, interleaved
(x0,y0,z0, x1,y1,z1, …). The constraint: |vertex| ≤ 127 (more precisely
x²+y²+z² ≤ 127²). Because rotation preserves length, that guarantees a rotated
corner still fits in a signed byte — so the whole rotation stage stays in fast
8-bit math. For a cube it means a half-side of ≤ 73 (its corner is then ≈ 126 from
the centre).
A face list — each face is an ordered loop of vertex indices, all wound
the same way (e.g. counter-clockwise when seen from outside the solid). The
winding is what lets face_facing tell front from back, so be consistent. Edges
are implied by the faces — you don't keep a separate edge list; you draw each
visible face's outline.
Here's a complete cube model in ca65:
HS = 60 ; half-side (≤ 73 to stay in the byte budget)
; 8 vertices, signed bytes, interleaved x,y,z
cube_verts:
.byte -HS,-HS,-HS ; 0
.byte HS,-HS,-HS ; 1
.byte HS, HS,-HS ; 2
.byte -HS, HS,-HS ; 3
.byte -HS,-HS, HS ; 4
.byte HS,-HS, HS ; 5
.byte HS, HS, HS ; 6
.byte -HS, HS, HS ; 7
NVERTS = 8
; 6 faces, each 4 vertex indices, wound so the OUTWARD side is positive
cube_faces:
.byte 0,3,2,1 ; back (−Z)
.byte 4,5,6,7 ; front (+Z)
.byte 0,1,5,4 ; bottom (−Y)
.byte 1,2,6,5 ; right (+X)
.byte 2,3,7,6 ; top (+Y)
.byte 3,0,4,7 ; left (−X)
NFACES = 6
That's the entire definition of a cube. Swap these two tables and you have a pyramid, an octahedron, a ship — the pipeline doesn't care what the model is.
Now the calls. The library gives you four operations, each in a fast byte
version and an off-screen-aware raw version, plus the two clipping builders
that draw the results. You'll rarely call the low-level ones directly — mesh_xform
does the per-vertex work in one call — but understanding each makes the pipeline
clear.
rot3d — rotate one point#Rotates a single point by three Euler angles, applied X then Y then Z:
API_ROT3D = $FF81
; OS_ARG+0,1,2 = x, y, z (signed bytes, IN — overwritten with x',y',z' OUT)
; OS_ARG+3,4,5 = ax, ay, az (brad, preserved)
You mostly let mesh_xform call this for you; reach for it directly only for a
one-off point (e.g. rotating a single direction vector).
project — one world point to the screen#Perspective-projects a world point to a screen pixel:
API_PROJECT = $FF84
; OS_ARG+0/1, +2/3, +4/5 = world x, y, z (16-bit signed, IN)
; OUT: OS_ARG+0 = sx, +1 = sy (screen bytes), +2 = flag (0 visible, $80 behind camera)
The camera constants (focal length D = 128, screen centre 100,75) are baked in —
a fixed camera needs no runtime parameters. The flag is important: $80 means
the point is behind the eye and its 2-D position is meaningless (don't draw edges
that touch it — see the limit at the end). project_raw ($FF8D) is identical but
returns signed-16 sx/sy (+0/1, +2/3) and the flag in +4, so off-screen
points keep their true position instead of clamping.
mesh_xform — the workhorse#This is the call you actually use. It transforms a whole vertex list in one go: for each vertex it rotates, adds the object's world position, and projects — computing the six sin/cos values once for the entire mesh.
API_MESH_XFORM = $FF87
; OS_ARG+0/1 = pointer to vertex array (N × 3 signed bytes)
; OS_ARG+2 = N (vertex count)
; OS_ARG+3,4,5 = ax, ay, az (brad)
; OS_ARG+6..11 = posX, posY, posZ (16-bit signed each)
; OS_ARG+12/13 = output pointer
; OUT: N records of [sx, sy, flag] (3 bytes each) at the output pointer
mesh_xform_raw ($FF90) takes the same arguments but writes 5-byte
records [sx_lo, sx_hi, sy_lo, sy_hi, flag] (signed-16) — so size your output
buffer at 5 × N, not 3 × N. That's the only difference at the call site.
face_facing — is this face visible?#Given three projected corners of a face, it returns whether the face points toward the camera (draw it) or away (cull it):
API_FACE_FACING = $FF8A
; OS_ARG+0..5 = x0,y0, x1,y1, x2,y2 (projected screen bytes)
; OUT: A = 1 (front: draw) or 0 (back/degenerate: cull)
It works from the sign of the screen-space cross product of the first three
corners — i.e. whether they appear clockwise or counter-clockwise on screen, which
flips depending on whether you're seeing the face's front or back. This is why
consistent winding matters: wind every face the same way and "front" always
comes out positive. face_facing_raw ($FF96) does the same test on signed-16
corners (OS_ARG+0..11, little-endian pairs) — and you must use the raw test
when the object can go off-screen, because clamped corners can flip the sign and
make faces flicker between front and back.
gpu_dotline_clip and gpu_dotpixels_clip#These take the signed-16 results and draw them, clipped to the screen:
gpu_dotline_clip ($FF93) — one dotted line, signed-16 endpoints
(OS_ARG+0..7 = X1,Y1,X2,Y2), Cohen–Sutherland-clipped to the field. Fully
off-screen → nothing drawn; crossing → trimmed at the edge, slope intact. This is
the edge-drawing call for wireframes.gpu_dotpixels_clip ($FF99) — a whole cloud of signed-16 points
(OS_ARG+0/1 → {N, x0,y0,…}), off-screen ones dropped. Perfect for a 3-D
starfield behind your object.The ROM owns the heavy math (rotate, project, the cross product). Your game owns the model, the winding, and the draw loop. The standard wireframe pipeline is three stages:
mesh_xform_raw → transform all vertices to signed-16 screen records (1 call)
face_facing_raw → per face: front or back? (cull the backs)
gpu_dotline_clip → per edge of a visible face: draw it (clipped)
The full per-vertex transform (rotate + translate + project) costs roughly
2–2.5 k cycles. In isolation that suggests dozens of vertices per frame — but
the real cost in a scene is higher than the raw multiply, because culling
(face_facing_raw does signed 16×16 cross products), edge assembly, and your game
logic all pile on top. In practice the budget fills faster than the per-vertex
figure implies: the Chapter 23 cube-plus-starfield scene already sits around
65% of CPU1. So treat 3-D as a handful of simple solids — which is the
wireframe aesthetic anyway — and trust the meter, not arithmetic, for how much
you can afford. If you need more, spin fewer/simpler objects, cap the point count,
or transform one mesh_xform result and draw it at several positions.
Let's assemble a real spinning, hidden-line-correct cube — the same pipeline the built-in demo uses — one layer at a time.
Once per frame, push the 8 cube vertices through mesh_xform_raw into a screen
buffer. We keep the cube centred in X/Y and choose its Z (distance), and we spin
it by bumping an angle each frame.
; --- our state, in game ZP / RAM ---
spin = $80 ; current Y angle (brad), incremented each frame
; screen output: 8 vertices × 5 bytes each = 40 bytes
scr: .res 40
draw_cube:
lda #<cube_verts
sta OS_ARG+0
lda #>cube_verts
sta OS_ARG+1
lda #NVERTS
sta OS_ARG+2 ; N = 8
stz OS_ARG+3 ; ax = 0
lda spin
sta OS_ARG+4 ; ay = spin (tumble about vertical)
stz OS_ARG+5 ; az = 0
; world position: centred, 200 units away
stz OS_ARG+6
stz OS_ARG+7 ; posX = 0
stz OS_ARG+8
stz OS_ARG+9 ; posY = 0
lda #<200
sta OS_ARG+10
lda #>200
sta OS_ARG+11 ; posZ = 200
lda #<scr
sta OS_ARG+12
lda #>scr
sta OS_ARG+13
jsr API_MESH_XFORM_RAW ; $FF90 — scr now holds 8 × [sx_lo,sx_hi,sy_lo,sy_hi,flag]
After this, scr has every corner's true signed-16 screen position. One call did
all the rotation and perspective.
Walk the 6 faces. For each, test its first three corners with face_facing_raw;
remember which faces are front-facing in a small flag array. (Reading a vertex's
signed-16 X/Y out of scr is index × 5 plus 0/2.)
facevis: .res NFACES ; 1 = front (visible), 0 = culled
cull_faces:
ldx #0 ; face index
: ; ... load the 3 corner X/Y pairs of face X from scr into OS_ARG+0..11 ...
jsr API_FACE_FACING_RAW ; A = 1 front / 0 back
sta facevis,x
inx
cpx #NFACES
bne :-
rts
Here's the one subtlety worth getting right. A cube's edges are each shared by two faces. If you just draw every front face's outline, the shared edges get drawn twice — wasteful, and (because forward vs. reverse line-drawing can differ by a pixel) occasionally doubled-looking. The clean fix is to draw edges, not face outlines: for each of the 12 edges, draw it once if either of the two faces it borders is front-facing.
So alongside the face list, give the model a small edge table — each entry is
[v0, v1, faceA, faceB] — and walk it:
; 12 edges: [vertex0, vertex1, faceA, faceB]
cube_edges:
.byte 0,1, 0,2
.byte 1,2, 0,3
; ... all 12 edges, each tagged with the two faces it borders ...
NEDGES = 12
draw_edges:
ldx #0
next_edge:
; visible if EITHER bordering face is front-facing
; ... lda facevis[faceA] ; ora facevis[faceB] ; beq skip ...
; load edge endpoints' signed-16 X/Y from scr into OS_ARG+0..7:
; OS_ARG+0/1 = scr[v0].sx, +2/3 = scr[v0].sy
; OS_ARG+4/5 = scr[v1].sx, +6/7 = scr[v1].sy
jsr API_GPU_DOTLINE_CLIP ; draw it, clipped to the screen
skip:
; advance X to the next 4-byte edge record ...
cpx #NEDGES*4
bne next_edge
rts
That's a fully hidden-line-correct convex wireframe: every visible edge drawn once, back edges hidden, and each line clipped so the cube can grow past the screen edges without bending.
The per-frame routine ties it together and adds motion:
cart_frame:
; spin a little each frame (wraps at 256 for free)
lda spin
clc
adc #2 ; ~2 brad/frame
sta spin
jsr draw_cube ; mesh_xform_raw
jsr cull_faces ; face_facing_raw
jsr draw_edges ; gpu_dotline_clip per visible edge
rts
From this skeleton the demo's flourishes are small additions:
posZ far away (say 2000) and step it down to 200 over the
first ~120 frames — the cube flies in from a distant speck. Because zoom is just
Z, it's one subtraction per frame.JOY1 FIRE, ramp posZ down further (and back up on
release). As the cube gets big its corners cross the screen edges — which is
exactly why we used gpu_dotline_clip.mesh_xform regardless).gpu_dotpixels_clip so the ones
that pass off-screen simply vanish. Instant "flying through space."Two honest caveats:
face_facing decides front/back from integer
screen positions, so a face seen almost exactly edge-on can occasionally test
"front" when it's a hair past 90°, adding a faint extra edge for a frame. It's
sub-pixel and mostly invisible on a tumbling shape; it's the price of integer
culling.$80) set, its 2-D position is meaningless and you should skip any edge
touching it (check the flag byte in each vertex's scr record). Properly clipping
such an edge would need true 3-D near-plane clipping before projection, which v1
doesn't do — in practice, just don't let objects pass through the camera, or drop
the offending edges for that frame.With those understood, you have everything for real 3-D on the MAD-65: define a
convex model, mesh_xform_raw it, cull with face_facing_raw, draw edges once
with gpu_dotline_clip, and animate by nudging position and angles. Keeping it all
inside the frame budget is Part VI's job — and a tumbling wireframe is exactly the
kind of scene worth putting on the meter.
The chapters are meant to be read. These are meant to be flipped to — the things you want on screen next to your editor.
Chapter 20 is the full API reference, with arguments and explanations. These cards are the compressed version: what you want when you know the call and need to remember one detail.
60 entries, $FF00–$FFB1, three bytes apart. The table is append-only: new
calls are added at the end and removed ones leave a no-op stub behind ($FF12,
$FF18), so a cartridge built against an older kernel keeps working on a newer one.
$FF00 os_run |
$FF3F gpu_vreg |
$FF7E cos |
$FF03 cart_bank |
$FF42 gpu_led |
$FF81 rot3d |
$FF06 cart_load |
$FF45 gpu_raw |
$FF84 project |
$FF09 gpu_begin |
$FF48 snd_init |
$FF87 mesh_xform |
$FF0C gpu_end |
$FF4B snd_tone |
$FF8A face_facing |
$FF0F gpu_pixel |
$FF4E snd_off |
$FF8D project_raw |
$FF12 (reserved) |
$FF51 snd_noise |
$FF90 mesh_xform_raw |
$FF15 gpu_line |
$FF54 snd_beep |
$FF93 dotline_clip |
$FF18 (reserved) |
$FF57 sfx_play |
$FF96 face_facing_raw |
$FF1B gpu_dotline |
$FF5A audio_tick¹ |
$FF99 gpu_dotpixels_clip |
$FF1E gpu_dotlines |
$FF5D ym_inst |
$FF9C gpu_load_cart |
$FF21 gpu_dotpixel |
$FF60 ym_note_on |
$FF9F gpu_load_cart_begin |
$FF24 gpu_dotpixels |
$FF63 ym_note_off |
$FFA2 gpu_load_cart_n |
$FF27 gpu_dotcircle |
$FF66 vgm_play |
$FFA5 gpu_load_cart_bg |
$FF2A gpu_sprite |
$FF69 vgm_stop |
$FFA8 gpu_load_cart_bg_n |
$FF2D gpu_text |
$FF6C vgm_tick¹ |
$FFAB sfx_play_ptr |
$FF30 gpu_text_bg |
$FF6F joy_read¹ |
$FFAE vgm_play_loop |
$FF33 gpu_tile |
$FF72 mul16 |
$FFB1 gpu_hdotline |
$FF36 gpu_tile_bg |
$FF75 div16 |
|
$FF39 gpu_load |
$FF78 rng |
|
$FF3C gpu_clearbg |
$FF7B sin |
¹ Called for you by the OS every frame — you don't normally invoke these.
Both ports, same layout. 1 = pressed.
| Bit | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|
| FIRE2 | FIRE | RIGHT | LEFT | DOWN | UP |
JOY_UP = %00000001
JOY_DOWN = %00000010
JOY_LEFT = %00000100
JOY_RIGHT = %00001000
JOY_FIRE = %00010000
JOY_FIRE2 = %00100000 ; DE-9 pin 9, Amiga-style second fire
Read JOY1/JOY2 for held state and JOY1_PRESS/JOY2_PRESS for
just-pressed this frame (Chapter 10). Never read or write the hardware ports.
$00–$7F is the OS's. $80–$FF is yours — 128 bytes, the fastest memory you
have. Of the OS's half, these are published and safe to read from game code:
| Addr | Name | Meaning |
|---|---|---|
$00 |
CPU_STATUS |
mirror of the mailbox status byte |
$01 |
FRAME_COUNT |
free-running frame counter, wraps every ~4.2 s |
$02 |
VSYNC_FLAG |
set by the frame interrupt |
$03 |
OVERRUN_FLAG |
sticky — a frame was late. Clear it yourself |
$08 |
PP_OVERFLOW |
a command was dropped: the scene exceeded 2 KB |
$09 |
CART_SHADOW |
current cartridge bank (the register is write-only) |
$0A–$0F |
JOY1, JOY1_PREV, JOY1_PRESS, JOY2, JOY2_PREV, JOY2_PRESS |
input |
$20–$2F |
OS_ARG |
the API argument block — you write this |
$12–$1F is OS scratch, clobbered by any API call — never keep a value there
across a jsr into the kernel. Outside zero page, one published location matters:
LOAD_REM ($0240) — pages still to stream (Chapter 16).
gpu_vreg, $FF3F)#Pass the sub-op in A:
| A | Effect |
|---|---|
| 0 | background→image copy on (normal) |
| 1 | background→image copy off |
| 2 | unlit pixels are black (normal) |
| 3 | unlit pixels are dark grey |
| 4 | blinder off — screen visible |
| 5 | blinder on — screen blanked |
Sub-ops 2/3 set the colour of unlit pixels, not the visibility of the background layer — dark grey lifts the black level slightly, which can make a sparse wireframe scene read better on some monitors. Sub-ops 0/1 control the free hardware copy of the background under your image; leaving it off means the background never appears.
The blinder (4/5) is how you hide a multi-frame load or a screen transition: turn it
on, do the work, turn it off. Any other value in A is ignored silently.
| Thing | Count | Size | Format |
|---|---|---|---|
| Font glyph | 96 ($20–$7F) |
8×8 | 8 bytes, MSB = left. ROM, not editable |
| Tile | 255 ($01–$FF) |
8×8 | 8 bytes, MSB = left. $00 is a reserved sentinel |
| Sprite bitmap | ≤ 256 ids | W ∈ {8,16,32,64} × 1–255 | W bytes/row, 1 = white, 0 = transparent |
| Sprite overlay | optional plane | same W×H | interleaved after each bitmap row, 1 = black |
| Background bitmap | — | 400×300 | 50 bytes/row, MSB = leftmost |
Remember the slot arithmetic from Chapter 17: a sprite costs one definition per tile, and canvas width decides how many.
The built-in font covers printable ASCII $20–$7F — space, digits,
punctuation, uppercase and lowercase. Strings are NUL-terminated.
The last character of a string is never drawn. Always append a trailing space before the terminator:
.byte "SCORE", " ", 0. This catches everyone once.
A text row is 50 columns of 8 px. Text drawn to the background persists; text drawn to the image must be redrawn every frame (Chapter 18).
Start here. On this machine the symptom almost never points at its cause: a timing rule you broke in the background layer shows up as flickering art, a bad music export shows up as silence with no error, and a stale build shows up as code that ignores your edits. Below is the lookup table — find what you're seeing, not what you think is wrong.
| Symptom | Likely cause |
|---|---|
| Screen is black, nothing at all | The blinder is still on — sub-op 5 was issued and never cleared (App. A.4). |
| Your drawing never appears | You drew in cart_init instead of cart_frame. The image layer is wiped every frame — persistent means redrawn, not drawn once (Ch 3). |
| Background art never appears | The background→image copy is off (gpu_vreg sub-op 1), or you drew to the image layer expecting persistence. |
| Sprites draw as garbage or nothing | Their pixel data or definition pages never arrived. Check the loader actually finished — LOAD_REM reached 0 — before anything drew (Ch 16). |
| The last letter of every string is missing | Working as designed. Append a trailing space before the NUL (App. A.6). |
| Symptom | Likely cause |
|---|---|
| Content blinks every other frame | The classic. Two background ops too close together — one reached only one of the two buffers. One bg op per frame, one idle frame between (Ch 15). |
| A cleared screen still shows the old one, or clears "halfway" | gpu_clearbg didn't get its two quiet frames. Clear → settle → stream (Ch 18). |
| The whole picture blinks black occasionally | A frame overran. Check OVERRUN_FLAG and the meter (Ch 23). |
| Decorative things vanish on busy frames | A GPU overrun trimmed the tail of your command list. That's the designed failure mode — but check your draw order is priority-first (Ch 24). |
| Part of your scene silently disappears | PP_OVERFLOW — the scene exceeded the 2 KB mailbox (Ch 6). |
| Symptom | Likely cause |
|---|---|
| A sprite jumps position when its animation changes frame | Frames authored on different canvas sizes. Pad every frame of a group to one canvas (Ch 17). |
| A sprite shoots from the wrong place, or its hitbox is offset | Art isn't centred in its canvas — the canvas is the anchor (Ch 17). |
| A sprite renders as a solid white rectangle | Opaque black background in the source image. Every non-transparent pixel is lit (Ch 17). |
| Random sprites are corrupted after boot | A definition page was dropped during loading. gpu_load_cart returns carry set when it doesn't fit — track progress with a cursor, not a flag (Ch 16). |
| "I can't fit another animation frame" | You've run out of definition slots, not pixel memory. Pad canvases to power-of-two byte widths (Ch 17). |
| An animation stutters on one particular frame | A fixed-period counter whose period isn't divisible by the frame count. Check every place that implements animation — the bug hides in the copies. |
| Symptom | Likely cause |
|---|---|
| Music plays nothing at all — total silence | Wrong FM chip in the export. A Genesis/YM2612 export is rejected on its first command; you need SN76489 + YM2413 (Ch 21). |
| Music plays nothing, and the export was right | The header wasn't stripped. The player does no header parsing — it executes whatever bytes you point it at (Ch 21). |
| Music plays ~20% too fast | The song is 50 Hz (PAL). Author at 60 Hz (Ch 21). |
| Music stops partway through, always at the same spot | An unsupported command in the stream. The player faults to silence (Ch 21). |
| Sound effects cut the music off | You're playing effects on the music chip. Effects have their own PSG (Ch 21). |
| Symptom | Likely cause |
|---|---|
| Out of RAM at ~26 KB, with the cartridge nearly empty | Expected — RAM is the ceiling, not ROM. Work the reclaim levers (Ch 12). |
| Link map says something different from reality | The map wasn't regenerated. Relink with -m (Ch 12). |
| Code vanishes / crashes after a screen change | An overlay was swapped while its address was on the stack, or a per-frame routine wasn't pinned to a resident segment (Ch 12). |
| Crash immediately after switching banks | You re-banked the window your code was executing from (Ch 11). |
| Reading cart data gives garbage sometimes | The bank was left switched by an earlier borrow. Save/select/restore, and hand the window back before calling the OS (Ch 12). |
| Frame budget red on CPU1 | Computation — usually 3-D math or per-object loops (Ch 23). |
| Frame budget red on the GPU | Pixels. Move static art to the background; it is the single biggest win (Ch 24). |
| Symptom | Likely cause |
|---|---|
| Source edits have no effect at all | Stale build: the file isn't in your Makefile's prerequisite list, so nothing rebuilt. Suspect this before debugging the code — touch the file or clean and rebuild (App. F). |
| Assets changed but the game shows the old ones | Same cause, on the asset side — the generated files weren't regenerated. |
| An asset is subtly wrong with no error anywhere | Your converter isn't validating. Make it fail the build (Ch 17). |
Two templates, for two moments in a project.
Starting out? Use the single-bank Model A cartridge in Chapter 9 — one file,
one cart.cfg, builds and runs. Don't start here.
Building the real thing? This is the skeleton for a Model B project: code in RAM, assets in banks, generated files, and a build that regenerates them.
mygame/
├── Makefile
├── cart.cfg # memory regions = bank map (order matters)
├── src/
│ ├── header.s # signature + vectors — always bank 0
│ ├── bootstrap.s # the only code that runs from the window
│ ├── main.s # includes everything else; one translation unit
│ └── … # game modules, .include'd by main.s
├── assets/
│ ├── art/*.png # source art
│ ├── music/*.vgm # source songs
│ └── *.bin, *.inc # GENERATED — not in version control
└── tools/ # your converters
Generated files and build outputs do not belong in version control. The cartridge
image, the asset blobs and the stripped music streams are all reproducible from
sources; committing them adds hundreds of kilobytes of churn per commit and tells you
nothing. Commit the sources, the tools and the generated .inc files if your
assembler needs them present to build.
; ---- src/header.s — the first bytes of bank 0, always -------------------
.segment "HEADER"
.byte "MAD65"
.addr cart_init
.addr cart_frame
; ---- src/bootstrap.s — runs from the window, moves everything to RAM ----
; This is the ONLY code that executes out of $8000-$9FFF. Its whole job is
; to pull the RAM-resident code in and jump to it; after that the window is
; free for asset banking.
API_CART_LOAD = $FF06
OS_ARG = $20
.segment "BOOT"
cart_init:
; --- copy MAINCODE (stored in banks 0-3) to its run address ---
; cart_load crosses bank boundaries by itself, so one call moves
; the whole thing however far it grows.
lda #0
sta OS_ARG+0 ; source bank
lda #<__MAINCODE_LOAD__
sta OS_ARG+1
lda #>__MAINCODE_LOAD__
sta OS_ARG+2 ; source: where the linker STORED it
; (not $8000 — HEADER and BOOT are first)
lda #<__MAINCODE_RUN__
sta OS_ARG+3
lda #>__MAINCODE_RUN__
sta OS_ARG+4 ; destination: the RAM run address
lda #<__MAINCODE_SIZE__
sta OS_ARG+5
lda #>__MAINCODE_SIZE__
sta OS_ARG+6 ; length, from the linker
jsr API_CART_LOAD
jmp game_init ; linked at its RAM address
cart_frame:
jmp (frame_vec) ; the current screen's handler
Keeping cart_frame as nothing but an indirect jump is worth doing from day one —
it's what lets each screen own its own per-frame routine (Chapter 13).
The order of MEMORY declarations is the bank order, so this file is your bank map. Keep a comment on each one; you will refer to it constantly.
# cart.cfg — Model B: code runs from RAM, assets live in banks
MEMORY {
# --- RAM: where things RUN ---
ZP: start=$0080, size=$0080, type=rw, define=yes; # yours: $80-$FF
BSS: start=$0400, size=$0C00, type=rw, define=yes; # variables
MAIN: start=$1000, size=$6800, type=rw, define=yes; # code+tables, to $77FF
HIRAM: start=$A000, size=$1F00, type=rw, define=yes; # overlay, to $BEFF
# --- cartridge banks: where things are STORED ---
BANK0: start=$8000, size=$2000, type=ro, fill=yes, file=%O; # boot + code
BANK1: start=$8000, size=$2000, type=ro, fill=yes, file=%O;
BANK2: start=$8000, size=$2000, type=ro, fill=yes, file=%O;
BANK3: start=$8000, size=$2000, type=ro, fill=yes, file=%O;
SPRITES: start=$8000, size=$4000, type=ro, fill=yes, file=%O; # 2 banks
MUSIC: start=$8000, size=$4000, type=ro, fill=yes, file=%O; # 2 banks
OVL: start=$8000, size=$2000, type=ro, fill=yes, file=%O; # overlay code
RESERVE: start=$8000, size=$2000, type=ro, fill=yes, file=%O; # keep some
}
SEGMENTS {
ZEROPAGE: load=ZP, type=zp;
HEADER: load=BANK0, type=ro, start=$8000; # signature first, always
BOOT: load=BANK0, type=ro; # runs from the window
MAINCODE: load=BANK0, run=MAIN, type=ro, define=yes; # load != run
RODATA: load=BANK0, run=MAIN, type=ro, define=yes;
UICODE: load=OVL, run=HIRAM, type=ro, define=yes; # overlay
BSSSEG: load=BSS, type=bss;
SPRBLOB: load=SPRITES, type=ro;
MUSBLOB: load=MUSIC, type=ro;
}
Two things to note. MAINCODE and RODATA have load ≠ run: stored in bank 0
(spilling into 1–3 as they grow), linked to execute from $1000. And UICODE shares
HIRAM with any other overlay segment — that's the overlay trick from Chapter 12, and
it is just two segments naming the same run region.
Reserve a couple of empty banks. Bank numbers are baked into your code as constants, so inserting a bank later renumbers everything after it. Spare banks at the end cost nothing (they're fill bytes) and save a painful edit.
CL65 = cl65 -t none --cpu 65C02 --asm-include-dir .
SRC = src/header.s src/bootstrap.s src/main.s
# Everything main.s .include's. NOT on the command line — but make still has
# to know about them, or edits to them won't trigger a rebuild.
INCLUDES = $(wildcard src/*.s) $(wildcard src/*.inc)
GENERATED = assets/sprites.bin assets/sprites.inc assets/music_stream.bin
cart.bin: $(SRC) $(INCLUDES) $(GENERATED) cart.cfg
$(CL65) -C cart.cfg -o $@ $(SRC)
# a fresh link map — the checked-in one is always stale
map:
$(CL65) -C cart.cfg -m fresh.map -o /dev/null $(SRC)
assets/sprites.bin assets/sprites.inc: $(wildcard assets/art/*.png) tools/spritegen.py
python tools/spritegen.py
assets/%_stream.bin: assets/music/%.vgm tools/vgmstrip.py
python tools/vgmstrip.py $< $@
run: cart.bin
madsim --cart cart.bin
clean:
rm -f cart.bin $(GENERATED) fresh.map
The
INCLUDESline is not optional, and forgetting it is the single most confusing bug in this whole document. Whenmain.sis one big translation unit, only three files are on the compiler command line — somakesees only those three as prerequisites. Add a module, forget to list it, and editing that module rebuilds nothing: the old binary keeps running and your changes appear to do absolutely nothing. You will debug the code. The code is fine. The wildcard above avoids this by construction; if you list files explicitly instead, add every new one.
make map) — and start watching it.Once a project is running, most work is adding another one of something that already exists. These are the checklists. They assume the project shape from Appendix F and are deliberately terse — each step links to the chapter that explains it.
make. Blobs, definitions and tables
regenerate together.If it looks wrong: jumps between frames → inconsistent canvas sizes. Solid white rectangle → opaque black background in the source. Offset from its hitbox → art not centred in the canvas. (Appendix E.)
cart.cfg, starting at a bank boundary, plus the
matching base-bank constant in your source. Keep the two in lockstep.vgm_play (loop the whole stream) or vgm_play_loop (one-shot
intro, then loop the body). Starting a song just re-points a cursor, so it's safe
to call from anywhere and needs no explicit stop first.$10 slot (Chapter 14).<screen>_enter (music, arm loads, reset counters) and <screen>_update.sfx_play_ptr, passing a pointer to your data.gpu_load_cart_begin + gpu_load_cart_bg_n, straight from the
cartridge — no RAM staging (Chapter 16). The OS handles the two-frame replay.LOAD_REM
is 0.