The Night Is Held
The game learns to end: printing with the Spectrum's own ROM font — demonstrated on a claim nobody earned — then a game_state byte, a gated loop, and a win check that fires the frame the eighth lamp lights. THE NIGHT IS HELD.
Light all eight lamps and the square turns gold — and then nothing. The game keeps running, aglow and purposeless: the loop beats, the keys work, the lamplighter wanders a town with nothing left to do in it. Reaching the goal changes nothing, because no code anywhere knows the goal exists. A game needs to be able to end — and an ending, mechanically, is two things this program doesn’t have yet: a way to say something, and a way to stop.
This unit builds both. Words first.
The machine’s own letters
The closing line is text, and text is glyphs — but not glyphs we have to draw. The Spectrum carries its entire character set in ROM, eight bytes per character, exactly the format every glyph in this game already uses. One constant finds it:
FONT equ $3C00 ; the ROM font, addressed so that
; ASCII x 8 lands on the right glyph
The arrangement is tidy enough to feel like a gift: take a character’s ASCII code, multiply by eight, add FONT, and you’re looking at its eight rows. (The font’s first real glyph — space, code 32 — physically sits at $3D00; the $3C00 base is chosen 256 bytes early, which is exactly 32 characters’ worth, so the codes need no adjusting. The offset is baked into the constant.)
So print_char is Unit 3’s glyph-draw with the bytes coming from ROM: three add hl, hl for the times-eight, attribute first, eight rows down the cell. And print_string is Unit 11’s table-walking loop wearing different clothes — read a byte, $FF means done, push/pop around the call to keep the string pointer safe, step one column right. You have written both of these shapes before; this time they spell.
Milestone 1 — words before meaning
Build the printing pair, add the string — and prove it works the blunt way: print the win line at boot, checked by nothing.
| 17 | 17 | PIP_BASE equ $5800 + 12 ; row 0, column 12 — the HUD ledge, | |
| 18 | 18 | ; eight cells, centred over the square | |
| 19 | 19 | NUM_LAMPS equ 8 | |
| 20 | + | | |
| 21 | + | MSG_ATTR equ %01000111 ; message text: bright white on black | |
| 22 | + | MSG_ROW equ 11 | |
| 23 | + | WIN_COL equ 7 ; centres the seventeen characters | |
| 24 | + | FONT equ $3C00 ; the ROM font, addressed so that | |
| 25 | + | ; ASCII x 8 lands on the right glyph | |
| 20 | 26 | | |
| 21 | 27 | START_COL equ 15 ; where the lamplighter begins | |
| 22 | 28 | START_ROW equ 11 | |
| ... | |||
| 75 | 81 | ; save what he is about to stand on, BEFORE the first draw | |
| 76 | 82 | call save_under | |
| 77 | 83 | call draw_lamp | |
| 84 | + | ; the claim, unearned (this step only): print the win text at | |
| 85 | + | ; boot, checked by nothing — the words before the meaning | |
| 86 | + | ld hl, win_text | |
| 87 | + | ld b, MSG_ROW | |
| 88 | + | ld c, WIN_COL | |
| 89 | + | call print_string | |
| 78 | 90 | | |
| 79 | 91 | ; --- start the heartbeat --- | |
| 80 | 92 | ; IM 1: every 50 Hz frame interrupt calls the ROM's handler. | |
| ... | |||
| 391 | 403 | defb 5, 5, 4, 3 | |
| 392 | 404 | defb 23, 5, 4, 3 | |
| 393 | 405 | defb $FF | |
| 406 | + | | |
| 407 | + | ; ---------------------------------------------------------------------------- | |
| 408 | + | ; print_string / print_char — the machine's own letters. The Spectrum | |
| 409 | + | ; keeps its font in ROM: eight bytes per character, and FONT is chosen | |
| 410 | + | ; so that ASCII code x 8 + FONT is the glyph's address. Each character | |
| 411 | + | ; is drawn like any cell: attribute first, eight rows down the cell. | |
| 412 | + | ; ---------------------------------------------------------------------------- | |
| 413 | + | print_string: | |
| 414 | + | .ps: | |
| 415 | + | ld a, (hl) | |
| 416 | + | cp $FF | |
| 417 | + | ret z | |
| 418 | + | push hl | |
| 419 | + | push bc | |
| 420 | + | call print_char | |
| 421 | + | pop bc | |
| 422 | + | pop hl | |
| 423 | + | inc hl | |
| 424 | + | inc c | |
| 425 | + | jr .ps | |
| 426 | + | | |
| 427 | + | print_char: | |
| 428 | + | ld l, a | |
| 429 | + | ld h, 0 | |
| 430 | + | add hl, hl | |
| 431 | + | add hl, hl | |
| 432 | + | add hl, hl | |
| 433 | + | ld de, FONT | |
| 434 | + | add hl, de | |
| 435 | + | ex de, hl | |
| 436 | + | push de | |
| 437 | + | call attr_addr_cr | |
| 438 | + | ld (hl), MSG_ATTR | |
| 439 | + | call scr_addr_cr | |
| 440 | + | pop de | |
| 441 | + | ld b, 8 | |
| 442 | + | .pc: | |
| 443 | + | ld a, (de) | |
| 444 | + | ld (hl), a | |
| 445 | + | inc de | |
| 446 | + | inc h | |
| 447 | + | djnz .pc | |
| 448 | + | ret | |
| 394 | 449 | | |
| 395 | 450 | ; ---------------------------------------------------------------------------- | |
| 396 | 451 | ; light_pip / draw_pips. | |
| ... | |||
| 644 | 699 | defb %01111110 | |
| 645 | 700 | defb %01111110 | |
| 646 | 701 | defb %00111100 | |
| 702 | + | | |
| 703 | + | win_text: | |
| 704 | + | defb "THE NIGHT IS HELD" | |
| 705 | + | defb $FF | |
| 647 | 706 | | |
| 648 | 707 | end start | |
| 649 | 708 | |
The complete step 1 program
; Gloaming — Unit 15: The Night Is Held
; Cumulative build; every step runs on its own. Narrative: the unit page.
; All lamps lit is the win — the game gains states and an ending.
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
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
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
; the claim, unearned (this step only): print the win text at
; boot, checked by nothing — the words before the meaning
ld hl, win_text
ld b, MSG_ROW
ld c, WIN_COL
call print_string
; --- 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
; ----------------------------------------------------------------------------
; 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
; ----------------------------------------------------------------------------
; 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
win_text:
defb "THE NIGHT IS HELD"
defb $FF
end start

The printing works perfectly, and the screen is a lie. Every lamp is cold; the night is not held; the machine wrote it anyway, because nothing connects the words to the world. Worse — the words aren’t even safe. Look closely at the message row: the lamplighter boots underneath it (his start cell sits mid-sentence, wearing a letter), and if you walk him away, restore faithfully puts back the cobbles his buffer saved — tearing a character out of the line. Text on this machine is cells, exactly like everything else: nothing defends it, and nothing earned it. Both jobs belong to the same missing thing.
The game gains states
That missing thing is a byte:
STATE_PLAY equ 1
STATE_WIN equ 2 ; the night is held — the game is won
game_state:
defb STATE_PLAY
And its power is where it’s read: the main loop, which until tonight has beaten out the same rhythm since Unit 4 — halt, play, repeat, forever. Now the beat asks a question first:
main_loop:
halt
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
Read the non-PLAY path carefully, because it’s the whole concept: the machine doesn’t stop. The heartbeat still ticks fifty times a second; the loop still spins; the interrupt still fires. What changes is that the game declines to advance. Keys are never read, moves never proposed, the screen never touched — the world simply rests, held exactly as its last PLAY frame left it. An ending isn’t a place where code stops running; it’s a state the loop settles into. Every game you have ever finished did this — this byte, wearing different names.
Milestone 2 — earn it
Strike the boot print. Set the state in the setup, gate the loop, and put the win check where it belongs — in play_step, right after the player’s beat, the only moment the count can have changed:
| 23 | 23 | WIN_COL equ 7 ; centres the seventeen characters | |
| 24 | 24 | FONT equ $3C00 ; the ROM font, addressed so that | |
| 25 | 25 | ; ASCII x 8 lands on the right glyph | |
| 26 | + | | |
| 27 | + | STATE_PLAY equ 1 | |
| 28 | + | STATE_WIN equ 2 ; the night is held — the game is won | |
| 26 | 29 | | |
| 27 | 30 | START_COL equ 15 ; where the lamplighter begins | |
| 28 | 31 | START_ROW equ 11 | |
| ... | |||
| 81 | 84 | ; save what he is about to stand on, BEFORE the first draw | |
| 82 | 85 | call save_under | |
| 83 | 86 | call draw_lamp | |
| 84 | - | ; the claim, unearned (this step only): print the win text at | |
| 85 | - | ; boot, checked by nothing — the words before the meaning | |
| 86 | - | ld hl, win_text | |
| 87 | - | ld b, MSG_ROW | |
| 88 | - | ld c, WIN_COL | |
| 89 | - | call print_string | |
| 87 | + | ld a, STATE_PLAY | |
| 88 | + | ld (game_state), a | |
| 90 | 89 | | |
| 91 | 90 | ; --- start the heartbeat --- | |
| 92 | 91 | ; IM 1: every 50 Hz frame interrupt calls the ROM's handler. | |
| ... | |||
| 97 | 96 | | |
| 98 | 97 | main_loop: | |
| 99 | 98 | halt | |
| 99 | + | ; the state gate: only PLAY beats the game forward — any | |
| 100 | + | ; other state leaves the screen exactly as it stands | |
| 101 | + | ld a, (game_state) | |
| 102 | + | cp STATE_PLAY | |
| 103 | + | jr z, .do_play | |
| 104 | + | jr main_loop ; WIN or LOSE — the screen holds | |
| 105 | + | .do_play: | |
| 100 | 106 | call play_step | |
| 101 | 107 | jr main_loop | |
| 102 | 108 | | |
| 103 | 109 | ; play_step — one beat of the game: ask the keyboard. | |
| 104 | 110 | play_step: | |
| 105 | 111 | call player_step | |
| 112 | + | ; the win check, at the only beat the count can have changed | |
| 113 | + | ld a, (lit_count) | |
| 114 | + | cp NUM_LAMPS | |
| 115 | + | ret nz | |
| 116 | + | call draw_win_screen | |
| 117 | + | ld a, STATE_WIN | |
| 118 | + | ld (game_state), a | |
| 106 | 119 | ret | |
| 107 | 120 | | |
| 108 | 121 | ; ---------------------------------------------------------------------------- | |
| ... | |||
| 151 | 164 | ld de, 32 | |
| 152 | 165 | add hl, de | |
| 153 | 166 | djnz .ws | |
| 167 | + | ret | |
| 168 | + | | |
| 169 | + | ; ---------------------------------------------------------------------------- | |
| 170 | + | ; Screens. | |
| 171 | + | ; ---------------------------------------------------------------------------- | |
| 172 | + | | |
| 173 | + | ; draw_win_screen — lift the lamplighter off the board (restore his | |
| 174 | + | ; cell: the eighth lamp, lit, comes out of the buffer), then say it. | |
| 175 | + | draw_win_screen: | |
| 176 | + | call restore_under | |
| 177 | + | ld hl, win_text | |
| 178 | + | ld b, MSG_ROW | |
| 179 | + | ld c, WIN_COL | |
| 180 | + | call print_string | |
| 154 | 181 | ret | |
| 155 | 182 | | |
| 156 | 183 | ; ---------------------------------------------------------------------------- | |
| ... | |||
| 635 | 662 | ; ---------------------------------------------------------------------------- | |
| 636 | 663 | ; Data. | |
| 637 | 664 | ; ---------------------------------------------------------------------------- | |
| 665 | + | | |
| 666 | + | game_state: | |
| 667 | + | defb STATE_PLAY | |
| 638 | 668 | | |
| 639 | 669 | wall_ramp: | |
| 640 | 670 | ; The square warms in the game's own vocabulary: the stone |
The complete program
; Gloaming — Unit 15: The Night Is Held
; Cumulative build; every step runs on its own. Narrative: the unit page.
; All lamps lit is the win — the game gains states and an ending.
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
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
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
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
; ----------------------------------------------------------------------------
; 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
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
win_text:
defb "THE NIGHT IS HELD"
defb $FF
end start
The check is three lines — a load, a cp NUM_LAMPS, a ret nz — and a win turns out not to be a system at all: it’s state you were already tracking, crossing a line you care about, noticed at the moment it can cross. When it fires, draw_win_screen does two things in the right order: restore_under first, so the lamplighter steps out of the world and the eighth lamp emerges from the buffer already lit — his work complete, himself no longer needed — and then the line prints, this time over a screen that makes it true. Then game_state becomes STATE_WIN, and the loop holds it all, forever.

And notice what the same-frame timing quietly guarantees: the golden wall from last unit’s try-this — walkable the instant the ramp goes gold — can never be reached, because between the eighth lamp lighting and your next possible keypress, the state has already left PLAY. The win check doesn’t just end the game; it seals last unit’s loophole without knowing it exists. Tight state machines fix bugs their authors never met.
When it’s wrong, see why
- The text is garbled — right shapes, wrong letters. The times-eight is off: three
add hl, hl, no more, no fewer, before addingFONT. One doubling short and every glyph comes from half the right address. - The line never appears, however many lamps you light.
NUM_LAMPSdisagrees withlamp_data— Unit 13’s agreement, still binding. Eight pips can fill while a ninth lamp keepslit_countfrom ever equalling the constant… or seven lamps leave it one short forever. - The words appear, but he keeps walking through them. The check fired and printed, but
game_statewas never set toSTATE_WIN— or the loop’s gate compares against the wrong constant. The state must change and the loop must honour it. - The game is dead from boot.
game_statenever initialised toSTATE_PLAY— itsdefbsays PLAY, but if the setup doesn’t write it too, a re-entered game (Unit 20 is coming) would inherit whatever the last game left. Write your states at start-up; never trust adefbto stay fresh. - The win screen shows him standing in it.
draw_win_screenprinted before restoring — or never restored. The order is restore first, then print: he leaves, the lamp he’s standing on emerges lit, the words land on a finished world.
Before and after
The unit opened with a machine that could only ever continue and closed with a game that can conclude. The printing was the smaller half, for all its charm — two routines you’d effectively written before, pointed at ROM. The larger half is the byte: game_state, read at the top of every beat, written at exactly two moments — birth and victory. That byte is the difference between a program that runs and a game that rests; between mechanics that happen and an experience with a shape. Phase 3 set out to make “a game” — lamps, a verb, a score, a mood, and now an ending you can reach. It’s a game.
Try this: your own closing line
Change win_text — "DAWN COMES", "WELL DONE", your name. Keep the $FF, and re-centre: a line of N characters starts at column (32 − N) ÷ 2, which is what WIN_COL 7 is for seventeen. The machinery doesn’t care what it says; that’s rather the point of machinery.
Try this: a flourish before the rest
The freeze is dignified but abrupt. In draw_win_screen, before the print, flash the border: a short loop of out ($FE), a with cycling colours, a few frames each. A win deserves a heartbeat of spectacle — and notice it costs nothing ongoing, because it happens once, on the way into the rest state.
Try this: the crudest restart
In the loop’s non-PLAY path, read SPACE (Unit 5 taught you the port: $7FFE, bit 0) and jp start when it’s pressed. It works — and it’s wrong in an instructive way: start re-runs the whole setup, but watch carefully for what it doesn’t reset, and imagine a version where some state slipped through. Restarting properly — what must be reborn, what may persist — is Unit 20’s whole subject. For now, enjoy the crude version and notice its edges.
What you’ve learnt
- The Spectrum’s font lives in ROM — eight bytes a character, ASCII × 8 from a base that bakes in the offset; printing is sprite-drawing that spells.
- Text on this machine is cells like any other — undefended, and only as truthful as the code behind it.
- A win is a check, not a system: tracked state crossing a meaningful line, tested where it changes.
- game_state and the gated loop: an ending is a state the loop rests in, not a place the program stops.
- Same-frame checks seal windows you didn’t know were open — the golden wall was never reachable.
What’s next
Gloaming can be won — which means it now has exactly one flaw as a game: it cannot be lost. Nothing in the square opposes you; the night, so far, just sits there while you take it apart lamp by lamp. Unit 16 gives the dark its agent: the draught — a cold wisp that hunts the lamplighter through the streets, moving on the same engine he does, steered by one compare-and-step rule that reads, on the screen, unmistakably as intent. Phase 4 begins, and the game pushes back.