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

The Square Warms

Progress becomes atmosphere: the frame's colour stops being a constant and becomes a nine-entry ramp indexed by lit_count — dusk stone, warming mortar, a bright glow, and gold at eight — repainted at the same event that warms a pip.

70% of Gloaming

The ledge knows the score now — but only the ledge. Step back from the machine and look at the game: a dark square that stays exactly as dark on your eighth lamp as on your first. The pips are information; they aren’t feeling. Good games tell you how it’s going through the world itself — the music lifts, the sky pales, the room glows — so that progress is something you sense before you ever read a number. Gloaming is a game about bringing light to a dark place. The dark place should notice.

This unit makes the square itself respond: with every lamp lit, the stone frame around the town warms — dusk-blue, then lamplit mortar, then a bright glow, and at the eighth lamp, gold.

A colour with nine values

The frame’s colour has been a constant since Unit 1 — WALL, one byte, dusk stone with white mortar. Warmth means that byte now depends on the game: nine possible values, one for each count of lit lamps from zero to eight. Nine values that depend on an index — that’s not a chain of compares, that’s a table:

wall_ramp:
        defb %00001111     ; 0 lamps — dusk stone, white mortar
        defb %00001111     ; 1
        defb %00001111     ; 2
        defb %00001110     ; 3 — the mortar warms: yellow ink
        defb %00001110     ; 4
        defb %00001110     ; 5
        defb %01001110     ; 6 — the stone catches the glow: BRIGHT
        defb %01001110     ; 7
        defb %01110000     ; 8 — the square aglow: warm yellow stone

Nine entries, note — not eight. The count runs 0 through 8 inclusive, and the table needs a value for every count it can be asked about, including “none yet”. Off-by-one fencepost errors live and die on this.

Read it as design, because it is one — this ramp is the game’s art direction in nine bytes. The stone stays dusk-blue for the whole climb: only the ink changes at three (the brick texture’s mortar lines pick up lamplight), only the BRIGHT bit at six (same colours, more voltage). The full transformation — yellow PAPER, the stone itself gold — is hoarded for the moment all eight burn. Restraint is what makes the last step land. And every value stays in the blue-to-gold family: warmth here is yellow, never a red-shifted magenta — the Spectrum’s palette is small enough that discipline about which colours mean something is most of what visual style is.

Milestone 1 — the colour becomes a lookup

paint_walls has one job-defining line: ld c, WALL. Replace the constant with the lookup — read lit_count, add it to the table’s base, take the byte found there — and rename the routine for what it now does. The painting loop beneath is untouched.

Step 1: paint_walls becomes warm_walls — a constant becomes data
+28-4
6363 ld bc, 767
6464 ldir
6565
66- call paint_walls
66+ call warm_walls
6767 call paint_buildings
6868
6969 ; --- brick the walls ---
...
9494 ret
9595
9696 ; ----------------------------------------------------------------------------
97-; paint_walls — the square's edge, one attribute write per cell.
97+; warm_walls — the square's edge, one attribute write per cell; the
98+; colour is no longer a constant but the wall_ramp entry for how many
99+; lamps are lit. Same painting loop as ever — only where C comes from
100+; has changed.
98101 ; ----------------------------------------------------------------------------
99-paint_walls:
100- ld c, WALL ; the byte every wall cell gets
102+warm_walls:
103+ ld a, (lit_count)
104+ ld e, a
105+ ld d, 0
106+ ld hl, wall_ramp
107+ add hl, de
108+ ld c, (hl) ; the byte every wall cell gets
101109
102110 ; the top wall: row 1 is 32 cells in a row from $5820
103111 ; (row 0 is kept back — it becomes the HUD later)
...
571579 ; ----------------------------------------------------------------------------
572580 ; Data.
573581 ; ----------------------------------------------------------------------------
582+
583+wall_ramp:
584+ ; The square warms in the game's own vocabulary: the stone
585+ ; stays dusk-blue; the lamplight catches the mortar first
586+ ; (ink white -> yellow), then the whole face glows BRIGHT.
587+ ; Never magenta — warmth is yellow here, not red-shifted blue.
588+ defb %00001111 ; dusk stone, white mortar
589+ defb %00001111
590+ defb %00001111
591+ defb %00001110 ; the mortar warms — yellow ink
592+ defb %00001110
593+ defb %00001110
594+ defb %01001110 ; the stone catches the glow — BRIGHT
595+ defb %01001110
596+ defb %01110000 ; all eight lit: the square aglow —
597+ ; warm yellow stone, only at the win
574598
575599 lamp_data:
576600 defb 4, 3
The complete step 1 program
; Gloaming — Unit 14: The Square Warms
; Cumulative build; every step runs on its own. Narrative: the unit page.
; Progress as atmosphere: a data table indexed by lit_count.

            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

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

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

            ; --- start the heartbeat ---
            ; IM 1: every 50 Hz frame interrupt calls the ROM's handler.
            ; EI: let it. HALT then sleeps until the next frame arrives,
            ; so the loop below beats exactly once per frame.
            im      1
            ei

main_loop:
            halt
            call    play_step
            jr      main_loop

; play_step — one beat of the game: ask the keyboard.
play_step:
            call    player_step
            ret

; ----------------------------------------------------------------------------
; 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

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

; ----------------------------------------------------------------------------
; 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

; ----------------------------------------------------------------------------
; Data.
; ----------------------------------------------------------------------------

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
player_timer:
            defb    0

under_lamp:
            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

            end     start

Boot it: nothing looks different, and nothing should — at startup lit_count is zero and wall_ramp[0] is the old WALL byte. Now light three lamps:

Three lamps lit and three pips warm, but the walls are still cold dusk-blue.
Three lamps, three pips — and stone-cold walls. The mechanism is right there in the code, and nothing is running it.
The stale build: lamps light, pips warm, and the frame ignores all of it — warm_walls ran once, at boot, when the count was zero, and never again.

The lookup is correct and the atmosphere is stale, because a lookup answers the question when it runs — and it ran once, in the setup, when the honest answer was “zero lamps”. State changed afterwards; nobody asked again. You’ve met this discipline before, one unit ago, from the other side: the tally worked because we hung its update on the event. The same rule, felt as an absence.

Milestone 2 — ask again at the event

So: one line, in the one place the count changes — right beside light_pip, inside the cold-lamp branch.

Step 2: re-warm the frame at the same event that warms a pip
+1
257257 ld a, LAMP_LIT
258258 ld (under_lamp + 8), a
259259 call light_pip
260+ call warm_walls
260261 .pdrawn:
261262 call draw_lamp
262263 ret
The complete program
; Gloaming — Unit 14: The Square Warms
; Cumulative build; every step runs on its own. Narrative: the unit page.
; Progress as atmosphere: a data table indexed by lit_count.

            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

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

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

            ; --- start the heartbeat ---
            ; IM 1: every 50 Hz frame interrupt calls the ROM's handler.
            ; EI: let it. HALT then sleeps until the next frame arrives,
            ; so the loop below beats exactly once per frame.
            im      1
            ei

main_loop:
            halt
            call    play_step
            jr      main_loop

; play_step — one beat of the game: ask the keyboard.
play_step:
            call    player_step
            ret

; ----------------------------------------------------------------------------
; 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

; ----------------------------------------------------------------------------
; 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

; ----------------------------------------------------------------------------
; 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

; ----------------------------------------------------------------------------
; Data.
; ----------------------------------------------------------------------------

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
player_timer:
            defb    0

under_lamp:
            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

            end     start

And now the full circuit — all eight lamps, watching the frame as you go:

The whole climb in sixteen seconds: dusk for the first two lamps, the mortar catching lamplight at the third, the stone brightening at the sixth — and at the eighth, the square goes gold.
The square with three lamps lit; the frame's mortar lines are now yellow.
Three lamps: look closely at the brickwork — the mortar lines have gone from white to yellow, as if catching the light. Subtle on purpose; atmosphere that shouts is a HUD.
The square with six lamps lit; the frame noticeably brighter.
Six lamps: the BRIGHT bit — same colours, more voltage. You feel this one across the room, which is the point of saving it for the second act.
The whole frame glowing bright yellow-gold; all eight lamps lit and all eight pips warm.
Eight: the stone itself goes gold, the mortar now dark seams in warm rock. The buildings keep their own shadow — the lamplight warms the square's edge, not the world. This screen is the win Unit 15 will hang words on.

Notice how cheap this is. warm_walls repaints the frame’s hundred-and-ten cells — but only when a lamp lights, which happens exactly eight times a game. Not per frame, not per step: per event. The same placement discipline that kept the tally honest keeps the atmosphere affordable.

One more thing worth seeing: the buildings stay dusk-blue throughout. warm_walls inherits paint_walls’ loop, and that loop paints the perimeter — the buildings were never its job. The warming reads as the square’s edge catching the collective lamplight, while the town’s stone keeps its own shadow. A happy consequence of routine boundaries, doing duty as art direction.

When it’s wrong, see why

  • The walls never warm. Step 1’s build — the lookup exists but nothing re-runs it. call warm_walls belongs beside light_pip, inside the cp LAMP_UNLIT branch.
  • They warm one lamp late. Order within the branch: light_pip increments the count, so warm_walls must run after it. Before it, the frame paints yesterday’s number.
  • Gold arrives on the seventh lamp, or never. The ramp is short an entry, or the entries are shifted — nine counts, zero through eight, need nine bytes. Count the defbs.
  • The frame goes black or garish mid-game. The index escaped the table — if lit_count can exceed 8 (see Unit 13’s NUM_LAMPS agreement), the lookup reads whatever byte follows wall_ramp in memory and paints the frame with it. Tables index safely only as far as their data runs.
  • He walks into the frame at some ramp stage. You’ve edited a mid-game entry and dropped bit 3 — the wall bit. wall_at reads the frame’s current colour like any attribute, so every ramp entry that should mean “solid” must keep bit 3 set. (The gold entry gets away without it only because the win freezes play — see the try-this below.)

Before and after

A constant became a table, and a repaint found its event. That’s the entire mechanical content of this unit — perhaps fifteen lines — and yet it’s the moment Gloaming starts to feel like the game it’s named for: the square answers the work you do in it, quietly at first, then unmistakably, then gold. The number on the ledge and the light on the stone are the same byte, lit_count, read out two ways — one for your eyes to check, one for your spine to notice.

Try this: your own dusk

Rewrite the ramp. A slower burn — dusk holding until five, then three quick steps to gold. A colder opening — start the mortar cyan (%00001101) so even the first lamp’s warmth is visible against it. All nine entries are yours; rebuild and feel the difference in pacing. This is the tuning loop from Unit 7’s PLAYER_REPEAT again, played on colour instead of time: atmosphere is data, and data is cheap to try.

Try this: walk through the golden wall

Light all eight lamps, then walk at the frame. He passes straight through it — because the gold entry %01110000 has black PAPER’s bit 3… gone. The paper is yellow now, and yellow’s bottom bit is 0: the byte that means “warm stone” to your eye means “floor” to wall_at. One byte serving two masters — look and physics — just stopped serving one of them. The shipped game never exposes this: next unit, the win fires on the very frame the eighth lamp lights, and the screen holds before you can take a step. But tonight, in this build, you can walk through a golden wall — remember it as the sharpest lesson this course has about packing meanings into bits: when one byte carries two jobs, every edit to it must answer to both.

Try this: warm the cobbles instead

Point the mechanism elsewhere: make a ramp for the floor — have warm_walls (or a sibling) repaint the ground attribute from a ground_ramp, so the whole square pales toward dawn as lamps light. It’s a bigger repaint (the full 700-cell interior), still only eight times a game. Does it read better or worse than the frame alone? Atmosphere is taste, and now you have the machinery to have opinions with.

What you’ve learnt

  • Progress should be felt, not just read — atmosphere is a readout for the spine.
  • A value that depends on a count is a table indexed by the count — nine counts, nine entries, fenceposts included.
  • A lookup answers when it runs — state that changes needs the question re-asked, at the event.
  • Restraint is design: ink before brightness before paper, and gold hoarded for the win.
  • Dual-purpose bytes demand double-checked edits — the golden wall walks because look and physics share a bit.

What’s next

The square can reach gold — and when it does, nothing says so. The game just… continues, aglow, waiting for a meaning nobody wrote. Unit 15 writes it: a game_state byte, a win check where the count changes, and THE NIGHT IS HELD across the screen in the Spectrum’s own ROM font — the first words this game has ever spoken, and the moment a toy with a goal becomes a game you can finish.