MAD-65
Contents

MAD-65 Software Developer's Guide#

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).

About this guide#

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.


Part I — Orientation#

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.

1. What is the MAD-65? (the developer's-eye view)#

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.

What a "game" physically is#

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:

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.

What the console does for you, and what is yours#

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:

The one golden rule#

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.

How to read this guide#

2. The two-brain model#

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 — the game brain (this is where you live)#

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 (CPU2) — the artist#

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.

They never share variables — they pass notes#

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:

  1. Each frame, CPU1 writes a fresh list of drawing requests into the mailbox.
  2. At the frame boundary, the mailbox is handed over to the GPU.
  3. The GPU reads the list and draws it.

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).

A built-in delay — on purpose#

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.

Why split the work in two?#

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.

The picture to carry forward#

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 II — How the machine thinks#

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.

3. The 60 Hz heartbeat#

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.

You describe the next frame; you do not draw the screen#

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.

Everything is measured in frames#

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.

Render 60 times a second — or not at all#

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.

4. The data-flow tour (cartridge → screen)#

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 buildersgpu_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.

The two-frame delay, concretely#

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.

Two things you get for free#

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.

Why this flow is good news#

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.

5. One frame, step by step#

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.

6. Hardware features & limits you must respect#

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 screen#

Two coordinate systems (an easy thing to get wrong)#

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.

The GPU does no bounds checking — clipping is your job#

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.

How much you can draw per frame: the 2 KB mailbox#

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.

CPU1 — your side#

The GPU — the other side#

What kind of games is the MAD-65 good for?#

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.

Things that will bite you — the short list#


Part III — Programming the CPU (your game's home)#

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.

7. Using the simulator (madsim)#

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.

Building and running it#

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

Controls#

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.

The dev loop#

Your day-to-day rhythm is short:

  1. Assemble your cart: cl65 -t none -C cart.cfg -o game.bin game.s.
  2. Run it: madsim --cart game.bin.
  3. Observe — play it, watch the screen and the meter.
  4. Iterate — edit, rebuild, rerun. (Pause with 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.

When to use the other simulator#

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.

8. The OS kernel & its ABI — what it is and why you must use it#

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.

The jump table: one fixed address per service#

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

Why call through the table instead of the hardware?#

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_text service, 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 in OS_ARG+0, you reach it with JSR." 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.

The calling convention: how you pass arguments#

Every service follows one of two simple patterns:

A couple of standing rules to internalise now (they prevent real bugs):

OS_ARG  = $20                   ; API argument block, $20–$2F (little-endian)

A complete minimal 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.

9. The frame loop & the frame handler#

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.

The loop is the OS's; the frame is yours#

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.

The two routines you provide#

A cartridge hands the OS two entry points through its header (Chapter 1):

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.

A complete do-nothing cartridge#

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:

The linker config#

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.

10. Input / Output#

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.

Reading the joysticks#

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.

Held vs. just-pressed — the key distinction#

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:

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 labelsbeq :+ 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.

Writing registers#

You will rarely write hardware registers directly, because the kernel wraps the ones that matter. The two you might reach for:

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).

Worked example: move a sprite, fire on button#

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).

11. Loading data from the cartridge into RAM#

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.

Why copy into RAM at all?#

Two reasons:

  1. You can only see 8 KB at once. Your assets live scattered across many banks. To use a level map in bank 3 and a tune in bank 7, you can't have both visible at the same address — you'd have to keep flipping the window. Copying each piece into RAM once lets you address all of them normally afterward.
  2. You must not switch the bank of code you're running. Your cartridge code executes from the window. If that code switches the window to a different bank, it just yanked itself out from under its own feet — the next instruction fetch reads whatever is now there. So the safe model is: code stays in bank 0; everything else gets copied to RAM before use.

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.

Example: load a level map into RAM at boot#

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 pattern: load once, run from RAM#

The healthy shape for a game is:

Where your code runs: bank 0, or RAM-resident#

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.

Pitfalls to avoid#

12. The RAM budget & your memory map#

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.

What you actually get#

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:

Code overlays: the same address, twice#

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 tail jmp, and control returns to the OS via rts from 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 / .popseg around 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.

Getting space back#

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–$9FFF can 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.

13. Building your game loop#

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.

The anatomy of a frame#

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.

Counting frames is how you keep time#

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.

State machines: one game, several screens#

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.

Putting it together#

The skeleton of essentially every MAD-65 game is now in your hands:

  1. cart_initcart_load your first screen's assets into RAM, upload graphics to the GPU, draw the initial background, set game_state.
  2. cart_frame — dispatch on game_state; each state reads input, updates, and draws; everything is timed by counting frames.
  3. RAM holds your live state; the cartridge banks hold your assets; you copy between them at boundaries, never in the hot path.

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.

14. Anatomy of a full-size game#

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.

One dispatcher, one screen at a time#

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:

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.

One place where drawing happens#

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.

One animation player#

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 n with a counter that wraps at 256, then any n that 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.

Static pixels belong to the background#

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:

  1. Cache the values each line displays.
  2. Every frame, compare; if nothing changed, emit nothing.
  3. When a value does change, redraw that whole line.

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 IV — Putting things on screen (GPU, high level)#

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.

15. How the GPU draws for you#

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.

The drawing toolbox at a glance#

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.

Two layers: the image, and the free background#

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_bg or gpu_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 _bg builders that exist (text/tile/load/clearbg) all write whole aligned bytes. Build a background picture with gpu_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 _bg draw next frame. But that replay needs room, and that costs you two more rules on top of the obvious one:

  1. Leave the source data unchanged until the frame after you issued the draw (a string, a tile list, a gpu_load page) — the OS re-reads it on the replay.
  2. At most one background op per frame. Never two gpu_text_bg lines in one frame, never a gpu_clearbg plus anything else.
  3. Leave one idle frame between background ops. A second _bg op 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.

Notice what's missing#

Two things you might expect, and won't find:

The cost model: pixels are the currency#

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.

16. Graphic data structures (all hardwired)#

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.

The one bitmap format to rule them all#

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.)

Font / TEXT — built in, not yours#

Tiles — your 256 custom 8×8 blocks#

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.

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 — moving objects with transparency#

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:

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:

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.

Getting your data into the GPU: 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_load is "install this artwork in the GPU." You do it once at startup (or at level load). gpu_tile / gpu_sprite are "draw the artwork I already installed" — cheap, every frame.

Straight from the cartridge: 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 LOAD page costs 258 bytes of the 2 KB mailbox, so if this frame's scene already filled the list, gpu_load_cart emits 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:

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).

Loading a background image: the _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)

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).

Quick-reference: the hardwired formats#

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

17. The asset pipeline: getting art into the machine#

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.

The budget that actually runs out: definition slots#

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.

The canvas is the anchor#

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.

Two traps in how pixels become bits#

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.

Not everything has to be resident#

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.

Stable handles, unstable ids#

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.

Make the build fail#

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.

18. Text, tiles & backgrounds in practice#

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.

Printing text#

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_text does 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 the 0 won't show. Tiles don't have this quirk — they draw every cell.

Smooth scrolling with the 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.)

Laying out a tilemap#

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.

The background layer: draw once, free forever#

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.)

Painting several background lines: one per frame#

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:

  1. Clear on an otherwise near-empty frame (under a blinder, so the player never sees the wipe).
  2. Settle — spend the next frame or two doing nothing GPU-heavy at all.
  3. Stream the new background in, then drop the blinder.

Never fire gpu_clearbg and 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."

The classic layout: static background + moving sprites#

Put it together and you have the shape of most MAD-65 games:

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.

19. Drawing primitives#

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.

The line family — which to use#

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_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:

Circles and pixels#

When to use primitives vs. tiles/sprites#

A quick rule of thumb:

Worked example: a radar HUD#

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.


Part V — The ABI cookbook (reference with examples)#

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.

20. Kernel API — the one-page reference#

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):

System / lifecycle#

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

GPU frame bracket (the OS calls these for you)#

Addr Name In Out See
$FF09 gpu_begin resets list, replays BG Ch 9
$FF0C gpu_end caps list, sets CPU_READY Ch 9

GPU drawing builders#

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

Audio — effects (SN76489 #2)#

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

Audio — FM music (YM2413) & VGM player#

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

Input#

Addr Name In See
$FF6F joy_read — (IRQ calls it; read the ZP shadows) Ch 10

Math & utility#

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

Vector / 3-D (covered in Part VII)#

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

Cartridge → GPU loading#

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

A few recipes#

Common combinations worth keeping in muscle memory:

21. Audio#

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.

The three chips, split by role#

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.

Sound effects — the easy path#

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 is sfx_play_ptr ($FFAB): write the effect as the same little step program (type, then frames,note|mode,vol triples, $FF terminator) 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 driving snd_tone frame-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/2 instead of $FF to 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 (Y is ignored for noise effects, which always use voice 7). You can of course still drive snd_tone directly if you want per-frame control of the chip.

Direct tones and noise#

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.

FM music by hand (YM2413)#

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 VGM player — drop in a song#

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:

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.

  1. 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.
  2. The header and GD3 tag must be gone. Feed the player a whole .vgm file and it will read the header bytes as commands, hit garbage, and stop. The loop point would be wrong too — vgm_play loops back to the address you gave it, so it would loop the header.
  3. 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.
  4. 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.

Worked example: music + a pickup sound#

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.

22. Math & utility helpers#

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.

Why fixed-point? (no floats here)#

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 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

Worked examples#

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.


Part VI — Tooling & performance#

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.

23. The frame budget & measuring it#

Everything in this guide has pointed at one number. Here it is, made measurable.

The budget, and the meter#

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%  ▓▓▓▓▓▓░░░░░░░

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.

What costs cycles — on each CPU#

The two CPUs run out of budget for different reasons, and you fix each differently.

CPU1 (your game logic) burns cycles on computation:

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.

What a real scene costs#

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.

Measuring a scene and finding the bottleneck#

The method is simple and empirical:

  1. Run the scene and read both meter lines. Whichever is higher is your bottleneck — and it tells you which CPU to relieve.
  2. If the GPU is hot, you're drawing too much area: fewer/smaller sprites, dotted instead of solid lines, move static stuff into the free background, draw fewer vectors.
  3. If CPU1 is hot, you're computing too much: fewer vertices, simpler models, precompute tables, spread big work across frames.
  4. Toggle features (pause, comment out a draw call, rebuild) and watch the meter move — that's how you attribute cost to a specific thing.
  5. Watch the worst-case moment, not the average. The frame with the most enemies and the longest lines is the one that blinks. Design so that frame fits.

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.

Worked example: profiling the cube + starfield#

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:

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:

24. Optimization patterns & shipping#

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.

Design to the budget, from the start#

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.

The optimization toolbox#

When a CPU goes red, reach for these, roughly in order:

Graceful degradation#

Even a well-budgeted game can hit a pathological moment. Design so an overrun is a flicker, not a crash:

Pre-ship checklist#

Before you call a cartridge done:

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.


Part VII — 3-D & vector graphics (the big chapter)#

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.

25. Vector graphics on a 1-bit screen#

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.

Why wireframe, and what that costs you#

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.

Two pipelines: on-screen (fast) vs. off-screen-aware (clipped)#

The library comes in two flavours, and choosing the right one avoids a classic glitch:

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.

26. Defining a scene#

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.

The camera never moves#

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 visible volume (frustum) and depth#

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.

An object = shape + position + spin#

Every 3-D object in your game is just three things:

  1. A shape — a fixed list of vertices and faces (the model, below). This never changes; it's constant data in your cart.
  2. A world position(posX, posY, posZ), 16-bit signed. Where the object sits in the frustum.
  3. Three spin angles(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.

The model format: vertices + faces#

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.

27. The vector-math toolbox#

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.

The drawing builders: gpu_dotline_clip and gpu_dotpixels_clip#

These take the signed-16 results and draw them, clipped to the screen:

How it fits together, and who owns what#

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)

Budget#

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.

28. A complete 3-D example, built up#

Let's assemble a real spinning, hidden-line-correct cube — the same pipeline the built-in demo uses — one layer at a time.

Step 1 — transform the vertices#

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.

Step 2 — cull the back faces#

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

Step 3 — draw each visible edge exactly once#

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.

Step 4 — animate, and add the flourishes#

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:

Known limits — and how to live with them#

Two honest caveats:

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.


Appendices#

The chapters are meant to be read. These are meant to be flipped to — the things you want on screen next to your editor.


Appendix A — Quick-reference cards#

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.

A.1 The jump table at a glance#

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.

A.2 Joystick bits#

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.

A.3 Zero page#

$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).

A.4 Video register sub-ops (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.

A.5 Data formats#

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.

A.6 Character set#

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).


Appendix E — Troubleshooting: the symptom index#

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.

Nothing appears#

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).

Sprites and animation misbehave#

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.

Audio problems#

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).

Space and speed#

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).

The build itself#

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).

Appendix F — Cartridge template#

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.

F.1 Layout#

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.

F.2 The header and bootstrap#

; ---- 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).

F.3 The linker config#

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 loadrun: 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.

F.4 The Makefile#

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 INCLUDES line is not optional, and forgetting it is the single most confusing bug in this whole document. When main.s is one big translation unit, only three files are on the compiler command line — so make sees 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.

F.5 First steps from here#

  1. Build and run the Model A cartridge from Chapter 9 — confirm the toolchain works.
  2. Move to this skeleton; confirm it still prints something from RAM-resident code.
  3. Add one sprite end-to-end: art → converter → bank → load → draw. Getting the whole pipeline working once, with one asset, is worth more than any amount of planning.
  4. Add your frame dispatcher and a second screen (Chapter 13).
  5. Check the link map (make map) — and start watching it.

Appendix G — Recipes: adding one more of something#

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.

G.1 Add a sprite (or an animation)#

  1. Draw it. Every frame of the group on an identical canvas, art centred in it, canvas padded to a width whose byte count is a power of two — 8, 16, 32 or 64 px, or 128 for a two-tile piece (Chapter 17).
  2. Drop the file in your art folder and run make. Blobs, definitions and tables regenerate together.
  3. Check the two budgets your converter prints: definition slots ≤ 256, and shared + largest window set within the pool. Slots run out first.
  4. Refer to it in code by group name and frame index — never by tile id, which moves on every rebuild.
  5. If it animates, add its sequence to your animation data (Chapter 14). Appending a frame is safe; reordering or removing frames means revisiting the sequence.

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.)

G.2 Add a song#

  1. Export as SN76489 + YM2413 — the "Master System (+FM)" target. Not Genesis; that's a different FM chip and it will play nothing (Chapter 21).
  2. Author at 60 Hz.
  3. Drop the file in your music folder. Your build step strips the header and GD3 tag, validates every command, and computes the loop anchor — and fails the build if anything is unsupported.
  4. Give it a cartridge region in cart.cfg, starting at a bank boundary, plus the matching base-bank constant in your source. Keep the two in lockstep.
  5. Start it with 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.

G.3 Add a screen#

  1. Pick a state id in a free $10 slot (Chapter 14).
  2. Write <screen>_enter (music, arm loads, reset counters) and <screen>_update.
  3. Register both in your state dispatch.
  4. If it paints background text or art: blinder on → clear → settle a frame or two → stream → blinder off, and never more than one background op per frame (Chapter 15). A multi-line paint is a per-frame sequencer, not a loop (Chapter 18).
  5. If it never coexists with your biggest resident code, put it in an overlay segment — that's usually 2–3 KB of lower RAM saved (Chapter 12).
  6. Reset its state on entry, not on exit. Screens get re-entered.

G.4 Add a sound effect#

  1. Write it as a step program in your own data — effects are game-owned.
  2. Play it with sfx_play_ptr, passing a pointer to your data.
  3. Effects run on the dedicated effects PSG, so they never steal a voice from the music. Route by category (shots on one voice, impacts on another) so a rapid weapon can't cut off a hit (Chapter 21).

G.5 Add a full-screen background image#

  1. Convert to 400×300, 1 bpp, 50 bytes per row, MSB = leftmost pixel.
  2. Give it a cartridge region; a full screen is ~15 KB, so two banks.
  3. Load it with 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.
  4. Do it under the blinder, in its own loading stage, and drain until LOAD_REM is 0.
  5. Remember to clear it when leaving the screen, or it shows through the next one.

G.6 Add a level#

  1. Author the level as data, not code — a byte stream your interpreter walks.
  2. Store it in its own cartridge bank and read it straight from the window; a level script is read once per frame at most and has no business occupying RAM (Chapter 12).
  3. Borrow the window with save / select / read / restore, and hand it back before calling any OS routine.
  4. Add whatever per-level assets it needs (G.1, G.2, G.5) and wire them to its index.
  5. Reset all per-level state in your prepare stage — not in the level data.