The Lamps
The game's first real objects: an unlit lantern glyph, drawn once the direct way — then eight of them scattered from a data table, standing on walkable floor, and surviving the lamplighter's passage untouched.
The stage is built — five units of canvas and five of honest movement — and now the game starts arriving. Gloaming is about a lamplighter, which means it’s about lamps: eight of them, standing cold around the square, waiting for him. This unit puts them there. Nothing lights yet — a lamp you can light needs a lamp that exists first — but by the end the square has contents, a purpose you can see, and one more proof that the machinery you built was worth building carefully.
A lantern is a cell
Like the lamplighter, a lantern is one character cell: eight bitmap bytes and an attribute. Here’s its shape —
— and its colour is doing quiet double duty:
LAMP_UNLIT equ %00000101 ; cyan INK on black PAPER
Cyan on black: cold, unlit, waiting. But look at bit 3 — the PAPER field’s bottom bit, the one wall_at tests — it’s clear. That’s a design decision, not an accident: a lamp reads as floor. The lamplighter has to be able to stand on a lamp, because standing on one is how he’ll light it next unit. One byte carries the look and the physics, which is the same trick the walls play, pointed the other way.
Milestone 1 — one lantern, the direct way
draw_lantern is nothing new — attribute first, then eight glyph bytes down the cell, the exact shape draw_lamp has had since Unit 3. Place one, the obvious way: put the column and row in registers and call it.
| 8 | 8 | WALL equ %00001111 ; PAPER blue (1), INK white (7) — pale stone | |
| 9 | 9 | WALL_BIT equ 3 ; the attribute bit that says "this is wall" | |
| 10 | 10 | LAMP_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — his own light | |
| 11 | + | LAMP_UNLIT equ %00000101 ; cold cyan INK on black PAPER — bit 3 | |
| 12 | + | ; clear, so a lamp reads as floor | |
| 11 | 13 | | |
| 12 | 14 | START_COL equ 15 ; where the lamplighter begins | |
| 13 | 15 | START_ROW equ 11 | |
| ... | |||
| 59 | 61 | ; Now that the wall cells are painted, fill_walls can read the | |
| 60 | 62 | ; map back and lay brick wherever the wall bit is set. | |
| 61 | 63 | call fill_walls | |
| 64 | + | ; one lantern, the direct way (this step only): named numbers, | |
| 65 | + | ; one call — and a second lamp would mean three more lines | |
| 66 | + | ld c, 4 | |
| 67 | + | ld b, 3 | |
| 68 | + | call draw_lantern | |
| 62 | 69 | ; save what he is about to stand on, BEFORE the first draw | |
| 63 | 70 | call save_under | |
| 64 | 71 | call draw_lamp | |
| ... | |||
| 359 | 366 | defb 5, 5, 4, 3 | |
| 360 | 367 | defb 23, 5, 4, 3 | |
| 361 | 368 | defb $FF | |
| 369 | + | | |
| 370 | + | ; ---------------------------------------------------------------------------- | |
| 371 | + | ; draw_lantern — an unlit lamp into cell (C, B): cold cyan attribute, | |
| 372 | + | ; then the lantern glyph down the cell like any texture. | |
| 373 | + | ; ---------------------------------------------------------------------------- | |
| 374 | + | draw_lantern: | |
| 375 | + | call attr_addr_cr | |
| 376 | + | ld (hl), LAMP_UNLIT | |
| 377 | + | call scr_addr_cr | |
| 378 | + | ld de, lantern | |
| 379 | + | ld b, 8 | |
| 380 | + | .dlt: | |
| 381 | + | ld a, (de) | |
| 382 | + | ld (hl), a | |
| 383 | + | inc de | |
| 384 | + | inc h | |
| 385 | + | djnz .dlt | |
| 386 | + | ret | |
| 362 | 387 | | |
| 363 | 388 | ; ---------------------------------------------------------------------------- | |
| 364 | 389 | ; scr_addr_cr — HL = bitmap address of cell (C, B)'s first pixel row. | |
| ... | |||
| 505 | 530 | defb %00011000 | |
| 506 | 531 | defb %00100100 | |
| 507 | 532 | defb %01000010 | |
| 533 | + | | |
| 534 | + | lantern: | |
| 535 | + | defb %00011000 | |
| 536 | + | defb %00100100 | |
| 537 | + | defb %01111110 | |
| 538 | + | defb %01111110 | |
| 539 | + | defb %01011010 | |
| 540 | + | defb %01111110 | |
| 541 | + | defb %01111110 | |
| 542 | + | defb %00111100 | |
| 508 | 543 | | |
| 509 | 544 | end start | |
| 510 | 545 | |
The complete step 1 program
; Gloaming — Unit 11: The Lamps
; Cumulative build; every step runs on its own. Narrative: the unit page.
; Eight unlit lanterns as cells, surviving your passage like any ground.
org 32768
COBBLE equ %00000001 ; PAPER black (0), INK blue (1) — dark ground
WALL equ %00001111 ; PAPER blue (1), INK white (7) — pale stone
WALL_BIT equ 3 ; the attribute bit that says "this is wall"
LAMP_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — his own light
LAMP_UNLIT equ %00000101 ; cold cyan INK on black PAPER — bit 3
; clear, so a lamp reads as floor
START_COL equ 15 ; where the lamplighter begins
START_ROW equ 11
PLAYER_REPEAT equ 6 ; frames between steps while a key is held
KEYS_OP equ $DFFE ; half-row P O I U Y — bits 1 and 0
KEYS_Q equ $FBFE ; half-row Q W E R T — bit 0 is Q
KEYS_A equ $FDFE ; half-row A S D F G — bit 0 is A
start:
; --- the border goes black — the night beyond the square ---
; Port $FE bits 0-2 set the BORDER colour. A = 0 = black.
ld a, 0
out ($FE), a
; --- place the lamplighter ---
; His position is data. Everything that draws him reads it.
ld a, START_COL
ld (lamp_col), a
ld a, START_ROW
ld (lamp_row), a
xor a
ld (player_timer), a
; --- wipe the canvas ---
; The bitmap ($4000-$57FF) is the pixel layer; whatever was on
; screen before us still lives there. Zero it so only our
; attribute colours show.
call clear_bitmap
; --- texture the ground ---
; Blit the cobble stipple into every cell's bitmap, rows 1-23.
; The attributes will colour these pixels in a moment.
call fill_ground
; --- wash in the cobbles ---
; Seed the first attribute cell, point DE one cell ahead, and
; let LDIR cascade the byte through all 768 cells.
ld hl, $5800
ld de, $5801
ld (hl), COBBLE
ld bc, 767
ldir
call paint_walls
call paint_buildings
; --- brick the walls ---
; Now that the wall cells are painted, fill_walls can read the
; map back and lay brick wherever the wall bit is set.
call fill_walls
; one lantern, the direct way (this step only): named numbers,
; one call — and a second lamp would mean three more lines
ld c, 4
ld b, 3
call draw_lantern
; save what he is about to stand on, BEFORE the first draw
call save_under
call draw_lamp
; --- start the heartbeat ---
; IM 1: every 50 Hz frame interrupt calls the ROM's handler.
; EI: let it. HALT then sleeps until the next frame arrives,
; so the loop below beats exactly once per frame.
im 1
ei
main_loop:
halt
call play_step
jr main_loop
; play_step — one beat of the game: ask the keyboard.
play_step:
call player_step
ret
; ----------------------------------------------------------------------------
; paint_walls — the square's edge, one attribute write per cell.
; ----------------------------------------------------------------------------
paint_walls:
ld c, WALL ; the byte every wall cell gets
; the top wall: row 1 is 32 cells in a row from $5820
; (row 0 is kept back — it becomes the HUD later)
ld hl, $5820
ld b, 32
.wt:
ld (hl), c
inc hl
djnz .wt
; the bottom wall: row 23, 32 cells from $5AE0
ld hl, $5AE0
ld b, 32
.wb:
ld (hl), c
inc hl
djnz .wb
; the side walls: column 0 and column 31 of rows 1-23.
; Write the row's first cell, hop 31 cells to its last,
; then step a full row (32) down — 23 times.
ld hl, $5820
ld b, 23
.ws:
ld (hl), c
push hl
ld de, 31
add hl, de
ld (hl), c
pop hl
ld de, 32
add hl, de
djnz .ws
ret
; ----------------------------------------------------------------------------
; clear_bitmap — zero the pixel layer, $4000-$57FF, with the same
; seed-and-cascade LDIR idiom the cobble wash uses.
; ----------------------------------------------------------------------------
clear_bitmap:
ld hl, $4000
ld de, $4001
ld (hl), 0
ld bc, 6143
ldir
ret
; ----------------------------------------------------------------------------
; player_step — the keys become movement. Each direction key edits a
; TARGET position (tcol, trow) — a proposal, not yet a move — so it
; can be vetoed before it becomes real. Then the move commits: leave
; the old cell, take the new one, draw.
; ----------------------------------------------------------------------------
player_step:
; --- propose: the target starts where he stands ---
ld a, (lamp_col)
ld (tcol), a
ld a, (lamp_row)
ld (trow), a
; The held-key gate: the first press steps at once, then one
; step every PLAYER_REPEAT frames. Releasing every direction
; key re-arms the instant first step, so taps stay crisp.
ld bc, KEYS_OP
in a, (c)
cpl
and %00000011
ld e, a
ld bc, KEYS_Q
in a, (c)
cpl
and %00000001
or e
ld e, a
ld bc, KEYS_A
in a, (c)
cpl
and %00000001
or e
jr nz, .held
xor a
ld (player_timer), a
ret
.held:
ld a, (player_timer)
or a
jr z, .stepnow
dec a
ld (player_timer), a
ret
.stepnow:
ld a, PLAYER_REPEAT
ld (player_timer), a
ld bc, KEYS_OP
in a, (c)
bit 1, a ; O — a zero bit is a pressed key
jr z, .pleft
bit 0, a ; P, same half-row
jr z, .pright
ld bc, KEYS_Q
in a, (c)
bit 0, a ; Q
jr z, .pup
ld bc, KEYS_A
in a, (c)
bit 0, a ; A
jr z, .pdown
ret ; nothing held — nothing to do
.pleft:
ld hl, tcol
dec (hl)
jr .pmove
.pright:
ld hl, tcol
inc (hl)
jr .pmove
.pup:
ld hl, trow
dec (hl)
jr .pmove
.pdown:
ld hl, trow
inc (hl)
.pmove:
; The veto: ask the target cell's attribute whether it's wall.
; NZ means brick — the proposal dies here and he stays put.
ld a, (trow)
ld b, a
ld a, (tcol)
ld c, a
call wall_at
ret nz
; --- commit: restore, step, save, draw — in that order ---
call restore_under
ld a, (tcol)
ld (lamp_col), a
ld a, (trow)
ld (lamp_row), a
call save_under
call draw_lamp
ret
; ----------------------------------------------------------------------------
; fill_ground — the cobble stipple. Not decoration: the stipple is what
; makes ground-state changes visible later, when the game starts
; recolouring these pixels. Rows 1-23 (row 0 is the HUD).
; ----------------------------------------------------------------------------
fill_ground:
ld b, 1 ; rows 1-23 (row 0 is the HUD)
.fgr:
ld c, 0
.fgc:
ld de, cobble_tex
call blit_tex
inc c
ld a, c
cp 32
jr c, .fgc
inc b
ld a, b
cp 24
jr c, .fgr
ret
; fill_walls — brickwork. Driven by the wall attribute bit, so anything
; painted as wall — now or later in the game — gets its brick for free:
; the map itself decides where the brick goes.
fill_walls:
ld b, 1
.fwr:
ld c, 0
.fwc:
push bc
call attr_addr_cr
bit WALL_BIT, (hl)
pop bc
jr z, .fwn
ld de, brick_tex
call blit_tex
.fwn:
inc c
ld a, c
cp 32
jr c, .fwc
inc b
ld a, b
cp 24
jr c, .fwr
ret
; blit_tex — write the 8-byte texture at DE into cell (C, B)'s bitmap.
; scr_addr_cr finds the cell's first pixel row; INC H steps down the
; other seven, 256 bytes apart.
blit_tex:
push bc
call scr_addr_cr
ld b, 8
.bt:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .bt
pop bc
ret
cobble_tex:
defb %10000010
defb %00000000
defb %00001000
defb %00000000
defb %00100001
defb %00000000
defb %00010000
defb %00000000
brick_tex:
; mortar courses with staggered verticals — dusk-lit stone
defb %00001000
defb %00001000
defb %00001000
defb %11111111
defb %10000000
defb %10000000
defb %10000000
defb %11111111
; paint_buildings — walk the rectangle table: each entry is col, row,
; width, height; $FF ends the list. Every cell inside a rectangle gets
; the WALL attribute — and because fill_walls textures by the wall bit,
; the brickwork arrives without another line of drawing code.
paint_buildings:
ld hl, bldg_data
.pb:
ld a, (hl)
cp $FF
ret z
ld c, a ; col
inc hl
ld b, (hl) ; row
inc hl
ld d, (hl) ; width
inc hl
ld e, (hl) ; height
inc hl
push hl
.pbrow:
push bc
push de
.pbcol:
push bc
push de
call attr_addr_cr
ld (hl), WALL
pop de
pop bc
inc c
dec d
jr nz, .pbcol
pop de
pop bc
inc b
dec e
jr nz, .pbrow
pop hl
jr .pb
bldg_data:
defb 5, 5, 4, 3
defb 23, 5, 4, 3
defb $FF
; ----------------------------------------------------------------------------
; draw_lantern — an unlit lamp into cell (C, B): cold cyan attribute,
; then the lantern glyph down the cell like any texture.
; ----------------------------------------------------------------------------
draw_lantern:
call attr_addr_cr
ld (hl), LAMP_UNLIT
call scr_addr_cr
ld de, lantern
ld b, 8
.dlt:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dlt
ret
; ----------------------------------------------------------------------------
; scr_addr_cr — HL = bitmap address of cell (C, B)'s first pixel row.
; The row's top two bits pick the third of the screen (H), its bottom
; three become L's top bits, and the column fills L's low five.
; ----------------------------------------------------------------------------
scr_addr_cr:
ld a, b
and %00011000 ; the third (row bits 4-3) ...
or %01000000 ; ... under the screen base $40xx
ld h, a
ld a, b
and %00000111 ; the char row within the third ...
rrca ; ... rotated into bits 7-5
rrca
rrca
or c ; the column in bits 4-0
ld l, a
ret
; attr_addr_cr — HL = attribute address of cell (C, B):
; $5800 + row*32 + col, the row shifted up five times.
attr_addr_cr:
ld a, b
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
add hl, hl
add hl, hl
ld de, $5800
add hl, de
ld a, c
ld e, a
ld d, 0
add hl, de
ret
; wall_at — is cell (C, B) wall? The answer is already on the screen:
; every wall cell's attribute has WALL_BIT set, so one bit-test of
; attribute memory is the whole collision system. NZ = wall.
wall_at:
call attr_addr_cr
bit WALL_BIT, (hl)
ret
; ----------------------------------------------------------------------------
; The lamplighter's save / restore / draw.
; ----------------------------------------------------------------------------
; pos_bc — the lamplighter's cell into (C, B), read fresh from the data.
pos_bc:
ld a, (lamp_row)
ld b, a
ld a, (lamp_col)
ld c, a
ret
; save_under — copy the nine bytes of his cell into the buffer: eight
; bitmap rows, then the attribute. Runs as he ARRIVES, before the
; draw — so the buffer always holds true ground, never him.
save_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.su:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .su
call pos_bc
call attr_addr_cr
ld a, (hl)
ld (under_lamp + 8), a
ret
; restore_under — the same nine bytes back the other way: the ground
; returns exactly as it was. Runs as he LEAVES, while the position
; still points at the old cell.
restore_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.ru:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .ru
call pos_bc
call attr_addr_cr
ld a, (under_lamp + 8)
ld (hl), a
ret
draw_lamp:
; his colour first: the cell's attribute becomes his own —
; bright white on the black, his own light about him
call pos_bc
call attr_addr_cr
ld (hl), LAMP_ATTR
; then his shape, eight bytes down the cell like any texture
call pos_bc
call scr_addr_cr
ld de, lamplighter
ld b, 8
.dl:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dl
ret
; ----------------------------------------------------------------------------
; Data.
; ----------------------------------------------------------------------------
lamp_col:
defb START_COL
lamp_row:
defb START_ROW
tcol:
defb 0
trow:
defb 0
player_timer:
defb 0
under_lamp:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
lamplighter:
defb %00111100
defb %00111100
defb %00011000
defb %01111110
defb %00011000
defb %00011000
defb %00100100
defb %01000010
lantern:
defb %00011000
defb %00100100
defb %01111110
defb %01111110
defb %01011010
defb %01111110
defb %01111110
defb %00111100
end start

It works, and you can feel the problem from here. The game wants eight lamps. Eight lamps this way is twenty-four lines of loads and calls, and every one of them is placement pretending to be code — change the level and you’re editing instructions. You’ve already seen the better shape: the buildings went through exactly this door two units ago.
Milestone 2 — the town gets its lamps
So: a table. Column/row pairs, one per lamp, $FF to finish — and draw_lamps, a loop that reads a pair, draws a lantern there, and goes round again until it meets the sentinel.
| 61 | 61 | ; Now that the wall cells are painted, fill_walls can read the | |
| 62 | 62 | ; map back and lay brick wherever the wall bit is set. | |
| 63 | 63 | call fill_walls | |
| 64 | - | ; one lantern, the direct way (this step only): named numbers, | |
| 65 | - | ; one call — and a second lamp would mean three more lines | |
| 66 | - | ld c, 4 | |
| 67 | - | ld b, 3 | |
| 68 | - | call draw_lantern | |
| 64 | + | call draw_lamps | |
| 69 | 65 | ; save what he is about to stand on, BEFORE the first draw | |
| 70 | 66 | call save_under | |
| 71 | 67 | call draw_lamp | |
| ... | |||
| 367 | 363 | defb 23, 5, 4, 3 | |
| 368 | 364 | defb $FF | |
| 369 | 365 | | |
| 366 | + | ; ---------------------------------------------------------------------------- | |
| 367 | + | ; draw_lamps — walk the position table: col, row pairs, $FF to finish. | |
| 368 | + | ; Placement is data; the drawing code neither knows nor cares how many | |
| 369 | + | ; lamps the town has tonight. | |
| 370 | 370 | ; ---------------------------------------------------------------------------- | |
| 371 | + | draw_lamps: | |
| 372 | + | ld hl, lamp_data | |
| 373 | + | .next: | |
| 374 | + | ld a, (hl) | |
| 375 | + | cp $FF | |
| 376 | + | ret z | |
| 377 | + | ld c, a | |
| 378 | + | inc hl | |
| 379 | + | ld b, (hl) | |
| 380 | + | inc hl | |
| 381 | + | push hl | |
| 382 | + | call draw_lantern | |
| 383 | + | pop hl | |
| 384 | + | jr .next | |
| 385 | + | | |
| 371 | 386 | ; draw_lantern — an unlit lamp into cell (C, B): cold cyan attribute, | |
| 372 | 387 | ; then the lantern glyph down the cell like any texture. | |
| 373 | - | ; ---------------------------------------------------------------------------- | |
| 374 | 388 | draw_lantern: | |
| 375 | 389 | call attr_addr_cr | |
| 376 | 390 | ld (hl), LAMP_UNLIT | |
| ... | |||
| 506 | 520 | ; ---------------------------------------------------------------------------- | |
| 507 | 521 | ; Data. | |
| 508 | 522 | ; ---------------------------------------------------------------------------- | |
| 523 | + | | |
| 524 | + | lamp_data: | |
| 525 | + | defb 4, 3 | |
| 526 | + | defb 27, 3 | |
| 527 | + | defb 9, 7 | |
| 528 | + | defb 22, 7 | |
| 529 | + | defb 6, 15 | |
| 530 | + | defb 25, 15 | |
| 531 | + | defb 13, 20 | |
| 532 | + | defb 18, 20 | |
| 533 | + | defb $FF | |
| 509 | 534 | | |
| 510 | 535 | lamp_col: | |
| 511 | 536 | defb START_COL |
The complete program
; Gloaming — Unit 11: The Lamps
; Cumulative build; every step runs on its own. Narrative: the unit page.
; Eight unlit lanterns as cells, surviving your passage like any ground.
org 32768
COBBLE equ %00000001 ; PAPER black (0), INK blue (1) — dark ground
WALL equ %00001111 ; PAPER blue (1), INK white (7) — pale stone
WALL_BIT equ 3 ; the attribute bit that says "this is wall"
LAMP_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — his own light
LAMP_UNLIT equ %00000101 ; cold cyan INK on black PAPER — bit 3
; clear, so a lamp reads as floor
START_COL equ 15 ; where the lamplighter begins
START_ROW equ 11
PLAYER_REPEAT equ 6 ; frames between steps while a key is held
KEYS_OP equ $DFFE ; half-row P O I U Y — bits 1 and 0
KEYS_Q equ $FBFE ; half-row Q W E R T — bit 0 is Q
KEYS_A equ $FDFE ; half-row A S D F G — bit 0 is A
start:
; --- the border goes black — the night beyond the square ---
; Port $FE bits 0-2 set the BORDER colour. A = 0 = black.
ld a, 0
out ($FE), a
; --- place the lamplighter ---
; His position is data. Everything that draws him reads it.
ld a, START_COL
ld (lamp_col), a
ld a, START_ROW
ld (lamp_row), a
xor a
ld (player_timer), a
; --- wipe the canvas ---
; The bitmap ($4000-$57FF) is the pixel layer; whatever was on
; screen before us still lives there. Zero it so only our
; attribute colours show.
call clear_bitmap
; --- texture the ground ---
; Blit the cobble stipple into every cell's bitmap, rows 1-23.
; The attributes will colour these pixels in a moment.
call fill_ground
; --- wash in the cobbles ---
; Seed the first attribute cell, point DE one cell ahead, and
; let LDIR cascade the byte through all 768 cells.
ld hl, $5800
ld de, $5801
ld (hl), COBBLE
ld bc, 767
ldir
call paint_walls
call paint_buildings
; --- brick the walls ---
; Now that the wall cells are painted, fill_walls can read the
; map back and lay brick wherever the wall bit is set.
call fill_walls
call draw_lamps
; save what he is about to stand on, BEFORE the first draw
call save_under
call draw_lamp
; --- start the heartbeat ---
; IM 1: every 50 Hz frame interrupt calls the ROM's handler.
; EI: let it. HALT then sleeps until the next frame arrives,
; so the loop below beats exactly once per frame.
im 1
ei
main_loop:
halt
call play_step
jr main_loop
; play_step — one beat of the game: ask the keyboard.
play_step:
call player_step
ret
; ----------------------------------------------------------------------------
; paint_walls — the square's edge, one attribute write per cell.
; ----------------------------------------------------------------------------
paint_walls:
ld c, WALL ; the byte every wall cell gets
; the top wall: row 1 is 32 cells in a row from $5820
; (row 0 is kept back — it becomes the HUD later)
ld hl, $5820
ld b, 32
.wt:
ld (hl), c
inc hl
djnz .wt
; the bottom wall: row 23, 32 cells from $5AE0
ld hl, $5AE0
ld b, 32
.wb:
ld (hl), c
inc hl
djnz .wb
; the side walls: column 0 and column 31 of rows 1-23.
; Write the row's first cell, hop 31 cells to its last,
; then step a full row (32) down — 23 times.
ld hl, $5820
ld b, 23
.ws:
ld (hl), c
push hl
ld de, 31
add hl, de
ld (hl), c
pop hl
ld de, 32
add hl, de
djnz .ws
ret
; ----------------------------------------------------------------------------
; clear_bitmap — zero the pixel layer, $4000-$57FF, with the same
; seed-and-cascade LDIR idiom the cobble wash uses.
; ----------------------------------------------------------------------------
clear_bitmap:
ld hl, $4000
ld de, $4001
ld (hl), 0
ld bc, 6143
ldir
ret
; ----------------------------------------------------------------------------
; player_step — the keys become movement. Each direction key edits a
; TARGET position (tcol, trow) — a proposal, not yet a move — so it
; can be vetoed before it becomes real. Then the move commits: leave
; the old cell, take the new one, draw.
; ----------------------------------------------------------------------------
player_step:
; --- propose: the target starts where he stands ---
ld a, (lamp_col)
ld (tcol), a
ld a, (lamp_row)
ld (trow), a
; The held-key gate: the first press steps at once, then one
; step every PLAYER_REPEAT frames. Releasing every direction
; key re-arms the instant first step, so taps stay crisp.
ld bc, KEYS_OP
in a, (c)
cpl
and %00000011
ld e, a
ld bc, KEYS_Q
in a, (c)
cpl
and %00000001
or e
ld e, a
ld bc, KEYS_A
in a, (c)
cpl
and %00000001
or e
jr nz, .held
xor a
ld (player_timer), a
ret
.held:
ld a, (player_timer)
or a
jr z, .stepnow
dec a
ld (player_timer), a
ret
.stepnow:
ld a, PLAYER_REPEAT
ld (player_timer), a
ld bc, KEYS_OP
in a, (c)
bit 1, a ; O — a zero bit is a pressed key
jr z, .pleft
bit 0, a ; P, same half-row
jr z, .pright
ld bc, KEYS_Q
in a, (c)
bit 0, a ; Q
jr z, .pup
ld bc, KEYS_A
in a, (c)
bit 0, a ; A
jr z, .pdown
ret ; nothing held — nothing to do
.pleft:
ld hl, tcol
dec (hl)
jr .pmove
.pright:
ld hl, tcol
inc (hl)
jr .pmove
.pup:
ld hl, trow
dec (hl)
jr .pmove
.pdown:
ld hl, trow
inc (hl)
.pmove:
; The veto: ask the target cell's attribute whether it's wall.
; NZ means brick — the proposal dies here and he stays put.
ld a, (trow)
ld b, a
ld a, (tcol)
ld c, a
call wall_at
ret nz
; --- commit: restore, step, save, draw — in that order ---
call restore_under
ld a, (tcol)
ld (lamp_col), a
ld a, (trow)
ld (lamp_row), a
call save_under
call draw_lamp
ret
; ----------------------------------------------------------------------------
; fill_ground — the cobble stipple. Not decoration: the stipple is what
; makes ground-state changes visible later, when the game starts
; recolouring these pixels. Rows 1-23 (row 0 is the HUD).
; ----------------------------------------------------------------------------
fill_ground:
ld b, 1 ; rows 1-23 (row 0 is the HUD)
.fgr:
ld c, 0
.fgc:
ld de, cobble_tex
call blit_tex
inc c
ld a, c
cp 32
jr c, .fgc
inc b
ld a, b
cp 24
jr c, .fgr
ret
; fill_walls — brickwork. Driven by the wall attribute bit, so anything
; painted as wall — now or later in the game — gets its brick for free:
; the map itself decides where the brick goes.
fill_walls:
ld b, 1
.fwr:
ld c, 0
.fwc:
push bc
call attr_addr_cr
bit WALL_BIT, (hl)
pop bc
jr z, .fwn
ld de, brick_tex
call blit_tex
.fwn:
inc c
ld a, c
cp 32
jr c, .fwc
inc b
ld a, b
cp 24
jr c, .fwr
ret
; blit_tex — write the 8-byte texture at DE into cell (C, B)'s bitmap.
; scr_addr_cr finds the cell's first pixel row; INC H steps down the
; other seven, 256 bytes apart.
blit_tex:
push bc
call scr_addr_cr
ld b, 8
.bt:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .bt
pop bc
ret
cobble_tex:
defb %10000010
defb %00000000
defb %00001000
defb %00000000
defb %00100001
defb %00000000
defb %00010000
defb %00000000
brick_tex:
; mortar courses with staggered verticals — dusk-lit stone
defb %00001000
defb %00001000
defb %00001000
defb %11111111
defb %10000000
defb %10000000
defb %10000000
defb %11111111
; paint_buildings — walk the rectangle table: each entry is col, row,
; width, height; $FF ends the list. Every cell inside a rectangle gets
; the WALL attribute — and because fill_walls textures by the wall bit,
; the brickwork arrives without another line of drawing code.
paint_buildings:
ld hl, bldg_data
.pb:
ld a, (hl)
cp $FF
ret z
ld c, a ; col
inc hl
ld b, (hl) ; row
inc hl
ld d, (hl) ; width
inc hl
ld e, (hl) ; height
inc hl
push hl
.pbrow:
push bc
push de
.pbcol:
push bc
push de
call attr_addr_cr
ld (hl), WALL
pop de
pop bc
inc c
dec d
jr nz, .pbcol
pop de
pop bc
inc b
dec e
jr nz, .pbrow
pop hl
jr .pb
bldg_data:
defb 5, 5, 4, 3
defb 23, 5, 4, 3
defb $FF
; ----------------------------------------------------------------------------
; draw_lamps — walk the position table: col, row pairs, $FF to finish.
; Placement is data; the drawing code neither knows nor cares how many
; lamps the town has tonight.
; ----------------------------------------------------------------------------
draw_lamps:
ld hl, lamp_data
.next:
ld a, (hl)
cp $FF
ret z
ld c, a
inc hl
ld b, (hl)
inc hl
push hl
call draw_lantern
pop hl
jr .next
; draw_lantern — an unlit lamp into cell (C, B): cold cyan attribute,
; then the lantern glyph down the cell like any texture.
draw_lantern:
call attr_addr_cr
ld (hl), LAMP_UNLIT
call scr_addr_cr
ld de, lantern
ld b, 8
.dlt:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dlt
ret
; ----------------------------------------------------------------------------
; scr_addr_cr — HL = bitmap address of cell (C, B)'s first pixel row.
; The row's top two bits pick the third of the screen (H), its bottom
; three become L's top bits, and the column fills L's low five.
; ----------------------------------------------------------------------------
scr_addr_cr:
ld a, b
and %00011000 ; the third (row bits 4-3) ...
or %01000000 ; ... under the screen base $40xx
ld h, a
ld a, b
and %00000111 ; the char row within the third ...
rrca ; ... rotated into bits 7-5
rrca
rrca
or c ; the column in bits 4-0
ld l, a
ret
; attr_addr_cr — HL = attribute address of cell (C, B):
; $5800 + row*32 + col, the row shifted up five times.
attr_addr_cr:
ld a, b
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
add hl, hl
add hl, hl
ld de, $5800
add hl, de
ld a, c
ld e, a
ld d, 0
add hl, de
ret
; wall_at — is cell (C, B) wall? The answer is already on the screen:
; every wall cell's attribute has WALL_BIT set, so one bit-test of
; attribute memory is the whole collision system. NZ = wall.
wall_at:
call attr_addr_cr
bit WALL_BIT, (hl)
ret
; ----------------------------------------------------------------------------
; The lamplighter's save / restore / draw.
; ----------------------------------------------------------------------------
; pos_bc — the lamplighter's cell into (C, B), read fresh from the data.
pos_bc:
ld a, (lamp_row)
ld b, a
ld a, (lamp_col)
ld c, a
ret
; save_under — copy the nine bytes of his cell into the buffer: eight
; bitmap rows, then the attribute. Runs as he ARRIVES, before the
; draw — so the buffer always holds true ground, never him.
save_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.su:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .su
call pos_bc
call attr_addr_cr
ld a, (hl)
ld (under_lamp + 8), a
ret
; restore_under — the same nine bytes back the other way: the ground
; returns exactly as it was. Runs as he LEAVES, while the position
; still points at the old cell.
restore_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.ru:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .ru
call pos_bc
call attr_addr_cr
ld a, (under_lamp + 8)
ld (hl), a
ret
draw_lamp:
; his colour first: the cell's attribute becomes his own —
; bright white on the black, his own light about him
call pos_bc
call attr_addr_cr
ld (hl), LAMP_ATTR
; then his shape, eight bytes down the cell like any texture
call pos_bc
call scr_addr_cr
ld de, lamplighter
ld b, 8
.dl:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dl
ret
; ----------------------------------------------------------------------------
; Data.
; ----------------------------------------------------------------------------
lamp_data:
defb 4, 3
defb 27, 3
defb 9, 7
defb 22, 7
defb 6, 15
defb 25, 15
defb 13, 20
defb 18, 20
defb $FF
lamp_col:
defb START_COL
lamp_row:
defb START_ROW
tcol:
defb 0
trow:
defb 0
player_timer:
defb 0
under_lamp:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
lamplighter:
defb %00111100
defb %00111100
defb %00011000
defb %01111110
defb %00011000
defb %00011000
defb %00100100
defb %01000010
lantern:
defb %00011000
defb %00100100
defb %01111110
defb %01111110
defb %01011010
defb %01111110
defb %01111110
defb %00111100
end start

One detail in the loop deserves a look: push hl / pop hl around the call draw_lantern. HL is the loop’s finger, keeping its place in the table — but draw_lantern uses HL for screen addresses. The push tucks the finger away on the stack, the pop brings it back; without that pair, the loop would come back from drawing the first lamp having forgotten where it was reading. Saving what a call would trample is register hygiene you’ll use everywhere loops call helpers.
And that’s the second data table in three units, so the pattern is now officially the town’s architecture: shape as code, placement as data. The buildings are rectangles in bldg_data; the lamps are pairs in lamp_data; the drawing routines neither know nor care how many of either the town has tonight. A different level is a different table.
The crossing, again — with something to lose
The lamps sit on the floor and read as floor, so he can walk over them. Should he? Unit 8’s marker witness said yes — but the marker was scaffolding we painted. The lamps are the first game objects to stand in his path, and the buffer has never been tested on something the game can’t afford to lose:


Nothing in this unit’s diff touches movement, collision or the buffer — the lamps are protected by code that has never heard of them. That’s what building the general mechanism honestly buys: save_under copies nine bytes of whatever is there, and “whatever” now includes the game’s most precious objects.
Take one more look at that still, though. While he stands on the lamp, the lantern lives in the buffer — nine bytes in ordinary memory, temporarily the only copy in the world. Next unit that stops being trivia and becomes the whole mechanism: if the game were to edit those bytes while it holds them, the lamp would come back changed when he steps away. Hold that thought — it’s the best trick in this module.
When it’s wrong, see why
The table and the lamp’s colour are where this unit slips:
- No lamps appear.
draw_lampsisn’t called, or it’s called before the ground exists. Its place in the setup matters: afterfill_walls(so the floor doesn’t overwrite the lamps), beforesave_under(so if a level ever starts him on a lamp, the buffer holds it). - Eight lamps, then garbage marching on across the square. The
$FFsentinel is missing, and the loop is reading whatever bytes live after the table as coordinates — in this file, that’s the lamplighter’s own position data. End the table. - A lamp blocks him like a wall. Its attribute has bit 3 set.
LAMP_UNLITmust keep black PAPER —%00000101— orwall_atreads it as brick. One bit is the difference between an object he can use and an obstacle he can’t reach. - The first lamp draws, then the loop goes wild. The
push hl/pop hlguard is missing —draw_lanterntrampled the table pointer, and the loop resumed reading from a screen address. - Walking over a lamp deletes it. The commit isn’t the Unit 8 dance — an
erase_lamphas crept back in somewhere. Restore, step, save, draw; nothing else touches his cells.
Before and after
The square began this unit empty and ended it as a level: eight cold lanterns, placed from sixteen bytes you can rewrite at will, drawn by a loop that will never need to change. The hardcoded first lamp was the honest “before” — placement as code, three lines per object — and the table is the “after” the buildings already taught. And the crossing settled something no scaffold could: the buffer protects the game’s real treasures, sight unseen. Everything on this screen is now either data or protected by code that treats everything as data. The night can begin.
Try this: redraw the level
Edit lamp_data: move a lamp, add a ninth pair, thin them to three, tuck one in a corner or flat against a building’s wall. Keep the $FF, rebuild, and the level redraws itself — no code changed. This is what the table bought: the level is yours to author, in data, exactly as the buildings were.
Try this: reshape the lantern
Design your own lantern in the editor below — click cells on, read off the bytes, and replace the eight defbs under lantern:. Every lamp on the board changes at once, because eight lamps share one shape:
Try this: a lamp indoors
Add a lamp inside a building — defb 6, 6 — and rebuild. It draws (draw_lantern writes wherever it’s told; the buildings are just attributes), but its cell now reads as floor in a sea of brick: he can’t reach it, and the level is quietly broken in a way no code can notice. Data tables give you the power to author levels — and with it, the power to author impossible ones. Level validation is a real job in real games; here, it’s your eyes.
What you’ve learnt
- Game objects arrive as data: a glyph (shape), an attribute (look and physics), and table entries (placement).
- The hardcoded version is the honest before — three lines per object, placement trapped inside code.
- A sentinel-terminated table loop places any number of objects, and
push/popkeeps its pointer safe across calls. - One attribute bit decides walkable or wall — lamps deliberately read as floor so he can stand on them.
- General mechanisms pay compound interest: save/restore protects objects it was never told about.
What’s next
Eight lamps, all cold — and a lamplighter with no way to do the one thing he’s named for. Unit 12 gives him his job: step onto a lamp and light it. The mechanism is the one you glimpsed in the stand-still: while he covers a lamp, its nine bytes sit in the buffer — so lighting it is nothing more than editing the saved attribute while the game holds it. The floor stops being scenery and becomes state you can change.