The Draught
The dark gets its agent: a second cell sprite on the lamplighter's own engine, hunting him with one compare-and-step rule that reads as intent — ghosting through stone, tangling buffers on contact, and finally taught to stop at his side.
Gloaming can be won and cannot be lost, and you can feel what that costs it: there’s no reason to hurry, no reason to route, no pressure on a single decision you make. The game is a stroll. Phase 4 fixes that, and it starts by giving the dark something the lamplighter has had to himself since Unit 3 — a body, and a will.
Meet the draught: a cold wisp that enters at the far corner, waits, and then comes for him.
Milestone 1 — a second body
Before it can hunt, it has to exist — and here fifteen units of careful machinery pay their largest dividend yet. A mover, in this game, is: a position (two bytes), a glyph (eight), an attribute, a nine-byte under-buffer, and the save/restore/draw trio that shuttles between them. None of that code ever assumed it was about a lamplighter. So the draught is the same engine, run twice:
| 11 | 11 | LAMP_UNLIT equ %00000101 ; cold cyan INK on black PAPER — bit 3 | |
| 12 | 12 | ; clear, so a lamp reads as floor | |
| 13 | 13 | LAMP_LIT equ %01000110 ; BRIGHT yellow INK on black — a held flame | |
| 14 | + | | |
| 15 | + | DRAUGHT_ATTR equ %01000101 ; the wisp: BRIGHT cyan on black — cold light | |
| 16 | + | DRAUGHT_COL0 equ 28 ; the corner the dark enters from | |
| 17 | + | DRAUGHT_ROW0 equ 4 | |
| 14 | 18 | | |
| 15 | 19 | PIP_UNLIT equ %00101000 ; a cold pip: cyan PAPER, a solid block | |
| 16 | 20 | PIP_LIT equ %01110000 ; a warm pip: BRIGHT yellow PAPER | |
| ... | |||
| 49 | 53 | ld (lamp_col), a | |
| 50 | 54 | ld a, START_ROW | |
| 51 | 55 | ld (lamp_row), a | |
| 56 | + | ; --- and, in the far corner, something else --- | |
| 57 | + | ld a, DRAUGHT_COL0 | |
| 58 | + | ld (draught_col), a | |
| 59 | + | ld a, DRAUGHT_ROW0 | |
| 60 | + | ld (draught_row), a | |
| 52 | 61 | xor a | |
| 53 | 62 | ld (player_timer), a | |
| 54 | 63 | | |
| ... | |||
| 84 | 93 | ; save what he is about to stand on, BEFORE the first draw | |
| 85 | 94 | call save_under | |
| 86 | 95 | call draw_lamp | |
| 96 | + | call save_draught | |
| 97 | + | call draw_draught | |
| 87 | 98 | ld a, STATE_PLAY | |
| 88 | 99 | ld (game_state), a | |
| 89 | 100 | | |
| ... | |||
| 657 | 668 | inc de | |
| 658 | 669 | inc h | |
| 659 | 670 | djnz .dl | |
| 671 | + | ret | |
| 672 | + | | |
| 673 | + | ; ---------------------------------------------------------------------------- | |
| 674 | + | ; The draught's save / restore / draw — the lamplighter's engine, twice. | |
| 675 | + | ; Same loops, same nine-byte contract, its own buffer and its own | |
| 676 | + | ; position: two movers can share a world but never a buffer. | |
| 677 | + | ; ---------------------------------------------------------------------------- | |
| 678 | + | dpos_bc: | |
| 679 | + | ld a, (draught_row) | |
| 680 | + | ld b, a | |
| 681 | + | ld a, (draught_col) | |
| 682 | + | ld c, a | |
| 683 | + | ret | |
| 684 | + | | |
| 685 | + | save_draught: | |
| 686 | + | call dpos_bc | |
| 687 | + | call scr_addr_cr | |
| 688 | + | ld de, under_draught | |
| 689 | + | ld b, 8 | |
| 690 | + | .sd: | |
| 691 | + | ld a, (hl) | |
| 692 | + | ld (de), a | |
| 693 | + | inc de | |
| 694 | + | inc h | |
| 695 | + | djnz .sd | |
| 696 | + | call dpos_bc | |
| 697 | + | call attr_addr_cr | |
| 698 | + | ld a, (hl) | |
| 699 | + | ld (under_draught + 8), a | |
| 700 | + | ret | |
| 701 | + | | |
| 702 | + | restore_draught: | |
| 703 | + | call dpos_bc | |
| 704 | + | call scr_addr_cr | |
| 705 | + | ld de, under_draught | |
| 706 | + | ld b, 8 | |
| 707 | + | .rd: | |
| 708 | + | ld a, (de) | |
| 709 | + | ld (hl), a | |
| 710 | + | inc de | |
| 711 | + | inc h | |
| 712 | + | djnz .rd | |
| 713 | + | call dpos_bc | |
| 714 | + | call attr_addr_cr | |
| 715 | + | ld a, (under_draught + 8) | |
| 716 | + | ld (hl), a | |
| 717 | + | ret | |
| 718 | + | | |
| 719 | + | draw_draught: | |
| 720 | + | call dpos_bc | |
| 721 | + | call attr_addr_cr | |
| 722 | + | ld (hl), DRAUGHT_ATTR | |
| 723 | + | call dpos_bc | |
| 724 | + | call scr_addr_cr | |
| 725 | + | ld de, draught_glyph | |
| 726 | + | ld b, 8 | |
| 727 | + | .dd: | |
| 728 | + | ld a, (de) | |
| 729 | + | ld (hl), a | |
| 730 | + | inc de | |
| 731 | + | inc h | |
| 732 | + | djnz .dd | |
| 660 | 733 | ret | |
| 661 | 734 | | |
| 662 | 735 | ; ---------------------------------------------------------------------------- | |
| ... | |||
| 704 | 777 | | |
| 705 | 778 | lit_count: | |
| 706 | 779 | defb 0 | |
| 780 | + | | |
| 781 | + | draught_col: | |
| 782 | + | defb 28 | |
| 783 | + | draught_row: | |
| 784 | + | defb 4 | |
| 707 | 785 | player_timer: | |
| 708 | 786 | defb 0 | |
| 709 | 787 | | |
| 710 | 788 | under_lamp: | |
| 789 | + | defb 0, 0, 0, 0, 0, 0, 0, 0, 0 | |
| 790 | + | under_draught: | |
| 711 | 791 | defb 0, 0, 0, 0, 0, 0, 0, 0, 0 | |
| 712 | 792 | | |
| 713 | 793 | lamplighter: | |
| ... | |||
| 726 | 806 | defb %01111110 | |
| 727 | 807 | defb %01111110 | |
| 728 | 808 | defb %01011010 | |
| 809 | + | defb %01111110 | |
| 810 | + | defb %01111110 | |
| 811 | + | defb %00111100 | |
| 812 | + | | |
| 813 | + | draught_glyph: | |
| 814 | + | defb %00000000 | |
| 815 | + | defb %00111100 | |
| 729 | 816 | defb %01111110 | |
| 817 | + | defb %11111111 | |
| 818 | + | defb %11111111 | |
| 730 | 819 | defb %01111110 | |
| 731 | 820 | defb %00111100 | |
| 821 | + | defb %00000000 | |
| 732 | 822 | | |
| 733 | 823 | win_text: | |
| 734 | 824 | defb "THE NIGHT IS HELD" |
The complete step 1 program
; Gloaming — Unit 16: The Draught
; Cumulative build; every step runs on its own. Narrative: the unit page.
; One rule reads as intent: step toward the player; the wisp ghosts through stone.
org 32768
COBBLE equ %00000001 ; PAPER black (0), INK blue (1) — dark ground
WALL equ %00001111 ; PAPER blue (1), INK white (7) — pale stone
WALL_BIT equ 3 ; the attribute bit that says "this is wall"
LAMP_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — his own light
LAMP_UNLIT equ %00000101 ; cold cyan INK on black PAPER — bit 3
; clear, so a lamp reads as floor
LAMP_LIT equ %01000110 ; BRIGHT yellow INK on black — a held flame
DRAUGHT_ATTR equ %01000101 ; the wisp: BRIGHT cyan on black — cold light
DRAUGHT_COL0 equ 28 ; the corner the dark enters from
DRAUGHT_ROW0 equ 4
PIP_UNLIT equ %00101000 ; a cold pip: cyan PAPER, a solid block
PIP_LIT equ %01110000 ; a warm pip: BRIGHT yellow PAPER
PIP_BASE equ $5800 + 12 ; row 0, column 12 — the HUD ledge,
; eight cells, centred over the square
NUM_LAMPS equ 8
MSG_ATTR equ %01000111 ; message text: bright white on black
MSG_ROW equ 11
WIN_COL equ 7 ; centres the seventeen characters
FONT equ $3C00 ; the ROM font, addressed so that
; ASCII x 8 lands on the right glyph
STATE_PLAY equ 1
STATE_WIN equ 2 ; the night is held — the game is won
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
xor a
ld (lit_count), 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
; --- and, in the far corner, something else ---
ld a, DRAUGHT_COL0
ld (draught_col), a
ld a, DRAUGHT_ROW0
ld (draught_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 warm_walls
call paint_buildings
; --- brick the walls ---
; Now that the wall cells are painted, fill_walls can read the
; map back and lay brick wherever the wall bit is set.
call fill_walls
call draw_pips
call draw_lamps
; save what he is about to stand on, BEFORE the first draw
call save_under
call draw_lamp
call save_draught
call draw_draught
ld a, STATE_PLAY
ld (game_state), a
; --- 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
; the state gate: only PLAY beats the game forward — any
; other state leaves the screen exactly as it stands
ld a, (game_state)
cp STATE_PLAY
jr z, .do_play
jr main_loop ; WIN or LOSE — the screen holds
.do_play:
call play_step
jr main_loop
; play_step — one beat of the game: ask the keyboard.
play_step:
call player_step
; the win check, at the only beat the count can have changed
ld a, (lit_count)
cp NUM_LAMPS
ret nz
call draw_win_screen
ld a, STATE_WIN
ld (game_state), a
ret
; ----------------------------------------------------------------------------
; warm_walls — the square's edge, one attribute write per cell; the
; colour is no longer a constant but the wall_ramp entry for how many
; lamps are lit. Same painting loop as ever — only where C comes from
; has changed.
; ----------------------------------------------------------------------------
warm_walls:
ld a, (lit_count)
ld e, a
ld d, 0
ld hl, wall_ramp
add hl, de
ld c, (hl) ; 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
; ----------------------------------------------------------------------------
; Screens.
; ----------------------------------------------------------------------------
; draw_win_screen — lift the lamplighter off the board (restore his
; cell: the eighth lamp, lit, comes out of the buffer), then say it.
draw_win_screen:
call restore_under
ld hl, win_text
ld b, MSG_ROW
ld c, WIN_COL
call print_string
ret
; ----------------------------------------------------------------------------
; clear_bitmap — zero the pixel layer, $4000-$57FF, with the same
; seed-and-cascade LDIR idiom the cobble wash uses.
; ----------------------------------------------------------------------------
clear_bitmap:
ld hl, $4000
ld de, $4001
ld (hl), 0
ld bc, 6143
ldir
ret
; ----------------------------------------------------------------------------
; player_step — the keys become movement. Each direction key edits a
; TARGET position (tcol, trow) — a proposal, not yet a move — so it
; can be vetoed before it becomes real. Then the move commits: leave
; the old cell, take the new one, draw.
; ----------------------------------------------------------------------------
player_step:
; --- propose: the target starts where he stands ---
ld a, (lamp_col)
ld (tcol), a
ld a, (lamp_row)
ld (trow), a
; The held-key gate: the first press steps at once, then one
; step every PLAYER_REPEAT frames. Releasing every direction
; key re-arms the instant first step, so taps stay crisp.
ld bc, KEYS_OP
in a, (c)
cpl
and %00000011
ld e, a
ld bc, KEYS_Q
in a, (c)
cpl
and %00000001
or e
ld e, a
ld bc, KEYS_A
in a, (c)
cpl
and %00000001
or e
jr nz, .held
xor a
ld (player_timer), a
ret
.held:
ld a, (player_timer)
or a
jr z, .stepnow
dec a
ld (player_timer), a
ret
.stepnow:
ld a, PLAYER_REPEAT
ld (player_timer), a
ld bc, KEYS_OP
in a, (c)
bit 1, a ; O — a zero bit is a pressed key
jr z, .pleft
bit 0, a ; P, same half-row
jr z, .pright
ld bc, KEYS_Q
in a, (c)
bit 0, a ; Q
jr z, .pup
ld bc, KEYS_A
in a, (c)
bit 0, a ; A
jr z, .pdown
ret ; nothing held — nothing to do
.pleft:
ld hl, tcol
dec (hl)
jr .pmove
.pright:
ld hl, tcol
inc (hl)
jr .pmove
.pup:
ld hl, trow
dec (hl)
jr .pmove
.pdown:
ld hl, trow
inc (hl)
.pmove:
; The veto: ask the target cell's attribute whether it's wall.
; NZ means brick — the proposal dies here and he stays put.
ld a, (trow)
ld b, a
ld a, (tcol)
ld c, a
call wall_at
ret nz
; --- commit: restore, step, save, draw — in that order ---
call restore_under
ld a, (tcol)
ld (lamp_col), a
ld a, (trow)
ld (lamp_row), a
call save_under
; light it where it lives: while he covers the lamp, its truth
; is the buffer — rewrite the saved attribute, and restore will
; paint the lamp back lit when he leaves
ld a, (under_lamp + 8)
cp LAMP_UNLIT
jr nz, .pdrawn
ld a, LAMP_LIT
ld (under_lamp + 8), a
call light_pip
call warm_walls
.pdrawn:
call draw_lamp
ret
; ----------------------------------------------------------------------------
; fill_ground — the cobble stipple. Not decoration: the stipple is what
; makes ground-state changes visible later, when the game starts
; recolouring these pixels. Rows 1-23 (row 0 is the HUD).
; ----------------------------------------------------------------------------
fill_ground:
ld b, 1 ; rows 1-23 (row 0 is the HUD)
.fgr:
ld c, 0
.fgc:
ld de, cobble_tex
call blit_tex
inc c
ld a, c
cp 32
jr c, .fgc
inc b
ld a, b
cp 24
jr c, .fgr
ret
; fill_walls — brickwork. Driven by the wall attribute bit, so anything
; painted as wall — now or later in the game — gets its brick for free:
; the map itself decides where the brick goes.
fill_walls:
ld b, 1
.fwr:
ld c, 0
.fwc:
push bc
call attr_addr_cr
bit WALL_BIT, (hl)
pop bc
jr z, .fwn
ld de, brick_tex
call blit_tex
.fwn:
inc c
ld a, c
cp 32
jr c, .fwc
inc b
ld a, b
cp 24
jr c, .fwr
ret
; blit_tex — write the 8-byte texture at DE into cell (C, B)'s bitmap.
; scr_addr_cr finds the cell's first pixel row; INC H steps down the
; other seven, 256 bytes apart.
blit_tex:
push bc
call scr_addr_cr
ld b, 8
.bt:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .bt
pop bc
ret
cobble_tex:
defb %10000010
defb %00000000
defb %00001000
defb %00000000
defb %00100001
defb %00000000
defb %00010000
defb %00000000
brick_tex:
; mortar courses with staggered verticals — dusk-lit stone
defb %00001000
defb %00001000
defb %00001000
defb %11111111
defb %10000000
defb %10000000
defb %10000000
defb %11111111
; paint_buildings — walk the rectangle table: each entry is col, row,
; width, height; $FF ends the list. Every cell inside a rectangle gets
; the WALL attribute — and because fill_walls textures by the wall bit,
; the brickwork arrives without another line of drawing code.
paint_buildings:
ld hl, bldg_data
.pb:
ld a, (hl)
cp $FF
ret z
ld c, a ; col
inc hl
ld b, (hl) ; row
inc hl
ld d, (hl) ; width
inc hl
ld e, (hl) ; height
inc hl
push hl
.pbrow:
push bc
push de
.pbcol:
push bc
push de
call attr_addr_cr
ld (hl), WALL
pop de
pop bc
inc c
dec d
jr nz, .pbcol
pop de
pop bc
inc b
dec e
jr nz, .pbrow
pop hl
jr .pb
bldg_data:
defb 5, 5, 4, 3
defb 23, 5, 4, 3
defb $FF
; ----------------------------------------------------------------------------
; print_string / print_char — the machine's own letters. The Spectrum
; keeps its font in ROM: eight bytes per character, and FONT is chosen
; so that ASCII code x 8 + FONT is the glyph's address. Each character
; is drawn like any cell: attribute first, eight rows down the cell.
; ----------------------------------------------------------------------------
print_string:
.ps:
ld a, (hl)
cp $FF
ret z
push hl
push bc
call print_char
pop bc
pop hl
inc hl
inc c
jr .ps
print_char:
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
ld de, FONT
add hl, de
ex de, hl
push de
call attr_addr_cr
ld (hl), MSG_ATTR
call scr_addr_cr
pop de
ld b, 8
.pc:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .pc
ret
; ----------------------------------------------------------------------------
; light_pip / draw_pips.
; ----------------------------------------------------------------------------
; light_pip — warm the next pip along and count the lamp. lit_count is
; the index of the pip to light AND the number of lamps lit so far —
; read it for the address, then step it.
light_pip:
ld a, (lit_count)
ld e, a
ld d, 0
inc a
ld (lit_count), a
ld hl, PIP_BASE
add hl, de
ld (hl), PIP_LIT
ret
; draw_pips — the tally row: one cell per lamp on the HUD ledge, all
; cold to start. A pip is pure attribute — no glyph, just a block of
; PAPER — so the row costs eight bytes of screen and no bitmap at all.
draw_pips:
ld hl, PIP_BASE
ld b, NUM_LAMPS
ld a, PIP_UNLIT
.dp:
ld (hl), a
inc hl
djnz .dp
ret
; ----------------------------------------------------------------------------
; draw_lamps — walk the position table: col, row pairs, $FF to finish.
; Placement is data; the drawing code neither knows nor cares how many
; lamps the town has tonight.
; ----------------------------------------------------------------------------
draw_lamps:
ld hl, lamp_data
.next:
ld a, (hl)
cp $FF
ret z
ld c, a
inc hl
ld b, (hl)
inc hl
push hl
call draw_lantern
pop hl
jr .next
; draw_lantern — an unlit lamp into cell (C, B): cold cyan attribute,
; then the lantern glyph down the cell like any texture.
draw_lantern:
call attr_addr_cr
ld (hl), LAMP_UNLIT
call scr_addr_cr
ld de, lantern
ld b, 8
.dlt:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dlt
ret
; ----------------------------------------------------------------------------
; scr_addr_cr — HL = bitmap address of cell (C, B)'s first pixel row.
; The row's top two bits pick the third of the screen (H), its bottom
; three become L's top bits, and the column fills L's low five.
; ----------------------------------------------------------------------------
scr_addr_cr:
ld a, b
and %00011000 ; the third (row bits 4-3) ...
or %01000000 ; ... under the screen base $40xx
ld h, a
ld a, b
and %00000111 ; the char row within the third ...
rrca ; ... rotated into bits 7-5
rrca
rrca
or c ; the column in bits 4-0
ld l, a
ret
; attr_addr_cr — HL = attribute address of cell (C, B):
; $5800 + row*32 + col, the row shifted up five times.
attr_addr_cr:
ld a, b
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
add hl, hl
add hl, hl
ld de, $5800
add hl, de
ld a, c
ld e, a
ld d, 0
add hl, de
ret
; wall_at — is cell (C, B) wall? The answer is already on the screen:
; every wall cell's attribute has WALL_BIT set, so one bit-test of
; attribute memory is the whole collision system. NZ = wall.
wall_at:
call attr_addr_cr
bit WALL_BIT, (hl)
ret
; ----------------------------------------------------------------------------
; The lamplighter's save / restore / draw.
; ----------------------------------------------------------------------------
; pos_bc — the lamplighter's cell into (C, B), read fresh from the data.
pos_bc:
ld a, (lamp_row)
ld b, a
ld a, (lamp_col)
ld c, a
ret
; save_under — copy the nine bytes of his cell into the buffer: eight
; bitmap rows, then the attribute. Runs as he ARRIVES, before the
; draw — so the buffer always holds true ground, never him.
save_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.su:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .su
call pos_bc
call attr_addr_cr
ld a, (hl)
ld (under_lamp + 8), a
ret
; restore_under — the same nine bytes back the other way: the ground
; returns exactly as it was. Runs as he LEAVES, while the position
; still points at the old cell.
restore_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.ru:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .ru
call pos_bc
call attr_addr_cr
ld a, (under_lamp + 8)
ld (hl), a
ret
draw_lamp:
; his colour first: the cell's attribute becomes his own —
; bright white on the black, his own light about him
call pos_bc
call attr_addr_cr
ld (hl), LAMP_ATTR
; then his shape, eight bytes down the cell like any texture
call pos_bc
call scr_addr_cr
ld de, lamplighter
ld b, 8
.dl:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dl
ret
; ----------------------------------------------------------------------------
; The draught's save / restore / draw — the lamplighter's engine, twice.
; Same loops, same nine-byte contract, its own buffer and its own
; position: two movers can share a world but never a buffer.
; ----------------------------------------------------------------------------
dpos_bc:
ld a, (draught_row)
ld b, a
ld a, (draught_col)
ld c, a
ret
save_draught:
call dpos_bc
call scr_addr_cr
ld de, under_draught
ld b, 8
.sd:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .sd
call dpos_bc
call attr_addr_cr
ld a, (hl)
ld (under_draught + 8), a
ret
restore_draught:
call dpos_bc
call scr_addr_cr
ld de, under_draught
ld b, 8
.rd:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .rd
call dpos_bc
call attr_addr_cr
ld a, (under_draught + 8)
ld (hl), a
ret
draw_draught:
call dpos_bc
call attr_addr_cr
ld (hl), DRAUGHT_ATTR
call dpos_bc
call scr_addr_cr
ld de, draught_glyph
ld b, 8
.dd:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dd
ret
; ----------------------------------------------------------------------------
; Data.
; ----------------------------------------------------------------------------
game_state:
defb STATE_PLAY
wall_ramp:
; The square warms in the game's own vocabulary: the stone
; stays dusk-blue; the lamplight catches the mortar first
; (ink white -> yellow), then the whole face glows BRIGHT.
; Never magenta — warmth is yellow here, not red-shifted blue.
defb %00001111 ; dusk stone, white mortar
defb %00001111
defb %00001111
defb %00001110 ; the mortar warms — yellow ink
defb %00001110
defb %00001110
defb %01001110 ; the stone catches the glow — BRIGHT
defb %01001110
defb %01110000 ; all eight lit: the square aglow —
; warm yellow stone, only at the win
lamp_data:
defb 4, 3
defb 27, 3
defb 9, 7
defb 22, 7
defb 6, 15
defb 25, 15
defb 13, 20
defb 18, 20
defb $FF
lamp_col:
defb START_COL
lamp_row:
defb START_ROW
tcol:
defb 0
trow:
defb 0
lit_count:
defb 0
draught_col:
defb 28
draught_row:
defb 4
player_timer:
defb 0
under_lamp:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
under_draught:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
lamplighter:
defb %00111100
defb %00111100
defb %00011000
defb %01111110
defb %00011000
defb %00011000
defb %00100100
defb %01000010
lantern:
defb %00011000
defb %00100100
defb %01111110
defb %01111110
defb %01011010
defb %01111110
defb %01111110
defb %00111100
draught_glyph:
defb %00000000
defb %00111100
defb %01111110
defb %11111111
defb %11111111
defb %01111110
defb %00111100
defb %00000000
win_text:
defb "THE NIGHT IS HELD"
defb $FF
end start
Read save_draught next to save_under and you’ll see the lesson is the repetition: the same loops, line for line, pointed at draught_col/draught_row and under_draught instead. (A bigger game would fold the pair into one routine taking “which sprite” as a parameter — a later game in this course does exactly that. Seeing them side by side first is how you earn that abstraction.) The one non-negotiable: its own buffer. Two movers can share a world, a screen, an engine — never a buffer. Hold that thought; this unit will show you why the hard way.

One rule that reads as intent
Now the will. Here is the entire mind of the thing: step one cell along the axis with the greater distance to the lamplighter; ties go sideways. That’s it — two absolute differences, one compare, one step, once every DRAUGHT_SPEED frames. No pathfinding, no memory, no plan.
Watch it run, though, and none of that is what you’ll see. You’ll see it notice him. You’ll see it round on him when he breaks left, cut the corner as he flees, close the last cells with what looks like patience. This is the oldest secret in game AI — the arcade ghosts that terrorised a generation ran rules this size — and it’s worth stating as a law: players read intent into motion, and the intent they read can vastly exceed the rule that made it. You don’t need a clever enemy; you need a legible one, stepped toward its goal.
Two details in the design deserve their reasons. The wisp moves on its own timer — DRAUGHT_SPEED 16, one step to roughly every two of the lamplighter’s flat-out pace — because a threat you can outrun but not ignore is pressure, and a threat you can’t outrun is just a countdown. And it doesn’t move at all for its first GATHER 120 frames: two and a half seconds of stillness at boot, long enough for the player to see the whole board, pick a plan, and understand what’s about to start moving. Menace benefits from an introduction.
Milestone 2 — the hunt, with the touch left free
Wire the rule in — draught_step on its timer, called from play_step right after the player’s beat — and deliberately leave one question unanswered: what happens when it reaches him? For now, nothing. The proposal commits wherever it lands, including on top of the lamplighter.
| 13 | 13 | LAMP_LIT equ %01000110 ; BRIGHT yellow INK on black — a held flame | |
| 14 | 14 | | |
| 15 | 15 | DRAUGHT_ATTR equ %01000101 ; the wisp: BRIGHT cyan on black — cold light | |
| 16 | + | DRAUGHT_SPEED equ 16 ; frames between the wisp's steps | |
| 16 | 17 | DRAUGHT_COL0 equ 28 ; the corner the dark enters from | |
| 17 | 18 | DRAUGHT_ROW0 equ 4 | |
| 18 | 19 | | |
| ... | |||
| 34 | 35 | START_COL equ 15 ; where the lamplighter begins | |
| 35 | 36 | START_ROW equ 11 | |
| 36 | 37 | PLAYER_REPEAT equ 6 ; frames between steps while a key is held | |
| 38 | + | GATHER equ 120 ; frames the dark gathers before its first step | |
| 37 | 39 | | |
| 38 | 40 | KEYS_OP equ $DFFE ; half-row P O I U Y — bits 1 and 0 | |
| 39 | 41 | KEYS_Q equ $FBFE ; half-row Q W E R T — bit 0 is Q | |
| ... | |||
| 58 | 60 | ld (draught_col), a | |
| 59 | 61 | ld a, DRAUGHT_ROW0 | |
| 60 | 62 | ld (draught_row), a | |
| 63 | + | ; the dark gathers before its first step — a beat to read | |
| 64 | + | ; the square before the hunt begins | |
| 65 | + | ld a, GATHER | |
| 66 | + | ld (draught_timer), a | |
| 61 | 67 | xor a | |
| 62 | 68 | ld (player_timer), a | |
| 63 | 69 | | |
| ... | |||
| 120 | 126 | ; play_step — one beat of the game: ask the keyboard. | |
| 121 | 127 | play_step: | |
| 122 | 128 | call player_step | |
| 129 | + | call draught_step | |
| 123 | 130 | ; the win check, at the only beat the count can have changed | |
| 124 | 131 | ld a, (lit_count) | |
| 125 | 132 | cp NUM_LAMPS | |
| ... | |||
| 396 | 403 | defb %10000000 | |
| 397 | 404 | defb %10000000 | |
| 398 | 405 | defb %11111111 | |
| 406 | + | | |
| 407 | + | ; ---------------------------------------------------------------------------- | |
| 408 | + | ; draught_step — the hunt. One rule: step one cell along the axis with | |
| 409 | + | ; the greater distance to the lamplighter (ties go sideways). No walls, | |
| 410 | + | ; no vetoes — the wisp is not of the town, and stone means nothing to it. | |
| 411 | + | ; ---------------------------------------------------------------------------- | |
| 412 | + | draught_step: | |
| 413 | + | ld a, (draught_timer) | |
| 414 | + | dec a | |
| 415 | + | ld (draught_timer), a | |
| 416 | + | ret nz | |
| 417 | + | ld a, DRAUGHT_SPEED | |
| 418 | + | ld (draught_timer), a | |
| 419 | + | | |
| 420 | + | ; the dark hunts the lamplighter's own flame | |
| 421 | + | ld a, (lamp_col) | |
| 422 | + | ld (seek_col), a | |
| 423 | + | ld a, (lamp_row) | |
| 424 | + | ld (seek_row), a | |
| 425 | + | | |
| 426 | + | .chase: | |
| 427 | + | ; step one cell along the axis with the greater distance | |
| 428 | + | ; (ties go sideways) — the night is 4-connected, like the | |
| 429 | + | ; lamplighter | |
| 430 | + | ld a, (draught_col) | |
| 431 | + | ld (dtcol), a | |
| 432 | + | ld a, (draught_row) | |
| 433 | + | ld (dtrow), a | |
| 434 | + | | |
| 435 | + | ld a, (seek_col) | |
| 436 | + | ld hl, draught_col | |
| 437 | + | sub (hl) | |
| 438 | + | jp p, .absc | |
| 439 | + | neg | |
| 440 | + | .absc: | |
| 441 | + | ld d, a ; D = |dc| | |
| 442 | + | ld a, (seek_row) | |
| 443 | + | ld hl, draught_row | |
| 444 | + | sub (hl) | |
| 445 | + | jp p, .absr | |
| 446 | + | neg | |
| 447 | + | .absr: | |
| 448 | + | cp d ; |dr| vs |dc| | |
| 449 | + | jr z, .tie | |
| 450 | + | jr c, .horiz | |
| 451 | + | jr .vert | |
| 452 | + | .tie: | |
| 453 | + | ld a, d | |
| 454 | + | or a | |
| 455 | + | ret z ; already on the light | |
| 456 | + | jr .horiz | |
| 457 | + | .horiz: | |
| 458 | + | ld a, (seek_col) | |
| 459 | + | ld hl, draught_col | |
| 460 | + | cp (hl) | |
| 461 | + | jr c, .hleft | |
| 462 | + | ld a, (hl) | |
| 463 | + | inc a | |
| 464 | + | ld (dtcol), a | |
| 465 | + | jr .contact | |
| 466 | + | .hleft: | |
| 467 | + | ld a, (hl) | |
| 468 | + | dec a | |
| 469 | + | ld (dtcol), a | |
| 470 | + | jr .contact | |
| 471 | + | .vert: | |
| 472 | + | ld a, (seek_row) | |
| 473 | + | ld hl, draught_row | |
| 474 | + | cp (hl) | |
| 475 | + | jr c, .vup | |
| 476 | + | ld a, (hl) | |
| 477 | + | inc a | |
| 478 | + | ld (dtrow), a | |
| 479 | + | jr .contact | |
| 480 | + | .vup: | |
| 481 | + | ld a, (hl) | |
| 482 | + | dec a | |
| 483 | + | ld (dtrow), a | |
| 484 | + | .contact: | |
| 485 | + | ; (nothing here yet — the wisp's touch is free, and it will | |
| 486 | + | ; walk straight onto him. watch what that does to the buffers) | |
| 487 | + | .dmove: | |
| 488 | + | call restore_draught | |
| 489 | + | ld a, (dtcol) | |
| 490 | + | ld (draught_col), a | |
| 491 | + | ld a, (dtrow) | |
| 492 | + | ld (draught_row), a | |
| 493 | + | call save_draught | |
| 494 | + | call draw_draught | |
| 495 | + | ret | |
| 399 | 496 | | |
| 400 | 497 | ; paint_buildings — walk the rectangle table: each entry is col, row, | |
| 401 | 498 | ; width, height; $FF ends the list. Every cell inside a rectangle gets | |
| ... | |||
| 782 | 879 | defb 28 | |
| 783 | 880 | draught_row: | |
| 784 | 881 | defb 4 | |
| 882 | + | draught_timer: | |
| 883 | + | defb 16 | |
| 785 | 884 | player_timer: | |
| 885 | + | defb 0 | |
| 886 | + | seek_col: | |
| 887 | + | defb 0 | |
| 888 | + | seek_row: | |
| 889 | + | defb 0 | |
| 890 | + | dtcol: | |
| 891 | + | defb 0 | |
| 892 | + | dtrow: | |
| 786 | 893 | defb 0 | |
| 787 | 894 | | |
| 788 | 895 | under_lamp: |
The complete step 2 program
; Gloaming — Unit 16: The Draught
; Cumulative build; every step runs on its own. Narrative: the unit page.
; One rule reads as intent: step toward the player; the wisp ghosts through stone.
org 32768
COBBLE equ %00000001 ; PAPER black (0), INK blue (1) — dark ground
WALL equ %00001111 ; PAPER blue (1), INK white (7) — pale stone
WALL_BIT equ 3 ; the attribute bit that says "this is wall"
LAMP_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — his own light
LAMP_UNLIT equ %00000101 ; cold cyan INK on black PAPER — bit 3
; clear, so a lamp reads as floor
LAMP_LIT equ %01000110 ; BRIGHT yellow INK on black — a held flame
DRAUGHT_ATTR equ %01000101 ; the wisp: BRIGHT cyan on black — cold light
DRAUGHT_SPEED equ 16 ; frames between the wisp's steps
DRAUGHT_COL0 equ 28 ; the corner the dark enters from
DRAUGHT_ROW0 equ 4
PIP_UNLIT equ %00101000 ; a cold pip: cyan PAPER, a solid block
PIP_LIT equ %01110000 ; a warm pip: BRIGHT yellow PAPER
PIP_BASE equ $5800 + 12 ; row 0, column 12 — the HUD ledge,
; eight cells, centred over the square
NUM_LAMPS equ 8
MSG_ATTR equ %01000111 ; message text: bright white on black
MSG_ROW equ 11
WIN_COL equ 7 ; centres the seventeen characters
FONT equ $3C00 ; the ROM font, addressed so that
; ASCII x 8 lands on the right glyph
STATE_PLAY equ 1
STATE_WIN equ 2 ; the night is held — the game is won
START_COL equ 15 ; where the lamplighter begins
START_ROW equ 11
PLAYER_REPEAT equ 6 ; frames between steps while a key is held
GATHER equ 120 ; frames the dark gathers before its first step
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
xor a
ld (lit_count), 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
; --- and, in the far corner, something else ---
ld a, DRAUGHT_COL0
ld (draught_col), a
ld a, DRAUGHT_ROW0
ld (draught_row), a
; the dark gathers before its first step — a beat to read
; the square before the hunt begins
ld a, GATHER
ld (draught_timer), 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 warm_walls
call paint_buildings
; --- brick the walls ---
; Now that the wall cells are painted, fill_walls can read the
; map back and lay brick wherever the wall bit is set.
call fill_walls
call draw_pips
call draw_lamps
; save what he is about to stand on, BEFORE the first draw
call save_under
call draw_lamp
call save_draught
call draw_draught
ld a, STATE_PLAY
ld (game_state), a
; --- 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
; the state gate: only PLAY beats the game forward — any
; other state leaves the screen exactly as it stands
ld a, (game_state)
cp STATE_PLAY
jr z, .do_play
jr main_loop ; WIN or LOSE — the screen holds
.do_play:
call play_step
jr main_loop
; play_step — one beat of the game: ask the keyboard.
play_step:
call player_step
call draught_step
; the win check, at the only beat the count can have changed
ld a, (lit_count)
cp NUM_LAMPS
ret nz
call draw_win_screen
ld a, STATE_WIN
ld (game_state), a
ret
; ----------------------------------------------------------------------------
; warm_walls — the square's edge, one attribute write per cell; the
; colour is no longer a constant but the wall_ramp entry for how many
; lamps are lit. Same painting loop as ever — only where C comes from
; has changed.
; ----------------------------------------------------------------------------
warm_walls:
ld a, (lit_count)
ld e, a
ld d, 0
ld hl, wall_ramp
add hl, de
ld c, (hl) ; 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
; ----------------------------------------------------------------------------
; Screens.
; ----------------------------------------------------------------------------
; draw_win_screen — lift the lamplighter off the board (restore his
; cell: the eighth lamp, lit, comes out of the buffer), then say it.
draw_win_screen:
call restore_under
ld hl, win_text
ld b, MSG_ROW
ld c, WIN_COL
call print_string
ret
; ----------------------------------------------------------------------------
; clear_bitmap — zero the pixel layer, $4000-$57FF, with the same
; seed-and-cascade LDIR idiom the cobble wash uses.
; ----------------------------------------------------------------------------
clear_bitmap:
ld hl, $4000
ld de, $4001
ld (hl), 0
ld bc, 6143
ldir
ret
; ----------------------------------------------------------------------------
; player_step — the keys become movement. Each direction key edits a
; TARGET position (tcol, trow) — a proposal, not yet a move — so it
; can be vetoed before it becomes real. Then the move commits: leave
; the old cell, take the new one, draw.
; ----------------------------------------------------------------------------
player_step:
; --- propose: the target starts where he stands ---
ld a, (lamp_col)
ld (tcol), a
ld a, (lamp_row)
ld (trow), a
; The held-key gate: the first press steps at once, then one
; step every PLAYER_REPEAT frames. Releasing every direction
; key re-arms the instant first step, so taps stay crisp.
ld bc, KEYS_OP
in a, (c)
cpl
and %00000011
ld e, a
ld bc, KEYS_Q
in a, (c)
cpl
and %00000001
or e
ld e, a
ld bc, KEYS_A
in a, (c)
cpl
and %00000001
or e
jr nz, .held
xor a
ld (player_timer), a
ret
.held:
ld a, (player_timer)
or a
jr z, .stepnow
dec a
ld (player_timer), a
ret
.stepnow:
ld a, PLAYER_REPEAT
ld (player_timer), a
ld bc, KEYS_OP
in a, (c)
bit 1, a ; O — a zero bit is a pressed key
jr z, .pleft
bit 0, a ; P, same half-row
jr z, .pright
ld bc, KEYS_Q
in a, (c)
bit 0, a ; Q
jr z, .pup
ld bc, KEYS_A
in a, (c)
bit 0, a ; A
jr z, .pdown
ret ; nothing held — nothing to do
.pleft:
ld hl, tcol
dec (hl)
jr .pmove
.pright:
ld hl, tcol
inc (hl)
jr .pmove
.pup:
ld hl, trow
dec (hl)
jr .pmove
.pdown:
ld hl, trow
inc (hl)
.pmove:
; The veto: ask the target cell's attribute whether it's wall.
; NZ means brick — the proposal dies here and he stays put.
ld a, (trow)
ld b, a
ld a, (tcol)
ld c, a
call wall_at
ret nz
; --- commit: restore, step, save, draw — in that order ---
call restore_under
ld a, (tcol)
ld (lamp_col), a
ld a, (trow)
ld (lamp_row), a
call save_under
; light it where it lives: while he covers the lamp, its truth
; is the buffer — rewrite the saved attribute, and restore will
; paint the lamp back lit when he leaves
ld a, (under_lamp + 8)
cp LAMP_UNLIT
jr nz, .pdrawn
ld a, LAMP_LIT
ld (under_lamp + 8), a
call light_pip
call warm_walls
.pdrawn:
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
; ----------------------------------------------------------------------------
; draught_step — the hunt. One rule: step one cell along the axis with
; the greater distance to the lamplighter (ties go sideways). No walls,
; no vetoes — the wisp is not of the town, and stone means nothing to it.
; ----------------------------------------------------------------------------
draught_step:
ld a, (draught_timer)
dec a
ld (draught_timer), a
ret nz
ld a, DRAUGHT_SPEED
ld (draught_timer), a
; the dark hunts the lamplighter's own flame
ld a, (lamp_col)
ld (seek_col), a
ld a, (lamp_row)
ld (seek_row), a
.chase:
; step one cell along the axis with the greater distance
; (ties go sideways) — the night is 4-connected, like the
; lamplighter
ld a, (draught_col)
ld (dtcol), a
ld a, (draught_row)
ld (dtrow), a
ld a, (seek_col)
ld hl, draught_col
sub (hl)
jp p, .absc
neg
.absc:
ld d, a ; D = |dc|
ld a, (seek_row)
ld hl, draught_row
sub (hl)
jp p, .absr
neg
.absr:
cp d ; |dr| vs |dc|
jr z, .tie
jr c, .horiz
jr .vert
.tie:
ld a, d
or a
ret z ; already on the light
jr .horiz
.horiz:
ld a, (seek_col)
ld hl, draught_col
cp (hl)
jr c, .hleft
ld a, (hl)
inc a
ld (dtcol), a
jr .contact
.hleft:
ld a, (hl)
dec a
ld (dtcol), a
jr .contact
.vert:
ld a, (seek_row)
ld hl, draught_row
cp (hl)
jr c, .vup
ld a, (hl)
inc a
ld (dtrow), a
jr .contact
.vup:
ld a, (hl)
dec a
ld (dtrow), a
.contact:
; (nothing here yet — the wisp's touch is free, and it will
; walk straight onto him. watch what that does to the buffers)
.dmove:
call restore_draught
ld a, (dtcol)
ld (draught_col), a
ld a, (dtrow)
ld (draught_row), a
call save_draught
call draw_draught
ret
; paint_buildings — walk the rectangle table: each entry is col, row,
; width, height; $FF ends the list. Every cell inside a rectangle gets
; the WALL attribute — and because fill_walls textures by the wall bit,
; the brickwork arrives without another line of drawing code.
paint_buildings:
ld hl, bldg_data
.pb:
ld a, (hl)
cp $FF
ret z
ld c, a ; col
inc hl
ld b, (hl) ; row
inc hl
ld d, (hl) ; width
inc hl
ld e, (hl) ; height
inc hl
push hl
.pbrow:
push bc
push de
.pbcol:
push bc
push de
call attr_addr_cr
ld (hl), WALL
pop de
pop bc
inc c
dec d
jr nz, .pbcol
pop de
pop bc
inc b
dec e
jr nz, .pbrow
pop hl
jr .pb
bldg_data:
defb 5, 5, 4, 3
defb 23, 5, 4, 3
defb $FF
; ----------------------------------------------------------------------------
; print_string / print_char — the machine's own letters. The Spectrum
; keeps its font in ROM: eight bytes per character, and FONT is chosen
; so that ASCII code x 8 + FONT is the glyph's address. Each character
; is drawn like any cell: attribute first, eight rows down the cell.
; ----------------------------------------------------------------------------
print_string:
.ps:
ld a, (hl)
cp $FF
ret z
push hl
push bc
call print_char
pop bc
pop hl
inc hl
inc c
jr .ps
print_char:
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
ld de, FONT
add hl, de
ex de, hl
push de
call attr_addr_cr
ld (hl), MSG_ATTR
call scr_addr_cr
pop de
ld b, 8
.pc:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .pc
ret
; ----------------------------------------------------------------------------
; light_pip / draw_pips.
; ----------------------------------------------------------------------------
; light_pip — warm the next pip along and count the lamp. lit_count is
; the index of the pip to light AND the number of lamps lit so far —
; read it for the address, then step it.
light_pip:
ld a, (lit_count)
ld e, a
ld d, 0
inc a
ld (lit_count), a
ld hl, PIP_BASE
add hl, de
ld (hl), PIP_LIT
ret
; draw_pips — the tally row: one cell per lamp on the HUD ledge, all
; cold to start. A pip is pure attribute — no glyph, just a block of
; PAPER — so the row costs eight bytes of screen and no bitmap at all.
draw_pips:
ld hl, PIP_BASE
ld b, NUM_LAMPS
ld a, PIP_UNLIT
.dp:
ld (hl), a
inc hl
djnz .dp
ret
; ----------------------------------------------------------------------------
; draw_lamps — walk the position table: col, row pairs, $FF to finish.
; Placement is data; the drawing code neither knows nor cares how many
; lamps the town has tonight.
; ----------------------------------------------------------------------------
draw_lamps:
ld hl, lamp_data
.next:
ld a, (hl)
cp $FF
ret z
ld c, a
inc hl
ld b, (hl)
inc hl
push hl
call draw_lantern
pop hl
jr .next
; draw_lantern — an unlit lamp into cell (C, B): cold cyan attribute,
; then the lantern glyph down the cell like any texture.
draw_lantern:
call attr_addr_cr
ld (hl), LAMP_UNLIT
call scr_addr_cr
ld de, lantern
ld b, 8
.dlt:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dlt
ret
; ----------------------------------------------------------------------------
; scr_addr_cr — HL = bitmap address of cell (C, B)'s first pixel row.
; The row's top two bits pick the third of the screen (H), its bottom
; three become L's top bits, and the column fills L's low five.
; ----------------------------------------------------------------------------
scr_addr_cr:
ld a, b
and %00011000 ; the third (row bits 4-3) ...
or %01000000 ; ... under the screen base $40xx
ld h, a
ld a, b
and %00000111 ; the char row within the third ...
rrca ; ... rotated into bits 7-5
rrca
rrca
or c ; the column in bits 4-0
ld l, a
ret
; attr_addr_cr — HL = attribute address of cell (C, B):
; $5800 + row*32 + col, the row shifted up five times.
attr_addr_cr:
ld a, b
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
add hl, hl
add hl, hl
ld de, $5800
add hl, de
ld a, c
ld e, a
ld d, 0
add hl, de
ret
; wall_at — is cell (C, B) wall? The answer is already on the screen:
; every wall cell's attribute has WALL_BIT set, so one bit-test of
; attribute memory is the whole collision system. NZ = wall.
wall_at:
call attr_addr_cr
bit WALL_BIT, (hl)
ret
; ----------------------------------------------------------------------------
; The lamplighter's save / restore / draw.
; ----------------------------------------------------------------------------
; pos_bc — the lamplighter's cell into (C, B), read fresh from the data.
pos_bc:
ld a, (lamp_row)
ld b, a
ld a, (lamp_col)
ld c, a
ret
; save_under — copy the nine bytes of his cell into the buffer: eight
; bitmap rows, then the attribute. Runs as he ARRIVES, before the
; draw — so the buffer always holds true ground, never him.
save_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.su:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .su
call pos_bc
call attr_addr_cr
ld a, (hl)
ld (under_lamp + 8), a
ret
; restore_under — the same nine bytes back the other way: the ground
; returns exactly as it was. Runs as he LEAVES, while the position
; still points at the old cell.
restore_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.ru:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .ru
call pos_bc
call attr_addr_cr
ld a, (under_lamp + 8)
ld (hl), a
ret
draw_lamp:
; his colour first: the cell's attribute becomes his own —
; bright white on the black, his own light about him
call pos_bc
call attr_addr_cr
ld (hl), LAMP_ATTR
; then his shape, eight bytes down the cell like any texture
call pos_bc
call scr_addr_cr
ld de, lamplighter
ld b, 8
.dl:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dl
ret
; ----------------------------------------------------------------------------
; The draught's save / restore / draw — the lamplighter's engine, twice.
; Same loops, same nine-byte contract, its own buffer and its own
; position: two movers can share a world but never a buffer.
; ----------------------------------------------------------------------------
dpos_bc:
ld a, (draught_row)
ld b, a
ld a, (draught_col)
ld c, a
ret
save_draught:
call dpos_bc
call scr_addr_cr
ld de, under_draught
ld b, 8
.sd:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .sd
call dpos_bc
call attr_addr_cr
ld a, (hl)
ld (under_draught + 8), a
ret
restore_draught:
call dpos_bc
call scr_addr_cr
ld de, under_draught
ld b, 8
.rd:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .rd
call dpos_bc
call attr_addr_cr
ld a, (under_draught + 8)
ld (hl), a
ret
draw_draught:
call dpos_bc
call attr_addr_cr
ld (hl), DRAUGHT_ATTR
call dpos_bc
call scr_addr_cr
ld de, draught_glyph
ld b, 8
.dd:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dd
ret
; ----------------------------------------------------------------------------
; Data.
; ----------------------------------------------------------------------------
game_state:
defb STATE_PLAY
wall_ramp:
; The square warms in the game's own vocabulary: the stone
; stays dusk-blue; the lamplight catches the mortar first
; (ink white -> yellow), then the whole face glows BRIGHT.
; Never magenta — warmth is yellow here, not red-shifted blue.
defb %00001111 ; dusk stone, white mortar
defb %00001111
defb %00001111
defb %00001110 ; the mortar warms — yellow ink
defb %00001110
defb %00001110
defb %01001110 ; the stone catches the glow — BRIGHT
defb %01001110
defb %01110000 ; all eight lit: the square aglow —
; warm yellow stone, only at the win
lamp_data:
defb 4, 3
defb 27, 3
defb 9, 7
defb 22, 7
defb 6, 15
defb 25, 15
defb 13, 20
defb 18, 20
defb $FF
lamp_col:
defb START_COL
lamp_row:
defb START_ROW
tcol:
defb 0
trow:
defb 0
lit_count:
defb 0
draught_col:
defb 28
draught_row:
defb 4
draught_timer:
defb 16
player_timer:
defb 0
seek_col:
defb 0
seek_row:
defb 0
dtcol:
defb 0
dtrow:
defb 0
under_lamp:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
under_draught:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
lamplighter:
defb %00111100
defb %00111100
defb %00011000
defb %01111110
defb %00011000
defb %00011000
defb %00100100
defb %01000010
lantern:
defb %00011000
defb %00100100
defb %01111110
defb %01111110
defb %01011010
defb %01111110
defb %01111110
defb %00111100
draught_glyph:
defb %00000000
defb %00111100
defb %01111110
defb %11111111
defb %11111111
defb %01111110
defb %00111100
defb %00000000
win_text:
defb "THE NIGHT IS HELD"
defb $FF
end start
Stand still and let it come:


Unwind it slowly, because this is the two-mover bug and you will meet it in every game you ever write with more than one sprite. The wisp steps onto his cell: its save_draught runs, and the “ground” it dutifully banks is the lamplighter’s image. He flees: his own restore correctly heals his cell — erasing the wisp that was drawn there, which its data doesn’t know. Next beat, the wisp moves on and restores what it saved: a pixel-perfect lamplighter, standing where nobody is. Neither buffer malfunctioned. Both did exactly what Unit 8 built them to do — they photographed each other. A cell can only ever be under one mover, and the moment two occupy it, each one’s “ground” is the other.
Milestone 3 — adjacent is as close as the night comes
So the fix isn’t cleverer buffers — it’s a rule that keeps the situation from existing. Two rules, in fact, one for each mover, both vetoes in the propose-veto-commit shape that’s carried this game since Unit 6:
- In
draught_step: if the proposed cell is the lamplighter’s, don’t move — the wisp draws up at his side and holds. - In
player_step, right afterwall_at: if the proposed cell is the wisp’s, the step dies — you cannot walk through the cold.
| 297 | 297 | ld c, a | |
| 298 | 298 | call wall_at | |
| 299 | 299 | ret nz | |
| 300 | + | | |
| 301 | + | ; and the mirror rule: he cannot step onto the wisp either | |
| 302 | + | ld a, (tcol) | |
| 303 | + | ld hl, draught_col | |
| 304 | + | cp (hl) | |
| 305 | + | jr nz, .pcommit | |
| 306 | + | ld a, (trow) | |
| 307 | + | ld hl, draught_row | |
| 308 | + | cp (hl) | |
| 309 | + | jr nz, .pcommit | |
| 310 | + | ret ; the wisp holds its ground — you cannot pass | |
| 300 | 311 | | |
| 312 | + | .pcommit: | |
| 301 | 313 | ; --- commit: restore, step, save, draw — in that order --- | |
| 302 | 314 | call restore_under | |
| 303 | 315 | ld a, (tcol) | |
| ... | |||
| 482 | 494 | dec a | |
| 483 | 495 | ld (dtrow), a | |
| 484 | 496 | .contact: | |
| 485 | - | ; (nothing here yet — the wisp's touch is free, and it will | |
| 486 | - | ; walk straight onto him. watch what that does to the buffers) | |
| 497 | + | ; never step ONTO the lamplighter: adjacent is as close as | |
| 498 | + | ; the night comes. Two movers, two buffers — and one cell | |
| 499 | + | ; can only ever be under one of them. | |
| 500 | + | ld a, (dtcol) | |
| 501 | + | ld hl, lamp_col | |
| 502 | + | cp (hl) | |
| 503 | + | jr nz, .dmove | |
| 504 | + | ld a, (dtrow) | |
| 505 | + | ld hl, lamp_row | |
| 506 | + | cp (hl) | |
| 507 | + | jr nz, .dmove | |
| 508 | + | ret ; the wisp is at your side — its touch has no price yet | |
| 509 | + | | |
| 487 | 510 | .dmove: | |
| 488 | 511 | call restore_draught | |
| 489 | 512 | ld a, (dtcol) |
The complete program
; Gloaming — Unit 16: The Draught
; Cumulative build; every step runs on its own. Narrative: the unit page.
; One rule reads as intent: step toward the player; the wisp ghosts through stone.
org 32768
COBBLE equ %00000001 ; PAPER black (0), INK blue (1) — dark ground
WALL equ %00001111 ; PAPER blue (1), INK white (7) — pale stone
WALL_BIT equ 3 ; the attribute bit that says "this is wall"
LAMP_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — his own light
LAMP_UNLIT equ %00000101 ; cold cyan INK on black PAPER — bit 3
; clear, so a lamp reads as floor
LAMP_LIT equ %01000110 ; BRIGHT yellow INK on black — a held flame
DRAUGHT_ATTR equ %01000101 ; the wisp: BRIGHT cyan on black — cold light
DRAUGHT_SPEED equ 16 ; frames between the wisp's steps
DRAUGHT_COL0 equ 28 ; the corner the dark enters from
DRAUGHT_ROW0 equ 4
PIP_UNLIT equ %00101000 ; a cold pip: cyan PAPER, a solid block
PIP_LIT equ %01110000 ; a warm pip: BRIGHT yellow PAPER
PIP_BASE equ $5800 + 12 ; row 0, column 12 — the HUD ledge,
; eight cells, centred over the square
NUM_LAMPS equ 8
MSG_ATTR equ %01000111 ; message text: bright white on black
MSG_ROW equ 11
WIN_COL equ 7 ; centres the seventeen characters
FONT equ $3C00 ; the ROM font, addressed so that
; ASCII x 8 lands on the right glyph
STATE_PLAY equ 1
STATE_WIN equ 2 ; the night is held — the game is won
START_COL equ 15 ; where the lamplighter begins
START_ROW equ 11
PLAYER_REPEAT equ 6 ; frames between steps while a key is held
GATHER equ 120 ; frames the dark gathers before its first step
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
xor a
ld (lit_count), 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
; --- and, in the far corner, something else ---
ld a, DRAUGHT_COL0
ld (draught_col), a
ld a, DRAUGHT_ROW0
ld (draught_row), a
; the dark gathers before its first step — a beat to read
; the square before the hunt begins
ld a, GATHER
ld (draught_timer), 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 warm_walls
call paint_buildings
; --- brick the walls ---
; Now that the wall cells are painted, fill_walls can read the
; map back and lay brick wherever the wall bit is set.
call fill_walls
call draw_pips
call draw_lamps
; save what he is about to stand on, BEFORE the first draw
call save_under
call draw_lamp
call save_draught
call draw_draught
ld a, STATE_PLAY
ld (game_state), a
; --- 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
; the state gate: only PLAY beats the game forward — any
; other state leaves the screen exactly as it stands
ld a, (game_state)
cp STATE_PLAY
jr z, .do_play
jr main_loop ; WIN or LOSE — the screen holds
.do_play:
call play_step
jr main_loop
; play_step — one beat of the game: ask the keyboard.
play_step:
call player_step
call draught_step
; the win check, at the only beat the count can have changed
ld a, (lit_count)
cp NUM_LAMPS
ret nz
call draw_win_screen
ld a, STATE_WIN
ld (game_state), a
ret
; ----------------------------------------------------------------------------
; warm_walls — the square's edge, one attribute write per cell; the
; colour is no longer a constant but the wall_ramp entry for how many
; lamps are lit. Same painting loop as ever — only where C comes from
; has changed.
; ----------------------------------------------------------------------------
warm_walls:
ld a, (lit_count)
ld e, a
ld d, 0
ld hl, wall_ramp
add hl, de
ld c, (hl) ; 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
; ----------------------------------------------------------------------------
; Screens.
; ----------------------------------------------------------------------------
; draw_win_screen — lift the lamplighter off the board (restore his
; cell: the eighth lamp, lit, comes out of the buffer), then say it.
draw_win_screen:
call restore_under
ld hl, win_text
ld b, MSG_ROW
ld c, WIN_COL
call print_string
ret
; ----------------------------------------------------------------------------
; clear_bitmap — zero the pixel layer, $4000-$57FF, with the same
; seed-and-cascade LDIR idiom the cobble wash uses.
; ----------------------------------------------------------------------------
clear_bitmap:
ld hl, $4000
ld de, $4001
ld (hl), 0
ld bc, 6143
ldir
ret
; ----------------------------------------------------------------------------
; player_step — the keys become movement. Each direction key edits a
; TARGET position (tcol, trow) — a proposal, not yet a move — so it
; can be vetoed before it becomes real. Then the move commits: leave
; the old cell, take the new one, draw.
; ----------------------------------------------------------------------------
player_step:
; --- propose: the target starts where he stands ---
ld a, (lamp_col)
ld (tcol), a
ld a, (lamp_row)
ld (trow), a
; The held-key gate: the first press steps at once, then one
; step every PLAYER_REPEAT frames. Releasing every direction
; key re-arms the instant first step, so taps stay crisp.
ld bc, KEYS_OP
in a, (c)
cpl
and %00000011
ld e, a
ld bc, KEYS_Q
in a, (c)
cpl
and %00000001
or e
ld e, a
ld bc, KEYS_A
in a, (c)
cpl
and %00000001
or e
jr nz, .held
xor a
ld (player_timer), a
ret
.held:
ld a, (player_timer)
or a
jr z, .stepnow
dec a
ld (player_timer), a
ret
.stepnow:
ld a, PLAYER_REPEAT
ld (player_timer), a
ld bc, KEYS_OP
in a, (c)
bit 1, a ; O — a zero bit is a pressed key
jr z, .pleft
bit 0, a ; P, same half-row
jr z, .pright
ld bc, KEYS_Q
in a, (c)
bit 0, a ; Q
jr z, .pup
ld bc, KEYS_A
in a, (c)
bit 0, a ; A
jr z, .pdown
ret ; nothing held — nothing to do
.pleft:
ld hl, tcol
dec (hl)
jr .pmove
.pright:
ld hl, tcol
inc (hl)
jr .pmove
.pup:
ld hl, trow
dec (hl)
jr .pmove
.pdown:
ld hl, trow
inc (hl)
.pmove:
; The veto: ask the target cell's attribute whether it's wall.
; NZ means brick — the proposal dies here and he stays put.
ld a, (trow)
ld b, a
ld a, (tcol)
ld c, a
call wall_at
ret nz
; and the mirror rule: he cannot step onto the wisp either
ld a, (tcol)
ld hl, draught_col
cp (hl)
jr nz, .pcommit
ld a, (trow)
ld hl, draught_row
cp (hl)
jr nz, .pcommit
ret ; the wisp holds its ground — you cannot pass
.pcommit:
; --- commit: restore, step, save, draw — in that order ---
call restore_under
ld a, (tcol)
ld (lamp_col), a
ld a, (trow)
ld (lamp_row), a
call save_under
; light it where it lives: while he covers the lamp, its truth
; is the buffer — rewrite the saved attribute, and restore will
; paint the lamp back lit when he leaves
ld a, (under_lamp + 8)
cp LAMP_UNLIT
jr nz, .pdrawn
ld a, LAMP_LIT
ld (under_lamp + 8), a
call light_pip
call warm_walls
.pdrawn:
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
; ----------------------------------------------------------------------------
; draught_step — the hunt. One rule: step one cell along the axis with
; the greater distance to the lamplighter (ties go sideways). No walls,
; no vetoes — the wisp is not of the town, and stone means nothing to it.
; ----------------------------------------------------------------------------
draught_step:
ld a, (draught_timer)
dec a
ld (draught_timer), a
ret nz
ld a, DRAUGHT_SPEED
ld (draught_timer), a
; the dark hunts the lamplighter's own flame
ld a, (lamp_col)
ld (seek_col), a
ld a, (lamp_row)
ld (seek_row), a
.chase:
; step one cell along the axis with the greater distance
; (ties go sideways) — the night is 4-connected, like the
; lamplighter
ld a, (draught_col)
ld (dtcol), a
ld a, (draught_row)
ld (dtrow), a
ld a, (seek_col)
ld hl, draught_col
sub (hl)
jp p, .absc
neg
.absc:
ld d, a ; D = |dc|
ld a, (seek_row)
ld hl, draught_row
sub (hl)
jp p, .absr
neg
.absr:
cp d ; |dr| vs |dc|
jr z, .tie
jr c, .horiz
jr .vert
.tie:
ld a, d
or a
ret z ; already on the light
jr .horiz
.horiz:
ld a, (seek_col)
ld hl, draught_col
cp (hl)
jr c, .hleft
ld a, (hl)
inc a
ld (dtcol), a
jr .contact
.hleft:
ld a, (hl)
dec a
ld (dtcol), a
jr .contact
.vert:
ld a, (seek_row)
ld hl, draught_row
cp (hl)
jr c, .vup
ld a, (hl)
inc a
ld (dtrow), a
jr .contact
.vup:
ld a, (hl)
dec a
ld (dtrow), a
.contact:
; never step ONTO the lamplighter: adjacent is as close as
; the night comes. Two movers, two buffers — and one cell
; can only ever be under one of them.
ld a, (dtcol)
ld hl, lamp_col
cp (hl)
jr nz, .dmove
ld a, (dtrow)
ld hl, lamp_row
cp (hl)
jr nz, .dmove
ret ; the wisp is at your side — its touch has no price yet
.dmove:
call restore_draught
ld a, (dtcol)
ld (draught_col), a
ld a, (dtrow)
ld (draught_row), a
call save_draught
call draw_draught
ret
; paint_buildings — walk the rectangle table: each entry is col, row,
; width, height; $FF ends the list. Every cell inside a rectangle gets
; the WALL attribute — and because fill_walls textures by the wall bit,
; the brickwork arrives without another line of drawing code.
paint_buildings:
ld hl, bldg_data
.pb:
ld a, (hl)
cp $FF
ret z
ld c, a ; col
inc hl
ld b, (hl) ; row
inc hl
ld d, (hl) ; width
inc hl
ld e, (hl) ; height
inc hl
push hl
.pbrow:
push bc
push de
.pbcol:
push bc
push de
call attr_addr_cr
ld (hl), WALL
pop de
pop bc
inc c
dec d
jr nz, .pbcol
pop de
pop bc
inc b
dec e
jr nz, .pbrow
pop hl
jr .pb
bldg_data:
defb 5, 5, 4, 3
defb 23, 5, 4, 3
defb $FF
; ----------------------------------------------------------------------------
; print_string / print_char — the machine's own letters. The Spectrum
; keeps its font in ROM: eight bytes per character, and FONT is chosen
; so that ASCII code x 8 + FONT is the glyph's address. Each character
; is drawn like any cell: attribute first, eight rows down the cell.
; ----------------------------------------------------------------------------
print_string:
.ps:
ld a, (hl)
cp $FF
ret z
push hl
push bc
call print_char
pop bc
pop hl
inc hl
inc c
jr .ps
print_char:
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
ld de, FONT
add hl, de
ex de, hl
push de
call attr_addr_cr
ld (hl), MSG_ATTR
call scr_addr_cr
pop de
ld b, 8
.pc:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .pc
ret
; ----------------------------------------------------------------------------
; light_pip / draw_pips.
; ----------------------------------------------------------------------------
; light_pip — warm the next pip along and count the lamp. lit_count is
; the index of the pip to light AND the number of lamps lit so far —
; read it for the address, then step it.
light_pip:
ld a, (lit_count)
ld e, a
ld d, 0
inc a
ld (lit_count), a
ld hl, PIP_BASE
add hl, de
ld (hl), PIP_LIT
ret
; draw_pips — the tally row: one cell per lamp on the HUD ledge, all
; cold to start. A pip is pure attribute — no glyph, just a block of
; PAPER — so the row costs eight bytes of screen and no bitmap at all.
draw_pips:
ld hl, PIP_BASE
ld b, NUM_LAMPS
ld a, PIP_UNLIT
.dp:
ld (hl), a
inc hl
djnz .dp
ret
; ----------------------------------------------------------------------------
; draw_lamps — walk the position table: col, row pairs, $FF to finish.
; Placement is data; the drawing code neither knows nor cares how many
; lamps the town has tonight.
; ----------------------------------------------------------------------------
draw_lamps:
ld hl, lamp_data
.next:
ld a, (hl)
cp $FF
ret z
ld c, a
inc hl
ld b, (hl)
inc hl
push hl
call draw_lantern
pop hl
jr .next
; draw_lantern — an unlit lamp into cell (C, B): cold cyan attribute,
; then the lantern glyph down the cell like any texture.
draw_lantern:
call attr_addr_cr
ld (hl), LAMP_UNLIT
call scr_addr_cr
ld de, lantern
ld b, 8
.dlt:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dlt
ret
; ----------------------------------------------------------------------------
; scr_addr_cr — HL = bitmap address of cell (C, B)'s first pixel row.
; The row's top two bits pick the third of the screen (H), its bottom
; three become L's top bits, and the column fills L's low five.
; ----------------------------------------------------------------------------
scr_addr_cr:
ld a, b
and %00011000 ; the third (row bits 4-3) ...
or %01000000 ; ... under the screen base $40xx
ld h, a
ld a, b
and %00000111 ; the char row within the third ...
rrca ; ... rotated into bits 7-5
rrca
rrca
or c ; the column in bits 4-0
ld l, a
ret
; attr_addr_cr — HL = attribute address of cell (C, B):
; $5800 + row*32 + col, the row shifted up five times.
attr_addr_cr:
ld a, b
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
add hl, hl
add hl, hl
ld de, $5800
add hl, de
ld a, c
ld e, a
ld d, 0
add hl, de
ret
; wall_at — is cell (C, B) wall? The answer is already on the screen:
; every wall cell's attribute has WALL_BIT set, so one bit-test of
; attribute memory is the whole collision system. NZ = wall.
wall_at:
call attr_addr_cr
bit WALL_BIT, (hl)
ret
; ----------------------------------------------------------------------------
; The lamplighter's save / restore / draw.
; ----------------------------------------------------------------------------
; pos_bc — the lamplighter's cell into (C, B), read fresh from the data.
pos_bc:
ld a, (lamp_row)
ld b, a
ld a, (lamp_col)
ld c, a
ret
; save_under — copy the nine bytes of his cell into the buffer: eight
; bitmap rows, then the attribute. Runs as he ARRIVES, before the
; draw — so the buffer always holds true ground, never him.
save_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.su:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .su
call pos_bc
call attr_addr_cr
ld a, (hl)
ld (under_lamp + 8), a
ret
; restore_under — the same nine bytes back the other way: the ground
; returns exactly as it was. Runs as he LEAVES, while the position
; still points at the old cell.
restore_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.ru:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .ru
call pos_bc
call attr_addr_cr
ld a, (under_lamp + 8)
ld (hl), a
ret
draw_lamp:
; his colour first: the cell's attribute becomes his own —
; bright white on the black, his own light about him
call pos_bc
call attr_addr_cr
ld (hl), LAMP_ATTR
; then his shape, eight bytes down the cell like any texture
call pos_bc
call scr_addr_cr
ld de, lamplighter
ld b, 8
.dl:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dl
ret
; ----------------------------------------------------------------------------
; The draught's save / restore / draw — the lamplighter's engine, twice.
; Same loops, same nine-byte contract, its own buffer and its own
; position: two movers can share a world but never a buffer.
; ----------------------------------------------------------------------------
dpos_bc:
ld a, (draught_row)
ld b, a
ld a, (draught_col)
ld c, a
ret
save_draught:
call dpos_bc
call scr_addr_cr
ld de, under_draught
ld b, 8
.sd:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .sd
call dpos_bc
call attr_addr_cr
ld a, (hl)
ld (under_draught + 8), a
ret
restore_draught:
call dpos_bc
call scr_addr_cr
ld de, under_draught
ld b, 8
.rd:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .rd
call dpos_bc
call attr_addr_cr
ld a, (under_draught + 8)
ld (hl), a
ret
draw_draught:
call dpos_bc
call attr_addr_cr
ld (hl), DRAUGHT_ATTR
call dpos_bc
call scr_addr_cr
ld de, draught_glyph
ld b, 8
.dd:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dd
ret
; ----------------------------------------------------------------------------
; Data.
; ----------------------------------------------------------------------------
game_state:
defb STATE_PLAY
wall_ramp:
; The square warms in the game's own vocabulary: the stone
; stays dusk-blue; the lamplight catches the mortar first
; (ink white -> yellow), then the whole face glows BRIGHT.
; Never magenta — warmth is yellow here, not red-shifted blue.
defb %00001111 ; dusk stone, white mortar
defb %00001111
defb %00001111
defb %00001110 ; the mortar warms — yellow ink
defb %00001110
defb %00001110
defb %01001110 ; the stone catches the glow — BRIGHT
defb %01001110
defb %01110000 ; all eight lit: the square aglow —
; warm yellow stone, only at the win
lamp_data:
defb 4, 3
defb 27, 3
defb 9, 7
defb 22, 7
defb 6, 15
defb 25, 15
defb 13, 20
defb 18, 20
defb $FF
lamp_col:
defb START_COL
lamp_row:
defb START_ROW
tcol:
defb 0
trow:
defb 0
lit_count:
defb 0
draught_col:
defb 28
draught_row:
defb 4
draught_timer:
defb 16
player_timer:
defb 0
seek_col:
defb 0
seek_row:
defb 0
dtcol:
defb 0
dtrow:
defb 0
under_lamp:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
under_draught:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
lamplighter:
defb %00111100
defb %00111100
defb %00011000
defb %01111110
defb %00011000
defb %00011000
defb %00100100
defb %01000010
lantern:
defb %00011000
defb %00100100
defb %01111110
defb %01111110
defb %01011010
defb %01111110
defb %01111110
defb %00111100
draught_glyph:
defb %00000000
defb %00111100
defb %01111110
defb %11111111
defb %11111111
defb %01111110
defb %00111100
defb %00000000
win_text:
defb "THE NIGHT IS HELD"
defb $FF
end start

Watch the video’s middle third again: the wisp goes through the building. That’s not an oversight — draught_step never calls wall_at, and the choice is doing three jobs at once. It’s theme: the draught is not of the town, and stone means nothing to what it is. It’s threat: no wall between you and it is safe, which changes how every corner of the square feels. And it’s engineering: a hunter that respects walls needs to route around them — real pathfinding, a different course — while a hunter that ignores them needs nothing but the rule it already has. The brick heals behind it because its under-buffer preserves stone as indifferently as it photographed a lamplighter. Same machinery, now with a rule keeping it honest.
Its touch still costs nothing — pinned is frightening, not fatal. Pricing the touch is the next unit, and it will close phase 4’s trap: something that hunts you, that you cannot pass, whose contact you cannot afford.
When it’s wrong, see why
- It never moves. The timer isn’t reaching zero — check the
dec/reload pair, and thatplay_stepcallsdraught_stepevery frame. (Remember it holds still forGATHERframes first; patience before panic.) - The square grows lamplighters. You’ve built step 2 — a contact veto is missing or checks the wrong bytes. Both vetoes compare the proposal (
dtcol/dtrow, ortcol/trow) against the other mover’s position, before any commit. - It leaves a trail of wisps instead. Its restore isn’t running, or both movers share one buffer — the mistake Milestone 1 warned about. One mover, one buffer, no exceptions.
- It hunts a spot he left long ago. The seek must re-read
lamp_col/lamp_rowevery beat — copy them at the top of each step, not once at boot. - It jams in a corner of a building and never comes out. You’ve given it
wall_at— see the try-this below for why that’s a bigger decision than it looks. - He can walk through it (or it stops him a cell early). The player’s veto belongs after
wall_atand before the commit, comparing against the wisp’s current cell — not its proposal.
Before and after
The square had one will in it; now it has two, and only one of them is yours. The second mover cost a fraction of the first — the engine was already general — and its mind is a single compare that plays far bigger than it is. The unit’s real lesson lived in the middle: two correct buffers photographing each other, phantoms walking, and the discovery that some bugs aren’t fixed in the machinery at all — they’re fixed by a rule of the world: one cell, one occupant. The dark has a body, a will, and a manner. Next it gets a price.
Try this: turn the fear dial
DRAUGHT_SPEED is dread, quantified. At 8 the square is barely survivable; at 32 the wisp is a slow tide you plan around. And GATHER is the fuse: cut it to 25 and the game starts mean. Feel for the pace where you’re never safe but always culpable — where every pin was your route’s fault. That’s where pressure becomes play.
Try this: give it respect for stone
Add the two lines yourself: call wall_at / ret nz on the wisp’s proposal, just before its commit. Rebuild, then stand west of a building and watch it arrive — and jam against the east face, stepping into the brick and refusing, forever, because the greater-axis rule has no concept of around. To honour walls it would need to route, and routing is pathfinding — a genuinely harder program. Take the lines out and appreciate what the ghosting bought: theme, threat, and a hundred lines you didn’t have to write, all from removing a check.
Try this: a second wisp
Duplicate the data — draught2_col, its own timer, its own nine-byte buffer, its own step call in the loop — and give it a different corner and a different speed. Two hunters, one rule each, and suddenly routes that dodged one wisp die to the crossfire. Nothing in the engine minds; it never knew how many movers there were. (Watch what happens when the wisps overlap each other, though — and now you know exactly which rule they’re missing.)
What you’ve learnt
- The cell-sprite engine was general all along — a second mover is data plus the same trio, and its own buffer is non-negotiable.
- Perceived intent outruns the rule: a greater-axis compare reads as a hunter with patience.
- Per-object timers give each mover its own tempo — and tempo is the threat level.
- The two-mover bug: overlapping buffers photograph each other — and the fix is a world-rule (one cell, one occupant), not smarter code.
- Ignoring walls can be a feature — theme, fairness and simplicity in one deleted check.
What’s next
Pinned is not punished — the wisp can corner him and cost him nothing but nerves. Unit 17 makes the dark collect: contact takes a life from a small row of pips, the wisp recoils to its corner to hunt again — and when the last life goes, the screen darkens and NIGHT FALLS. The game Gloaming has been promising since its name: winnable, and now losable.