2023-05-12
A Very Big GameBoy "Game"
I made a big GameBoy "game"! Here's the code including the ROM.
All you can do is scroll around to see the level. It only has one level and it's 32,766x16,384 pixels big!
It looks like this:
Ok not very exciting.
Let's see how it works!
How it Works
I'm not using any compression or in-game procedural generation for the level map so the entire 32,768x16,384 pixel level is stored entirely in the game ROM or on the cartridge if you were to put it on a cartridge for some reason. The GameBoy uses tile-based graphics with 8x8 tiles. So the level is 4,096x2,048 tiles big.
The game is designed to use an MBC5 type cartridge. I chose that type of cartridge because it's allows the biggest ROM size for standard cartridge size, Giving me 8MB to work with! It supports 512 ROM banks, each 16KB.
To make things simple bank 0 is only used for code and the tile graphics. The map data is in the other 511 banks.
The map data is made up of 1-byte tile indexes that say what tile goes where. As you move around the level the game copies those indexes from the game ROM to the tile map in the GameBoy's VRAM.
So the real problem is to know where to copy from and where to copy to. I ended up solving that problem by keeping track of what part of the level map was on screen with two 16-bit variables: vWorldColumn
and vWorldRow
. I used those two variables track what tile in the world/level map is being displayed in the top left corner of the screen. The trick was that I could use a world column and row coordinate to get the ROM address of the needed tile map entry, and the address of where it goes in the VRAM tile map.
Why it's Complicated
If you've ever done any programming where you've had to switch between 2D array indexing and 1D array indexing this might not sound very complicated. If you know the 2D array's dimensions W
xH
and you want the value at some 2D index x,y
then you can do some math:
index = x + y * W
and that gives you the 1D index. So that would let you get the ROM address of the map data you're looking for. To get the VRAM address you could do some more math:
romIndex = (x % 32) + (y % 32) * 32
The first problem is that the GameBoy is an 8-bit computer and those variables are 16-bits. That math isn't coming cheap. Getting the romIndex
would be easy enough but the x + y * W
would be a pain.
The other problem is ROM banks. If you're not careful then you could end up doing a bunch of bank changes while copying one column of tile map entries. I'd rather avoid that.
How I Map
I got around these problems by assigning meaning to the different bits of vWorldRow
and vWorldColumn
.
vWorldRow
has the structure:
0000 0PQQ QQRr rrrr
and vWorldColumn
has the structure:
0000 SSSS TTTt tttt
The first thing to notice is the leading zeros in both. vWorldRow
only uses the 11 least significant bits and vWorldColumn
uses 12. That lets me have 2,048 rows and 4,096 columns.
The different letters categorize the rest of the bits into their different meanings.
P
and Q
from vWorldRow
and S
from vWorldColumn
are the 9 bits of the bank number:
0000 000P QQQQ SSSS
The rest are for the ROM address to where the tile map entry is stored:
01Rr rrrr TTTt tttt
The address starts with 01
because that's the banked half of the ROM addresses (controlled by the current bank number). Address that start with 00
are always bank 0. Address that start with 1
aren't for ROM.
The lower case letters give you the tile map entry's address in VRAM:
1001 10rr rrrt tttt
The address starts with 1001 10
because I'm using tile map 0, which starts at $9800
.
How Does That Compute
The easy part is getting the ROM address since the needed bits align nicely to 8-bit values. I can just take the rightmost 8 bits from vWorldColumn
as is to get the rightmost 8 bits of the ROM address. I can copy the rightmost 8 bits of vWorldRow
and set the left 2 bits to 01
to get the leftmost 8 bits of the ROM address.
Since the bank number portion of vWorldRow
is spread across 2 bytes it's a bit more trouble. Here's some code:
worldToSource:
;; Converts vWorldColumn and vWorldRow into a copy source address and bank.
;; Stores the address in vCopySoure and the bank in vSourceBank.
;; Modifies:
;; A, B, C
;; vWorldRow has the form:
;; xxxx xPQQ QQRR RRRR
;; vWorldColumn has the form:
;; xxxx SSSS TTTT TTTT
;;
;; This function produces
;; vCopySource with the form:
;; 01RR RRRR TTTT TTTT
;; vSourceBank with the form:
;; 0000 000P QQQQ SSSS
;; First get the source address because it's simple.
ldh a, [vWorldRow + 1]
ld c, a ; need this later for the bank too
and %00111111
set 6, a
ldh [vCopySource], a
ldh a, [vWorldColumn + 1]
ldh [vCopySource + 1], a
;; Next get the source bank.
ldh a, [vWorldRow]
and %00000111
sla c ; Get the highest bit of the lower byte of vWorldRow in the carry flag.
rla ; Rotate the carry flag into the higher byte of vWorldRow and get the
; high bit into the carry flag.
sla c
rla
;; A is now %000PQQQQ
swap a
;; A is now %QQQQ000P
ld b, a
res 0, b ; B is now %QQQQ0000
and %00000001 ; Clear all but P from A.
ldh [vSourceBank], a
ldh a, [vWorldColumn]
and %00001111
;; A now has 0000 SSSS
;; and B has QQQQ 0000
or b
;; Now A has QQQQ SSSS
ldh [vSourceBank + 1], a
ret
Getting the VRAM address involves a bit of bit twiddling. Getting the right bits out of vWorldColumn
and vWorldRow
is simple enough, but the bits need to be shifted around to get the right VRAM address:
worldToDest:
;; Converts vWorldColumn and vWorldRow into a copy destination based on
;; _SCRN0. Stores the address in vCopyDest.
;; Modifies:
;; A, B
;; vWorldRow has the form:
;; xxxx xPQQ QQRr rrrr
;; vWorldColumn has the form:
;; xxxx SSSS TTTt tttt
;;
;; This function produces
;; vCopyDest with the form:
;; 1001 10rr rrrt tttt
ldh a, [vWorldRow + 1]
and %00011111
sla a
;; A now has 00rr rrr0.
swap a
ld b, a ; Will use B to calculate the lower byte later.
;; Use A to calculate the high byte.
and %00000011
or HIGH(_SCRN0)
;; A now contains 1001 10rr.
ld [vCopyDest], a
ld a, b
and %11100000
ld b, a
ldh a, [vWorldColumn + 1]
and %00011111
or b
ld [vCopyDest + 1], a
ret
Problem
One problem that this scheme has is that it assumes bank 0 is usable for tile map entries but I said that I avoided putting any of that data in bank 0. To fix that I just created a big dead-zone for the part of the level that would have been stored in bank 0. I render it as just # characters. That area is 256x64 tiles big so the level is actually 32,766x16,384 - 2048x512 pixels. You can still go to that area it's just even more boring than the rest of the level.
Generating the Level
The level is generating with a python script. I made it to help debug and validate the GameBoy code. It splits the level into 8 tile wide chunks and each chunk displays its row and column. That way it helps me figure out if it's loading the right tile map entries as I scroll.
The code is pretty short:
#!/usr/bin/python3
import random
def pad(l, length):
needed_padding = length - len(l)
return ([0] * needed_padding) + l
def toHexCharacters(n):
return [int(d, 16) for d in list(hex(n))[2:]]
def main():
tile_values_sections = []
col_start = 0x100
for i in range(0x4000, 0x800000, 8):
col_addr = i & 0xff
row_addr = (i >> 8) & 0x3f
col_bank = (i >> 14) & 0xf
row_bank = (i >> 18) & 0x1f
row = (row_bank << 6) | row_addr
col = (col_bank << 8) | (col_addr >> 3)
row_digits = pad(toHexCharacters(row), 3)
col_digits = pad(toHexCharacters(col), 3)
tile_values_sections.append(row_digits + [16] + col_digits + [17])
map_values = [i for c in tile_values_sections for i in c]
map_bytes = bytes(map_values)
with open('long_map.tilemap', 'wb') as f:
f.write(map_bytes)
if __name__ == '__main__':
main()