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

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.

80% of Gloaming

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:

Step 1: the lamplighter's engine, pointed at new data
+90
1111 LAMP_UNLIT equ %00000101 ; cold cyan INK on black PAPER — bit 3
1212 ; clear, so a lamp reads as floor
1313 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
1418
1519 PIP_UNLIT equ %00101000 ; a cold pip: cyan PAPER, a solid block
1620 PIP_LIT equ %01110000 ; a warm pip: BRIGHT yellow PAPER
...
4953 ld (lamp_col), a
5054 ld a, START_ROW
5155 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
5261 xor a
5362 ld (player_timer), a
5463
...
8493 ; save what he is about to stand on, BEFORE the first draw
8594 call save_under
8695 call draw_lamp
96+ call save_draught
97+ call draw_draught
8798 ld a, STATE_PLAY
8899 ld (game_state), a
89100
...
657668 inc de
658669 inc h
659670 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
660733 ret
661734
662735 ; ----------------------------------------------------------------------------
...
704777
705778 lit_count:
706779 defb 0
780+
781+draught_col:
782+ defb 28
783+draught_row:
784+ defb 4
707785 player_timer:
708786 defb 0
709787
710788 under_lamp:
789+ defb 0, 0, 0, 0, 0, 0, 0, 0, 0
790+under_draught:
711791 defb 0, 0, 0, 0, 0, 0, 0, 0, 0
712792
713793 lamplighter:
...
726806 defb %01111110
727807 defb %01111110
728808 defb %01011010
809+ defb %01111110
810+ defb %01111110
811+ defb %00111100
812+
813+draught_glyph:
814+ defb %00000000
815+ defb %00111100
729816 defb %01111110
817+ defb %11111111
818+ defb %11111111
730819 defb %01111110
731820 defb %00111100
821+ defb %00000000
732822
733823 win_text:
734824 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.

A pale cyan orb in the far north-east corner of the square, the lamplighter at the centre.
The draught, gathered in the corner it entered from: a soft cyan orb, drawn once at setup. It does nothing at all — and it already changes how the square feels to look at.

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.

Step 2: the chase — timer, seek, greater-axis step, and no contact rule
+107
1313 LAMP_LIT equ %01000110 ; BRIGHT yellow INK on black — a held flame
1414
1515 DRAUGHT_ATTR equ %01000101 ; the wisp: BRIGHT cyan on black — cold light
16+DRAUGHT_SPEED equ 16 ; frames between the wisp's steps
1617 DRAUGHT_COL0 equ 28 ; the corner the dark enters from
1718 DRAUGHT_ROW0 equ 4
1819
...
3435 START_COL equ 15 ; where the lamplighter begins
3536 START_ROW equ 11
3637 PLAYER_REPEAT equ 6 ; frames between steps while a key is held
38+GATHER equ 120 ; frames the dark gathers before its first step
3739
3840 KEYS_OP equ $DFFE ; half-row P O I U Y — bits 1 and 0
3941 KEYS_Q equ $FBFE ; half-row Q W E R T — bit 0 is Q
...
5860 ld (draught_col), a
5961 ld a, DRAUGHT_ROW0
6062 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
6167 xor a
6268 ld (player_timer), a
6369
...
120126 ; play_step — one beat of the game: ask the keyboard.
121127 play_step:
122128 call player_step
129+ call draught_step
123130 ; the win check, at the only beat the count can have changed
124131 ld a, (lit_count)
125132 cp NUM_LAMPS
...
396403 defb %10000000
397404 defb %10000000
398405 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
399496
400497 ; paint_buildings — walk the rectangle table: each entry is col, row,
401498 ; width, height; $FF ends the list. Every cell inside a rectangle gets
...
782879 defb 28
783880 draught_row:
784881 defb 4
882+draught_timer:
883+ defb 16
785884 player_timer:
885+ defb 0
886+seek_col:
887+ defb 0
888+seek_row:
889+ defb 0
890+dtcol:
891+ defb 0
892+dtrow:
786893 defb 0
787894
788895 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:

The cyan orb sitting where the lamplighter stood; no lamplighter visible anywhere.
Contact. The wisp has stepped onto his cell and drawn itself over him — save_draught has just photographed a lamplighter and filed him under 'ground'.
The hunt works beautifully — right up to the touch. It covers him; he flees; and the square starts growing lamplighters. Every one of those figures is a buffer faithfully restoring what it saved — and what it saved was him.
Two lamplighter figures on the square with the orb between them — one real and fleeing, one a phantom left standing.
One of these lamplighters is real. The other is a photograph: the wisp's buffer, restored onto the cell where they overlapped, exact to the pixel and completely wrong.

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 after wall_at: if the proposed cell is the wisp’s, the step dies — you cannot walk through the cold.
Step 3: the two contact vetoes — no cell ever holds two movers
+25-2
297297 ld c, a
298298 call wall_at
299299 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
300311
312+.pcommit:
301313 ; --- commit: restore, step, save, draw — in that order ---
302314 call restore_under
303315 ld a, (tcol)
...
482494 dec a
483495 ld (dtrow), a
484496 .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+
487510 .dmove:
488511 call restore_draught
489512 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
The cyan orb holding position directly above the lamplighter, adjacent.
Pinned: the wisp at his side, holding — and the held Q in this shot does nothing, because his own veto refuses the step into it. Cornered by something that hasn't touched him. Yet.
The finished hunt: it gathers, descends — passes clean through the building's brick, which heals behind it — pins the idle lamplighter, refuses to overlap him, and gives chase the moment he runs.

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 that play_step calls draught_step every frame. (Remember it holds still for GATHER frames 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, or tcol/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_row every 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_at and 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.