Emulator guide Play Snake
BEHIND THE SCENES · PART 2

How Snake was made

The first guide explained how the emulator imitates a Game Boy. This one goes the other direction: writing a brand-new game for the Game Boy, in the CPU's own machine language, the way games were written in 1990. No engine, no libraries, no operating system. Just 64 KB of addresses and a CPU that starts at $0100.

1,103lines of assembly
1,498bytes of machine code
2,648bytes of graphics and maps
13%of the 32 KB cartridge used

01Starting from a blank cartridge

On a PC, a program runs inside an operating system that gives it files, windows, a keyboard and a clock. On the Game Boy, your game is the only software. When the console turns on, a tiny boot program scrolls the logo, then jumps to address $0100 of the cartridge. From that moment on, everything is your job:

  • There is no print(). To show the letter "A" you design an 8×8 picture of an A, copy it into video memory, and place its number in the background map.
  • There is no random(), no clock, no file system. Saving means writing bytes to a RAM chip on the cartridge that a coin battery keeps alive.
  • There is no multiplication instruction. y × 32 is five additions of a number to itself.
  • You may only touch video memory during specific moments, or your writes are silently lost.
Why assembly

Game Boy games can also be written in C (with GBDK) and the result runs fine. Assembly was chosen because it's how the original games were made, it produces the smallest and fastest code, and it maps one-to-one onto everything explained in the emulator guide: each line becomes exactly one CPU instruction that the emulator's cpu.ts executes.

02The toolchain

Snake is built with RGBDS, the open-source assembler the Game Boy homebrew community uses. Building is a four-step pipeline, all in games/snake/build.sh:

gen_assets.pyASCII art → bytes rgbasmassembly → object rgblinkplace in memory map rgbfixheader + checksums assets.inc main.o snake.gb → public/games/snake.gb

rgbfix deserves a word. Every cartridge has a header at $0100–$014F that the console checks before starting the game:

AddressContentSnake's value
$0100Entry point: 4 bytes of codenop + jp Start
$0104The logo bitmap. The boot ROM compares it byte for byte and freezes if it doesn't match.filled in by rgbfix
$0134TitleSNAKE
$0143Color support$80: works on both models
$0147Cartridge type$03: MBC1 + RAM + battery
$0149Save RAM size$02: 8 KB
$014DHeader checksum. A wrong value also freezes a real console.computed by rgbfix

The emulator reads exactly these bytes in cartridge.ts: that's how it knows Snake wants color mode and a battery-backed save chip.

03Planning memory by hand

There's no new or malloc. Every variable gets a fixed address, decided up front. The linker's map file shows where everything ended up:

AddressSizeWhatWhy there
$00403 BVBlank interrupt vectorThe CPU jumps here every frame. Fixed by hardware.
$01501,498 BAll game codeRight after the header
$072A2,648 BTiles, palettes, screen mapsGenerated by Python
$C000256 BSnake X coordinatesAligned to 256, see section 8
$C100256 BSnake Y coordinatesAligned to 256
$C800576 BShadow copy of the playfieldChosen so the address math is one addition, see section 7
$C20072 BEverything else: score, direction, queue…Anywhere, linker decides
$FF802 B"Is this a Color?", "frame done" flagHigh RAM: the fast ldh instruction reaches it
$DFFF ↓The stack, growing downwardld sp, $E000 at boot

The whole game uses 1.1 KB of the 8 KB work RAM. The 1990 originals worked under the same constraints, just with less room to spare.

04Power on: setting up the machine

When the boot ROM hands over, the CPU registers hold leftovers. One of them is useful: on a Game Boy Color, register A contains $11. That's the official way to detect the hardware:

Start:
    di                      ; no interrupts while we set up
    ld  sp, $E000           ; stack at the top of work RAM
    cp  $11                 ; Game Boy Color?
    ld  a, 0                ; ld does not change flags, so the cp result survives
    jr  nz, .dmg
    inc a
.dmg
    ldh [hIsCGB], a

Then setup runs in a strict order:

  1. Turn the LCD off. Only then is video memory freely writable. But the LCD may only be switched off during VBlank, so LcdOff first waits until the line counter LY reaches 144. Nintendo's manual warned that switching off mid-frame can damage real screens.
  2. Clear video memory. 8 KB of zeros, twice on a Color (bank 1 holds per-tile color attributes).
  3. Copy the tiles (1,536 bytes) to $8000.
  4. Set palettes, switch on sound, load the high score from the save chip.
  5. Enable the VBlank interrupt and ei. From now on, the game's heartbeat is running.

05Drawing tiles as ASCII art

Graphics are 8×8 tiles with 4 colors, stored in the odd split-bit format from the emulator guide. Nobody wants to write that by hand, so the tiles are drawn as text in tools/gen_assets.py, where . is color 0 and 1 to 3 are the other colors:

HEAD_RIGHT = [        APPLE = [
    '33333...',           '....31..',   # stem and leaf
    '222223..',           '...311..',
    '2213223.',  # eye     '.333333.',
    '22222223',           '32.22223',   # shine
    '22222223',           '3.222223',
    '2213223.',  # eye     '32222223',
    '222223..',           '.322223.',
    '33333...',           '..3333..',
]                     ]

Only the right-facing head was drawn. The other three are generated by rotating it 90° at a time, so the four always match:

def rot_ccw(t):   # column c of the new tile = row c of the old one, read right to left
    return [''.join(t[r][7 - c] for r in range(8)) for c in range(8)]

The real tiles from the game

Rendered from the same ASCII art the build uses. Switch between the Game Boy Color palettes and the four green shades of the original Game Boy.

A tile-numbering trick for text

Tiles are numbered 0–255. Snake puts its 8 game tiles at 0–7 and each font letter at the number of its ASCII code: "A" is tile 65, "0" is tile 48. So a text string in the source code is already a list of tile numbers, and printing needs no translation table. One exception is declared with CHARMAP " ", 0: spaces map to tile 0, the empty floor, so text and playfield share the same blank tile.

A bug caught before it happened

Strings in C end with a zero byte. But with spaces mapped to 0, the first space would end the string: "PRESS START" would print as "PRESS". So Snake's strings end with $FF instead. A one-character design decision that would otherwise cost an hour of confused debugging.

The three screens (title, game, game over) are also drawn as text, 20 characters × 18 rows, one character per tile. # is a wall, o a body segment, * an apple. The big SNAKE logo on the title screen is just body segments spelled out:

'ooo o o  o  o o ooo '
'o   ooo o o o o o   '
'ooo ooo ooo oo  oo  '
'  o o o o o o o o   '
'ooo o o o o o o ooo '

06The golden rule: only touch video memory during VBlank

While the screen is being drawn, the graphics chip owns video memory, and CPU writes are ignored. The safe window is VBlank: the ~1.1 ms after the last line, before the next frame starts. That's 1,140 CPU cycles, 60 times a second.

So Snake separates deciding what changes from drawing it. Game logic calls QueueTile, which appends a 3-byte entry (address low, address high, tile) to a queue. The VBlank interrupt drains it:

VBlankHandler:
    push af, bc, de, hl ...     ; an interrupt must leave registers untouched
    ld   a, [wQueueLen]
    and  a
    jr   z, .flushed
    ld   c, a
    ld   de, wQueue
.flush
    ld a, [de] / ld l, a / inc de    ; address low
    ld a, [de] / ld h, a / inc de    ; address high
    ld a, [de] / inc de              ; tile
    call PutTileVram               ; tile + (on Color) its palette attribute
    dec  c
    jr   nz, .flush
    ...
    reti

The budget. One queue entry costs about 70 cycles on a Color (the attribute write needs a VRAM bank switch). 1,140 ÷ 70 ≈ 16, so the queue holds at most 16 entries. A normal snake move needs 3 (new head, old head becomes body, tail erased). Eating adds 5 (new apple + 4 score digits). That comfortably fits.

A race condition

The main code appends to the queue and the interrupt empties it. If the interrupt fired between "write the entry" and "increase the length", an entry could be lost or flushed twice. Classic concurrency bug, even on a 1989 handheld. The fix is equally classic: QueueTile wraps its few critical instructions in di / ei (interrupts off, then on again).

One more trick: when the LCD is off (while a whole new screen is drawn), video memory is always writable. QueueTile checks bit 7 of LCDC and writes directly in that case. So the same function works for drawing a full screen and for single updates during play.

07The playfield is the background map

Snake uses no sprites at all. Every snake segment, apple and wall is a background tile, one per grid cell. The screen is 20×18 cells: row 0 shows the score, then walls surround an 18×15 playing area.

To check "what's in this cell?", the game would have to read video memory, which runs into the golden rule again. So it keeps a shadow grid in work RAM with the same layout as the background map. The address of the shadow grid was chosen carefully:

background map:  $9800 + y × 32 + x
shadow grid:     $C800 + y × 32 + x      ; exactly $3000 higher

Converting one address into the other is adding $30 to the high byte: two instructions, no multiplication. And y × 32 itself, on a CPU without a multiply instruction, is five doublings of the 16-bit register pair HL:

CellAddr:              ; d = x, e = y  →  hl = $9800 + y*32 + x
    ld  h, 0
    ld  l, e
    add hl, hl         ; ×2
    add hl, hl         ; ×4
    add hl, hl         ; ×8
    add hl, hl         ; ×16
    add hl, hl         ; ×32
    ld  a, l
    add d              ; + x
    ld  l, a
    ld  a, h
    adc HIGH(MAP)      ; + $98, plus the carry from the line above
    ld  h, a
    ret

Collision detection becomes trivial: read the shadow grid at the new head position. 0 means free, 7 means apple, anything else (wall, body) means death.

08The snake: a ring buffer

A moving snake changes at both ends: a new head appears in front, the tail disappears at the back. Everything in between stays still. That's exactly what a ring buffer is for: a fixed array with a "head" index and a "tail" index that walk forward and wrap around.

wBodyX / wBodyY: 256 slots each. Only the slots between tail and head are the snake. ▲ tail = 40 erased next move ▲ head = 44 next move writes slot 45

The indices are single bytes. When the head index is 255 and the game does inc a, it becomes 0 by itself: the CPU's 8-bit overflow is the wraparound. And because the arrays sit at $C000 and $C100 (multiples of 256), looking up segment i needs no addition at all. The high byte selects the array, the index is the low byte:

ld a, [wHead]
ld l, a
ld h, HIGH(wBodyX)    ; $C0 → hl = $C0xx
ld d, [hl]           ; x of the head
ld h, HIGH(wBodyY)    ; $C1 → same index, other array
ld e, [hl]           ; y of the head

One step, in the right order

  1. Take the buffered direction and compute the new head position.
  2. Look at the shadow grid there. If it's an apple: eat (score, sound, speed-up) and skip removing the tail. That's how the snake grows: one move where the back stays put.
  3. Otherwise remove the tail first, then check the new cell for collision. The order matters: moving into the cell your own tail is just leaving is legal in Snake. Check first and it would wrongly kill you.
  4. Turn the old head tile into a body tile, write the new head (one of four rotated tiles), advance the head index.
  5. If something was eaten, place a new apple.

The buffer holds 256 segments, but the field has 270 free cells. Rather than lose the game to an overflowing buffer, the snake stops growing at length 250. Reaching it would take a very good player.

09Reading the buttons

The Game Boy's 8 buttons are wired as a 2×4 matrix behind a single register, $FF00. You first select a row (d-pad or buttons) by writing to it, then read 4 bits back. Pressed buttons read as 0, not 1, a leftover of how the circuit is wired.

ld  a, $20          ; select the d-pad row
ldh [rP1], a
ldh a, [rP1]
ldh a, [rP1]       ; read twice: the lines need a moment to settle
cpl                ; flip bits so pressed = 1
and $0F
swap a             ; d-pad into the high nibble
...                ; same for the button row (read 4 times, it's slower)
ld  a, [wKeys]      ; last frame's state
xor b              ; bits that changed...
and b              ; ...and are pressed now  =  newly pressed
ld  [wPressed], a

The last three instructions compute "newly pressed this frame" from "held now" and "held last frame". That's the difference between pressing START once and having pause flicker on and off 60 times per second while the button is held.

The turn buffer: the most important feel detail

The snake moves only every few frames, but your fingers are faster. If you press UP then LEFT quickly to make a tight U-turn, both presses can land between two moves. With a single "next direction" variable, LEFT overwrites UP, and the snake turns left into its own body. Infuriating.

Snake keeps two buffered turns. The first press goes into slot 1, the second into slot 2, and each move consumes one. Each request is also validated against the direction before it, not the current one, by IsValidTurn:

IsValidTurn:        ; b = from, c = to. Returns Z if the turn makes no sense.
    ld  a, b
    cp  c            ; same direction? pointless
    ret z
    add 2            ; directions are 0 up, 1 right, 2 down, 3 left,
    and 3            ; so (dir + 2) mod 4 is the opposite
    cp  c            ; reversing into yourself? not allowed
    ret

10The game loop and timing

The loop runs once per frame, synchronized to the screen:

WaitFrame:
    xor  a
    ldh  [hVBlankFlag], a
.wait
    halt                  ; sleep until an interrupt (saves battery on hardware)
    ldh  a, [hVBlankFlag]   ; was it the VBlank one?
    and  a
    jr   z, .wait
    ret

Each frame: read buttons, handle pause, buffer turns, then count down a timer. When it hits zero, the snake takes one step and the timer reloads from wSpeed. Speed is measured in frames per step: 9 at the start (6.6 steps per second), one less every 3 apples, down to 4 (15 steps per second). Counting frames instead of measuring time is how nearly every console game of the era handled timing: the screen refresh is the clock.

11Randomness without a random number generator

The apple needs a random position. The Game Boy has no random source, so Snake uses xorshift, a pseudo-random algorithm that only needs shifts and XORs. This 16-bit version (shifts 7, 9, 8) cycles through all 65,535 non-zero states:

ld  a, h
rra                ; rotate right through carry: the lowest bit of h goes into carry
ld  a, l
rra                ; ...and comes out on top of l: that's a 16-bit shift by 1
xor h
ld  h, a
...                ; 13 instructions total

A pseudo-random generator always produces the same sequence from the same starting value, so every game would have identical apples. The fix uses the player as the random source: the generator is advanced once every frame on the title screen, so the moment you press START (to a 60th of a second) picks the starting point. The hardware divider register DIV, which ticks 16,384 times a second, is mixed in as well.

Placing the apple uses rejection sampling: take a random number from 0–31, throw it away if it's 18 or more, otherwise use it as the column. Same for the row. If the chosen cell isn't empty, try again. This is perfectly uniform and avoids division, which the CPU can't do either.

12Score in decimal, high score on a battery

The score is shown in decimal, but converting binary to decimal needs division. So the score is stored in decimal: BCD, one digit per 4 bits. The hex value $0123 means 123 points. After each addition, DAA fixes up the result (it's explained in the emulator guide's deep dive 14.2):

ld  a, [wScore]
add 1
daa                ; $09 + 1 = $0A  →  DAA makes it $10
ld  [wScore], a
ld  a, [wScore + 1]
adc 0              ; carry from $99 → $00 goes into the hundreds
daa
ld  [wScore + 1], a

Printing is then trivial: each 4-bit nibble is one digit, and adding 48 (the ASCII code of "0") gives its tile number, thanks to the font trick from section 5.

Saving

The cartridge has 8 KB of RAM kept alive by a battery. To protect it from crashes, it's locked by default. You unlock it by writing $0A to a ROM address, a command the bank controller chip intercepts, and lock it again right after:

ld  a, $0A
ld  [RAM_ENABLE], a     ; $0000: "open the save chip"
...                     ; write "SNK" + 2 score bytes at $A000
xor a
ld  [RAM_ENABLE], a     ; lock it again

The three letters SNK are a signature. A brand-new cartridge's RAM contains random garbage (in the emulator: $FF bytes). Without the signature, the game would show that garbage as a high score. With it: no "SNK", no valid save, start at 0000.

Comparing two BCD scores needs no conversion either: compare the high bytes, and only if they're equal, the low bytes. BCD keeps the ordering of normal numbers.

13One cartridge, two consoles

A classic Game Boy has one palette for the whole background: register BGP maps the 4 color numbers to 4 shades. %11100100 means "color 0 = lightest … color 3 = darkest".

A Game Boy Color adds 8 background palettes of 4 colors each, stored as 15-bit RGB, and a second layer of video memory that says which palette each map cell uses. Snake defines 4 palettes:

PaletteUsed byColors 1 / 2 / 3
0Floor, textmuted greens
1Snakelight green / green / dark green
2Appleleaf green / red / dark red
3Wallstan / brick / mortar

Whenever a tile is written, PutTileVram also looks up its palette in a 16-byte table and writes it to VRAM bank 1. The table is placed at an address that's a multiple of 16 (ALIGN[4]), so looking up entry n is a single add l with no carry into the high byte. On a classic Game Boy the function skips this step entirely. The tiles were drawn so they read well in both: red apple and grey apple both have a dark outline and a light shine.

14Sound effects from five register writes

There's no audio file anywhere in Snake. Each sound effect is a handful of writes to the sound chip's registers, which then plays it on its own:

SfxEat:                   ; channel 1: a rising "bloop"
    ld a, $16 → NR10        ; sweep: pitch goes UP, step 1, shift 6
    ld a, $80 → NR11        ; 50% square wave
    ld a, $F2 → NR12        ; start at volume 15, fade out
    ld a, $06 → NR13        ; frequency low byte
    ld a, $87 → NR14        ; frequency high bits + TRIGGER

The sweep unit raises the pitch by 1/64 of itself 128 times a second, so the tone accelerates upward until it passes the chip's highest frequency, where the hardware switches the channel off by itself. A self-ending chirp, no timer needed. The crash is channel 4's noise generator with a slow fade, and the menu blips use channel 2. Because they use different channels, an eat sound and a crash can play at the same time without cutting each other off.

15Juice: small things that make it feel good

  • Screen shake on crash. The background scroll register SCX is set to +2, −2, +2… for 24 frames. The entire screen jolts sideways, for 4 instructions per frame.
  • Blinking "PRESS START". Every 32 frames the text is queued again, alternating with spaces. Bit 5 of the frame counter decides which.
  • Input delay on game over. Buttons are ignored for 40 frames, so a frantic last key press doesn't skip the result screen.
  • NEW RECORD! appears only when the high score was beaten, and is saved right then.
  • Pause swaps the "HI 0000" in the corner for "PAUSED" and restores it after, through the same queue.

16Testing with a robot player

Before anyone pressed a key, Snake was tested by a bot inside the emulator from part 1, running in Node.js. The bot cheats in an interesting way: it reads the game's shadow grid straight out of the emulated RAM at $C800 to find the apple and the head, then presses the d-pad toward the apple, avoiding walls and its own body.

const grid = (x, y) => gb.mmu.read(0xc800 + y * 32 + x);   // peek into Snake's memory
RunApples eatenSave RAM afterwards
Game Boy Color mode1153 4E 4B 11 00
Classic mode2553 4E 4B 25 00

The save RAM bytes are "SNK" in ASCII followed by the BCD score, exactly as designed. Screenshots of every screen were checked in both modes. Then a real Chrome browser loaded the website, clicked the Snake card and played with simulated key presses at a steady 60 fps.

The only errors came from the assembler, not the game. SRAM turned out to be a reserved word in RGBDS (it's the name of a memory section type), so the constant became SAVE_RAM. The game itself ran correctly on its first launch, largely because the risky parts (the queue, the ring buffer, the step order) were designed on paper first.

17Where it could go next

  • Music. A background tune means writing a tiny sequencer: a table of notes and durations, advanced once per frame from the VBlank handler.
  • Smooth movement. Right now the snake jumps a whole cell per step. Drawing the head as a sprite that glides 1 pixel per frame would look much smoother, with the background catching up when it reaches the next cell.
  • Connected body graphics. Corner and straight pieces chosen by the directions of the neighboring segments, instead of round beads.
  • Levels. Extra walls inside the field: just more screen maps in the Python file.
  • Two players over the link cable, if the emulator ever learns to connect two browsers.