Skip to content
Game 1Unit 7 of 201 hr learning time

Taming the Key

Pay the WAY-TOO-FAST debt: a combined any-key scan, a countdown timer that gates the step, and a release re-arm — so a tap is one step, instantly, and a hold is a stride at a chosen pace.

35% of Gloaming

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 answer

A 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.

Step 1: scan for any key, and a countdown gates the step
+38
1111
1212 START_COL equ 15 ; where the lamplighter begins
1313 START_ROW equ 11
14+PLAYER_REPEAT equ 6 ; frames between steps while a key is held
1415
1516 KEYS_OP equ $DFFE ; half-row P O I U Y — bits 1 and 0
1617 KEYS_Q equ $FBFE ; half-row Q W E R T — bit 0 is Q
...
2829 ld (lamp_col), a
2930 ld a, START_ROW
3031 ld (lamp_row), a
32+ xor a
33+ ld (player_timer), a
3134
3235 ; --- wipe the canvas ---
3336 ; The bitmap ($4000-$57FF) is the pixel layer; whatever was on
...
138141 ld (tcol), a
139142 ld a, (lamp_row)
140143 ld (trow), a
144+
145+ ; The held-key gate: one combined answer — is ANY direction key
146+ ; down? Active-low reads flip under CPL so a pressed key becomes
147+ ; a 1, and OR gathers the three half-rows into one byte. Then
148+ ; the countdown: a step only when the timer reaches zero.
149+ ld bc, KEYS_OP
150+ in a, (c)
151+ cpl
152+ and %00000011
153+ ld e, a
154+ ld bc, KEYS_Q
155+ in a, (c)
156+ cpl
157+ and %00000001
158+ or e
159+ ld e, a
160+ ld bc, KEYS_A
161+ in a, (c)
162+ cpl
163+ and %00000001
164+ or e
165+ jr nz, .held
166+ ret
167+.held:
168+ ld a, (player_timer)
169+ or a
170+ jr z, .stepnow
171+ dec a
172+ ld (player_timer), a
173+ ret
174+.stepnow:
175+ ld a, PLAYER_REPEAT
176+ ld (player_timer), a
141177
142178 ld bc, KEYS_OP
143179 in a, (c)
...
378414 tcol:
379415 defb 0
380416 trow:
417+ defb 0
418+player_timer:
381419 defb 0
382420
383421 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     start

With PLAYER_REPEAT at 6, a held key steps once every seventh frame — about seven cells a second. Hold P and he walks:

A held P, then a held A — and instead of the teleport-blur, a measured stride: one step every seventh frame, a pace you can actually steer.
The lamplighter two cells right and one down from the centre, with a two-cell nick in the stipple behind him.
The exact input that carved Unit 6's long gouge across the square now moves him three cells. Same keys, same frames — a gate in between.

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.

Step 2: releasing every key re-arms the instant first step
+5-4
142142 ld a, (lamp_row)
143143 ld (trow), a
144144
145- ; The held-key gate: one combined answer — is ANY direction key
146- ; down? Active-low reads flip under CPL so a pressed key becomes
147- ; a 1, and OR gathers the three half-rows into one byte. Then
148- ; the countdown: a step only when the timer reaches zero.
145+ ; The held-key gate: the first press steps at once, then one
146+ ; step every PLAYER_REPEAT frames. Releasing every direction
147+ ; key re-arms the instant first step, so taps stay crisp.
149148 ld bc, KEYS_OP
150149 in a, (c)
151150 cpl
...
163162 and %00000001
164163 or e
165164 jr nz, .held
165+ xor a
166+ ld (player_timer), a
166167 ret
167168 .held:
168169 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     start

Here is the same run of five quick taps against both builds — watch how many actually land:

Step 1's gate: five identical taps, and only the first and fourth step — the rest die inside a frozen countdown. He ends two cells from where he started.
Step 2's gate, same five taps: five steps, each landing on the frame of its press. That's the contract — a tap always steps, instantly.

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, .stepnow fires 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 — .stepnow must store PLAYER_REPEAT back 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 a alone changes only the copy in A.
  • One key strides, another doesn’t. The combined scan’s masks. The $DFFE row 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/OR gather: 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.