The lamplighter moves — at fifty cells a second, which is not moving so much as teleporting angrily. The trouble is a mismatch of clocks: the loop asks the keyboard fifty times a second, and your thumb answers about seven. Every frame a key stays down, Unit 6 takes another step. When this game was first playtested in its shipped form, the very first line of feedback was “WAY TOO FAST” — and this unit is the repair, almost line for line as it was made.
Two questions, not one
Unit 5’s reads answer which direction? — they stay exactly as they are, deciding the step. What’s missing is a second, separate question: is it time to take a step at all? For that we need a single yes-or-no — is any direction key down? — and there’s an idiom for it. The port reads are active low, so CPL flips each one to make pressed keys read as 1; AND masks each half-row down to just the keys we care about; and OR gathers the three rows into one byte. Nonzero means something is held:
in a, (c) ; five keys, active low
cpl ; now a pressed key is a 1
and %00000011 ; keep only O and P
or e ; gather into the running answerA byte that counts down to permission
The pacing itself is one variable and one habit. player_timer holds “frames until the next step is allowed”. While a key is down: if the timer is zero, step — and reload the timer with PLAYER_REPEAT; if it isn’t, just count it down and do nothing. That’s the whole mechanism. Learn its shape well, because it is the timing idiom of this machine’s games — a byte, a decrement, a zero test — and it will not stay yours alone: much later, when the night itself starts taking steps toward you, it will pace its hunt on exactly this pattern.
Milestone 1 — the gate
The gate slots between the target setup and the direction decode: combined scan, timer check, and only then the keys choose a direction.
START_COL equ 15 ; where the lamplighter begins
START_ROW equ 11
Added: 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
… ld (lamp_col), a
ld a, START_ROW
ld (lamp_row), a
Added: xor a
Added: ld (player_timer), a
; --- wipe the canvas ---
; The bitmap ($4000-$57FF) is the pixel layer; whatever was on
… ld (tcol), a
ld a, (lamp_row)
ld (trow), a
Added:
Added: ; The held-key gate: one combined answer — is ANY direction key
Added: ; down? Active-low reads flip under CPL so a pressed key becomes
Added: ; a 1, and OR gathers the three half-rows into one byte. Then
Added: ; the countdown: a step only when the timer reaches zero.
Added: ld bc, KEYS_OP
Added: in a, (c)
Added: cpl
Added: and %00000011
Added: ld e, a
Added: ld bc, KEYS_Q
Added: in a, (c)
Added: cpl
Added: and %00000001
Added: or e
Added: ld e, a
Added: ld bc, KEYS_A
Added: in a, (c)
Added: cpl
Added: and %00000001
Added: or e
Added: jr nz, .held
Added: ret
Added: .held:
Added: ld a, (player_timer)
Added: or a
Added: jr z, .stepnow
Added: dec a
Added: ld (player_timer), a
Added: ret
Added: .stepnow:
Added: ld a, PLAYER_REPEAT
Added: ld (player_timer), a
ld bc, KEYS_OP
in a, (c)
…tcol:
defb 0
trow:
Added: defb 0
Added: player_timer:
defb 0
lamplighter:
The complete step 1 program
; Gloaming — Unit 7: Taming the Key
; Cumulative build; every step runs on its own. Narrative: the unit page.
; The repeat gate: one press is one step, a held key is a stride, not a blur.
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
; --- 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_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: one combined answer — is ANY direction key
; down? Active-low reads flip under CPL so a pressed key becomes
; a 1, and OR gathers the three half-rows into one byte. Then
; the countdown: a step only when the timer reaches zero.
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
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:
; 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 23
ret nc
ld a, (tcol)
cp 1
ret c
cp 31
ret nc
; --- commit: erase where he was, move, draw where he is ---
call erase_lamp
ld a, (tcol)
ld (lamp_col), a
ld a, (trow)
ld (lamp_row), a
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
; ----------------------------------------------------------------------------
; 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
; ----------------------------------------------------------------------------
; The lamplighter's 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
; erase_lamp — the naive erase: blank the cell the lamplighter leaves.
; Zero its eight bitmap bytes, repaint it ground colour. The cell is
; clean — and whatever the floor had there is gone with him.
; (Detour: watch what it does to the cobbles.)
erase_lamp:
call pos_bc
call scr_addr_cr
ld b, 8
xor a
.el:
ld (hl), a
inc h
djnz .el
call pos_bc
call attr_addr_cr
ld (hl), COBBLE
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
lamplighter:
defb %00111100
defb %00111100
defb %00011000
defb %01111110
defb %00011000
defb %00011000
defb %00100100
defb %01000010
end startWith PLAYER_REPEAT at 6, a held key steps once every seventh frame — about seven cells a second. Hold P and he walks:

The pace is the playtest’s, verbatim: 6 was the value that came back approved — “that works.” But hammer a key in quick taps and something’s off: some taps step, some vanish. The timer only counts down while a key is held, so releasing mid-countdown freezes it — and your next tap can land inside a stale count and die there. Rate-limited, yes. Crisp, no.
Milestone 2 — the re-arm
Two lines fix it. On the nothing held path, zero the timer. Now every release re-arms the instant step: the moment you press again, the count is clear and the step happens on that first frame.
ld a, (lamp_row)
ld (trow), a
Removed: ; The held-key gate: one combined answer — is ANY direction key
Removed: ; down? Active-low reads flip under CPL so a pressed key becomes
Removed: ; a 1, and OR gathers the three half-rows into one byte. Then
Removed: ; the countdown: a step only when the timer reaches zero.
Added: ; The held-key gate: the first press steps at once, then one
Added: ; step every PLAYER_REPEAT frames. Releasing every direction
Added: ; key re-arms the instant first step, so taps stay crisp.
ld bc, KEYS_OP
in a, (c)
cpl
… and %00000001
or e
jr nz, .held
Added: xor a
Added: ld (player_timer), a
ret
.held:
ld a, (player_timer)
The complete program
; Gloaming — Unit 7: Taming the Key
; Cumulative build; every step runs on its own. Narrative: the unit page.
; The repeat gate: one press is one step, a held key is a stride, not a blur.
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
; --- 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_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:
; 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 23
ret nc
ld a, (tcol)
cp 1
ret c
cp 31
ret nc
; --- commit: erase where he was, move, draw where he is ---
call erase_lamp
ld a, (tcol)
ld (lamp_col), a
ld a, (trow)
ld (lamp_row), a
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
; ----------------------------------------------------------------------------
; 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
; ----------------------------------------------------------------------------
; The lamplighter's 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
; erase_lamp — the naive erase: blank the cell the lamplighter leaves.
; Zero its eight bitmap bytes, repaint it ground colour. The cell is
; clean — and whatever the floor had there is gone with him.
; (Detour: watch what it does to the cobbles.)
erase_lamp:
call pos_bc
call scr_addr_cr
ld b, 8
xor a
.el:
ld (hl), a
inc h
djnz .el
call pos_bc
call attr_addr_cr
ld (hl), COBBLE
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
lamplighter:
defb %00111100
defb %00111100
defb %00011000
defb %01111110
defb %00011000
defb %00011000
defb %00100100
defb %01000010
end startHere is the same run of five quick taps against both builds — watch how many actually land:
Five taps: two steps against step 1, five against step 2 — the end positions three cells apart tell the story even in a still frame. The finished gate’s contract is worth saying in one breath, because the player will feel it even if they never name it: a tap is one step, immediately; a hold is a stride at the game’s pace; and letting go always re-arms the tap.
Game feel is a number you choose
Nothing about PLAYER_REPEAT 6 is sacred — it’s a dial, and it was set by hands, not arithmetic. Turn it down to 3 and he’s skittish, hard to stop on the cell you meant; up at 12 he trudges, and the square feels twice as big. The playtest settled on 6 because lamps are coming: this game will soon be about stopping on a particular cell, and crisp taps plus a steerable stride are what make that a skill instead of a fight. When a game of yours feels wrong in the hands, look for the number like this one — there usually is one, and it’s usually data.
When it’s wrong, see why
- He hesitates one frame, then blurs anyway. The gate’s zero test is backwards.
jr z, .stepnowfires when the timer is zero; inverted, the first frame counts the timer down past zero and every frame after that finds it nonzero — gate permanently open. - Still way too fast. The reload is missing —
.stepnowmust storePLAYER_REPEATback into the timer, or it stays zero and every frame steps. - A held key steps once, then never again. The countdown path isn’t storing the decremented value back;
dec aalone changes only the copy inA. - One key strides, another doesn’t. The combined scan’s masks. The
$DFFErow needs%00000011(O and P); Q’s and A’s rows need%00000001. A wrong mask makes a key invisible to the gate but visible to the decode — it moves only while another key holds the gate open. - Taps feel muddy. The re-arm is missing from the nothing-held path — that’s step 1’s build, and its cure is milestone 2.
Before and after
Unit 6 ended with movement nobody could steer; it ends here as a hand-tuned instrument. The mechanics are two small things — an any-key answer built from CPL/AND/OR, and a countdown byte standing between “held” and “step” — plus two lines of re-arm that turn a rate limiter into an input contract. The gouge in the floor is still there, unpaid, and it’s next.
Try this: turn the dial
Set PLAYER_REPEAT to 3, rebuild, and try to stop on a chosen cell. Then 12. Then back to 6, and feel it click. This is the tuning loop every game goes through, compressed to one constant.
Try this: back to the blur
Set PLAYER_REPEAT to 0. The reload stores zero, the gate is always open — and you’re back to Unit 6’s fifty steps a second, now as a deliberate setting rather than an accident. Some games want a turbo dial; now you own one.
Try this: hold two keys
Hold O and P together. He walks left — never both, never a wobble. The decode tests O’s bit first and takes the jump, so P is never consulted; hold Q and A and Q wins the same way. One direction per beat, and which one is decided by the order we wrote the tests in. When you want diagonals — some games do — this is the code that would change.
What you’ve learnt
- Input has two questions: which direction (per-key tests) and whether now (the gate) — and they separate cleanly.
- The
CPL/AND/ORgather: three active-low half-rows folded into one “anything held?” byte. - The countdown-to-permission idiom: a byte, a decrement, a zero test — this game’s universal timer, soon to be borrowed by its villain.
- The release re-arm, and the input contract it buys: tap = one step now; hold = stride at pace.
- Feel is data: one constant, tuned by hands, checked by playtest.
What’s next
One debt left. Every step still blanks the cell behind him — the gouge is slower now, but it’s still vandalism. Unit 8 repays it: before he stands anywhere, the game will remember what was there — eight bitmap bytes and an attribute in a little buffer — and put it back, exactly, as he leaves. The ground survives your passage.
Run it here
This unit's program, built for you.
- Program
- step-02.tap
- Machine
- ZX Spectrum 48K (PAL)