Build your own Super Nintendo game.
The other four courses in this collection explain how the machine works. This one hands you the keyboard. We set up the modern homebrew toolkit — ca65, Mesen2, superfamiconv — then build a tiny but complete paddle-and-ball game called Bounce, register write by register write: boot, graphics, input, sprites, the game loop, sound. We finish by putting it on real hardware and releasing it. Fourteen modules, fourteen live labs — including the finished game, playable right now in your browser. No Nintendo code, no ripped assets; everything here is yours to learn from and reuse.
The toolkit
What homebrew is (and the legal line, plainly), the assemblers and C kits people actually use, the working subset of 65816, and the edit–build–run–inspect loop with Mesen2's debugger.
Building “Bounce”
The game itself, one system at a time: boot to a stable frame, the ROM header, art into bytes, DMA uploads, the joypad, sprites, the frame-locked game loop, and a sound driver.
Shipping it
The classic homebrew bugs and their symptoms, the emulator-vs-hardware accuracy gauntlet, flash carts, the release checklist, and where your second game begins.
The toolkit
Before a single pixel moves we get honest about what SNES homebrew is in the 2020s: who does it, what's legal, and which tools are actually maintained. Then we install a working vocabulary — the dozen 65816 instructions you'll type every day, and the edit–build–run–inspect loop that turns a text file into a running cartridge image in about two seconds. If you've read the CPU course, some of this will feel like meeting old friends at a workbench; if you haven't, each module recaps just enough to stand on its own.
What homebrew is
Here is a fact that surprises people: new Super Nintendo
games ship every year. Not remakes on modern consoles —
new .sfc ROM files, written now, that run on a 1990
console. There's an annual SNESdev Compo run by
the community at snes.nesdev.org,
itch.io pages full of free releases, and a handful of boutique
publishers who will press a genuinely new game onto a real
cartridge with a box and a manual. The people doing this are called
homebrewers,
and the barrier to entry has never been lower: every tool is free,
every document is public, and the best SNES debugger ever made
(Module 04) runs on your laptop.
Now the question everyone asks first: is this legal? Writing your own program for the Super Nintendo is exactly as legal as writing one for your PC. The hardware is yours; the instruction set is public; no license from Nintendo is needed to assemble bytes that happen to run on their old console. What you must not do is just as plain: don't use Nintendo's assets (Mario's sprite, Zelda's tunes, the official logo art), don't use their trademarks (don't put “Nintendo” or the SNES trade dress on your box as if it were licensed), and don't distribute other people's ROMs alongside your own. Your code, your art, your sound, your name on it — that combination is yours to sell or give away. Every byte of the game in this course follows that rule.
The console is a beautifully built old stove. Nobody can stop you cooking your own recipes on it, and a whole club of chefs still does, swapping techniques for that specific oven. What you can't do is sell your dish with someone else's restaurant logo on the plate, or photocopy their cookbook and hand it out. The stove is fair game; the brand and the recipes are not.
The game we'll build: Bounce
Across Part II you'll build a paddle-and-ball game called
Bounce: one screen, a court drawn from background
tiles, a paddle you move with the D-pad, a ball that speeds up as
you rally, a score counter, and a blip when the ball connects.
It is deliberately tiny — and deliberately complete. It
boots from the reset vector, survives real hardware, and ends up a
legitimate .sfc file you could enter in the compo.
Every system a big game needs, Bounce needs too, just once each:
| Bounce needs… | Which is really… | Built in |
|---|---|---|
| To turn on without garbage on screen | The canonical init sequence, forced blank, clearing VRAM/CGRAM/OAM/WRAM | Module 05 |
| To be a valid cartridge | The ROM header at $FFC0, reset & NMI vectors, a LoROM linker map | Module 06 |
| A court, a paddle, a ball, digits | 4bpp planar tiles, 15-bit BGR palettes, a tilemap | Module 07 |
| That art inside the PPU | DMA to VRAM and CGRAM under forced blank, then screen on | Module 08 |
| To feel the D-pad | Auto-joypad read, $4218/9, the pressed/held/released idiom | Module 09 |
| Things that move | A shadow OAM buffer, metasprites, the 544-byte vblank DMA | Module 10 |
| Smooth 60 fps motion | The NMI-locked game loop and 8.8 fixed-point physics | Module 11 |
| A blip and a loop | A real sound driver uploaded to the SPC700 over $2140–3 | Module 12 |
.sfc image. The console (or an emulator) reads the reset vector and jumps into your code. Module 06 builds the header; Module 14 supplies the arrow.
Before any of that, play the ending. The lab below is Bounce —
simulated in your browser at the SNES's real resolution
(256×224), with colours quantised the way the console's
15-bit palette would (Module 07 explains the $-prefixed
hex numbers if you need a refresher — short version: base 16, one
digit per four bits, the CPU course
teaches it from zero). Arrow keys or the on-screen buttons move the
paddle. By Module 14 you'll know what every piece of it costs in
registers, bytes and scanlines.
- SNES homebrew is a living scene: an annual compo, free releases, even new physical cartridges.
- Writing and distributing your own ROM is legal; Nintendo's assets, trademarks and other people's ROMs are the lines you don't cross.
- Bounce — paddle, ball, score, blip — exercises every system a big game needs, exactly once each.
- A finished game is one
.sfcfile: code, graphics, audio, and a header that tells the console where to start.
The toolchain
A Super Nintendo can't run your text files. Something has to turn
human-readable source into the exact bytes the 65816 fetches, and
that something is your toolchain. The mainstream
choice — and the one this course writes its examples in — is
ca65, the assembler from the
cc65 suite,
paired with its linker ld65. It's free, maintained,
and its 65816 support (the .p816 directive and the
width hints you'll meet in Module 03) is solid. Two other
assemblers earn their keep: WLA-DX, a multi-console
assembler with a long SNES pedigree, and asar,
which grew up in the ROM-hacking world as a patching assembler and
is beloved for its terse syntax. All three produce identical bytes
in the end; pick one and stay put.
Prefer C? Two real options. PVSnesLib wraps a
65816 C compiler (a port of tcc, 816-tcc) in a library
of console routines — sprites, backgrounds, sound — so you write
consoleInit() instead of register writes.
libSFX sits in between: a ca65-based framework
that scaffolds the boring parts (init, interrupts, the build) but
leaves you writing assembly. The honest trade-off, and it matters
on a 3.58 MHz CPU: C gets your first playable build running
days sooner, but compiled 65816 code is several times
slower and fatter than hand-written assembly, because the chip has
too few registers for a C compiler to be comfortable. Bounce is
small enough that we go straight to assembly and understand every
byte.
| Route | Tool | You get | You pay |
|---|---|---|---|
| asm | ca65 + ld65 (cc65 suite) | Total control, best docs, linker configs, this course's syntax | You write every instruction yourself |
| asm | WLA-DX | Multi-console assembler, long SNES track record | Its own config dialect to learn |
| asm | asar | Terse syntax, patch-friendly, huge ROM-hack community | Leans patch-first; full-ROM builds need care |
| C | PVSnesLib (816-tcc) | Playable prototype in a weekend, batteries included | Generated code is markedly slower and larger |
| C+asm | libSFX | ca65 framework: init, NMI, build scaffolding done for you | You still write 65816 for the game itself |
From source to .sfc
Whatever you pick, the pipeline is the same four mechanical steps,
and it pays to know what each one consumes and produces — because
each fails with a different kind of error message.
The assembler turns each source file into an
object file;
the linker follows a linker config — a little map
saying “code goes at $8000, the header at
$FFC0” (Module 06) — to weld the objects into
one binary; then a pad-and-checksum step rounds the file up to a
clean power-of-two size and fixes the header checksum. The result
is a .sfc file: the exact bytes of a cartridge ROM.
You'll run that file in an emulator hundreds of times a day, so choose deliberately: develop in Mesen2, which has the best debugger on the platform (Module 04 is a tour), and sanity-check in bsnes, whose accuracy is the community reference — if it works in both, it very probably works on the real console (Module 13 covers the exceptions). Click through the pipeline below; each stage shows its inputs, outputs, and what it sounds like when it breaks.
- ca65/ld65, WLA-DX and asar are the real assembler choices; PVSnesLib and libSFX are the real C-flavoured ones.
- Assembly buys control and speed; C buys development pace at a real runtime cost on a 3.58 MHz CPU.
- The pipeline is always: assemble → link (with a config that maps the ROM) → pad + checksum →
.sfc. - Develop in Mesen2 for its debugger; verify in bsnes for accuracy. Passing both is the bar.
Just enough 65816
The CPU course teaches the 65816 properly —
from the fetch–decode–execute loop up through DMA. This module is
different: it's the phrasebook. Day to day, homebrew
assembly is maybe a dozen instructions arranged in half a dozen
idioms, and once your fingers know them the rest of the
instruction set is reference-manual material. Everything below is
real ca65 syntax: a file starts with
.p816 to enable 65816 opcodes, $ means
hexadecimal, # means “this literal value”
rather than “the memory at this address” — the single
most important character in the language.
Idiom one, the famous one: width control. The
65816's accumulator and index registers can each be 8-bit or
16-bit, switched at runtime by the M and X flags
(the CPU course's Module 08 tells the
full story). You flip them with rep (reset flag → 16-bit)
and sep (set flag → 8-bit): #$20 targets
the accumulator's M flag, #$10 the index registers'
X flag, #$30 both at once. The assembler can't see
flags at runtime, so you promise it the current width with
.a8/.a16 and .i8/.i16
hints — get the promise wrong and the assembler emits the wrong
number of operand bytes, and the CPU misreads everything after.
This is the classic 65816 footgun, which is why the idiom is
always written as a tight pair:
; the width idiom — flag change and assembler hint travel together sep #$20 ; M=1 → A is 8-bit… .a8 ; …and ca65 is told so lda #$8F ; one operand byte: $8F rep #$20 ; M=0 → A is 16-bit… .a16 ; …hint updated in the same breath lda #$0180 ; two operand bytes: $80 $01 (little-endian)
#$20 = accumulator width, #$10 = index width, #$30 = both.
Idiom two: your variables live in the direct page.
The 65816 can address one page of bank 0 with one-byte operands —
faster to fetch, quicker to run
(addressing modes, if you want the
mechanics). Homebrewers treat it as the global-variable area:
declare a name for an address once, then use it like a variable.
lda ball_x reads the variable;
lda #$28 loads the number $28. Idiom three:
loops count down, because dex sets
the flags for free and bne (branch if not equal to
zero) or bpl (branch if plus) close the loop with no
separate compare. And idiom four: jsr calls a
subroutine, rts returns, with the return address kept
on the stack.
; the daily idioms in one place ball_x = $20 ; a direct-page address, named — your "variable" lda #40 ; A ← the number 40 (immediate) sta ball_x ; the byte at $20 ← A (store to memory) ldx #$0F ; loop counter: 15 down to 0… clear: stz $0200,x ; store zero at $0200+X dex ; X = X − 1, flags set for free bpl clear ; …branch while X is still ≥ 0 jsr move_ball ; call — return address pushed for us ; … move_ball: rts ; return to just after the jsr
You don't learn a whole language before ordering coffee abroad —
you learn twelve phrases and use them constantly, and fluency
grows from there. The 65816 has 256 opcodes; a homebrewer's
daily set is lda sta stz ldx ldy inc dec dex bne bpl jsr
rts plus the rep/sep pair. Learn those as
reflexes here, and read the
full grammar when an instruction surprises
you.
The lab below is that phrasebook running. It's a real fragment of init-and-scorekeeping code: clear a 16-byte buffer with a count-down loop in 8-bit mode, then switch the accumulator to 16-bit and sum three score words. Step it and watch the registers, the flags (including M flipping the accumulator's width mid-run) and the memory bytes change — the same visible-machine format as the CPU course's toy CPU, but with the real instructions you'll type.
#means a literal value; no#means the memory at that address. Most beginner bugs are this line.rep/sep #$20/#$10/#$30switch register widths;.a8/.a16/.i8/.i16keep the assembler's promise in sync — always change both together.- Name direct-page addresses and treat them as your variables; they're the fastest memory the CPU has.
- Count loops down with
dex/bpl; structure code withjsr/rts. That's most of a real game's texture.
The dev loop
Professional SNES teams in 1992 burned EPROMs and walked them
across the office. You have it absurdly better: your loop is
edit → build → run → inspect, and a Bounce-sized
project builds in well under a second. Put the whole pipeline from
Module 02 behind one command — a three-line script or Makefile
that runs ca65, ld65 and the checksum
fix, then launches the ROM — and bind it to a key in your editor.
The tighter this loop, the more experiments per hour, and
experiments per hour is the real speed stat of a homebrewer.
The “inspect” step is where Mesen2 earns its place. It isn't just an emulator; it's an X-ray machine for the running console. The tools you'll live in:
| Tool | What it shows | You'll use it when… |
|---|---|---|
| Debugger | Source-level stepping, breakpoints on execute/read/write of any address | “Why did ball_x become $FF?” — break on write to it |
| Memory viewer | Live WRAM/VRAM/CGRAM/OAM hex, editable while running | Watching your variables change in real time |
| Tilemap / tile / sprite viewers | The PPU's actual state, decoded into pictures | “Did my tiles even arrive in VRAM?” (Module 08) |
| Palette viewer | All 256 CGRAM colours as swatches | Wrong colours — bad data, or bad palette index? |
| Event viewer | Every register write/read plotted on the frame, at the beam position where it happened | Timing bugs — the SNES's whole personality (lab below) |
| Trace logger | Every executed instruction with registers, to a file | The bug you can't catch live — search the log afterwards |
The one to understand deeply is the event viewer, because it makes the console's time visible. A video frame isn't an instant — the beam sweeps 262 scanlines top to bottom, and only during vblank (lines 225–261) is the PPU's memory safe to touch. The event viewer draws one full frame as a grid — 341 dots wide, 262 lines tall — and plots a dot at the exact beam position of every register write. Healthy homebrew looks like this: a clean burst of DMA and OAM dots packed into the vblank band, and nothing in the picture area. A write glowing mid-screen is a bug you can literally see (Module 11 explains the frame contract; Module 13 shows the wreckage).
And the humblest trick of all, straight from print-debugging: keep
a debug byte in WRAM. Write a different value at
each phase of your boot code — $01 after init,
$02 after upload, $03 in the main loop —
and when the screen stays black, one glance at that byte in the
memory viewer tells you how far you got. It costs one
sta and has un-stuck a thousand black screens.
- One command from source to running ROM; bind it to a key. Iteration speed is the real speed stat.
- Mesen2's debugger, memory viewer and PPU viewers answer “what is the console's actual state?” directly.
- The event viewer plots writes at their beam position — timing bugs become visible dots in the picture area.
- A WRAM debug byte — one
staper boot phase — is the fastest black-screen diagnostic there is.
Building “Bounce”
Eight modules, one game. We start at the reset vector with a machine in an unknown state and end with a paddle game running at a locked 60 frames per second with sound. The order is the order you really build in: boot cleanly, be a valid cartridge, make art, upload it, read the pad, move sprites, close the loop, add the blip. Every register address in this part is real, every code fragment is honest ca65, and every module's lab lets you operate the mechanism yourself before you trust it in your game.
Boot: from reset to a stable frame
Power on. The CPU reads the reset vector and jumps to your code — and at that moment nothing else is promised. The console wakes in the 6502-compatible emulation mode; the stack pointer is wherever it landed; and VRAM, CGRAM, OAM and WRAM hold whatever the silicon happened to power up as. Here is the trap that catches every beginner: emulators often clear that memory for you. Hardware does not. Real chips wake full of semi-random garbage that differs run to run and console to console — so a game that skips initialisation can look perfect in an emulator and boot into a screenful of static on the real machine (Module 13 returns to this with a checklist). The cure is a fixed ritual, the same in almost every SNES game ever shipped:
reset: sei ; 1 · no interrupts while the world is half-built clc xce ; 2 · leave 6502 emulation mode → native 65816 rep #$10 ; 3 · 16-bit index registers… sep #$20 ; …8-bit accumulator (the classic setup) .a8 .i16 ldx #$1FFF txs ; 4 · stack at $1FFF, top of low WRAM lda #$8F sta $2100 ; 5 · INIDISP: forced blank ON, brightness 15 jsr clear_vram ; 6 · 64 KB of $00 → VRAM (DMA, Module 08) jsr clear_cgram ; 512 bytes → CGRAM: all colours black jsr clear_oam ; 544 bytes → OAM: no stray sprites jsr clear_wram ; 128 KB of $00 → WRAM: variables known ; 7 · now upload real graphics & set the video mode (Module 08)… ; 8 · …and only then: screen on lda #$0F sta $2100 ; INIDISP: forced blank OFF, full brightness
$2100 is INIDISP: bit 7 is forced blank, low 4 bits are brightness — $8F = blanked, $0F = shining.
Two steps deserve a closer look. clc + xce
swaps the carry flag into the hidden E bit, dropping the CPU into
native 65816 mode — until then, half the instruction set isn't
yours. And forced blank ($2100 = $8F)
is the master safety switch: bit 7 tells the PPU to stop displaying
and release its memories, so you can write VRAM and CGRAM
freely at any beam position. The whole of the init and upload
happens behind this curtain; $2100 = $0F at the end is
the curtain going up. Skip a clearing step and the failure is
wonderfully specific: uncleared VRAM is a screen of garbage tiles,
uncleared CGRAM is right shapes in fever-dream colours, uncleared
OAM is 128 junk sprites crowding the picture, and an unset stack
crashes on the first jsr — before anything appears at
all.
Pilots don't inspect the plane in whatever order feels right — the checklist exists because each skipped line has a known, specific consequence, and some lines only make sense after others. Boot code is the same: forced blank before touching video memory, stack before the first call, clear before use. Run the list, every time, in order. Every SNES programmer's “it works on my machine but not on hardware” story ends at a skipped line.
The lab makes each omission visible. All the steps are ticked and the TV shows Bounce's title screen; untick any of them and the output degrades exactly the way it would on real hardware — including the one failure the TV can't show you, which is the point of it.
- At reset, nothing is initialised — and hardware, unlike most emulators, wakes full of garbage.
sei,clc/xce, width setup, stack: four instructions before anything else is safe.- Forced blank (
$2100 = $8F) frees the PPU's memories for writing;$0Fraises the curtain when — and only when — everything is ready. - Clear VRAM, CGRAM, OAM and WRAM every boot; each skipped clear has its own signature failure.
The ROM header & memory map
A ROM file isn't valid just because the code is right — the console
and every emulator first look at a small block of metadata baked
into the cartridge: the internal header. For
Bounce we choose LoROM, the simplest of the SNES's
cartridge wirings: your ROM appears in the upper half
($8000–$FFFF) of each 64 KB
bank,
32 KB per bank, which is why homebrew code classically starts
at $8000. (Why banks exist at all is
the CPU course's Module 09; how LoROM,
HiROM and the exotic maps differ is the
cartridge course's Module 04 — here we
just pick the simple one and move.)
The header lives at $FFC0–$FFDF of the first bank:
21 bytes of ASCII title, then one byte each for
the map mode ($20 = LoROM at normal
“slow” speed), the chipset
($00 = ROM only — no save RAM, no
co-processor), the ROM
size (as a power of two: the byte is
log2 of the size in KB, so 256 KB → $08),
the RAM size ($00 — Bounce saves
nothing), the region, a developer ID, a version —
and then the famous pair: the checksum and its
complement, two 16-bit values that must sum to
$FFFF. The checksum is simply every byte of the ROM
added up, truncated to 16 bits; emulators warn when it's wrong,
and some flash-cart menus nag. Your build script fixes it as the
final step (Module 14), so during development you mostly ignore
the nag.
Directly after the header sit the interrupt vectors
($FFE0–$FFFF): little slots holding the address the
CPU should jump to for each event. Only three matter to Bounce:
the native-mode NMI vector at $FFEA
(your once-per-frame vblank handler — the heartbeat of Module 11),
the native IRQ vector at $FFEE
(point it at a bare rti until you need it), and the
emulation-mode RESET vector at $FFFC
— the power-on entry, which is in the emulation set
because that's the mode the console wakes in
(interrupts, from zero).
Telling the linker where everything goes
In ca65 you don't place any of this yourself — you name
segments in source (.segment "CODE",
"HEADER", "VECTORS") and the
ld65 config pins them to addresses. This is the
file that makes “code at $8000, header at $FFC0” true:
# bounce.cfg — a minimal LoROM map for ld65 MEMORY { ROM: start = $8000, size = $8000, fill = yes, file = %O; } SEGMENTS { CODE: load = ROM, start = $8000; RODATA: load = ROM; # graphics data, tables HEADER: load = ROM, start = $FFC0; # the 32 bytes below VECTORS: load = ROM, start = $FFE0; # the jump table }
Build the block yourself below: type a title, pick the fields, and
watch the 64 bytes at $FFC0–$FFFF assemble live —
with the checksum/complement pair recomputed on every keystroke.
- LoROM: ROM in the top 32 KB of each bank, code from
$8000— the simplest map, and plenty for Bounce. - The header at
$FFC0: 21-char title, map mode$20, chipset, sizes, region — and a checksum/complement pair summing to$FFFF. - Vectors at
$FFE0–$FFFFpoint the CPU at your handlers: NMI at$FFEA, power-on RESET at$FFFC(in the emulation set). - The ld65 config is where addresses become real: segments in source, placement in one small file.
Art into bytes
The PPU doesn't know what a PNG is. Everything it draws is built from 8×8 tiles whose pixels are palette indices, not colours — the graphics course builds this idea from zero if it's new to you. For Bounce we need exactly four drawings: a wall tile for the court, a 16×16 ball, a 32×8 paddle (Module 10 splits it into sprites), and a set of score digits. Draw them in any editor you like at 1:1 pixels, few colours. The interesting part — the part this module exists for — is what those pixels become inside the ROM.
Backgrounds and sprites in Bounce's video mode use
4bpp — four bits per pixel, so each pixel picks
one of 16 palette entries. But the four bits of a pixel are
not stored together. The SNES stores tiles as
bitplanes:
byte one holds bit 0 of all eight pixels in a row, byte two holds
bit 1, and planes 2 and 3 follow later in the tile. Here's one
row, worked by hand. Say the eight pixels of the ball's top edge
use palette indices 0 1 2 3 3 2 1 0:
Write each index in binary (two bits are enough here):
00 01 10 11 11 10 01 00. Now read vertically.
Bit 0 of each pixel, left to right, is
0 1 0 1 1 0 1 0 → the byte %01011010 =
$5A. Bit 1 of each pixel is
0 0 1 1 1 1 0 0 → %00111100 =
$3C. Bits 2 and 3 are all zero (we only used
indices 0–3), so planes 2 and 3 contribute $00 $00.
In the tile's 32 bytes, planes 0 and 1 interleave row by row in
the first 16 bytes, planes 2 and 3 in the second 16. So this row
contributes $5A $3C at offsets 0–1 and
$00 $00 at offsets 16–17. Nobody converts art by
hand twice — but having done it once, the tile viewer's
hex will never look like noise again.
Colours travel separately. A palette entry is a 15-bit
BGR word — five bits each of blue, green, red,
packed %0BBBBBGGGGGRRRRR
(palettes and CGRAM). A
worked one: a sky blue with red 8, green 16, blue 24 becomes
(24 ≪ 10) | (16 ≪ 5) | 8 =
$6208. Five bits per channel is why SNES art has that
particular chunky-velvet colour feel — and why your art tool's
#FF7F00 will land on the nearest of 32 steps per channel. Bounce
keeps to two palettes — one for the court, one
for the sprites — which is both period-authentic discipline and
fewer bytes to upload.
The court itself is a tilemap: a 32×32 grid of
16-bit entries, each naming a tile number, a palette, and flip
bits (tilemaps). One wall tile,
flipped and repeated, draws the whole arena — the oldest trick in
console graphics. In practice the conversion is one tool call:
superfamiconv eats a PNG and emits tiles, palette
and map (superfamiconv -i court.png -t court.chr -p
court.pal -m court.map), and YY-CHR lets
you open the resulting .chr and nudge pixels directly.
Both free, both standard kit. The lab below is the whole story in
miniature: draw, and watch the exact planar bytes and BGR15 words
appear — then download them as a ca65 source file.
.byte stream ca65 will assemble: four 8×8 tiles (32 bytes each, planes 0/1 then 2/3), then the palette as .word BGR15 values.- All SNES art is 8×8 tiles of palette indices; Bounce needs four drawings and two palettes, total.
- 4bpp planar: bit n of every pixel in a row shares a byte; planes 0/1 interleave first, 2/3 second. You decoded a row by hand once — that's enough.
- Colours are 15-bit
%0BBBBBGGGGGRRRRRwords: five bits per channel, 32 steps each. - superfamiconv converts PNGs to tiles/palette/map; YY-CHR edits the binary directly. The tilemap repeats one wall tile into a whole court.
Uploading to the PPU
Your art is now bytes in the ROM — but the PPU can't draw from ROM. It draws only from its own memories: VRAM for tiles and maps, CGRAM for palettes (the graphics course maps this plumbing in detail). Copying kilobytes with a CPU loop would take ages, so we use the 5A22's DMA engine — one byte every eight master cycles, an order of magnitude faster than loads and stores (the CPU course's Module 10 is the deep dive). All of it happens behind the forced blank we set up in Module 05: with the screen off, VRAM and CGRAM are yours at any moment. Here is the real sequence, register by register:
; -- palette → CGRAM ------------------------------------------ stz $2121 ; CGADD: start at colour 0 lda #$00 sta $4300 ; DMAP0: mode 0 — one byte to one port, A→B lda #$22 sta $4301 ; BBAD0: B-bus target $2122 (CGDATA) ldx #.loword(pal) stx $4302 ; A1T0: source address (low 16)… lda #^pal sta $4304 ; …and source bank ldx #32 stx $4305 ; DAS0: 32 bytes = 16 colours lda #$01 sta $420B ; MDMAEN: fire channel 0 — CPU pauses, bytes fly ; -- tiles → VRAM --------------------------------------------- lda #$80 sta $2115 ; VMAIN: increment after high-byte write ldx #$0000 stx $2116 ; VMADD: VRAM word address 0 lda #$01 sta $4300 ; DMAP0: mode 1 — two bytes to two ports… lda #$18 sta $4301 ; …$2118/$2119 (VMDATA low/high) ; source = tiles, size = 2048 … then $420B again ; -- the look of the screen ----------------------------------- lda #$01 sta $2105 ; BGMODE: mode 1 (BG1/BG2 4bpp, BG3 2bpp) lda #$04 sta $2107 ; BG1SC: tilemap at word $0400, 32×32 lda #$01 sta $210B ; BG12NBA: BG1 tiles at word $1000 stz $210D stz $210D ; BG1HOFS: scroll x = 0 (write twice: 2×8 bits) lda #$11 sta $212C ; TM: main screen = BG1 + sprites lda #$0F sta $2100 ; INIDISP: curtain up
Read the DMA block as a sentence and it stops being arcane:
“channel 0, transfer pattern so-and-so, to B-bus port
such-and-such, from this address, this many bytes — go.”
The only genuinely SNES-flavoured details are that VRAM is
word-addressed through $2116 with its port pair at
$2118/9 (hence DMA mode 1, alternating two ports),
while CGRAM is a single byte-port at $2122 (mode 0)
— and that none of it is legal while the PPU is drawing. That last
rule is the one beginners break: fire a VRAM DMA mid-frame with
the screen on and the write lands wherever the PPU's own address
counter happens to be, shredding tiles you uploaded correctly a
frame earlier. The lab lets you commit exactly that crime,
safely.
- The PPU draws only from VRAM/CGRAM; DMA channel 0 ($4300–$4306, fired by $420B) is how kilobytes get there fast.
- VRAM: set
$2115/$2116, stream to$2118/9in DMA mode 1. CGRAM: set$2121, stream to$2122in mode 0. - Mode 1, tilemap base, tile base, scroll, TM — five small writes define the whole look of the screen.
- Upload under forced blank (or in vblank — Module 11); writes while the PPU draws land in the wrong place.
Reading the player
A SNES pad is, electrically, a
shift register:
twelve buttons clocked out one bit at a time. You could
strobe and clock it manually — 6502 veterans do it from muscle
memory — but the 5A22 has a better offer: auto-joypad
read. Set bit 0 of $4200
(NMITIMEN)
and every vblank the hardware clocks all pads itself and parks the
results in registers. Since Bounce also wants the vblank interrupt
(Module 11), our boot code ends with one write that arms both:
lda #$81 / sta $4200 — bit 7 NMI on, bit 0 auto-read
on.
Pad 1 lands in $4218 (low byte) and $4219
(high byte). Read as one 16-bit word, the layout is worth
memorising — buttons from bit 15 down:
B Y Select Start ↑ ↓ ← → A X L R,
then four low bits that identify the controller type
(0000 for a standard pad). One timing courtesy: the
hardware takes the first few scanlines of vblank to finish
clocking, and it signals “busy” on bit 0 of
$4212
(HVBJOY).
Read $4218 while that bit is still set and you get a
half-shifted mess — a genuinely classic bug (it stars in Module
13). Wait for bit 0 to clear, then read.
$4219 holds B, Y, Select, Start and the D-pad; $4218 holds A, X, L, R and the controller signature. Bounce cares about exactly two bits: ← (9) and → (8).Held is not pressed: the XOR idiom
Raw bits tell you a button is down. Games usually need
three finer questions — is it down (held: paddle
keeps moving), did it just go down this frame
(pressed: start the game, fire once), did it just
come up (released)? The idiom that answers all
three costs one XOR and two ANDs. Keep last frame's word; then
changed = now EOR last flags every bit that differs,
and masking picks the direction of the change:
; once per frame, after $4212 bit 0 clears (16-bit A) lda joy_now ; last frame's snapshot… sta joy_last ; …becomes "last" lda $4218 ; fresh 16-bit read: B Y Sel Sta ↑↓←→ A X L R ···· sta joy_now eor joy_last ; changed = now EOR last sta joy_chg and joy_now ; changed AND now → went down this frame sta joy_pressed lda joy_chg and joy_last ; changed AND last → came up this frame sta joy_released
The lab wires your keyboard (or the on-screen buttons) to a
simulated $4218/9. Watch bits light as you hold keys,
and watch pressed flash for exactly one frame while
held stays lit — then feel the difference drive a paddle.
$4200 = $81arms the two per-frame services at once: the NMI (bit 7) and the auto-joypad read (bit 0).- Pad 1 is the 16-bit word at
$4218/9: B Y Select Start ↑ ↓ ← → A X L R, plus a 4-bit controller signature. - Never read while
$4212bit 0 is set — the shift is still in flight. now EOR last, masked withnoworlast, yields pressed and released; the raw word is held.
Sprites: the ball & paddle
Backgrounds don't move freely; sprites do. The PPU keeps a private 544-byte table called OAM describing up to 128 of them — for each: an X and Y position, a tile number, and an attribute byte (palette, priority, flips) — plus a 32-byte high table we'll get to, because it gets everyone. The graphics course covers how the PPU evaluates all this per scanline; here we care about the homebrewer's question: how do you update it safely, every frame?
The answer is the pattern the whole platform runs on: you
never write OAM directly during gameplay. Instead
you keep a shadow OAM — a plain 544-byte buffer in
WRAM. Game logic writes the shadow whenever it likes, because WRAM
is always writable; then, once per frame inside vblank, one DMA
copies the whole shadow into the real OAM
($2102 ← 0, then 544 bytes to the
$2104 data port). Two sprites can't tear apart
mid-frame, you never race the beam, and the cost is fixed: 544
bytes of your vblank budget (Module 11), every frame, forever.
Predictable beats clever.
Bounce's cast is three hardware sprites. The ball is one 8×8. The
paddle is 32 pixels wide — wider than our small sprite size — so
it's a metasprite:
two 16×16 sprites placed side by side, moved as one by code that
writes paddle_x and paddle_x+16 into two
shadow entries. (The 8-vs-16 size choice per sprite comes from
OBSEL, $2101, plus that sprite's size bit.) This is
the pattern that scales: Mario is a metasprite; so is every boss
you've ever fought.
| Byte | Sprite 0 (ball) example | Meaning |
|---|---|---|
| +0 · X | $7C | X position, low 8 bits of 9 — the ninth lives in the high table |
| +1 · Y | $68 | Y position; park a sprite at Y = $F0 to hide it off-screen |
| +2 · tile | $04 | Which tile in the sprite character table draws this |
| +3 · attr | %00110000 | vhoopppN: v/h flip, priority, palette 0–7, tile-page bit |
The high-table gotcha
Notice X is only 8 bits in the main table — but the screen is 256
wide and sprites must be able to slide partially off the left
edge, which needs X values like −8. So X is really a 9-bit
value, and bit 8 lives in the cramped high table:
32 bytes at the end of OAM, two bits per sprite — the
ninth X bit and the size-select bit, four sprites packed per byte.
Forget the ninth bit and a sprite meant to be at X = −4
($1FC) appears at X = 252 — teleporting
from the left edge to the right. Every SNES programmer has watched
that happen once. The lab below lets you drive the paddle off the
left edge and watch the bit do its job.
- OAM: 128 sprites × 4 bytes, plus the 32-byte high table — 544 bytes total, DMA'd from a WRAM shadow every vblank.
- Game logic writes the shadow at any time; only the vblank DMA touches the real OAM. Predictable beats clever.
- Wide objects are metasprites — Bounce's paddle is two 16×16 sprites moved as one.
- X is 9 bits; the ninth lives in the high table. Forget it and sprites teleport across the screen at the left edge.
The game loop
Everything so far was setup. A game is a loop, and on the
SNES the loop is welded to the television. Each frame the beam
draws lines 0–224, then rests through lines 225–261 — the
vblank
— and at the instant vblank begins the console fires the
NMI
through your $FFEA vector
(interrupts, if the word is new).
That gives the frame a contract, and it's the most important
paragraph in this course: game logic runs during the
picture; PPU writes happen only in vblank. Logic — input,
physics, collisions — touches only WRAM and the shadow buffers,
so it's safe while the beam draws. The NMI handler then spends the
blank window doing the actual uploads: DMA the shadow OAM, poke
the two scroll registers, update the score tiles.
nmi_handler: ; runs at the top of every vblank jsr oam_dma ; 544 bytes, shadow → OAM jsr score_tiles ; a few words into the tilemap wait_pad: lda $4212 and #$01 bne wait_pad ; auto-joypad still shifting? wait jsr read_pad ; the Module 09 idiom lda #$01 sta frame_ready ; tell main: a new frame has begun rti main_loop: ; runs during the visible picture jsr move_paddle ; WRAM + shadow OAM only — never $21xx! jsr move_ball jsr collide frame_wait: wai ; sleep until an interrupt… lda frame_ready beq frame_wait ; …and confirm it was the NMI stz frame_ready bra main_loop
Vblank is short, so it has a byte budget: at DMA speed (roughly a byte per eight master cycles — the CPU course puts numbers on it) the ~37 blank lines move about 6 KB, minus whatever your handler spends on housekeeping. Bounce's frame bill is trivial — 544 bytes of OAM and a handful of tilemap words — but the discipline matters because both budgets fail the same way: if logic takes longer than a frame, or uploads outgrow vblank, you drop a frame — the NMI arrives while you're still working, the new picture isn't ready, and motion visibly hitches. Big games spend careers negotiating these two budgets; the lab below lets you bankrupt both with two sliders.
Subpixels: the 8.8 fixed-point primer
One more tool and Bounce's physics is done. Screen positions are
whole pixels, but a ball moving “1 pixel per frame” or
“2” only knows two speeds — arcade feel needs
1.4. The 65816 has no floating point, and doesn't need
it: use 8.8 fixed point. Keep positions and
velocities as 16-bit words and simply declare that the
high byte is whole pixels and the low byte is 256ths of a pixel.
Addition just works — lda ball_x / clc / adc ball_vx / sta
ball_x is the entire physics engine — and when drawing you
take the high byte (ball_x+1) as the pixel position.
Position starts at pixel 40: ball_x = $2800
($28 = 40 whole, $00 low byte). Velocity 1.5 px/frame:
ball_vx = $0180 (1 whole + $80/256 = .5).
Frame 1: $2800 + $0180 = $2980 → draw at pixel $29 = 41.
Frame 2: → $2B00 → pixel 43.
Frame 3: → $2C80 → pixel 44 — over three frames the
ball moved 4½ pixels, drawn as 1, 2, 1… The eye reads it as
perfectly smooth 1.5. Speeding up after each rally hit is one
instruction: add a little to ball_vx.
- The frame contract: logic during the picture (WRAM and shadows only), PPU writes only in vblank. Break it and Module 13 has a card with your name on it.
- The NMI is the heartbeat: DMA the shadow OAM, update tiles, wait out
$4212bit 0, read the pad, set a flag; main waits withwai. - Vblank moves ~6 KB by DMA; logic gets one frame. Overspend either and frames drop — a hitch you can see.
- 8.8 fixed point: high byte pixels, low byte 256ths. One
adcis a physics engine;ball_x+1is the pixel.
Sound, the honest version
Time for some honesty that will save you a month. The SNES's audio
side is a separate computer — an SPC700 CPU with its own
64 KB of RAM driving the S-DSP, connected to your world by
exactly four little mailbox ports at $2140–$2143
(the audio course tours the whole
apparatus). To make any sound at all, that computer needs a
program — a sound driver
— and writing a good one is a project the size of this entire
course. You do not hand-roll an SPC700 driver on day
one. Nobody does; even commercial games mostly shipped
Nintendo's stock N-SPC driver. You adopt one:
| Driver | What it is | Why pick it |
|---|---|---|
| Terrific Audio Driver | A modern, actively maintained homebrew driver with its own tooling and ca65 bindings | The current community default — music, sound effects, documented main-CPU API |
| SNESGSS | A tracker plus matching driver: compose in the GUI, export data and player together | Everything in one tool; well-trodden by jam games |
| N-SPC-style drivers | Community reimplementations patterned on Nintendo's stock engine | Familiar format for musicians coming from the ROM-hacking world |
Whichever you pick, your game's job shrinks to two phases.
Phase one, at boot: upload the driver. The SPC700
wakes running a tiny built-in loader (the IPL ROM) that announces
itself by putting $AA and $BB on the
ports. Your code performs the documented handshake over
$2140–3 — send a byte, wait for the echo, repeat —
streaming the driver program and every sound sample into that
64 KB of audio RAM, then telling it where to start. Every
driver ships this upload routine; you call it once and watch the
handshake tick in the debugger. (Samples are stored in the SNES's
compressed BRR format — your driver's
tools convert WAVs for you.)
Phase two, forever after: send commands. From the main CPU's point of view the entire sound system is now “write a command byte and a parameter to the mailbox, move on” — fire-and-forget, a few dozen cycles inside your NMI handler. Bounce needs precisely two commands: play the blip (on paddle contact, pitched up a touch as the rally grows) and start the loop (once, at title). The lab below is that command stream made visible — a 16-step grid where each cell is a mailbox write. A Web-Audio square and triangle stand in for the S-DSP, and we label them as the stand-ins they are; the byte pairs underneath are the real interface.
- Audio is a second computer; only a driver program running on it makes sound. Adopt one — Terrific Audio Driver, SNESGSS, or an N-SPC-style engine.
- “Uploading a driver” = the IPL-ROM handshake over
$2140–3: stream code and BRR samples into audio RAM, then jump. - After boot, sound is fire-and-forget: command bytes into the mailbox from your NMI handler.
- Bounce ships with two commands — a pitch-rising blip and one music loop. That's a complete, honest sound design.
Shipping it
A game that runs on your emulator is a prototype. A game that runs on a console you can't debug, from a flash cart, on a stranger's television, is a release. This last part is the gap between the two: the five classic homebrew bugs and their visible symptoms, the Mesen2-and-bsnes gauntlet that catches them, and then the good part — flash carts, the checksum, the compo, and the moment you hand your cartridge to someone else.
Debugging & the accuracy gauntlet
SNES bugs are unusually photogenic. Because so much of the machine is timing and memory discipline, each classic mistake produces a recognisable picture — experienced homebrewers diagnose from screenshots the way mechanics diagnose from engine sounds. The big five, which between them cover most of every beginner's first month:
| Symptom | Cause | Fix |
|---|---|---|
| Flickering / torn tiles during play | PPU writes outside vblank — the Module 11 contract, broken | Move every $21xx write into the NMI handler; check the event viewer |
| Garbage everywhere from power-on | Init skipped forced blank or the VRAM/CGRAM/OAM clears | Run the full Module 05 checklist, in order, every boot |
| Works in emulator, dies on hardware | Uninitialised WRAM or registers — emulators zero what silicon leaves as noise | Clear WRAM at boot; never branch on a variable before writing it |
| Inputs ghost, stick, or fire on their own | Reading $4218/9 while $4212 bit 0 says the auto-read is busy | Wait out the busy bit — three instructions (Module 11's handler) |
| “Random” behaviour that differs per emulator | Reading unmapped addresses — open bus — and treating the leftovers as data | Take randomness from a real seed (frame counter + player input), never from the bus |
The third row deserves its own paragraph, because it's the one
that breaks hearts at the compo deadline. An emulator that starts
all RAM at zero silently forgives every read-before-write
bug: your flag “happens” to be clear, your pointer
“happens” to be null, and the game runs perfectly for
months — until real silicon wakes with that byte as $B7
and the title screen never comes. This is why the gauntlet is
two emulators, deliberately configured. Develop
in Mesen2 with its lifesaver switched on —
break on uninitialised read, which pauses the instant any
code reads a byte nothing ever wrote, plus the option to randomise
RAM at power-on so “lucky zeroes” stop existing. Then
verify in bsnes, the accuracy reference, which
models timing tightly enough that vblank-budget sins and
beam-racing tricks fail there the way they'd fail on the console.
Clean in both, with randomised RAM, is as close to
“hardware-proof” as software testing gets.
Medical students learn rashes from photo cards long before they meet patients, because the fastest diagnosis is recognition, not deduction. The lab below is your card deck: five broken TVs, five causes. Get them into your visual memory now and your future self stares at a glitched screen for five seconds instead of five hours.
- The classic bugs announce themselves visually: tearing = writes outside vblank; boot garbage = skipped init; ghost inputs = ignored
$4212busy bit. - “Works in the emulator” means nothing until RAM is randomised — silicon doesn't hand out zeroes.
- Mesen2's break-on-uninitialised-read catches read-before-write bugs the day you write them, not at the deadline.
- The gauntlet is both emulators: Mesen2 to find bugs, bsnes to prove timing. Clean in both before hardware.
Real hardware & release
Nothing about this hobby beats the first time your code comes out
of a real console into a real television. The bridge is a
flash cart:
the FXPak Pro is the scene's favourite (an FPGA
cart that even recreates the enhancement
chips), with the Super EverDrive as the
budget classic — for a plain LoROM
game like Bounce, either is perfect. Copy the .sfc
onto the SD card, slot it, play. Do try a
CRT
if you can — zero lag and honest scanlines — but test on a modern
TV too, because that's where most players will meet your game,
added latency, upscaler quirks and all.
Before the file goes anywhere, three mechanical rites. Fix the
checksum — recompute the Module 06 pair over the
final padded ROM (your build script's last step; flash-cart menus
and emulators both check it). Pad the ROM to a clean
power-of-two size. And ship a bare
.sfc — no copier header, the obsolete
512-byte block that 1990s disk-copier hardware prepended; modern
tools treat it as corruption. Then release where the scene
actually lives: an itch.io page (free or paid —
it's your game), a thread on the SNESdev forums
at snes.nesdev.org and its
Discord, and — if you time it right — the annual
SNESdev Compo, where new games premiere every
year. Boutique publishers do press homebrew onto real boxed
cartridges; finish something people love and that conversation
can find you. The legal recap from Module 01, compressed to one
line: your code, your art, your sound, your title — and
nobody's trademarks.
One last look at Bounce
Here's the capstone again — the same game you played in Module 01.
But you're a different reader now, so this copy is annotated:
switch on the overlay and every part of the screen names the
module that built it. That border is a tilemap you can convert
with superfamiconv; that paddle is two shadow-OAM entries riding a
vblank DMA; that motion is an adc on an 8.8 word.
Nothing on this screen is magic anymore. That was the whole plan.
Where to go next
Bounce is small on purpose — a scaffold, not a ceiling. When your next game wants parallax, colour math or Mode 7, the graphics course is waiting; when it wants real music and echo, the audio course; when it outgrows 32 KB banks or wants a battery save or a co-processor, the cartridge course; and when you need to reason about cycles instead of guessing, the CPU course. For everything this collection doesn't cover, the community's reference wiki at snes.nesdev.org is the standard library, and the forums behind it are where working homebrewers answer questions. You have a toolchain, a working game, and a map of the machine. The compo runs every year.
- You can take a SNES game from empty file to real hardware: init, header, art, upload, input, sprites, loop, sound, debug, ship.
- Flash carts (FXPak Pro, Super EverDrive) make your console the last debugger; checksum fixed, power-of-two padded, no copier header.
- Release where the scene lives: itch.io, the SNESdev forums and Discord, the annual compo — with everything in the file yours.
- Now go build something weirder than Bounce.