Game Boy · assembly · raster tricks

fsub: porting F-Zero to the Game Boy

fsub is an F-Zero-style racer for the original Game Boy that draws a bending perspective road, a moving city and seven rivals by rewriting three hardware registers on every scanline over a picture that never changes.

The starting grid with seven rivals, and a hard left bend with a dirt patch
The starting grid, and a hard left-hander with a dirt patch. Both are captures from the ROM (October 9 build).

What fsub is

One picture that never moves

The background tilemap as colour numbers, beside the screen it produces
Left: everything in video memory, shown as colour numbers. Right: what the screen makes of it.

Speed from palettes (BGP)

Colour numbers times a per-line palette gives the picture
Colour numbers that never change, times one palette byte per scanline, gives the picture.
The shade of colour 2 on each ground line across the first 80 frames of a race
The first 80 frames of a race, one column per frame: every stripe is a building on its way to the camera.
BandScreen linesDepth (units)One table entryBlur window
Near0109-135173-2381 unitnone
Near189-108241-3331 unit5
Near275-88340-4621 unit11
Near365-74475-6401 unit21
Far051-64666-138716 unitsavenues only
Far143-501513-416016 unitsdistricts only
Far240-425547-16640statichaze

Bends from scroll (SCX)

The frame being drawn top to bottom, with the slice of the tilemap each line reads
One real frame being drawn. Left: the tilemap, lit where the screen has read it, the current line in orange. Right: the picture so far.
Four moments of the same frame: sky, upper ground, lower ground, status bar
Four moments of the same frame.
Tilemap, scroll offset and screen for a straight, a hard bend, pure steering, and both at once
Where each line reads from, for a straight, the hardest bend, pure steering, and both at once.
bend(d)=80·level32·(104−d103)2

d = 1 at the horizon, 104 at the bottom

shear(d)=s·d104

s = −20 … 20 pixels

Three frames approaching a left-hand corner: above a dashed line the road already bends, and the line moves down the screen
A left-hand corner on its way. Above the dashed line the road is drawn with the corner's bend; the line comes down the screen as the car closes in. (Current build, f01cb09.)

The skyline turns on the same layer

Three frames through a bend: the sky's slice of the map slides and wraps while the ground's stays put
Three frames through a bend. The cyan box is the sky's slice of the map; it slides and wraps while the ground's barely moves.

One frame, scanline by scanline

Interrupts run on 113 of the 154 lines in a frame; the top of the sky is freeOne frame, top to bottom. Each blue tick is one run of the per-line handler.skyhorizon glowgroundstatus barVBlankline 0314064136144153IRQscreenLines 0-31: no interruptsOne palette and one scroll, loaded in VBlank, serve all 32 lines.The CPU belongs to the main loop here.Line 31: LY = LYC interruptLoads the values for line 32, then re-arms STAT for HBlank instead.Line 64: tile block switchThe same handler flips one LCDC bit: tiles 0-127 now come from $9000.Lines 31-143: HBlank interrupt on every lineCopies three bytes for the next line: BGP, SCX, OBP1.54 machine cycles of the 114 in a scanline, about 35% of the CPU.Line 136: the status bar takes overThe hardware compares LY with WY and switches layer. No code runs.Lines 144-153: VBlank interrupt, onceSwap the line buffers, send the sprites by DMA, copy the status bar,load the three registers for line 0, arm LYC = 31.
One frame: 154 scanlines and 114 handler runs.

The interrupt handler

; fzero.asm, common path (comments added)
SECTION "stat vector", ROM0[$48]    ; the handler sits AT the vector: no jp
StatISR:
    push af
    push hl
    ldh a, [rLY]
    inc a
    ld l, a         ; L = the line about to be drawn
    ldh a, [hFront]
    ld h, a         ; H = first page of the live buffer
    ld a, [hl]
    ldh [rBGP], a   ; palette: forward motion
    inc h
    ld a, [hl]
    ldh [rSCX], a   ; scroll: bend and steering
    inc h
    ld a, [hl]
    ldh [rOBP1], a  ; sprite palette: rival paint
    ld a, l
    and %00011111   ; only lines 32 and 64 have extra work
    jr z, .event
    pop hl
    pop af
    reti
The handler only copies: the main loop precomputes, VBlank swaps a pointerTwo identical buffers. One is on screen while the other is being filled.fills3 bytesMain loop, once a frameWorks out all three values for every line,stages the sprites in a RAM copy of OAM,then sets hReady to say the frame is done.Spare bufferThree 256-byte pages, indexed by line.Never on screen while it is being filled.VBlank interrupt: hFront = hReadyA one-byte swap, plus the sprite DMA.The old live buffer becomes the spare.Live buffer: hFront is its high byteBGP pageSCX pageOBP1 pageL = LY + 1 picks the column,INC H steps down one page.HBlank handler, 113 times a frameH = hFront, L = LY + 1[HL] to BGP, then INC H[HL] to SCX, then INC H[HL] to OBP1Nothing is computed here.LCD registersPalette, scroll and sprite paint for the next line.
Two buffers of three pages, swapped in VBlank.

The status bar is a window

The background, window and sprite layers of one frame, and the composite
One frame taken apart: background, window, sprites, and all three together.

Every-other-frame drawing

Half the rivals move each frame; other work is spread out or skippedEight consecutive frames, left to right. A filled cell means the job runs on that frame.frame01234567Palette for the 96 ground linesRival paint on its scanlines (OBP1)Rivals 0, 2, 4, 6: move and redrawRivals 1, 3, 5: move and redrawDepth sort: compare one pair of rivalsScenery: look up one distance bandfar23456nearroof?Wreck: new noise in the spare tileScroll for the 96 ground linesSky palette, 40 linesStatus bar tilesrivals, on alternate framesruns on that frameruns only if its inputs changed
Ten jobs over eight frames.

Liveries are palettes

Sprite pixels times a per-line sprite palette gives seven liveries; below, all the car art in the ROM
Top: one car shape times a per-line sprite palette gives seven liveries. Bottom: every piece of car art in the ROM.

384 tiles from a 256-tile background

The screen tinted by which tile block each pixel came from, and the three blocks
Each pixel tinted by the tile block it came from; the white line is where the block switches.

Scenery, tunnels and fades are palette tables

City, underpass, water and wheat fields: four looks from one ground tilemap
City, underpass, water and wheat fields: four looks from one ground tilemap.

An explosion drawn into a tile

The car's sprite across the 70 frames of a wreck
The 70 frames of a wreck, sampled every tenth frame.

Python writes the tables, bots check the budget

Python writes the tables; bots in a headless emulator check the frame budgetTop row: the build. Bottom row: how the frame budget and the balance are checked.Python generatorsgen_gfx.py: art and tablesgen_tracks.py: four tracksuge.py: tracker songsGenerated every build384 tiles and the tilemappalette, bend, shear tablesfour 512-byte tracksconsts.inc: unrolled macrosrgbasm, rgblink, rgbfixfzero.gb: the 256 KB ROMfzero.sym: every addressHeadless emulatorbinjgb as WebAssembly in Node,the same build the web pageplays the game withThree bots drive a racemasher, casual, expertread RAM by symbol namepress buttons like a playerReport per raceplace, lap times, hitspeak CPU load, from hLoaddropped frames
The build and the playtest loop.

Code map

TechniqueWhere to read it
Per-line handler, self re-armingfzero.asm:167 StatISR
Buffer swap, sprite DMA, status bar copy, explosion tilefzero.asm:216 VBlankISR
Frame loop, ei + halt wait, load meterfzero.asm:813 MainLoop
Palette fill, scroll fill, skip-if-unchangedfzero.asm:886 BuildLines; macros FILL_BAND, FILL_LINE
Corner kink, jumping into an unrolled run part wayfzero.asm:1125 CornerAhead, fzero.asm:1221 GroundEntry, fzero.asm:1259 GroundLines
Unrolled per-line code and depth constantstools/gen_gfx.py:175 row_phase, emitted as FILL_ALL_BANDS
Scenery, one band a framefzero.asm:1285 UpdateScene
Paint across the road (finish line, plates)fzero.asm:2388 RoadFeatures, fzero.asm:2555 RoadBand
Rivals on alternate frames, paint, one-pair sortfzero.asm:1471 UpdateRivals
Road objects placed from a line's SCXfzero.asm:2008 UpdateObject
Skyline heading, physicsfzero.asm:3464 Drive
Status bar digestfzero.asm:2667 UpdateHud
Link cable: polling, packetsfzero.asm:2975 LinkPump, fzero.asm:3086 BuildPacket, fzero.asm:3182 LinkApply
Perspective tilemap, palette strips, bend and shear tablestools/gen_gfx.py:93 ground_row, tools/gen_gfx.py:149 pal_tables, tools/gen_gfx.py:367 bend_tables, tools/gen_gfx.py:602 shear_tables
Packing 384 tiles, sky slotstools/gen_gfx.py:739 build
Tracks as straights and corners, rival pacetools/gen_tracks.py:48 course, tools/gen_tracks.py:151 corner_speed, tools/gen_tracks.py:178 block
Playtest bots, link testtools/playtest.js, tools/linktest.c
Browser buildweb/index.html, deploy-web.sh