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:
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
H 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 has the structure:
0000 0PQQ QQRr rrrr
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.
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
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
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
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 ( * 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 +  + col_digits + ) 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()