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.
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.
| 11 | 11 | | |
| 12 | 12 | START_COL equ 15 ; where the lamplighter begins | |
| 13 | 13 | START_ROW equ 11 | |
| 14 | + | PLAYER_REPEAT equ 6 ; frames between steps while a key is held | |
| 14 | 15 | | |
| 15 | 16 | KEYS_OP equ $DFFE ; half-row P O I U Y — bits 1 and 0 | |
| 16 | 17 | KEYS_Q equ $FBFE ; half-row Q W E R T — bit 0 is Q | |
| ... | |||
| 28 | 29 | ld (lamp_col), a | |
| 29 | 30 | ld a, START_ROW | |
| 30 | 31 | ld (lamp_row), a | |
| 32 | + | xor a | |
| 33 | + | ld (player_timer), a | |
| 31 | 34 | | |
| 32 | 35 | ; --- wipe the canvas --- | |
| 33 | 36 | ; The bitmap ($4000-$57FF) is the pixel layer; whatever was on | |
| ... | |||
| 138 | 141 | ld (tcol), a | |
| 139 | 142 | ld a, (lamp_row) | |
| 140 | 143 | 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 | |
| 141 | 177 | | |
| 142 | 178 | ld bc, KEYS_OP | |
| 143 | 179 | in a, (c) | |
| ... | |||
| 378 | 414 | tcol: | |
| 379 | 415 | defb 0 | |
| 380 | 416 | trow: | |
| 417 | + | defb 0 | |
| 418 | + | player_timer: | |
| 381 | 419 | defb 0 | |
| 382 | 420 | | |
| 383 | 421 | 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:

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.
| 142 | 142 | ld a, (lamp_row) | |
| 143 | 143 | ld (trow), a | |
| 144 | 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. | |
| 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. | |
| 149 | 148 | ld bc, KEYS_OP | |
| 150 | 149 | in a, (c) | |
| 151 | 150 | cpl | |
| ... | |||
| 163 | 162 | and %00000001 | |
| 164 | 163 | or e | |
| 165 | 164 | jr nz, .held | |
| 165 | + | xor a | |
| 166 | + | ld (player_timer), a | |
| 166 | 167 | ret | |
| 167 | 168 | .held: | |
| 168 | 169 | 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:
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.