Nightheld
Win the game. When every lamp is lit, the lamplighter has held back the dark — and the game says so, printing a closing line with the Spectrum's own ROM font, then holding the end state.
The tally can fill — so filling it should mean something. This unit gives Gloaming its ending: light every lamp and the lamplighter has held back the night. The game recognises the moment, prints a closing line, and stops. After this unit you can sit down and win.
It also teaches the last new drawing trick the game needs: printing text, using the Spectrum's own font straight out of ROM.
Where we start
Unit 11's scored game — the tally fills, but reaching the top means nothing yet. We make filling it the win, and give the game an ending to come to rest in.
The win is a single check
We already keep lit_count, and it only ever changes at one moment: when a cold lamp is lit. So that's the only place we need to look. Right after lighting, ask the question:
ld a, (lit_count)
cp NUM_LAMPS ; all of them?
jp z, win ; then the game is won
That's the whole condition. A win isn't a special system — it's a state you're already tracking crossing a line you care about. Checking it the instant the count changes means the game ends the frame the last lamp warms.
Printing with the ROM font
The closing line is text, and text is more glyphs — except we don't have to draw the letters ourselves. The Spectrum keeps its entire character set in ROM, at $3C00, eight bytes per character, in code order. The shape for character code c sits at:
$3C00 + c * 8
So printing a letter is the glyph-draw from Unit 2 — eight bytes, INC H down the cell — with the bytes coming from ROM instead of our own data. print_char does one character; draw_message walks a string (ended with $FF), printing each and stepping one column along. The Spectrum draws its own font for us; we copy it where we want it.
Ending, and staying ended
On a win we do three small things: restore_under so the last lamp shows (the lamplighter steps out of the cell he's standing on), draw_message to print the line, and then a loop that just halts forever. The game is over; there's nothing more to do but hold the picture. That final loop is the end state — every game needs somewhere to come to rest.
Milestone — the win
We add the cp NUM_LAMPS / jp z, win check right where a lamp lights, a win routine that reveals the last lamp and prints the line, and the print_char / draw_message pair that blits glyphs from the ROM font. Everything else is the game you already had.
| 1 | 1 | ; Gloaming — Unit 12: Nightheld | |
| 2 | 2 | ; Cumulative build; every step runs on its own. Narrative: the unit page. | |
| 3 | - | ; step-00 is Unit 11's scored game — winnable in spirit, but with no ending. | |
| 3 | + | ; step-01 adds the win: all lamps lit prints a closing line from the ROM font. | |
| 4 | 4 | | |
| 5 | 5 | org 32768 | |
| 6 | 6 | | |
| 7 | - | COBBLE equ %00000001 ; PAPER black, INK blue — floor | |
| 8 | - | WALL equ %00001111 ; PAPER blue, INK white — solid | |
| 9 | - | LAMP_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — the figure | |
| 10 | - | LAMP_UNLIT equ %00000101 ; PAPER black, INK cyan — a cold lamp | |
| 11 | - | LAMP_LIT equ %01000110 ; BRIGHT, PAPER black, INK yellow — a lit lamp | |
| 7 | + | COBBLE equ %00000001 | |
| 8 | + | WALL equ %00001111 | |
| 9 | + | LAMP_ATTR equ %01000111 | |
| 10 | + | LAMP_UNLIT equ %00000101 | |
| 11 | + | LAMP_LIT equ %01000110 | |
| 12 | 12 | WALL_BIT equ 3 | |
| 13 | 13 | | |
| 14 | - | PIP_UNLIT equ %00101000 ; PAPER cyan — a cold pip | |
| 15 | - | PIP_LIT equ %01110000 ; BRIGHT, PAPER yellow — a warm pip | |
| 16 | - | PIP_BASE equ $5800 + 12 ; row 0, column 12 — first pip | |
| 14 | + | PIP_UNLIT equ %00101000 | |
| 15 | + | PIP_LIT equ %01110000 | |
| 16 | + | PIP_BASE equ $5800 + 12 | |
| 17 | 17 | NUM_LAMPS equ 8 | |
| 18 | + | | |
| 19 | + | MSG_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — the closing line | |
| 20 | + | MSG_ROW equ 11 | |
| 21 | + | MSG_COL equ 7 ; centred for a 17-character line | |
| 22 | + | FONT equ $3C00 ; ROM font base; glyph for code c is FONT + c*8 | |
| 18 | 23 | | |
| 19 | 24 | START_COL equ 15 | |
| 20 | 25 | START_ROW equ 11 | |
| ... | |||
| 24 | 29 | KEYS_A equ $FDFE | |
| 25 | 30 | | |
| 26 | 31 | ; ============================================================================ | |
| 27 | - | ; SETUP — runs once. The frame now starts at row 1, leaving row 0 for the HUD. | |
| 32 | + | ; SETUP — runs once. | |
| 28 | 33 | ; ============================================================================ | |
| 29 | 34 | start: | |
| 30 | - | ld a, 0 ; border black | |
| 35 | + | ld a, 0 | |
| 31 | 36 | out ($FE), a | |
| 32 | 37 | | |
| 33 | - | ld hl, $5800 ; wash the whole grid in cobbles | |
| 38 | + | ld hl, $5800 | |
| 34 | 39 | ld de, $5801 | |
| 35 | 40 | ld (hl), COBBLE | |
| 36 | 41 | ld bc, 767 | |
| 37 | 42 | ldir | |
| 38 | 43 | | |
| 39 | - | ld hl, $5820 ; top wall — ROW 1 now (row 0 is the HUD) | |
| 44 | + | ld hl, $5820 ; top wall — row 1 | |
| 40 | 45 | ld b, 32 | |
| 41 | 46 | .top: | |
| 42 | 47 | ld (hl), WALL | |
| ... | |||
| 50 | 55 | inc hl | |
| 51 | 56 | djnz .bottom | |
| 52 | 57 | | |
| 53 | - | ld hl, $5820 ; left and right columns, rows 1..23 | |
| 58 | + | ld hl, $5820 ; sides, rows 1..23 | |
| 54 | 59 | ld b, 23 | |
| 55 | 60 | .sides: | |
| 56 | 61 | ld (hl), WALL | |
| ... | |||
| 63 | 68 | add hl, de | |
| 64 | 69 | djnz .sides | |
| 65 | 70 | | |
| 66 | - | call draw_pips ; the cold tally | |
| 71 | + | call draw_pips | |
| 67 | 72 | call draw_lamps | |
| 68 | 73 | call save_under | |
| 69 | 74 | call draw_lamp | |
| 70 | 75 | | |
| 71 | 76 | ; ============================================================================ | |
| 72 | - | ; THE HEARTBEAT — move, light lamps, and warm a pip for each new one. | |
| 77 | + | ; THE HEARTBEAT — move, light, tally, and check for the win. | |
| 73 | 78 | ; ============================================================================ | |
| 74 | 79 | im 1 | |
| 75 | 80 | ei | |
| ... | |||
| 128 | 133 | ld (lamp_row), a | |
| 129 | 134 | call save_under | |
| 130 | 135 | | |
| 131 | - | ; --- light it, and warm a pip if this lamp was cold --- | |
| 132 | 136 | ld a, (under_lamp + 8) | |
| 133 | 137 | cp LAMP_UNLIT | |
| 134 | 138 | jr nz, .not_lamp | |
| 135 | 139 | ld a, LAMP_LIT | |
| 136 | - | ld (under_lamp + 8), a ; lamp will restore lit | |
| 137 | - | call light_pip ; warm the next pip on the tally | |
| 140 | + | ld (under_lamp + 8), a | |
| 141 | + | call light_pip | |
| 138 | 142 | .not_lamp: | |
| 139 | 143 | call draw_lamp | |
| 144 | + | | |
| 145 | + | ld a, (lit_count) ; all lamps lit? | |
| 146 | + | cp NUM_LAMPS | |
| 147 | + | jp z, win | |
| 140 | 148 | jr game_loop | |
| 141 | 149 | | |
| 142 | 150 | ; ---------------------------------------------------------------------------- | |
| 143 | - | ; light_pip — warm the pip at index lit_count, then bump lit_count. | |
| 151 | + | ; win — reveal the last lamp, print the closing line, hold the end state. | |
| 152 | + | ; ---------------------------------------------------------------------------- | |
| 153 | + | win: | |
| 154 | + | call restore_under ; the lamplighter steps aside; last lamp shows | |
| 155 | + | call draw_message | |
| 156 | + | .hold: | |
| 157 | + | halt | |
| 158 | + | jr .hold | |
| 159 | + | | |
| 160 | + | ; ---------------------------------------------------------------------------- | |
| 161 | + | ; draw_message — print msg_text from (MSG_ROW, MSG_COL), ended by $FF. | |
| 162 | + | ; ---------------------------------------------------------------------------- | |
| 163 | + | draw_message: | |
| 164 | + | ld hl, msg_text | |
| 165 | + | ld c, MSG_COL | |
| 166 | + | .dm: | |
| 167 | + | ld a, (hl) | |
| 168 | + | cp $FF | |
| 169 | + | ret z | |
| 170 | + | push hl | |
| 171 | + | ld b, MSG_ROW | |
| 172 | + | call print_char ; A=char, B=row, C=col; preserves C | |
| 173 | + | pop hl | |
| 174 | + | inc hl | |
| 175 | + | inc c ; next column | |
| 176 | + | jr .dm | |
| 177 | + | | |
| 178 | + | ; ---------------------------------------------------------------------------- | |
| 179 | + | ; print_char — A=char code, B=row, C=col. Copy the ROM-font glyph into the cell. | |
| 180 | + | ; ---------------------------------------------------------------------------- | |
| 181 | + | print_char: | |
| 182 | + | ld l, a ; HL = code * 8 | |
| 183 | + | ld h, 0 | |
| 184 | + | add hl, hl | |
| 185 | + | add hl, hl | |
| 186 | + | add hl, hl | |
| 187 | + | ld de, FONT | |
| 188 | + | add hl, de | |
| 189 | + | ex de, hl ; DE = glyph address in ROM | |
| 190 | + | push de | |
| 191 | + | call attr_addr_cr ; HL = attribute of (B,C); BC preserved | |
| 192 | + | ld (hl), MSG_ATTR | |
| 193 | + | call scr_addr_cr ; HL = screen of (B,C) | |
| 194 | + | pop de ; DE = glyph address | |
| 195 | + | ld b, 8 | |
| 196 | + | .pc: | |
| 197 | + | ld a, (de) | |
| 198 | + | ld (hl), a | |
| 199 | + | inc de | |
| 200 | + | inc h | |
| 201 | + | djnz .pc | |
| 202 | + | ret | |
| 203 | + | | |
| 204 | + | ; ---------------------------------------------------------------------------- | |
| 205 | + | ; light_pip / draw_pips (Unit 11). | |
| 144 | 206 | ; ---------------------------------------------------------------------------- | |
| 145 | 207 | light_pip: | |
| 146 | 208 | ld a, (lit_count) | |
| ... | |||
| 153 | 215 | ld (hl), PIP_LIT | |
| 154 | 216 | ret | |
| 155 | 217 | | |
| 156 | - | ; ---------------------------------------------------------------------------- | |
| 157 | - | ; draw_pips — lay down NUM_LAMPS cold pips in a row at the start of row 0. | |
| 158 | - | ; ---------------------------------------------------------------------------- | |
| 159 | 218 | draw_pips: | |
| 160 | 219 | ld hl, PIP_BASE | |
| 161 | 220 | ld b, NUM_LAMPS | |
| ... | |||
| 298 | 357 | ret | |
| 299 | 358 | | |
| 300 | 359 | ; ---------------------------------------------------------------------------- | |
| 301 | - | ; Level data, state, buffer, and shapes. | |
| 360 | + | ; Level data, state, buffer, shapes, and the closing line. | |
| 302 | 361 | ; ---------------------------------------------------------------------------- | |
| 303 | 362 | lamp_data: | |
| 304 | 363 | defb 4, 3 | |
| ... | |||
| 344 | 403 | defb %01111110 | |
| 345 | 404 | defb %01111110 | |
| 346 | 405 | defb %00111100 | |
| 406 | + | | |
| 407 | + | msg_text: | |
| 408 | + | defb "THE NIGHT IS HELD" | |
| 409 | + | defb $FF | |
| 347 | 410 | | |
| 348 | 411 | end start | |
| 349 | 412 | |
The complete program
; Gloaming — Unit 12: Nightheld
; Cumulative build; every step runs on its own. Narrative: the unit page.
; step-01 adds the win: all lamps lit prints a closing line from the ROM font.
org 32768
COBBLE equ %00000001
WALL equ %00001111
LAMP_ATTR equ %01000111
LAMP_UNLIT equ %00000101
LAMP_LIT equ %01000110
WALL_BIT equ 3
PIP_UNLIT equ %00101000
PIP_LIT equ %01110000
PIP_BASE equ $5800 + 12
NUM_LAMPS equ 8
MSG_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — the closing line
MSG_ROW equ 11
MSG_COL equ 7 ; centred for a 17-character line
FONT equ $3C00 ; ROM font base; glyph for code c is FONT + c*8
START_COL equ 15
START_ROW equ 11
KEYS_OP equ $DFFE
KEYS_Q equ $FBFE
KEYS_A equ $FDFE
; ============================================================================
; SETUP — runs once.
; ============================================================================
start:
ld a, 0
out ($FE), a
ld hl, $5800
ld de, $5801
ld (hl), COBBLE
ld bc, 767
ldir
ld hl, $5820 ; top wall — row 1
ld b, 32
.top:
ld (hl), WALL
inc hl
djnz .top
ld hl, $5AE0 ; bottom wall — row 23
ld b, 32
.bottom:
ld (hl), WALL
inc hl
djnz .bottom
ld hl, $5820 ; sides, rows 1..23
ld b, 23
.sides:
ld (hl), WALL
push hl
ld de, 31
add hl, de
ld (hl), WALL
pop hl
ld de, 32
add hl, de
djnz .sides
call draw_pips
call draw_lamps
call save_under
call draw_lamp
; ============================================================================
; THE HEARTBEAT — move, light, tally, and check for the win.
; ============================================================================
im 1
ei
game_loop:
halt
ld a, (lamp_col)
ld (tcol), a
ld a, (lamp_row)
ld (trow), a
ld bc, KEYS_OP
in a, (c)
bit 1, a
jr z, .left
bit 0, a
jr z, .right
ld bc, KEYS_Q
in a, (c)
bit 0, a
jr z, .up
ld bc, KEYS_A
in a, (c)
bit 0, a
jr z, .down
jr game_loop
.left:
ld hl, tcol
dec (hl)
jr .try
.right:
ld hl, tcol
inc (hl)
jr .try
.up:
ld hl, trow
dec (hl)
jr .try
.down:
ld hl, trow
inc (hl)
.try:
ld a, (trow)
ld b, a
ld a, (tcol)
ld c, a
call wall_at
jr nz, game_loop
call restore_under
ld a, (tcol)
ld (lamp_col), a
ld a, (trow)
ld (lamp_row), a
call save_under
ld a, (under_lamp + 8)
cp LAMP_UNLIT
jr nz, .not_lamp
ld a, LAMP_LIT
ld (under_lamp + 8), a
call light_pip
.not_lamp:
call draw_lamp
ld a, (lit_count) ; all lamps lit?
cp NUM_LAMPS
jp z, win
jr game_loop
; ----------------------------------------------------------------------------
; win — reveal the last lamp, print the closing line, hold the end state.
; ----------------------------------------------------------------------------
win:
call restore_under ; the lamplighter steps aside; last lamp shows
call draw_message
.hold:
halt
jr .hold
; ----------------------------------------------------------------------------
; draw_message — print msg_text from (MSG_ROW, MSG_COL), ended by $FF.
; ----------------------------------------------------------------------------
draw_message:
ld hl, msg_text
ld c, MSG_COL
.dm:
ld a, (hl)
cp $FF
ret z
push hl
ld b, MSG_ROW
call print_char ; A=char, B=row, C=col; preserves C
pop hl
inc hl
inc c ; next column
jr .dm
; ----------------------------------------------------------------------------
; print_char — A=char code, B=row, C=col. Copy the ROM-font glyph into the cell.
; ----------------------------------------------------------------------------
print_char:
ld l, a ; HL = code * 8
ld h, 0
add hl, hl
add hl, hl
add hl, hl
ld de, FONT
add hl, de
ex de, hl ; DE = glyph address in ROM
push de
call attr_addr_cr ; HL = attribute of (B,C); BC preserved
ld (hl), MSG_ATTR
call scr_addr_cr ; HL = screen of (B,C)
pop de ; DE = glyph address
ld b, 8
.pc:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .pc
ret
; ----------------------------------------------------------------------------
; light_pip / draw_pips (Unit 11).
; ----------------------------------------------------------------------------
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:
ld hl, PIP_BASE
ld b, NUM_LAMPS
ld a, PIP_UNLIT
.dp:
ld (hl), a
inc hl
djnz .dp
ret
; ----------------------------------------------------------------------------
; draw_lamps / draw_lantern (Unit 9).
; ----------------------------------------------------------------------------
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:
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 / attr_addr_cr / wall_at / pos_bc (Unit 8).
; ----------------------------------------------------------------------------
scr_addr_cr:
ld a, b
and %00011000
or %01000000
ld h, a
ld a, b
and %00000111
rrca
rrca
rrca
or c
ld l, a
ret
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:
call attr_addr_cr
bit WALL_BIT, (hl)
ret
pos_bc:
ld a, (lamp_row)
ld b, a
ld a, (lamp_col)
ld c, a
ret
; ----------------------------------------------------------------------------
; save_under / restore_under / draw_lamp (Unit 8).
; ----------------------------------------------------------------------------
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:
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:
call pos_bc
call attr_addr_cr
ld (hl), LAMP_ATTR
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
; ----------------------------------------------------------------------------
; Level data, state, buffer, shapes, and the closing line.
; ----------------------------------------------------------------------------
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
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
msg_text:
defb "THE NIGHT IS HELD"
defb $FF
end start
Light the eighth lamp and the square freezes into its ending — every lamp gold, the tally full, the line written across the middle:
When it's wrong, see why
The win and the text fail in their own ways:
- The line never appears. The win check isn't reached, or
NUM_LAMPSdoesn't match the lamps placed, solit_countnever gets there. Confirm thecp NUM_LAMPS/jp z, winsits right after a lamp is lit. - The text is garbled. The font address is wrong. Each glyph is at
$3C00 + code * 8— check thecode * 8(threeADD HL,HL) and the$3C00base. - The text prints in the wrong place or off the edge.
MSG_ROW/MSG_COLare off, or the line is too long to fit from that column. Re-centre it. - He keeps moving after the win. The
winroutine returned to the game loop. It must end in its ownhaltloop and never fall back through.
Before and after
You started with a game that couldn't end and finished with one you can win — and the win is a cp and a jp, slipped in where the only meaningful number changes. The ending is the glyph-draw you've had since Unit 2, fed from the ROM font; the rest-state is one halt loop. A loop of mechanics has become a game with a beginning you drop into, a middle you play, and an end you earn.
Try this: your own closing line
Change msg_text to whatever you like — "WELL DONE", "DAWN COMES", your name. Keep the $FF on the end, and adjust MSG_COL to re-centre it: a line of N characters starts at column (32 - N) / 2. Different words, same machinery.
Try this: a flourish on the win
A bare freeze is a little flat. Before printing the line, add a beat of celebration: flash the border a few times (out ($FE), a with changing colours in a short loop), or sweep all the lamps to bright white. A win deserves a moment of spectacle — and it's a few instructions.
Try this: lose your way back
Notice you can't restart without reloading. That's deliberate — restarting is its own unit (Unit 19, "Again"). For now, try adding a single key check in the end loop that jumps back to start: the crudest possible "play again". It'll work, and seeing why it's crude — the state isn't reset — is exactly the problem that later unit solves properly.
What you've learnt
- A win condition is a state check at the moment the state changes — not a separate system.
- The Spectrum's font lives in ROM at
$3C00, eight bytes per character; print text by blitting glyphs exactly like sprites. - A game needs an end state — a small loop it rests in once it's over.
- This is the line between a set of mechanics and a game you can finish.
What's next
Gloaming is winnable — but it can't yet be lost, and a game with no jeopardy has no tension. Phase D brings the threat. In Unit 13, "The Draught", a cold wisp drifts through the square: a second moving sprite, driven by the same cell-sprite engine the lamplighter uses — proof that the technique you built pays for more than one character. The dark starts to push back.