Introduction

The original Nintendo Gameboy was released in 1989 and is one of the most recognisable game consoles from the 90s era. I’ve always wanted to write a game for it, but sadly this was a project that I kept postponing, mainly due to the associated complexity and dedication required when working with assembly code.

This series of blog posts aim to serve as a collection of notes for myself, but I am also taking this opportunity to share my findings in the hope that they may be useful for your own projects.

Nintendo Gameboy
Nintendo Gameboy

My objective is to create a simple, yet fully functional game, written entirely in assembly. I will begin this journey with the simplest possible program that runs on the Gameboy and build upon it iteratively. In each iteration, I will go through the code, line by line, and explain what each instruction does. I will also try to give further context around some subtle details that might not be obvious at first glance.

In this post, I introduce the development tools and then write a simple program that prints “hello world!” on the screen.

The tools of the trade

My development platform is Debian Linux, however you should be able to follow the same process under any OS. I use the RGBDS toolchain, with Vim serving as my IDE, and BGB for debugging.

A screenshot of BGB's interface

You can download the toolchain and the debugger using the links below:

The toolchain is fairly simple to follow.

Toolchain diagram

rgbasm The assembler, translates the source code into an object file
rgblink The linker, links the object files rgbasm produces into a ROM file
rgbfix Utility that calculates ROM checksums and sets up the header

Hello Gameboy

So, without further ado, let’s dive into the implementation of a simple ‘hello world!’ program. Here’s the entire source code. It might look scary at first, but worry not, as I will go through every single line and explain what it does.

INCLUDE "hardware.inc" 
VRAM_FONT EQU $9000
SECTION "Header", ROM0[$100]                                      
Header:
    di
    jp Start
REPT $150 - $104
    db 0
ENDR
Start:                 
    .turnOffAudio
    xor a
    ld [rNR52], a
.waitVBlank
    ld a, [rLY] 
    cp SCRN_Y  
    jp c, .waitVBlank
.turnOffLCD
    xor a
    ld [rLCDC], a
.copyFontSetup
    ld hl, VRAM_FONT
    ld de, FontTiles
    ld bc, FontTilesEnd - FontTiles
.copyFont
    ld a, [de]
    ld [hli], a
    inc de
    dec bc
    ld a, b
    or c
    jp nz, .copyFont
.printString
    ld a, [de]
    ld [hli], a
    inc de
    and a
    jp nz, .printString
	ld a, %11100100
	ld [rBGP], a
	ld a, LCDCF_ON | LCDCF_BGON
	ld [rLCDC], a
.lockup
    jp .lockup
FontTiles:
INCBIN "font.chr"
FontTilesEnd:
HelloWorldStr:
    db "Hello World!", 0

This is what the result looks like on a gameboy:

The 'Hello world' ROM running in the emulator

Code walkthrough

I will explain the code by adding comments in each line. The program shown above is split into logical blocks and each block is analyzed separately.

hardware.inc

INCLUDE "hardware.inc"  ; This is a standard RGBDS header file
                        ; containing useful hardware definitions.

Definitions

VRAM_FONT EQU $9000  ; This address is carefully selected so that the
                     ; ascii value in a text string, corresponds to the
                     ; tile number in this address space. Observe the
                     ; tile values in the debugger for a more intuitive
                     ; demonstration of how this works.
SECTION "Header", ROM0[$100]                                      
Header:          ; This is where execution begins          Cycles  Bytes
    di           ; Disable interrupts                        1       1
    jp Start     ; Jump out of header space                  4       3
                 ; and go to the start of the program
REPT $150 - $104 ; This space is reserved for the header
    db 0         ; data. RGBFIX will modify the contents.
ENDR

Disable audio

Start:                 
.turnOffAudio
    ; The explicit way of turning audio off is:
    ;                        Cycles   Bytes     
    ;   ld a, AUDENA_OFF       2        1     
    ;   ld [rNR52], a          4        3     
    ;      
    ; Since AUDENA_OFF is equal to 0, a slightly more efficient
    ; version is:
    ;                        Cycles   Bytes     
    ;   xor a                  1        1     
    ;   ld [rNR52], a          4        3     
    ;      
    ; Note that All arithmetic/logic operations that use register
    ; A as destination can omit the destination as it is assumed
    ; to be register A by default. So the following two lines have
    ; the same effect:
    ;
    ;   or a, b
    ;   or b
    ;
    xor a         ; Set register a to 0
    ld [rNR52], a ; Set the value at the respective memory address 

Wait for Vertical Blank

.waitVBlank
    ld a, [rLY]       ; Load the LCD Y Coordinate.
    cp SCRN_Y         ; Check if the LCD is past VBlank
                      ; (rLY values of 144 to 153)
    jp c, .waitVBlank ; Loop until we are at VBlank 

Turn the LCD off

.turnOffLCD
    ; See notes from .turnOffAudio above. The same optimization applies
    ; here. The explicit version of this would do:
    ;   ld a, LCDCF_OFF
    xor a             ; Set a to 0
    ld [rLCDC], a     ; Set the value at the respective memory address 

Copy the font data

.copyFontSetup
    ld hl, VRAM_FONT  ; We will store the font data at this address
    ld de, FontTiles  ; Load the address for the font tile data in de 
                      ; Load the size of the buffer in bc
    ld bc, FontTilesEnd - FontTiles
                      ; The font data is exactly 2048 bytes long
                      ; After copying the font below, HL will point to
                      ; the display VRAM address $9800. This is
                      ; intentional, and very important to understand
                      ; in order to follow why we are able to copy data
                      ; to the screen. 

.copyFont
    ld a, [de]     ; Grab 1 byte from the source
    ld [hli], a    ; Load value at the destination, incrementing hl
    inc de         ; Move to next byte
    dec bc         ; Decrement count of bytes that we need to copy
    ld a, b        ; Check if count is 0, 'idec' doesn't update flags
    or c           ; Equivalent to 'b or c', 0 if all bits are 0
    jp nz, .copyFont ; Loop until the memory copy concludes
                     ; At this point HL will map to the display VRAM
                     ; address because of where the font is copied
                     ; and the size of the font data (see above). This
                     ; is a subtle but very important detail to
                     ; understand. 
.printString
    ld a, [de]   ; The string data starts right after the font data
                 ; which is exactly where DE is pointing to now
    ld [hli], a  ; Load value at the destination, incrementing hl
    inc de       ; Move to the next byte
    and a        ; Check if the byte we just copied is zero

    jp nz, .printString ; Loop until it is

Turn the LCD on

    ; During the first (blank) frame, initialize display registers
    ld a, %11100100
    ld [rBGP], a

    ; Turn the LCD on
    ld a, LCDCF_ON | LCDCF_BGON
    ld [rLCDC], a 

Lock up

.lockup
    jp .lockup  ; This is essentially an endless loop

Data

FontTiles:
INCBIN "font.chr"        ; Binary file containing the font data
FontTilesEnd:
HelloWorldStr:
    db "Hello World!", 0 ; The hello world string

References / Further Reading