Game Boy port of Snake in Assembly
A port of a classic Snake game for the Nintendo Game Boy, written in GBZ80 ASM. Covers the state machine, snake ring buffer, occupancy grid collision detection, and VBlank-safe rendering.
The classic Snake game is a perfect target for learning Game Boy development. Most people know it from the version preloaded on Nokia phones in the late 1990s, but the concept dates back much further, to arcade games like Blockade (1976)[05]. It requires handling input, managing game state, detecting collisions, and rendering graphics, all within the constraints of an 8-bit CPU running at around 4 MHz with just 8 KB of RAM[02].
This post goes over my Snake implementation written in GBZ80 assembly[01]. Rather than walking through every line, it focuses on the most important architectural decisions and hardware-specific techniques. Note that the demo below includes newer iterations of the game code and may differ from what is described here. The contents of this write up match what you see in version 1.1.
Below is the actual ROM running in a browser-based emulator. You can use the version selector to switch between releases and see how the game evolved across iterations.
Overview
The game is structured around three main screens managed by a state machine:
- Title Screen: Displays “SNAKE” and a blinking “PRESS START” prompt.
- Play Screen: The active game where the snake moves and eats food.
- Game Over Screen: Shows “GAME OVER” and waits for restart.
The core gameplay systems include:
- Ring Buffer: The snake body is stored as a circular array of coordinates.
- Occupancy Grid: A 20x18 byte array tracks which cells contain snake, food, or are empty.
- Dirty Rendering: Only changed tiles are updated during VBlank to avoid screen tearing.
- Input Handling: D-pad controls with reverse direction prevention.
Constants and Configuration
The game begins with hardware definitions[04] and configuration constants:
INCLUDE "hardware.inc" ; Common hardware symbols (RGBDS)
DEF PLAY_W EQU 20 ; Visible playfield width (tiles)
DEF PLAY_H EQU 18 ; Visible playfield height (tiles)
DEF X_MAX EQU (PLAY_W-2) ; Inner max X (18)
DEF Y_MAX EQU (PLAY_H-2) ; Inner max Y (16)
DEF MAX_SNAKE EQU 64 ; Must be power-of-two for AND wrap
DEF SNAKE_MASK EQU (MAX_SNAKE-1) ; 63
DEF SPEED_FRAMES EQU 8 ; Move every N frames
The playfield is 20x18 tiles, matching the Game Boy’s visible screen area. The snake can grow up to 64 segments, chosen as a power of two so we can use a simple AND operation for ring buffer wrapping instead of expensive division or modulo.
Game states and directions are defined as simple numeric constants:
DEF STATE_TITLE EQU 0 ; Title
DEF STATE_PLAY EQU 1 ; Play
DEF STATE_OVER EQU 2 ; Game over
DEF DIR_UP EQU 0 ; Up
DEF DIR_RIGHT EQU 1 ; Right
DEF DIR_DOWN EQU 2 ; Down
DEF DIR_LEFT EQU 3 ; Left
The direction values are arranged so that opposite directions differ by 2. This allows a simple XOR trick to detect reverse direction attempts:
ld a, [wDir] ; current direction
xor 2 ; opposite direction
cp b ; compare with candidate
ret z ; ignore if reverse
Initialization
The entry point sets up the hardware and prepares the initial game state:
Start: ; Entry point
di ; No interrupts
ld sp, $DFF0 ; Stack
xor a ; A = 0
ld [rNR52], a ; Sound off
ld a, [rLCDC] ; LCDC
res 7, a ; LCD OFF
ld [rLCDC], a ; Write
xor a ; A = 0
ld [rSCX], a ; SCX=0
ld [rSCY], a ; SCY=0
ld a, $E4 ; Palette
ld [rBGP], a ; Set palette
Interrupts are disabled since the game uses polling rather than interrupt- driven input. The LCD is turned off so we can safely write to VRAM without timing constraints. The scroll registers are zeroed to show the top-left corner of the background map.
The palette value $E4 (binary 11100100) sets up four shades:
| Color 0 | Lightest (binary 00) |
| Color 1 | Light (binary 01) |
| Color 2 | Dark (binary 10) |
| Color 3 | Darkest (binary 11) |
Next, tile graphics are copied to VRAM and the random number generator is seeded:
ld hl, TileData ; HL = tile ROM
ld de, _TileVRAM ; DE = tile VRAM
ld bc, TileDataEnd - TileData ; BC = bytes
call Memcpy ; Copy tiles (LCD off safe)
ld a, [rDIV] ; Seed
or $5A ; Mix
ld [wRand], a ; Store RNG
The DIV register is a free-running timer that increments every 256 clock cycles. Reading it at startup provides some randomness based on how long the boot ROM took and when the user started the game.
The Main Loop
The main loop follows a simple pattern: wait for VBlank, handle rendering, then process game logic:
MainLoop: ; Frame loop
call WaitVBlankStart ; Enter VBlank
call DoPendingRebuildInVBlank ; Handle queued rebuilds (LCD off bulk)
call TitleBlinkInVBlank ; Title blink (few writes)
call RenderDirtyInVBlank ; Play dirty updates (few writes)
call PauseOverlayInVBlank ; Pause overlay (few writes)
call WaitVBlankEnd ; Exit VBlank
call ReadJoypad ; Poll joypad
ld a, [wState] ; A = state
cp STATE_TITLE ; Title?
jr z, .S_Title ; Branch
cp STATE_PLAY ; Play?
jr z, .S_Play ; Branch
jr .S_Over ; Else over
All VRAM writes happen between WaitVBlankStart and WaitVBlankEnd. This is essential because the Game Boy’s PPU (pixel processing unit) locks VRAM during active display[02]. Writing outside VBlank causes corruption or is simply ignored.
The state machine uses simple comparisons to branch into state-specific handlers. Each state handles its own input and logic:
.S_Title: ; Title logic
ld a, [wJoyPressed] ; edges
bit JOY_START, a ; Start?
jr z, .DoneTitle ; no
ld a, STATE_PLAY ; next = play
ld [wNextState], a ; queue
ld a, 1 ; rebuild flag
ld [wNeedRebuild], a ; request rebuild
.DoneTitle: ; done
jr MainLoop ; loop
State transitions are deferred rather than immediate. Setting wNextState and wNeedRebuild queues the transition, which is processed at the start of the next frame’s VBlank period. This ensures screen rebuilds happen at a safe time.
VBlank-Safe Rendering
The Game Boy has approximately 1,140 CPU cycles during each VBlank period [02]. That is enough for small updates but not for redrawing the entire screen. The code uses two strategies:
Strategy 1: LCD-Off Bulk Writes
For full-screen changes (title screen, play screen initialization, game over), the LCD is turned off entirely:
DoPendingRebuildInVBlank: ; rebuild if requested
ld a, [wNeedRebuild] ; flag?
or a ; any?
ret z ; none => return
ld a, [rLCDC] ; LCDC
res 7, a ; LCD OFF
ld [rLCDC], a ; disable LCD
ld a, [wNextState] ; which screen?
cp STATE_TITLE ; title?
jr nz, .ChkPlay ; else
call InitTitleScreen ; build title
jr .Finish ; done
; ... similar for other states ...
.Finish: ; finish
ld a, %10010001 ; LCD ON config
ld [rLCDC], a ; enable LCD
With the LCD off, VRAM is fully accessible and we can take as long as needed to clear the background map, draw borders, and set up the initial game state.
Strategy 2: Dirty Tile Updates
During gameplay, only a few tiles change each frame: the snake’s head moves forward, the tail disappears (unless growing), and food might be placed. These are tracked with dirty flags:
DEF DIRTY_HEAD EQU 0 ; Draw head
DEF DIRTY_TAIL EQU 1 ; Clear tail
DEF DIRTY_FOOD EQU 2 ; Draw food
The renderer checks each flag and writes only the necessary tiles:
RenderDirtyInVBlank: ; apply dirty writes
ld a, [wState] ; state
cp STATE_PLAY ; only play
ret nz ; else return
ld a, [wDirtyFlags] ; flags
or a ; any?
ret z ; none
ld a, [wDirtyFlags] ; flags
bit DIRTY_TAIL, a ; tail?
jr z, .NoTail ; skip
ld a, [wTailY] ; B=tailY
ld b, a ; B
ld a, [wTailX] ; C=tailX
ld c, a ; C
ld a, TILE_EMPTY ; tile
call SetBGTile ; clear tail
.NoTail: ; done
; ... similar for head and food ...
xor a ; A=0
ld [wDirtyFlags], a ; clear flags
ret ; return
This approach writes at most 3 tiles per frame, which easily fits within the VBlank window.
Why Background Tiles Instead of Sprites?
The Game Boy offers two rendering layers: background tiles and sprites (also called objects). This game uses only background tiles. Here is why.
Sprites on the Game Boy have hard limits:
- 40 sprites maximum in OAM (Object Attribute Memory).
- 10 sprites per scanline - additional sprites on the same horizontal line are simply not drawn[02].
A snake that can grow to 64 segments would exceed both limits. Even a modest snake of 15-20 segments could easily violate the per-scanline limit when coiled tightly, causing parts of the body to flicker or disappear.
Using the background tilemap avoids these constraints entirely:
- No object limits - every cell in the 20x18 grid can show a snake segment simultaneously.
- Simpler rendering - writing a tile index to VRAM is one byte write; sprites require 4 bytes per object (Y, X, tile, attributes) plus OAM management.
- Grid-aligned by nature - snake games use discrete grid movement, which maps directly to the tile grid.
- No OAM DMA needed - sprite-based games typically copy a shadow OAM buffer to hardware OAM each frame using DMA, adding complexity.
The background-only approach has some limitations:
- No sub-tile movement - the snake moves one full tile per step, so movement appears discrete rather than smooth.
- No layering - the snake cannot pass over other background elements; everything shares the same layer.
- No per-segment attributes - sprites can be individually flipped or use different palettes; background tiles in a contiguous area share the same properties.
For a classic Snake game, these trade-offs are acceptable. The grid-based movement is actually desirable since it matches the original gameplay feel. Smooth pixel-by-pixel movement would change the game’s character and require more complex collision logic.
If this were a different type of game requiring smooth movement, layering, or many small independent objects, sprites would be the better choice. But for Snake, background tiles are the simpler and more robust solution.
The Snake Data Structure
The snake body is stored as two parallel arrays forming a ring buffer:
wSnakeX: ds MAX_SNAKE ; snake X ring
wSnakeY: ds MAX_SNAKE ; snake Y ring
Two indices track the head and tail positions:
wHeadIdx: ds 1 ; ring head index
wTailIdx: ds 1 ; ring tail index
When the snake moves, the head index advances and a new coordinate is written at that position. If the snake is not growing, the tail index also advances, effectively “forgetting” the oldest segment:
ld a, [wHeadIdx] ; headIdx
inc a ; headIdx++
and SNAKE_MASK ; wrap 0..63
ld [wHeadIdx], a ; store new headIdx
ld c, a ; C=headIdx
ld b, 0 ; B=0
ld hl, wSnakeX ; HL=X base
add hl, bc ; HL=&X[headIdx]
ld a, [wNewX] ; newX
ld [hl], a ; store
ld hl, wSnakeY ; HL=Y base
add hl, bc ; HL=&Y[headIdx]
ld a, [wNewY] ; newY
ld [hl], a ; store
The and SNAKE_MASK instruction handles wrapping. When headIdx reaches 64, the AND with 63 wraps it back to 0. This is why MAX_SNAKE must be a power of two.
Occupancy Grid Collision Detection
A naive collision check would iterate through all snake segments every frame. With up to 64 segments, that is expensive. Instead, we maintain an occupancy grid:
wOcc: ds PLAY_W*PLAY_H ; 20*18 occupancy grid (360 bytes)
DEF OCC_EMPTY EQU 0 ; Occupancy: empty
DEF OCC_SNAKE EQU 1 ; Occupancy: snake
DEF OCC_FOOD EQU 2 ; Occupancy: food
Each cell in the 20x18 grid contains a single byte indicating its contents. Collision detection becomes a single lookup:
ld a, [wNewY] ; B=y
ld b, a ; B=y
ld a, [wNewX] ; C=x
ld c, a ; C=x
call GetOcc ; A = occ at (x,y)
cp OCC_EMPTY ; empty?
jr z, .OccOK ; ok
cp OCC_FOOD ; food?
jr nz, .MaybeTail ; else maybe snake/tail
ld a, 1 ; grow=1
ld [wGrow], a ; store
jr .OccOK ; ok
The grid is kept synchronized with the snake’s movement. When the head moves to a new cell, that cell is marked as snake. When the tail leaves a cell, that cell is marked as empty:
; Mark new head position
ld a, [wNewY] ; B=y
ld b, a ; B=y
ld a, [wNewX] ; C=x
ld c, a ; C=x
ld a, OCC_SNAKE ; occ=snake
call SetOcc ; mark occupancy
; ... later, if not growing ...
; Clear old tail position
ld a, [wTailY] ; B = tailY
ld b, a ; B
ld a, [wTailX] ; C = tailX
ld c, a ; C
ld a, OCC_EMPTY ; occ = empty
call SetOcc ; clear occupancy
Grid Index Calculation
Converting (x, y) coordinates to a grid index requires computing y*20+x. Without multiplication instructions, this is done with shifts and adds:
FieldIndex: ; B=y, C=x -> HL = &wOcc[y*20+x]
ld h, 0 ; H=0
ld l, b ; L=y
add hl, hl ; y*2
add hl, hl ; y*4
ld d, h ; D=hi(y*4)
ld e, l ; E=lo(y*4)
add hl, hl ; y*8
add hl, hl ; y*16
add hl, de ; y*20
ld a, l ; A=low
add a, c ; +x
ld l, a ; store
jr nc, .NC ; carry?
inc h ; add carry
.NC: ; no carry
ld de, wOcc ; DE=base
add hl, de ; HL+=base
ret ; return
The calculation uses the identity y*20 = y*16 + y*4. We compute y*4 by shifting twice, save it, continue shifting to get y*16, then add the saved y*4 value.
Snake Movement Logic
Each movement step follows this sequence:
- Calculate the new head position based on current direction
- Check for wall collision
- Check the occupancy grid for body collision or food
- Update the ring buffer and occupancy grid
- Set dirty flags for rendering
Direction Application
The new head position starts as a copy of the current head, then one coordinate is modified based on direction:
ld a, [wDir] ; dir
cp DIR_UP ; up?
jr nz, .ChkR ; no
ld a, [wNewY] ; newY
dec a ; --
ld [wNewY], a ; store
jr .DirDone ; done
.ChkR: ; right?
cp DIR_RIGHT ; right?
jr nz, .ChkD ; no
ld a, [wNewX] ; newX
inc a ; ++
ld [wNewX], a ; store
jr .DirDone ; done
; ... similar for down and left ...
Wall Collision
Wall collision is a simple bounds check against the playfield edges:
ld a, [wNewX] ; x
cp 0 ; wall?
jp z, .GameOver ; yes
cp (PLAY_W-1) ; wall?
jp z, .GameOver ; yes
ld a, [wNewY] ; y
cp 0 ; wall?
jp z, .GameOver ; yes
cp (PLAY_H-1) ; wall?
jp z, .GameOver ; yes
The playfield has a 1-tile border on all sides, so valid positions are 1 through 18 for X and 1 through 16 for Y.
The Tail Chase Edge Case
There is a subtle edge case: the snake should be allowed to move into the cell currently occupied by its tail, because the tail will vacate that cell in the same movement step. The code handles this explicitly:
.MaybeTail: ; occ == snake
ld a, [wNewX] ; compare with tailX
ld d, a ; D=newX
ld a, [wTailX] ; A=tailX
cp d ; newX==tailX?
jp nz, .GameOver ; no => collide
ld a, [wNewY] ; compare with tailY
ld d, a ; D=newY
ld a, [wTailY] ; A=tailY
cp d ; newY==tailY?
jp nz, .GameOver ; no => collide
; yes => moving into tail tile is allowed
This allows the snake to turn tightly without incorrectly triggering a self-collision.
Direction Input
The joypad is read by selecting either the d-pad or button lines, then reading the result:
ReadJoypad: ; read joypad
ld a, $20 ; select d-pad
ld [rP1], a ; write
ld a, [rP1] ; dummy
ld a, [rP1] ; read
and $0F ; low nibble
ld b, a ; B=dpad
ld a, $10 ; select buttons
ld [rP1], a ; write
ld a, [rP1] ; dummy
ld a, [rP1] ; read
and $0F ; low nibble
swap a ; to high nibble
or b ; combine
cpl ; active-low -> pressed=1
ld [wJoyCur], a ; store held
The double read after writing to P1 is necessary because the hardware needs time to stabilize[02]. The Game Boy’s joypad lines are active-low, so we complement the result to get pressed=1.
Edge detection (newly pressed buttons) is computed by comparing with the previous frame:
ld a, [wJoyPrev] ; prev
cpl ; ~prev
ld b, a ; B=~prev
ld a, [wJoyCur] ; cur
and b ; edges
ld [wJoyPressed], a ; store edges
A button shows up in wJoyPressed only on the frame it transitions from released to pressed.
Preventing Reverse Direction
The snake cannot turn 180 degrees. If it could, moving right then immediately pressing left would cause instant self-collision. The direction update routine checks for this:
UpdateDirectionFromHeld: ; update direction
; ... determine candidate direction in B ...
.Try: ; apply if not reverse
ld a, [wDir] ; current
cp b ; same?
ret z ; no change
xor 2 ; opposite
cp b ; candidate==opposite?
ret z ; ignore reverse
ld a, b ; new dir
ld [wDir], a ; store
The xor 2 trick works because opposite directions differ by 2:
- UP (0) XOR 2 = DOWN (2)
- RIGHT (1) XOR 2 = LEFT (3)
- DOWN (2) XOR 2 = UP (0)
- LEFT (3) XOR 2 = RIGHT (1)
Food Spawning
When the snake eats food, a new food location must be chosen. The spawn routine tries random positions until it finds an empty cell:
SpawnFoodNoDraw: ; choose empty cell for food
.Try: ; retry
call Random8 ; rnd
and $1F ; 0..31
cp X_MAX ; >=18?
jr nc, .Try ; retry
inc a ; 1..18
ld [wFoodX], a ; store X
call Random8 ; rnd
and $1F ; 0..31
cp Y_MAX ; >=16?
jr nc, .Try ; retry
inc a ; 1..16
ld [wFoodY], a ; store Y
ld a, [wFoodY] ; B=y
ld b, a ; B
ld a, [wFoodX] ; C=x
ld c, a ; C
call GetOcc ; A=occ
cp OCC_EMPTY ; empty?
jr nz, .Try ; no => retry
ret ; yes => done
The random number is masked to 0-31, then values outside the valid range are rejected. This rejection sampling is simple but can loop many times if the snake fills most of the playfield.
Random Number Generation
The PRNG is a simple linear feedback shift register mixed with the DIV register:
Random8: ; tiny PRNG (state + DIV)
ld a, [wRand] ; state
ld b, a ; copy
ld a, b ; A=state
and 1 ; bit0
ld c, a ; C=bit0
ld a, b ; A=state
srl a ; >>1
and 1 ; bit1
xor c ; feedback
ld c, a ; C=feedback
ld a, b ; A=state
srl a ; >>1
ld b, a ; B=shifted
ld a, c ; feedback?
or a ; flags
jr z, .NoSet ; if 0 skip
set 7, b ; set MSB
.NoSet: ; done
ld a, b ; new state
ld [wRand], a ; store
ld b, a ; B=state
ld a, [rDIV] ; DIV
xor b ; mix
ret ; return A
Mixing with DIV adds entropy from the hardware timer, which varies based on exact timing of game events. This is not cryptographically secure but is sufficient for gameplay randomness.
Text Rendering
The game displays text for the title screen, game over message, and pause overlay. A simple text blitter converts ASCII to tile indices:
BlitTextASCII: ; B=y, C=x, DE=0-terminated ASCII
push de ; save DE
call CalcBGAddr ; HL = BG dest
pop de ; restore DE
.Loop: ; loop chars
ld a, [de] ; char
inc de ; advance
or a ; NUL?
ret z ; done
cp " " ; below space?
jr c, .Space ; map to space
cp $80 ; beyond 0x7F?
jr nc, .Space ; map to space
sub " " ; ASCII -> tile index
ld [hli], a ; write
jr .Loop ; next
.Space: ; space/unsupported
xor a ; TILE_EMPTY (space)
ld [hli], a ; write
jr .Loop ; next
The tile data is arranged so that tile 0 is space, tile 1 is ‘!’, tile 2 is ‘”’, and so on, matching ASCII order starting from character 32 (space). Converting ASCII to tile index is a simple subtraction.
Memory Layout
The game’s RAM usage is organized in the WRAM section:
SECTION "WRAM", WRAM0 ; Work RAM section
wState: ds 1 ; current state
wNextState: ds 1 ; queued state
wNeedRebuild: ds 1 ; rebuild flag
wJoyCur: ds 1 ; held buttons
wJoyPrev: ds 1 ; prev held
wJoyPressed: ds 1 ; edge presses
wFrame: ds 1 ; title blink frame
wBlink: ds 1 ; title blink toggle
wMoveCounter: ds 1 ; move pacing
wPaused: ds 1 ; pause flag
; ... more state variables ...
wSnakeX: ds MAX_SNAKE ; snake X ring (64 bytes)
wSnakeY: ds MAX_SNAKE ; snake Y ring (64 bytes)
wOcc: ds PLAY_W*PLAY_H ; occupancy grid (360 bytes)
Total RAM usage is under 500 bytes, well within the Game Boy’s 8 KB of work RAM. The largest allocations are the occupancy grid (360 bytes) and the snake coordinate arrays (128 bytes combined).
Conclusion
This Snake implementation demonstrates several fundamental Game Boy programming techniques:
- State machines for managing game flow.
- Ring buffers for efficient queue-like data structures.
- Occupancy grids for O(1) collision detection.
- VBlank-safe rendering to avoid screen corruption.
- Polling input with edge detection.
The code prioritizes clarity over optimization. There are opportunities for improvement: using hardware interrupts instead of busy-wait loops, speeding up the occupancy index calculation with a lookup table, or adding sound effects. But as a learning exercise and playable game, it accomplishes its goals.
The complete source code is available on GitHub.
If you enjoyed reading this post, you can support me by buying the game on itch.io. It helps me spend more time building small games, writing technical breakdowns like this one, and turning experiments into complete projects that other people can play and learn from.
Comments