Edges
Retire the scaffold: prove the numeric clamp is dead code by moving its fence where you can see it, then delete it whole — the square's own walls are the boundary, and the movement core converges with the finished game, byte for byte.
Unit 6 made a promise. When the numeric clamp went in — four compares boxing him into rows 2–22, columns 1–30 — the source called it scaffolding and named the day it would come down: “the walls take this job in unit 9; unit 10 retires the numbers.” Unit 9 kept the first half. This unit keeps the second, and it’s shorter than any unit before it, because its entire diff is a deletion.
But deletion done properly, which makes it a lesson rather than a chore. You don’t delete a boundary check because you believe it’s redundant — belief is how machines die at row 32. You prove it first.
Two boundaries are running
Since last unit the map has had two boundary systems, one on top of the other. The walls are geometry you can see: attribute data, tested per-proposal by wall_at, at the map’s actual edge. The clamp is numbers you can’t: four literal compares in the middle of player_step, agreeing with the walls only because someone once chose 2, 23, 1 and 31 to match where the walls stand.
That agreement is the problem. Nothing enforces it. Redraw the map — move a wall, widen the square for a level 2 — and the clamp’s numbers silently disagree with the world. The player hits fences that aren’t there, or worse, the fence sits past a wall that’s been moved inward and the “protection” guards nothing. Duplicate rules drift; whichever you forget to update becomes a bug. One of them has to go, and it should be the one you can’t see.
And notice the order the move asks its questions: the veto runs first. Any step toward the perimeter dies at wall_at before the clamp is even consulted — brick answers before arithmetic. For the clamp’s compares to matter at all, a proposal has to get past the walls, and on this map none can. The clamp isn’t just redundant — it’s unreachable.
Milestone 1 — make the invisible visible
Unreachable is a strong claim, so test it the honest way: make the two systems disagree, and see which one the player feels. One byte does it — the south check’s cp 23 becomes cp 20, dragging the numeric fence three cells north of the south wall:
| 227 | 227 | ld a, (trow) | |
| 228 | 228 | cp 2 | |
| 229 | 229 | ret c | |
| 230 | - | cp 23 | |
| 230 | + | cp 20 ; probe (this step only): fence moved | |
| 231 | + | ; three cells north of the south wall | |
| 231 | 232 | ret nc | |
| 232 | 233 | ld a, (tcol) | |
| 233 | 234 | cp 1 |
The complete step 1 program
; Gloaming — Unit 10: Edges
; Cumulative build; every step runs on its own. Narrative: the unit page.
; The square itself is the boundary — the scaffold clamp comes out.
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
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
; 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
; Scaffold (route skeleton): a numeric edge clamp so the detour
; cannot walk the lamplighter off the map — past the map's edge
; the address sums leave screen memory for the system's own. The
; walls take this job in unit 9; unit 10 retires the numbers.
ld a, (trow)
cp 2
ret c
cp 20 ; probe (this step only): fence moved
; three cells north of the south wall
ret nc
ld a, (tcol)
cp 1
ret c
cp 31
ret nc
; --- 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
; ----------------------------------------------------------------------------
; 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
end start

Two things proved at once. The clamp is alive — change its number and the world changes, so those compares really do run. And everywhere its numbers match the walls, it never gets to act, because the walls answer first. The probe shows the only thing the clamp can still do to this game: contradict the map. A rule the player can’t see, duplicating a rule they can — that’s not a safety net, it’s a liability with good intentions.
Milestone 2 — take it down
Put the byte back — and take the whole block with it. The four compares, the two loads, the scaffold comment: fourteen lines out, nothing in.
| 220 | 220 | call wall_at | |
| 221 | 221 | ret nz | |
| 222 | 222 | | |
| 223 | - | ; Scaffold (route skeleton): a numeric edge clamp so the detour | |
| 224 | - | ; cannot walk the lamplighter off the map — past the map's edge | |
| 225 | - | ; the address sums leave screen memory for the system's own. The | |
| 226 | - | ; walls take this job in unit 9; unit 10 retires the numbers. | |
| 227 | - | ld a, (trow) | |
| 228 | - | cp 2 | |
| 229 | - | ret c | |
| 230 | - | cp 20 ; probe (this step only): fence moved | |
| 231 | - | ; three cells north of the south wall | |
| 232 | - | ret nc | |
| 233 | - | ld a, (tcol) | |
| 234 | - | cp 1 | |
| 235 | - | ret c | |
| 236 | - | cp 31 | |
| 237 | - | ret nc | |
| 238 | 223 | ; --- commit: restore, step, save, draw — in that order --- | |
| 239 | 224 | call restore_under | |
| 240 | 225 | ld a, (tcol) |
The complete program
; Gloaming — Unit 10: Edges
; Cumulative build; every step runs on its own. Narrative: the unit page.
; The square itself is the boundary — the scaffold clamp comes out.
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
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
; 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
; ----------------------------------------------------------------------------
; 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
end start
The same held A that step 1 stopped in the open now walks him all the way to the stone:


The convergence
Here’s what makes this small unit a landmark. The clamp was the last piece of scaffolding — the last line in the program that existed for the course rather than the game. With it gone, the program you have just built is, byte for byte, the movement core of the finished Gloaming: assemble it and the snapshot matches the module’s target exactly. Not “equivalent to”, not “close enough” — identical.
That’s worth pausing on, because of what it says about the five units behind you. The detour through smears, gouges, blurring speed and ghost-buildings wasn’t a teaching version of the real thing — it was the real thing, approached in an order that let every mistake announce itself. From here to the end of the module, everything — lamps, light, the tally, the draught, the night — builds on this exact file.
When it’s wrong, see why
A deletion unit’s failures are about deleting the wrong thing, and each announces itself:
- He walks through the buildings and the frame. You took out the veto — the
call wall_at/ret nzpair — instead of, or along with, the clamp. That pair is the boundary now; the clamp was the one that did nothing. - The screen erupts at the edges, or the machine dies. Same deletion, worse luck: with no veto and no clamp, you’ve rebuilt Unit 6’s walk-off-the-world, and past the map every address still lands somewhere. Both protections gone is the one state this program must never ship in.
- He still stops short somewhere in the open. Half the clamp survives — a stray compare left behind. The block was fourteen lines; a deletion diff should show all fourteen gone, which is exactly why diffs of deletions deserve as careful a read as diffs of additions.
- Everything works. Correct. The most suspicious result in programming — but you proved it beforehand with the probe, which is the difference between confidence and hope.
Before and after
The unit began with two boundary systems agreeing by coincidence and ended with one, made of map data, plus fourteen fewer lines of code. The probe is the half worth keeping in your pocket: before deleting anything that looks load-bearing, find the experiment that makes its work visible — move the fence, break the agreement, watch what the player feels — and only then delete with a steady hand. Code you can prove idle is code you can remove without faith. And a diff that only deletes, backed by a proof, is about the best kind of diff there is.
Try this: break the wall, find the dark
The fence is gone, so the walls carry everything — test that honestly too. In setup, after call paint_walls, knock one cell out of the north wall:
ld hl, $5800 + 1*32 + 15
ld (hl), COBBLE
Walk up through the gap. He steps into the wall’s row, then onto the HUD row — and past that, the row arithmetic leaves the map entirely and his glyph bytes start landing where they shouldn’t, painting garbage colour down the screen. Unit 6’s dark is still out there; the only thing between him and it is wall data, which is precisely why the map must stay sealed. Reset, remove your hole, and respect the perimeter.
Try this: reshape the world
The boundary is data now, so edit it: in bldg_data, stretch the first building into a long wall — defb 5, 5, 4, 12 — and rebuild. The square becomes two chambers with a corridor along the south, and the movement code neither knows nor cares: no bounds to retune, no clamp numbers to keep in step. This is what retiring the clamp bought — the map is free to change shape, and collision follows it automatically.
Try this: read the diff backwards
Open the step 2 diff again and read it as a reviewer would: every changed line is a removal, and the program got better. Ask of each deleted line “what did this protect, and what protects it now?” — the row compares (the walls, via wall_at), the column compares (same), the loads that fed them (nothing needed them). When you can answer that question for every line, you’ve done a deletion review properly — a skill exactly as real as writing code.
What you’ve learnt
- Duplicate rules drift: two systems enforcing one boundary agree only by coincidence, and the invisible one should die.
- Prove before deleting: make the suspect code disagree with its twin and observe — unreachable code can’t change behaviour, so if behaviour changes only where they conflict, the twin was already doing all the work.
- Order matters in the asking: the veto answers before the clamp ever could — placement made the clamp unreachable, not luck.
- Boundaries as data: the map now bounds the game, so reshaping the map reshapes the game with no code changes.
- Convergence: the movement core is the finished game’s, byte for byte — the detour built the real thing all along.
What’s next
The stage is finished — canvas, heartbeat, honest movement, a town that pushes back. Unit 11 starts the game that lives on it: eight unlit lanterns, drawn from a data table just like the buildings were, standing on the floor waiting for him. And thanks to Unit 8, he can walk right over them and leave every one standing.