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:

  1. Title Screen: Displays “SNAKE” and a blinking “PRESS START” prompt.
  2. Play Screen: The active game where the snake moves and eats food.
  3. 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 0Lightest (binary 00)
Color 1Light (binary 01)
Color 2Dark (binary 10)
Color 3Darkest (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:

  1. Calculate the new head position based on current direction
  2. Check for wall collision
  3. Check the occupancy grid for body collision or food
  4. Update the ring buffer and occupancy grid
  5. 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.

References / Further Reading