Chapter 17 - A VDP Game
Skyfall never asked whether two shapes had met. The paddle and the drop shared eight columns, so a landing was one subtraction against a column number: a board game collides by arithmetic on cells. On the VDP, sprites stand at pixel positions on a 256x192 scene, gliding over a tile grid they never disturb, and the questions change with the scenery: when have two sprites met, and how does something sitting in the grid get picked up by something floating above it?
Both answers are comparisons on state, and this chapter builds a complete game around them: Lanternfly. You are a white fly over a night garden, steered with 2/4/6/8, one pixel per frame. A lantern glows somewhere in the grid; reach its cell and you gather it, the score climbs on the LCD, and a fresh lantern appears somewhere else. A wasp hunts you the whole time, and every lantern you take quickens its stride. When it reaches you, the game ends - splash, playing, and game-over cards, the loop Skyfall taught, now over VRAM shadows.
Lanternfly on paper
Chapter 14’s habit first: the design in Glimmer’s terms, before any
block. The facts split into two coordinate systems, and the split
is the chapter in miniature. Sprites glide, so FlyX/FlyY and
WaspX/WaspY hold the two movers’ top-left pixels. The lantern
sits in the grid and never moves between cells, so LampCol and
LampRow hold a grid column and row. Score counts lanterns for
the LCD, and Armed gates the restart.
The moments: four held pulses steer the fly, ChaseTick moves the
wasp, AnyKeyP starts and restarts, GateP opens the restart
gate. Two schedules drive the pair - Pace, a writable oscillator
that is the wasp’s stride and the game’s difficulty, and Wait,
the game-over one-shot - both jobs Skyfall’s timers already did,
and the cards are Skyfall’s three, joined in the same three-press
loop. The budget check: eight facts, seven moments, and
CurrentCard spend 16 of the 32 change-flag cells.
The scene and the declarations
The file opens as Grove’s did - program Lanternfly, the platform
line, display tms9918 - and then declares the cast:
sprite Fly color white
"X......X"
".X....X."
"..XXXX.."
".XXXXXX."
".XXXXXX."
"..XXXX.."
"...XX..."
"........"
end
tile Lantern color lightyellow on black
"...XX..."
"..XXXX.."
".X.XX.X."
".XXXXXX."
".XXXXXX."
"..XXXX.."
"...XX..."
"........"
end
A second sprite, Wasp color darkyellow, and a second tile, Reed
color medgreen on black, follow in the same shape; their rows are
yours to draw, and any eight strings of X and . serve. What the
build cares about is the order and the colour pairs: the sprites
take slots 0 and 1, Lantern’s pair comes first so black is the
screen background, and Reed’s pair opens the next bank - the
generated equates read Lantern .equ 1, Reed .equ 8. The rest of
the file’s top types in the design:
state FlyX : byte = 124 ; the fly's top-left pixel
state FlyY : byte = 92
state WaspX : byte = 8 ; the wasp's top-left pixel
state WaspY : byte = 8
state LampCol : byte = 24 ; the lantern's grid cell
state LampRow : byte = 6
state Score : byte ; lanterns gathered
state Armed : byte ; game-over gate open: restart allowed
pulse UpP
pulse DownP
pulse LeftP
pulse RightP
pulse ChaseTick
pulse AnyKeyP
pulse GateP
timer Pace : byte = 8 -> ChaseTick ; the wasp's stride; lanterns shrink it
timer Wait : word = 0 -> GateP once ; armed on game over
bind key KEY_2 held period 1 -> UpP
bind key KEY_8 held period 1 -> DownP
bind key KEY_4 held period 1 -> LeftP
bind key KEY_6 held period 1 -> RightP
bind key any rising -> AnyKeyP
text MsgTitle "LANTERNFLY "
text MsgRun "GATHER THE LAMPS"
text MsgOver "THE WASP GOT YOU"
text MsgAny "PRESS ANY KEY "
text MsgScore "LAMPS "
text MsgPad " "
No fact carries changed - the cards own startup, as in Skyfall -
and the row-one messages are padded to sixteen characters so each
covers whatever the previous card wrote. The initial values are the
round-start scene - fly at the centre, wasp in the far corner,
lantern at cell (24, 6) - and the entry block below leans on them.
The splash card plants the garden
Chapter 16 planted Grove’s scene with a changed cell and promised
cards would do it with an enter block. Here it is:
enter SplashShow
begin
lcd_row MsgTitle, LcdRow1
lcd_row MsgAny, LcdRow2
tile_at Reed, 3, 2
tile_at Reed, 11, 21
tile_at Reed, 17, 2
tile_at Reed, 24, 22
tile_at Reed, 29, 21
ld a,(LampCol) ; take the last round's lantern off the grid
ld d,a
ld a,(LampRow)
ld e,a
xor a
call NamePut
xor a ; hide slot 0: sprite scanning stops there
ld d,0
ld e,$D1
call SpriteSet
end
Five tile_at lines stand five reeds in the dark, coordinates
written as immediates because the reeds never move. The two calls
after them clear the stage. The first knows the lantern only
through state: whatever cell the last round left it in, LampCol
and LampRow still say, so the block loads them into D and E and
writes the blank tile through NamePut - the runtime-coordinate
path chapter 16 pointed at. The second parks sprite slot 0 at y =
$D1, the terminator value from chapter 16’s startup: the VDP
stops processing sprites at the first slot holding it, so one write
hides the fly and the wasp together. On the very first frame both
calls touch cells already blank and a sprite already hidden. The
card’s one effect, StartGame, is chapter 13’s opening move: on
AnyKeyP, goto Playing.
Entering play
card Playing opens with its enter block:
; Entry re-raises every cell this card's renders draw from; the body
; gives those cells their round-start values first.
enter StartRound
updates FlyX, FlyY, WaspX, WaspY, LampCol, LampRow, Score, Pace
begin
lcd_row MsgRun, LcdRow1
ld a,124
ld (FlyX),a
ld a,92
ld (FlyY),a
ld a,8
ld (WaspX),a
ld (WaspY),a
ld a,24
ld (LampCol),a
ld a,6
ld (LampRow),a
xor a
ld (Score),a
ld a,8
ld (Pace),a ; the wasp starts at a stroll
end
The entry re-raise from chapter 13 here carries a whole game. A
card-gated render never sees flags raised while its card slept, so
the updates line names every cell the Playing renders read, and
entry marks them all. The body writes the round-start values first,
and replay follows: every round begins exactly where the first did,
repainted from fresh values. Pace closes the list the way
Gravity closed Skyfall’s - a timer cell carries no flag, so its
entry documents the write and compiles to nothing.
Four move effects steer the fly, and they are Grove’s four moves from chapter 16 with the moth’s cells and pulses renamed for the fly: up and left stop at zero, down at 184, right at 248, each on its own held pulse at period 1.
The chaser
effect ChaseStep
on ChaseTick
updates WaspX, WaspY
begin
ld a,(FlyX)
ld b,a
ld a,(WaspX)
cp b
jr z,_vert ; level: no sideways step
jr c,_right ; left of the fly: step right
dec a
jr _wx
_right:
inc a
_wx:
ld (WaspX),a
_vert:
ld a,(FlyY)
ld b,a
ld a,(WaspY)
cp b
jr z,_done
jr c,_down
dec a
jr _wy
_down:
inc a
_wy:
ld (WaspY),a
_done:
end
Every ChaseTick, one compare per axis points the wasp at the fly:
carry from cp b means the wasp sits left of - or above - its
target, so it steps toward; no carry steps the other way; equal
skips the axis. The wasp carries no clamps: it only ever steps
toward the fly, whose own clamps fence the space. The stride is
Pace: at the opening period of 8 the wasp drifts, and every
gathered lantern will shrink the period, down to 1 - a step every
frame, the fly’s own speed, both axes in one tick. Past the first
few lanterns, only cornering saves you.
Gathering from the grid
The lantern test is the chapter’s new move: the fly lives in pixels, the lantern in cells, so the effect converts one into the other and compares.
effect Gather
on FlyX, FlyY
updates Score, LampCol, LampRow, Pace
begin
ld a,(FlyX) ; the grid cell under the fly's centre
add a,4
srl a
srl a
srl a
ld b,a
ld a,(LampCol)
cp b
jr nz,_done
ld a,(FlyY)
add a,4
srl a
srl a
srl a
ld b,a
ld a,(LampRow)
cp b
jr nz,_done
ld a,(LampCol) ; gathered: take the lantern off the grid
ld d,a
ld a,(LampRow)
ld e,a
xor a
call NamePut
ld a,(Score)
inc a
ld (Score),a
ld a,(Pace) ; quicken the wasp, floor at one frame
cp 2
jr c,_respawn
dec a
ld (Pace),a
_respawn:
ld c,ApiRandom
rst $10
and %00011111 ; column 0..31
ld (LampCol),a
ld c,ApiRandom
rst $10
and %00001111
add a,4 ; row 4..19: clear of the reeds
ld (LampRow),a
_done:
end
Adding 4 picks the fly’s centre pixel, and three shifts divide by
eight: pixel position to grid cell in four instructions. The cell
matches the lantern’s column and row or the block leaves. On a
match, the old cell goes blank through NamePut, and it must go
blank here, inside the effect: four lines later the respawn
overwrites LampCol and LampRow, and no render will ever again
know which cell to erase. Then the score climbs, Pace shrinks
with its floor - the difficulty screw, turned by an ordinary timer
write as in Skyfall - and the respawn masks one random byte to a
column and folds another into rows 4..19, the band the reeds stay
out of.
Drawing the new lantern belongs to a render. PlaceLantern, on
LampCol, LampRow, is the six-line runtime NamePut call from
SplashShow with ld a,Lantern in place of the xor a, and it
serves twice: when Gather respawns the lantern mid-round, and on
round entry, when StartRound’s re-raise gives the first lantern
its first draw. Two more renders, Grove’s PlaceMoth twice over,
place the movers: sprite_at Fly, FlyX, FlyY in PlaceFly, the
same for the wasp in PlaceWasp.
Colliding with the wasp
effect Caught
on FlyX, FlyY, WaspX, WaspY
updates CurrentCard
begin
ld a,(FlyX)
ld b,a
ld a,(WaspX)
sub b
jr nc,_ax
neg
_ax:
cp 6 ; tolerance: the bodies really overlap
jr nc,_done
ld a,(FlyY)
ld b,a
ld a,(WaspY)
sub b
jr nc,_ay
neg
_ay:
cp 6
jr nc,_done
ld a,Card.GameOver ; conditional navigation, as in Skyfall
ld (CurrentCard),a
_done:
end
Sprite collision is the distance between two facts. sub b and a
conditional neg produce the absolute pixel difference per axis,
and both differences under a tolerance means caught. The tolerance
is the design decision: two 8-pixel bodies touch edges at a
difference of 8, so cp 8 would end the game the frame the boxes
meet, while 6 waits for real overlap and gives the player the near
miss they will swear they earned. Skyfall resolved a landing with
one subtraction; sprites spend one per axis, folded absolute before
the compare. The ending writes CurrentCard directly - a
transition that depends on a runtime test, chapter 13’s rule.
The score on the LCD
render ShowScore
on Score
begin
lcd_row MsgScore, LcdRow2
ld a,(Score)
ld b,'0' ; tens digit, counted up in ASCII
_tens:
cp 10
jr c,_tdone
sub 10
inc b
jr _tens
_tdone:
ld a,b
ld c,ApiCharToLcd
rst $10
ld a,(Score) ; ones digit: reduce the score again
_ones:
cp 10
jr c,_odone
sub 10
jr _ones
_odone:
add a,'0'
ld c,ApiCharToLcd
rst $10
ld hl,MsgPad ; cover the tail of the old row-2 message
ld c,ApiStringToLcd
rst $10
end
The seven-segment HUD stayed with the matrix profile, so the LCD
carries the number, extending the idiom Skyfall’s lives display
built: lcd_row streams its string and leaves the cursor where the
digits belong. The tens digit counts up in B, starting at '0' and
stepping once per subtracted ten, and goes out through
ApiCharToLcd; the block then reads Score again and reduces it a
second time for the ones digit, so no register has to survive the
first API call. Eight spaces of MsgPad cover the tail of the
splash card’s invitation: LAMPS 07, whole row owned, every time
the score changes.
Game over, gated
The GameOver card is Skyfall’s, keystroke for keystroke:
GameOverShow writes MsgOver to row one, closes the gate with
Armed, and arms Wait at 90 frames; OpenGate fires on GateP,
writes MsgAny to row two, and opens the gate; Restart tests
Armed before writing Card.Splash. What the player sees while
the gate holds is new. Card gating stops the move and chase blocks,
so no shadow changes, no commit carries anything, and VRAM keeps
the final scene: the wasp frozen on top of the fly among the reeds,
the score on the LCD naming the run. A restart walks the loop
through Splash, where SplashShow hides the actors and strikes the
lantern.
Inside the generated file
Build the game and open lanternfly.main.asm at the render blocks:
; --- render block PlaceFly ---
.routine
Glim_PlaceFly:
sprite_at Fly, FlyX, FlyY
ret
The line stands in the generated file exactly as you wrote it,
because sprite_at is an AZM op: the assembler substitutes its
body at each call site, so this line assembles as the six
instructions from chapter 16’s op definition with this site’s
arguments folded in - FlyX and FlyY read into D and E, Fly
becoming ld a,0, then call SpriteSet. Glim_SplashShow reads
the same way: five tile_at lines, one op, five expansions, each
with its own reed’s coordinates as immediate loads.
The enter block’s wrapper is the chapter’s second lesson in delivery:
ld a,(Raised0) ; deliver to later phases this frame
or CHG_LAMPCOL + CHG_LAMPROW + CHG_SCORE
ld (Raised0),a
ld a,(Next0) ; a consumer already ran: defer to next frame
or CHG_FLYX + CHG_FLYY + CHG_WASPX + CHG_WASPY
ld (Next0),a
ret
StartRound names eight cells and Glimmer stages them in two
groups, by chapter 5’s exactly-once rule. LampCol, LampRow, and
Score have render consumers only, so they travel through
Raised0 and reach PlaceLantern and ShowScore the same frame:
lantern and score appear the instant play begins. The four sprite
cells also feed Gather and Caught, logic blocks whose phase has
already run, so the whole change defers through Next0 and the fly
and wasp take the stage one frame later - a sixtieth of a second
the eye cannot find, spent keeping every consumer’s view whole.
The same staging sets what motion costs. Trace a held key 6 through
to the screen. Frame N: MoveRight steps FlyX, and the change
defers, because Gather and Caught sit in the same phase. Frame
N+1: the two effects test the new position, PlaceFly runs, and
SpriteSet files two shadow bytes - y, then x - and sets
SpriteDirty. Frame N+2 opens in the vertical blank, GlimCommit
sees the flag and streams all 128 sprite-attribute bytes to VRAM,
and the fly stands one pixel to the right. Two frames of latency
from pulse to picture, and none of rate - the pipeline refills
every frame, so the held key still crosses the screen at sixty-odd
pixels a second. A gather frame adds up to two dirty name rows, 32
bytes each; a still frame costs the commit one clear flag and three
clear group bytes, and the VDP paints the standing scene without
help.
Reading Sprite Chase
The repository ships this chapter’s cousin:
examples/sprite-chase.glim, the same profile with the chase
turned inside out - your sprite hunts a fleeing target, and
catching it scores. Its single card Playing opens with the entry
re-raise idiom at its purest: StartPlaying has an empty body
and four position cells in its updates line - the initial values
already hold the scene, so entry marks them and the renders paint
them, StartRound with its reset code removed. Its MovePlayer
folds the four move effects into one block by reading the pulse
cells directly, ld a,(UpP) and its three siblings, legal because
a pulse is a byte cell like any other for the frame it holds.
The rest of the file reads as variations on your own blocks.
FleeTarget is ChaseStep with the conclusion flipped - carry
steps away - plus the clamps the wasp never needed, because
fleeing runs into walls. Collide is Caught at tolerance 8 with
a respawn where your game changes card, and the respawn masks
ApiRandom exactly as Gather does. The score display swaps
surfaces: DrawScore calls NamePut with a runtime column and
drops a Pip tile on the top grid row, the tile grid itself as
scoreboard where Lanternfly borrowed the LCD. Build it and corner
the target; every technique in it is now yours.
Summary
- Sprite collision is state arithmetic: absolute pixel difference per axis, each under a tolerance. 8 means bounding boxes touch; smaller tolerances demand overlap.
- Tile-grid pickups compare cells: centre the sprite (+4), divide
by eight (three shifts), and match the pickup’s cell state. Fixed
scenery takes
tile_atimmediates; a moving pickup takesNamePutwith runtime coordinates, and the effect that respawns it erases the old cell itself. - The entry re-raise carries the profile’s cards:
updatesnames every cell the card’s renders read, the body writes round-start values, and replay repaints from fresh state. Render-only cells arrive the same frame throughRaised0; cells with logic consumers defer whole throughNext0. - Motion costs two frames from pulse to picture - defer, shadow write, commit - with no loss of rate.
- The LCD is this profile’s number surface:
lcd_rowparks the cursor, repeated subtraction makes ASCII digits forApiCharToLcd, and a padding string owns the rest of the row.
Two complete games now stand finished, one on each display. The last chapter reads them side by side.