An interactive course · SNES homebrew

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.

Part I

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.

Modules 01–04Difficulty BeginnerLabs 4 interactive
Module 01 · Part I

What homebrew is

GoalUnderstand the living SNES homebrew scene, where the legal lines actually sit, and meet Bounce — the small, complete game you will have built by Module 14.

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.

Analogy · Cooking in a vintage kitchen

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 screenThe canonical init sequence, forced blank, clearing VRAM/CGRAM/OAM/WRAMModule 05
To be a valid cartridgeThe ROM header at $FFC0, reset & NMI vectors, a LoROM linker mapModule 06
A court, a paddle, a ball, digits4bpp planar tiles, 15-bit BGR palettes, a tilemapModule 07
That art inside the PPUDMA to VRAM and CGRAM under forced blank, then screen onModule 08
To feel the D-padAuto-joypad read, $4218/9, the pressed/held/released idiomModule 09
Things that moveA shadow OAM buffer, metasprites, the 544-byte vblank DMAModule 10
Smooth 60 fps motionThe NMI-locked game loop and 8.8 fixed-point physicsModule 11
A blip and a loopA real sound driver uploaded to the SPC700 over $2140–3Module 12
bounce.sfc — one file, four kinds of bytes, all yours code 65816, from $8000 graphics tiles + palettes + map audio driver + samples header & vectors · $FFC0–$FFFF title, map mode, checksum — plus where to start: the reset vector flash cart · Module 14 Super Nintendo reads the reset vector at $FFFC… …and your game is running the whole course is just filling in these boxes, left to right — then the arrow
A finished homebrew game is one file. Code, graphics, audio and a 64-byte header/vector block, assembled and linked into a .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.

The capstone, playable — this is what you'll have built by Module 14simulated · live
Status
Score 0
Best rally 0
Balls left 3
Press Start, then move with ← → (click the screen first) or the buttons. Keep the rally going — the ball speeds up.
Key takeaways
  • 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 .sfc file: code, graphics, audio, and a header that tells the console where to start.
Module 02 · Part I

The toolchain

GoalChoose your tools with open eyes: the assemblers and C kits the scene actually uses, the source-to-ROM build pipeline, and which emulator to develop in.

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.

RouteToolYou getYou pay
asmca65 + ld65 (cc65 suite)Total control, best docs, linker configs, this course's syntaxYou write every instruction yourself
asmWLA-DXMulti-console assembler, long SNES track recordIts own config dialect to learn
asmasarTerse syntax, patch-friendly, huge ROM-hack communityLeans patch-first; full-ROM builds need care
CPVSnesLib (816-tcc)Playable prototype in a weekend, batteries includedGenerated code is markedly slower and larger
C+asmlibSFXca65 framework: init, NMI, build scaffolding done for youYou 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.

Build-pipeline explorer — click a stage to inspect itinteractive
Key takeaways
  • 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.
Module 03 · Part I

Just enough 65816

GoalNot the whole CPU — the working subset a homebrewer types daily: width control, loads and stores, loops, subroutines, and the direct page as your variables.

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)
rep/sep + hint, always as a pair. #$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
Loads, stores, a count-down loop, a subroutine. This is most of what Bounce's source looks like.
Analogy · The phrasebook

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.

65816 playground — step a real ca65 fragmentsimulated · live
Source · ca65
Registers
Status flags · P
Memory · bank $00
Press Step to run one instruction, or Run to let it fly.
Cyan = memory read · magenta = memory written · amber flags are set.
Key takeaways
  • # means a literal value; no # means the memory at that address. Most beginner bugs are this line.
  • rep/sep #$20/#$10/#$30 switch register widths; .a8/.a16/.i8/.i16 keep 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 with jsr/rts. That's most of a real game's texture.
Module 04 · Part I

The dev loop

GoalMake the edit→build→run→inspect cycle a two-second reflex, and learn Mesen2's debugger — especially the event viewer, which plots your register writes onto the video frame itself.

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:

ToolWhat it showsYou'll use it when…
DebuggerSource-level stepping, breakpoints on execute/read/write of any address“Why did ball_x become $FF?” — break on write to it
Memory viewerLive WRAM/VRAM/CGRAM/OAM hex, editable while runningWatching your variables change in real time
Tilemap / tile / sprite viewersThe PPU's actual state, decoded into pictures“Did my tiles even arrive in VRAM?” (Module 08)
Palette viewerAll 256 CGRAM colours as swatchesWrong colours — bad data, or bad palette index?
Event viewerEvery register write/read plotted on the frame, at the beam position where it happenedTiming bugs — the SNES's whole personality (lab below)
Trace loggerEvery executed instruction with registers, to a fileThe 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.

Event-viewer mock — one frame, every write plotted where the beam wassimulated
All categories on. This is a healthy Bounce frame: everything lands in the vblank band.
Key takeaways
  • 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 sta per boot phase — is the fastest black-screen diagnostic there is.
Part II

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.

Modules 05–12Difficulty Hands-onLabs 8 interactive
Module 05 · Part II

Boot: from reset to a stable frame

GoalWalk the canonical init sequence — native mode, stack, forced blank, clear everything — and understand why every skipped step has a specific, visible failure.

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
The canonical boot. $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.

Analogy · The pre-flight checklist

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.

Init-sequence simulator — skip a step, see the damagesimulated · live
What the TV shows
Key takeaways
  • 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; $0F raises 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.
Module 07 · Part II

Art into bytes

GoalTurn drawings into the PPU's native food: 4bpp planar tiles, 15-bit BGR palette words, and a tilemap for the court — with the real conversion tools.

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:

Worked example · one 4bpp row, by hand

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.

Pixel-to-planar editor — draw a 16×16 sprite, get real bytesinteractive · generates code
On the court · 1:1 pixels
Palette · BGR15 words
generated in your browser — nothing leaves the page

                
The exact .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.
Key takeaways
  • 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 %0BBBBBGGGGGRRRRR words: 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.
Module 08 · Part II

Uploading to the PPU

GoalMove Bounce's tiles, palette and map into VRAM and CGRAM with DMA under forced blank, configure BG mode 1, and raise the curtain.

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
The full upload ritual. Every DMA is the same five settings — mode, target port, source, size, fire — pointed at a different destination.

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.

Upload sequencer — fire each write, watch the PPU's memories fillsimulated · live
The sequence · click in any order (that's the point)
What the TV shows
CGRAM · 512 B0%
VRAM · 64 KB0%
Forced blank is on (we arrive from Module 05's init). Fire the steps — in order, or out of it — and read the consequences.
Key takeaways
  • 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/9 in DMA mode 1. CGRAM: set $2121, stream to $2122 in 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.
Module 09 · Part II

Reading the player

GoalSwitch on the auto-joypad reader, learn the $4218/9 bit layout by heart, and derive pressed / held / released with the one-XOR idiom.

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.

JOY1 — $4219 (high byte) : $4218 (low byte), read as one 16-bit word B15 Y14 Sel13 Sta12 11 10 9 8 A7 X6 L5 R4 0 0 0 03–0 · pad signature high byte $4219 ——————————————— low byte $4218 ——————————— face buttons the D-pad — Bounce only reads bits 9 and 8 a held button reads 1 · everything is 0 while the auto-read is busy ($4212 bit 0)
Sixteen bits, memorised once, used forever. $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
Four direct-page words and five instructions buy every input question a game ever asks.

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.

Joypad register lab — $4218/9, live from your keyboardinteractive · live
Click here (or tab to it), then use ← → ↑ ↓, Z (B), X (A), Enter (Start) — or hold the buttons below.
JOY1 · a held button reads 1
$4219 $00
$4218 $00
pressed
held
released
Key takeaways
  • $4200 = $81 arms 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 $4212 bit 0 is set — the shift is still in flight.
  • now EOR last, masked with now or last, yields pressed and released; the raw word is held.
Module 10 · Part II

Sprites: the ball & paddle

GoalGive Bounce its moving parts: a shadow OAM buffer in WRAM, a two-sprite metasprite paddle, the 544-byte vblank DMA — and the high-table X bit that ambushes everyone.

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.

ByteSprite 0 (ball) exampleMeaning
+0 · X$7CX position, low 8 bits of 9 — the ninth lives in the high table
+1 · Y$68Y position; park a sprite at Y = $F0 to hide it off-screen
+2 · tile$04Which tile in the sprite character table draws this
+3 · attr%00110000vhoopppN: 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.

Shadow-OAM visualiser — WRAM updates now, OAM updates at vblanksimulated · live
next OAM DMA in
Shadow OAM (WRAM) → OAM (PPU)
Hold ◀ — the shadow bytes (cyan) change instantly; the screen and the PPU column only change when the DMA tick copies all 544 bytes.
Key takeaways
  • 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.
Module 11 · Part II

The game loop

GoalLock Bounce to the frame: logic during the picture, PPU writes only in vblank, an NMI heartbeat — and 8.8 fixed-point physics for motion smoother than whole pixels.

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
The frame contract in code: NMI uploads and flags; main computes and waits. Sixty times a second, forever.

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.

Worked example · a ball moving 1.5 px/frame

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.

Frame-budget visualiser — overspend either budget, drop framessimulated · live
Effective rate 60 fps
Vblank budget used 33%
Both budgets healthy: logic fits inside one frame, uploads fit inside vblank. This is a locked 60 fps.
Key takeaways
  • 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 $4212 bit 0, read the pad, set a flag; main waits with wai.
  • 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 adc is a physics engine; ball_x+1 is the pixel.
Module 12 · Part II

Sound, the honest version

GoalGet a bounce blip and a music loop into the game the way real homebrewers do: adopt a proven driver, upload it to the sound CPU, and send it commands.

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:

DriverWhat it isWhy pick it
Terrific Audio DriverA modern, actively maintained homebrew driver with its own tooling and ca65 bindingsThe current community default — music, sound effects, documented main-CPU API
SNESGSSA tracker plus matching driver: compose in the GUI, export data and player togetherEverything in one tool; well-trodden by jam games
N-SPC-style driversCommunity reimplementations patterned on Nintendo's stock engineFamiliar 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.

Driver-command sequencer — mailbox bytes on a 16-step gridweb-audio stand-in
a Web-Audio square & triangle stand in for the S-DSP — the bytes below are what your NMI would really write
press Play — each firing step logs its two port writes
Key takeaways
  • 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.
Part III

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.

Modules 13–14Difficulty Hands-onLabs 2 interactive
Module 13 · Part III

Debugging & the accuracy gauntlet

GoalLearn the classic homebrew bugs by their symptoms — most are visible from across the room — and the emulator settings that catch them before hardware does.

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:

SymptomCauseFix
Flickering / torn tiles during playPPU writes outside vblank — the Module 11 contract, brokenMove every $21xx write into the NMI handler; check the event viewer
Garbage everywhere from power-onInit skipped forced blank or the VRAM/CGRAM/OAM clearsRun the full Module 05 checklist, in order, every boot
Works in emulator, dies on hardwareUninitialised WRAM or registers — emulators zero what silicon leaves as noiseClear WRAM at boot; never branch on a variable before writing it
Inputs ghost, stick, or fire on their ownReading $4218/9 while $4212 bit 0 says the auto-read is busyWait out the busy bit — three instructions (Module 11's handler)
“Random” behaviour that differs per emulatorReading unmapped addresses — open bus — and treating the leftovers as dataTake 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.

Analogy · The doctor's picture cards

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.

Spot the bug — five symptoms, pick each causeinteractive
Diagnosed 0 / 5
Key takeaways
  • The classic bugs announce themselves visually: tearing = writes outside vblank; boot garbage = skipped init; ghost inputs = ignored $4212 busy 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.
Module 14 · Part III

Real hardware & release

GoalPut Bounce on a real console via a flash cart, run the release checklist, and send it into the world — legally, findably, and finished.

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

Release checklist — stamp the cartinteractive
SHIPPED

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.

Bounce, annotated — every piece labelled with the module that built itsimulated · live
Score 0
Best rally 0
Balls left 3
Same game, new eyes. Toggle the overlay and read the screen as a bill of materials.

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.

Course complete ✦
  • 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.

An interactive course in the “Inside the Super Nintendo” collection. Every lab on this page is a from-scratch simulation written for teaching — the Bounce game is a browser recreation, not an emulated ROM, and no Nintendo code or assets appear anywhere. Register addresses and formats are grounded in the community's public documentation at snes.nesdev.org; tools named (ca65, WLA-DX, asar, PVSnesLib, libSFX, superfamiconv, YY-CHR, Mesen2, bsnes, Terrific Audio Driver, SNESGSS) are real and free.

Homebrew register kit · $2100 INIDISP · $2116–$2119 VRAM · $2121/22 CGRAM · $2140–$2143 APU · $4200 NMITIMEN · $4212 HVBJOY · $4218/9 JOY1 · $4300–$4306 + $420B DMA · header $FFC0