The Keep Stands
Assemble every piece into a game. A state machine wraps a title screen (ROM font, theme looping), the keep you play, and a victory — title to play to win and back. A fresh game each time, finishable and replayable. The end of Arc 1.
Everything exists now — rooms, light, footsteps, a theme, gold, a win. What's missing is the thing that turns a collection of parts into a game: the loop a player sits down to. A title that waits for you. The keep, played. A victory when it's cleared. And back to the title, ready to go again. This unit builds that loop — and with it, Arc 1 of Shadowkeep is a complete, finishable game.
What you'll see by the end
The keep opens not into the Hall but into a title screen — its name, a prompt, the theme looping quietly. Press space and you're in: explore the chambers, lift the gold. Clear the last coin and the screen turns black again — THE KEEP STANDS — a victory flourish, and press space to return. Press it, and you're back at the title, the keep reset and waiting. A whole game, beginning to end to beginning.
A game is a state machine
Up to now the program did one thing: run the keep. A game does several things in turn — show a title, play, celebrate a win — and moves between them. That's a state machine, and at this scale it's just a few labels and jumps:
main_title:
call show_title ; wait at the title for SPACE
call new_game ; reset and draw a fresh keep
ei
.game_loop:
halt
ld a, (won)
or a
jr nz, .won ; cleared? -> win
call player_step
call mark_step
jr .game_loop
.won:
call show_win ; "THE KEEP STANDS", wait for SPACE
jr main_title ; round again
Title → play → win → title. Each state is a routine; the transitions are the jumps between them. No framework, no engine — structure is just deciding what runs next.
A fresh keep every time
Replayable means the second game must be as new as the first — so new_game doesn't just redraw, it resets state: it copies the room templates back over the working maps (returning every collected coin), and resets the thief, the gold counter and the win flag:
new_game:
; copy room templates -> working state (gold returns)
; ...
xor a
ld (won), a
ld a, TOTAL_GOLD
ld (gold_remaining), a
ld a, START_COL
ld (thief_col), a
; ... draw the opening room ...
ret
This is why we always edited the working copy of the maps, never the templates: the templates are the pristine original, ready to stamp out a new game on demand. The discipline from Unit 14 pays off here for free.
Words on screen, borrowed from the ROM
A title needs text — and instead of hand-pixelling a font, we borrow the one already in the machine. The Spectrum's 8×8 character set lives in ROM at $3D00; each glyph is 8 bytes. print_char copies a glyph straight to the screen:
print_char: ; A = char, B = row, C = col
sub 32 ; the font starts at space (code 32)
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl ; * 8 bytes per glyph
ld de, $3D00 ; the ROM font
add hl, de ; HL -> this character's 8 bytes
; ... copy 8 rows to the screen cell, set it bright white ...
print_string just walks a zero-terminated string, printing each character one cell to the right. The result is the title and prompts — and a reminder that the machine is full of things you can reach for, the ROM font among them.
str_title: defb "SHADOWKEEP", 0
str_prompt: defb "PRESS SPACE TO ENTER", 0
str_won: defb "THE KEEP STANDS", 0
Waiting, and the theme underneath
show_title draws the words, then loops: it plays the theme note by note, checking the space key between each, and the moment space is pressed it waits for release and returns. So the music plays and the title stays responsive — the tune loops back to its start when it ends, for as long as you linger:
.tt_note:
ld bc, KEYS_SPACE
in a, (c)
bit 0, a
jr z, .tt_pressed ; SPACE -> leave the title
; ... otherwise play the next note of the theme ...
ld a, (hl)
inc hl
cp $FF
jr z, .tt_tune ; tune ended -> loop it
; ...
Checking input between notes keeps the latency to a single note — short enough to feel instant, and far simpler than running music on the interrupt. show_win is the same shape: black screen, the words, the victory flourish, wait for space.
Milestone — wrap it in a game
A few labels and jumps make the state machine: show_title waits at the title (theme
looping, space polled between notes); new_game stamps the pristine templates back over
the working maps — returning every coin — and resets the thief, counter and win flag;
the game loop runs until won; show_win draws THE KEEP STANDS and waits. Title →
play → win → title. Text comes free from the ROM font at $3D00. The diff is structure,
not engine — and it's what turns sixteen units of parts into a game.
| 1 | 1 | ; Shadowkeep — Unit 16: The Keep Stands | |
| 2 | 2 | ; Cumulative build; every step runs on its own. Narrative: the unit page. | |
| 3 | - | ; step-00 = Unit 15's end: every piece built — but it opens straight into the keep, no title. | |
| 3 | + | ; step-01 wraps it in a state machine: title -> play -> win -> title, a finishable, replayable game. | |
| 4 | 4 | | |
| 5 | 5 | org 32768 | |
| 6 | 6 | | |
| ... | |||
| 30 | 30 | start: | |
| 31 | 31 | ld a, 0 | |
| 32 | 32 | out ($FE), a | |
| 33 | + | im 1 | |
| 34 | + | | |
| 35 | + | ; --- TITLE ----------------------------------------------------------------- | |
| 36 | + | ; Show the title, loop the theme, wait for SPACE. Then start a fresh game. | |
| 37 | + | main_title: | |
| 38 | + | call show_title ; returns (interrupts off) when SPACE pressed | |
| 39 | + | call new_game ; reset state, draw the keep | |
| 40 | + | ei | |
| 41 | + | .game_loop: | |
| 42 | + | halt | |
| 43 | + | ld a, (won) | |
| 44 | + | or a | |
| 45 | + | jr nz, .won | |
| 46 | + | call player_step | |
| 47 | + | call mark_step | |
| 48 | + | jr .game_loop | |
| 49 | + | .won: | |
| 50 | + | call show_win ; "THE KEEP STANDS", flourish, wait for SPACE | |
| 51 | + | jr main_title ; round again | |
| 33 | 52 | | |
| 53 | + | ; ---------------------------------------------------------------------------- | |
| 54 | + | ; new_game — start the keep afresh: copy templates back into the working state | |
| 55 | + | ; (so collected gold returns), reset the thief, the gold counter and the win | |
| 56 | + | ; flag, and draw the opening room. The reset is what makes the game REPLAYABLE. | |
| 57 | + | ; ---------------------------------------------------------------------------- | |
| 58 | + | new_game: | |
| 34 | 59 | ld hl, room0_template | |
| 35 | 60 | ld de, room0_state | |
| 36 | 61 | ld bc, 768 | |
| ... | |||
| 46 | 71 | | |
| 47 | 72 | xor a | |
| 48 | 73 | ld (current_room), a | |
| 74 | + | ld (won), a | |
| 75 | + | ld a, TOTAL_GOLD | |
| 76 | + | ld (gold_remaining), a | |
| 49 | 77 | ld a, START_COL | |
| 50 | 78 | ld (thief_col), a | |
| 51 | 79 | ld a, START_ROW | |
| ... | |||
| 54 | 82 | call draw_room | |
| 55 | 83 | call save_under | |
| 56 | 84 | call draw_thief | |
| 85 | + | ret | |
| 57 | 86 | | |
| 58 | - | di ; clean audio: no 50Hz tick over the tune | |
| 87 | + | ; ---------------------------------------------------------------------------- | |
| 88 | + | ; show_title — black screen, the keep's name and a prompt, theme looping. Runs | |
| 89 | + | ; with interrupts off (clean music) and returns the moment SPACE is pressed and | |
| 90 | + | ; released. Words use the ROM's own 8x8 font, which lives at $3D00. | |
| 91 | + | ; ---------------------------------------------------------------------------- | |
| 92 | + | show_title: | |
| 93 | + | di | |
| 94 | + | call clear_screen | |
| 95 | + | ld hl, str_title | |
| 96 | + | ld b, 8 | |
| 97 | + | ld c, 11 | |
| 98 | + | call print_string | |
| 99 | + | ld hl, str_prompt | |
| 100 | + | ld b, 16 | |
| 101 | + | ld c, 6 | |
| 102 | + | call print_string | |
| 103 | + | .tt_tune: | |
| 59 | 104 | ld hl, theme | |
| 60 | - | call play_tune | |
| 105 | + | .tt_note: | |
| 106 | + | ld bc, KEYS_SPACE | |
| 107 | + | in a, (c) | |
| 108 | + | bit 0, a | |
| 109 | + | jr z, .tt_pressed ; SPACE down -> leave | |
| 110 | + | ld a, (hl) | |
| 111 | + | inc hl | |
| 112 | + | cp $FF | |
| 113 | + | jr z, .tt_tune ; end of tune -> loop it | |
| 114 | + | ld c, a | |
| 115 | + | ld a, (hl) | |
| 116 | + | inc hl | |
| 117 | + | ld d, a | |
| 118 | + | ld a, c | |
| 119 | + | or a | |
| 120 | + | jr z, .tt_rest | |
| 121 | + | push hl | |
| 122 | + | call play_note | |
| 123 | + | pop hl | |
| 124 | + | jr .tt_note | |
| 125 | + | .tt_rest: | |
| 126 | + | push hl | |
| 127 | + | call rest_chunks | |
| 128 | + | pop hl | |
| 129 | + | jr .tt_note | |
| 130 | + | .tt_pressed: | |
| 131 | + | ld bc, KEYS_SPACE ; debounce: wait for release | |
| 132 | + | in a, (c) | |
| 133 | + | bit 0, a | |
| 134 | + | jr z, .tt_pressed | |
| 135 | + | ret | |
| 61 | 136 | | |
| 62 | - | im 1 | |
| 63 | - | ei | |
| 64 | - | .loop: | |
| 65 | - | halt | |
| 66 | - | ld a, (won) | |
| 137 | + | ; ---------------------------------------------------------------------------- | |
| 138 | + | ; show_win — the keep is cleared. Black screen, the words, the victory | |
| 139 | + | ; flourish, then wait for SPACE (and release) to return to the title. | |
| 140 | + | ; ---------------------------------------------------------------------------- | |
| 141 | + | show_win: | |
| 142 | + | di | |
| 143 | + | call clear_screen | |
| 144 | + | ld hl, str_won | |
| 145 | + | ld b, 9 | |
| 146 | + | ld c, 8 | |
| 147 | + | call print_string | |
| 148 | + | ld hl, str_again | |
| 149 | + | ld b, 15 | |
| 150 | + | ld c, 5 | |
| 151 | + | call print_string | |
| 152 | + | call sfx_win | |
| 153 | + | .sw_wait: | |
| 154 | + | ld bc, KEYS_SPACE | |
| 155 | + | in a, (c) | |
| 156 | + | bit 0, a | |
| 157 | + | jr nz, .sw_wait ; wait until SPACE pressed | |
| 158 | + | .sw_rel: | |
| 159 | + | ld bc, KEYS_SPACE | |
| 160 | + | in a, (c) | |
| 161 | + | bit 0, a | |
| 162 | + | jr z, .sw_rel ; wait for release | |
| 163 | + | ret | |
| 164 | + | | |
| 165 | + | ; clear_screen — fill bitmap and attributes ($4000..$5AFF) with zero: a black | |
| 166 | + | ; screen on black paper. Printed text supplies its own bright attribute. | |
| 167 | + | clear_screen: | |
| 168 | + | ld hl, $4000 | |
| 169 | + | ld (hl), 0 | |
| 170 | + | ld de, $4001 | |
| 171 | + | ld bc, 6911 | |
| 172 | + | ldir | |
| 173 | + | ret | |
| 174 | + | | |
| 175 | + | ; ---------------------------------------------------------------------------- | |
| 176 | + | ; print_string — HL -> zero-terminated text, B = row, C = col. Walks the | |
| 177 | + | ; string, printing each character and stepping one cell right. | |
| 178 | + | ; print_char — A = character (32..127), B = row, C = col. Copies the ROM font | |
| 179 | + | ; glyph at $3D00 + (char-32)*8 to the screen, bright white. | |
| 180 | + | ; ---------------------------------------------------------------------------- | |
| 181 | + | print_string: | |
| 182 | + | .ps_loop: | |
| 183 | + | ld a, (hl) | |
| 67 | 184 | or a | |
| 68 | - | jr nz, .loop ; keep won: freeze on the last coin | |
| 69 | - | call player_step | |
| 70 | - | call mark_step | |
| 71 | - | jr .loop | |
| 185 | + | ret z ; zero terminator | |
| 186 | + | push hl | |
| 187 | + | call print_char | |
| 188 | + | pop hl | |
| 189 | + | inc hl | |
| 190 | + | inc c ; next column | |
| 191 | + | jr .ps_loop | |
| 192 | + | | |
| 193 | + | print_char: | |
| 194 | + | push bc ; keep row/col | |
| 195 | + | sub 32 ; font starts at space | |
| 196 | + | ld l, a | |
| 197 | + | ld h, 0 | |
| 198 | + | add hl, hl | |
| 199 | + | add hl, hl | |
| 200 | + | add hl, hl ; * 8 bytes per glyph | |
| 201 | + | ld de, $3D00 ; ROM font base | |
| 202 | + | add hl, de ; HL -> glyph | |
| 203 | + | push hl | |
| 204 | + | call scr_addr_cr ; HL -> screen cell for (B,C) | |
| 205 | + | pop de ; DE -> glyph | |
| 206 | + | ld b, 8 | |
| 207 | + | .pc_row: | |
| 208 | + | ld a, (de) | |
| 209 | + | ld (hl), a | |
| 210 | + | inc de | |
| 211 | + | inc h | |
| 212 | + | djnz .pc_row | |
| 213 | + | pop bc ; restore row/col | |
| 214 | + | call attr_addr_cr | |
| 215 | + | ld (hl), %01000111 ; bright white ink, black paper | |
| 216 | + | ret | |
| 217 | + | | |
| 218 | + | str_title: | |
| 219 | + | defb "SHADOWKEEP", 0 | |
| 220 | + | str_prompt: | |
| 221 | + | defb "PRESS SPACE TO ENTER", 0 | |
| 222 | + | str_won: | |
| 223 | + | defb "THE KEEP STANDS", 0 | |
| 224 | + | str_again: | |
| 225 | + | defb "PRESS SPACE TO RETURN", 0 | |
| 72 | 226 | | |
| 73 | 227 | mark_step: | |
| 74 | 228 | ld bc, KEYS_SPACE |
The complete program
; Shadowkeep — Unit 16: The Keep Stands
; Cumulative build; every step runs on its own. Narrative: the unit page.
; step-01 wraps it in a state machine: title -> play -> win -> title, a finishable, replayable game.
org 32768
WALL_ATTR equ %01001000
FLOOR_ATTR equ %00001000
TORCH_ATTR equ %01001110
STATUE_ATTR equ %01001111
BANNER_ATTR equ %01001011
RUBBLE_ATTR equ %00001000
MARK_ATTR equ %00001111
GOLD_ATTR equ %00000110 ; yellow ink, no BRIGHT -> walkable gold
THIEF equ %01001010
WALL_BIT equ 6
START_COL equ 15
START_ROW equ 11
NO_EXIT equ $FF
MAX_SHADE equ 4
MAX_TORCHES equ 4
TOTAL_GOLD equ 6
KEYS_OP equ $DFFE
KEYS_Q equ $FBFE
KEYS_A equ $FDFE
KEYS_SPACE equ $7FFE
start:
ld a, 0
out ($FE), a
im 1
; --- TITLE -----------------------------------------------------------------
; Show the title, loop the theme, wait for SPACE. Then start a fresh game.
main_title:
call show_title ; returns (interrupts off) when SPACE pressed
call new_game ; reset state, draw the keep
ei
.game_loop:
halt
ld a, (won)
or a
jr nz, .won
call player_step
call mark_step
jr .game_loop
.won:
call show_win ; "THE KEEP STANDS", flourish, wait for SPACE
jr main_title ; round again
; ----------------------------------------------------------------------------
; new_game — start the keep afresh: copy templates back into the working state
; (so collected gold returns), reset the thief, the gold counter and the win
; flag, and draw the opening room. The reset is what makes the game REPLAYABLE.
; ----------------------------------------------------------------------------
new_game:
ld hl, room0_template
ld de, room0_state
ld bc, 768
ldir
ld hl, room1_template
ld de, room1_state
ld bc, 768
ldir
ld hl, room2_template
ld de, room2_state
ld bc, 768
ldir
xor a
ld (current_room), a
ld (won), a
ld a, TOTAL_GOLD
ld (gold_remaining), a
ld a, START_COL
ld (thief_col), a
ld a, START_ROW
ld (thief_row), a
call draw_room
call save_under
call draw_thief
ret
; ----------------------------------------------------------------------------
; show_title — black screen, the keep's name and a prompt, theme looping. Runs
; with interrupts off (clean music) and returns the moment SPACE is pressed and
; released. Words use the ROM's own 8x8 font, which lives at $3D00.
; ----------------------------------------------------------------------------
show_title:
di
call clear_screen
ld hl, str_title
ld b, 8
ld c, 11
call print_string
ld hl, str_prompt
ld b, 16
ld c, 6
call print_string
.tt_tune:
ld hl, theme
.tt_note:
ld bc, KEYS_SPACE
in a, (c)
bit 0, a
jr z, .tt_pressed ; SPACE down -> leave
ld a, (hl)
inc hl
cp $FF
jr z, .tt_tune ; end of tune -> loop it
ld c, a
ld a, (hl)
inc hl
ld d, a
ld a, c
or a
jr z, .tt_rest
push hl
call play_note
pop hl
jr .tt_note
.tt_rest:
push hl
call rest_chunks
pop hl
jr .tt_note
.tt_pressed:
ld bc, KEYS_SPACE ; debounce: wait for release
in a, (c)
bit 0, a
jr z, .tt_pressed
ret
; ----------------------------------------------------------------------------
; show_win — the keep is cleared. Black screen, the words, the victory
; flourish, then wait for SPACE (and release) to return to the title.
; ----------------------------------------------------------------------------
show_win:
di
call clear_screen
ld hl, str_won
ld b, 9
ld c, 8
call print_string
ld hl, str_again
ld b, 15
ld c, 5
call print_string
call sfx_win
.sw_wait:
ld bc, KEYS_SPACE
in a, (c)
bit 0, a
jr nz, .sw_wait ; wait until SPACE pressed
.sw_rel:
ld bc, KEYS_SPACE
in a, (c)
bit 0, a
jr z, .sw_rel ; wait for release
ret
; clear_screen — fill bitmap and attributes ($4000..$5AFF) with zero: a black
; screen on black paper. Printed text supplies its own bright attribute.
clear_screen:
ld hl, $4000
ld (hl), 0
ld de, $4001
ld bc, 6911
ldir
ret
; ----------------------------------------------------------------------------
; print_string — HL -> zero-terminated text, B = row, C = col. Walks the
; string, printing each character and stepping one cell right.
; print_char — A = character (32..127), B = row, C = col. Copies the ROM font
; glyph at $3D00 + (char-32)*8 to the screen, bright white.
; ----------------------------------------------------------------------------
print_string:
.ps_loop:
ld a, (hl)
or a
ret z ; zero terminator
push hl
call print_char
pop hl
inc hl
inc c ; next column
jr .ps_loop
print_char:
push bc ; keep row/col
sub 32 ; font starts at space
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl ; * 8 bytes per glyph
ld de, $3D00 ; ROM font base
add hl, de ; HL -> glyph
push hl
call scr_addr_cr ; HL -> screen cell for (B,C)
pop de ; DE -> glyph
ld b, 8
.pc_row:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .pc_row
pop bc ; restore row/col
call attr_addr_cr
ld (hl), %01000111 ; bright white ink, black paper
ret
str_title:
defb "SHADOWKEEP", 0
str_prompt:
defb "PRESS SPACE TO ENTER", 0
str_won:
defb "THE KEEP STANDS", 0
str_again:
defb "PRESS SPACE TO RETURN", 0
mark_step:
ld bc, KEYS_SPACE
in a, (c)
bit 0, a
ret nz
call cell_state_addr
ld (hl), '+'
ld hl, mark_tile
ld de, under_thief
ld bc, 8
ldir
ld a, MARK_ATTR
ld (under_thief + 8), a
ret
cell_state_addr:
call room_entry_addr
ld a, (hl)
inc hl
ld h, (hl)
ld l, a
push hl
ld a, (thief_row)
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
add hl, hl
add hl, hl
ld a, (thief_col)
ld e, a
ld d, 0
add hl, de
pop de
add hl, de
ret
room_entry_addr:
ld a, (current_room)
ld l, a
ld h, 0
ld d, h
ld e, l
add hl, hl
add hl, de
add hl, hl
ld de, rooms
add hl, de
ret
; ----------------------------------------------------------------------------
; find_torches — collect every 'T' in the current room (up to MAX_TORCHES) into
; torch_list as (col, row) pairs; torch_count says how many.
; ----------------------------------------------------------------------------
find_torches:
xor a
ld (torch_count), a
call room_entry_addr
ld a, (hl)
inc hl
ld h, (hl)
ld l, a
ld b, 0
.fr_row:
ld c, 0
.fr_col:
ld a, (hl)
cp 'T'
jr nz, .fr_skip
ld a, (torch_count)
cp MAX_TORCHES
jr nc, .fr_skip
push hl
add a, a ; count * 2
ld e, a
ld d, 0
ld hl, torch_list
add hl, de
ld (hl), c ; column
inc hl
ld (hl), b ; row
pop hl
ld a, (torch_count)
inc a
ld (torch_count), a
.fr_skip:
inc hl
inc c
ld a, c
cp 32
jr nz, .fr_col
inc b
ld a, b
cp 24
jr nz, .fr_row
ret
; ----------------------------------------------------------------------------
; shade_for_cell — row in B, column in C. Distance to the NEAREST torch,
; shifted by the room's falloff, clamped. Preserves B and C.
; ----------------------------------------------------------------------------
shade_for_cell:
push bc
ld a, b
ld (cell_row), a
ld a, c
ld (cell_col), a
ld a, (torch_count)
or a
jr z, .sf_dark
ld a, 255
ld (min_dist), a
ld hl, torch_list
ld a, (torch_count)
ld b, a ; loop count
.sf_loop:
ld a, (cell_col)
sub (hl) ; - torch col
jr nc, .sf_dc
neg
.sf_dc:
ld d, a ; |dc|
inc hl ; -> torch row
ld a, (cell_row)
sub (hl)
jr nc, .sf_dr
neg
.sf_dr:
inc hl ; -> next torch pair
cp d ; A = |dr|; take the larger
jr nc, .sf_mx
ld a, d
.sf_mx:
ld e, a ; this torch's distance
ld a, (min_dist)
cp e
jr c, .sf_keep ; min already smaller
ld a, e
ld (min_dist), a
.sf_keep:
djnz .sf_loop
ld a, (current_room)
ld c, a
ld b, 0
ld hl, room_falloff
add hl, bc
ld b, (hl)
ld a, (min_dist)
inc b
.sf_shift:
dec b
jr z, .sf_clamp
srl a
jr .sf_shift
.sf_clamp:
cp MAX_SHADE + 1
jr c, .sf_done
ld a, MAX_SHADE
.sf_done:
pop bc
ret
.sf_dark:
ld a, MAX_SHADE
pop bc
ret
draw_room:
call find_torches
call room_entry_addr
ld a, (hl)
inc hl
ld h, (hl)
ld l, a
ld (map_ptr), hl
ld b, 0
.room_row:
ld c, 0
.room_col:
ld hl, (map_ptr)
ld a, (hl)
cp '.'
jr nz, .not_floor
call draw_floor_cell
jr .cell_done
.not_floor:
call lookup_tile
call draw_tile
.cell_done:
ld hl, (map_ptr)
inc hl
ld (map_ptr), hl
inc c
ld a, c
cp 32
jr nz, .room_col
inc b
ld a, b
cp 24
jr nz, .room_row
ret
lookup_tile:
ld hl, palette
.scan:
cp (hl)
jr z, .found
inc hl
inc hl
inc hl
inc hl
jr .scan
.found:
inc hl
ld e, (hl)
inc hl
ld d, (hl)
ld (tile_ptr), de
inc hl
ld a, (hl)
ld (tile_attr), a
ret
draw_tile:
push bc
call attr_addr_cr
ld a, (tile_attr)
ld (hl), a
call scr_addr_cr
ld de, (tile_ptr)
ld b, 8
.tile_row:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .tile_row
pop bc
ret
player_step:
ld a, (thief_col)
ld (tcol), a
ld a, (thief_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
ret
.left:
ld hl, tcol
dec (hl)
jr .move
.right:
ld hl, tcol
inc (hl)
jr .move
.up:
ld hl, trow
dec (hl)
jr .move
.down:
ld hl, trow
inc (hl)
.move:
ld a, (trow)
ld b, a
ld a, (tcol)
ld c, a
call wall_at
ret nz
call restore_under
ld a, (tcol)
ld (thief_col), a
ld a, (trow)
ld (thief_row), a
call collect_gold ; new cell might be gold — lift it first
call save_under
call draw_thief
call sfx_step
call check_exit
ret
wall_at:
call attr_addr_cr
bit WALL_BIT, (hl)
ret
check_exit:
ld a, (thief_col)
or a
jr z, .west
cp 31
jr z, .east
ld a, (thief_row)
or a
jr z, .north
cp 23
jr z, .south
ret
.east:
call room_entry_addr
ld de, 4
add hl, de
ld a, (hl)
cp NO_EXIT
ret z
ld (current_room), a
ld a, 1
ld (thief_col), a
jr .enter
.west:
call room_entry_addr
ld de, 5
add hl, de
ld a, (hl)
cp NO_EXIT
ret z
ld (current_room), a
ld a, 30
ld (thief_col), a
jr .enter
.north:
call room_entry_addr
inc hl
inc hl
ld a, (hl)
cp NO_EXIT
ret z
ld (current_room), a
ld a, 22
ld (thief_row), a
jr .enter
.south:
call room_entry_addr
inc hl
inc hl
inc hl
ld a, (hl)
cp NO_EXIT
ret z
ld (current_room), a
ld a, 1
ld (thief_row), a
.enter:
call sfx_door
call draw_room
call save_under
call draw_thief
ret
; ----------------------------------------------------------------------------
; The sound-effects driver.
;
; beep — the one tone primitive. B = number of square-wave cycles (how long
; the tone lasts); C = the delay constant between speaker toggles (the pitch —
; a larger C waits longer between flips, so the wave is slower and the note is
; lower). Bit 4 of port $FE is the speaker; we write it high, wait C, write it
; low (and a black border with it), wait C, and repeat B times.
; ----------------------------------------------------------------------------
beep:
.bp_cycle:
ld a, %00010000 ; speaker high (border stays black)
out ($FE), a
ld e, c
.bp_hi:
dec e
jr nz, .bp_hi
xor a ; speaker low, border black
out ($FE), a
ld e, c
.bp_lo:
dec e
jr nz, .bp_lo
djnz .bp_cycle
ret
; sfx_step — a footfall: short and low. A handful of cycles of a low tone,
; gone almost before you notice it. Played under every successful move.
sfx_step:
ld b, 5
ld c, 90
call beep
ret
; sfx_door — a creak: a tone that falls in pitch as it plays. We start the
; delay constant small (higher) and let it grow (lower), a few cycles at each
; step, so the note groans downward — a heavy door swinging on its hinges.
sfx_door:
ld c, 36 ; start higher
.sd_sweep:
ld b, 3
push bc
call beep
pop bc
inc c ; lower the pitch a little
inc c
ld a, c
cp 130 ; until it has groaned down low
jr c, .sd_sweep
ret
; sfx_pickup — a bright two-blip chime: short, high, rising. The sound of gold.
sfx_pickup:
ld b, 8
ld c, 20
call beep
ld b, 8
ld c, 14 ; a touch higher — a little ting
call beep
ret
; sfx_win — an ascending flourish: a low note climbing in steps to a high one.
sfx_win:
ld c, 60 ; low-ish
.sw_loop:
ld b, 12
push bc
call beep
pop bc
ld a, c
sub 8 ; smaller delay -> higher pitch (rising)
ld c, a
cp 16
jr nc, .sw_loop ; until we've climbed high
ret
; ----------------------------------------------------------------------------
; draw_floor_cell — paint the floor at (row B, col C), shaded for the light.
; Extracted from draw_room so collection can repaint a cell as floor too.
; ----------------------------------------------------------------------------
draw_floor_cell:
call shade_for_cell
add a, a
ld e, a
ld d, 0
ld hl, shade_tiles
add hl, de
ld e, (hl)
inc hl
ld d, (hl)
ld (tile_ptr), de
ld a, FLOOR_ATTR
ld (tile_attr), a
call draw_tile
ret
; ----------------------------------------------------------------------------
; collect_gold — if the cell the thief just moved onto is gold, lift it: turn
; the map cell to floor (gone for good), repaint it, chime, and tick the
; counter down. At zero, the keep is won.
; ----------------------------------------------------------------------------
collect_gold:
call cell_state_addr ; HL -> map cell at the thief's position
ld a, (hl)
cp 'G'
ret nz
ld (hl), '.' ; the gold is taken — floor from now on
call pos_bc ; B = row, C = col
call draw_floor_cell ; so save_under grabs floor, not gold
call sfx_pickup
ld hl, gold_remaining
dec (hl)
ld a, (hl)
or a
call z, win
ret
; win — the last coin is gone. Flourish, and raise the flag the loop watches.
win:
ld a, 1
ld (won), a
call sfx_win
ret
; ----------------------------------------------------------------------------
; The music player — a note-table interpreter.
;
; play_tune — HL points at a stream of (pitch, duration) byte pairs:
; pitch $FF -> end of tune (return).
; pitch 0 -> rest: silence for `duration` chunks.
; else -> a note: tone at delay `pitch`, held for `duration` chunks.
; A short gap is inserted after every note so they articulate instead of
; slurring into one another.
; ----------------------------------------------------------------------------
play_tune:
.pt_next:
ld a, (hl) ; pitch
inc hl
cp $FF
ret z ; end of tune
ld c, a ; pitch -> C
ld a, (hl) ; duration
inc hl
ld d, a ; duration -> D (chunk count)
ld a, c
or a
jr z, .pt_rest
push hl
call play_note ; C = pitch, D = chunks
pop hl
jr .pt_gap
.pt_rest:
push hl
call rest_chunks ; D chunks of silence
pop hl
.pt_gap:
push hl
ld d, 1 ; one chunk of silence between notes
call rest_chunks
pop hl
jr .pt_next
; play_note — C = pitch (beep delay), D = duration in chunks. A note is just
; the beep primitive run for D fixed-size chunks, so a note can be long even
; though one beep's cycle count (B) is a single byte.
play_note:
.pn_loop:
ld b, 24 ; one chunk = 24 square-wave cycles
push de
call beep ; B cycles at pitch C
pop de
dec d
jr nz, .pn_loop
ret
; rest_chunks — D chunks of silence, timed to roughly match a note chunk so
; rests sit in the rhythm.
rest_chunks:
.rc_loop:
ld b, 24
.rc_inner:
ld e, 75
.rc_wait:
dec e
jr nz, .rc_wait
djnz .rc_inner
dec d
jr nz, .rc_loop
ret
; The theme — a short, solemn phrase in D minor. Pitch values are beep delay
; constants (larger = lower); durations are chunk counts. One voice, no
; harmony: the melody carries the whole mood by itself.
theme:
defb 75, 8 ; D5
defb 63, 8 ; F5
defb 50, 8 ; A5
defb 50, 12 ; A5 (held)
defb 56, 8 ; G5
defb 63, 8 ; F5
defb 66, 8 ; E5
defb 75, 16 ; D5 (resolve)
defb 0, 6 ; breath
defb 56, 8 ; G5
defb 63, 8 ; F5
defb 50, 8 ; A5
defb 75, 16 ; D5 (home)
defb $FF ; end
pos_bc:
ld a, (thief_row)
ld b, a
ld a, (thief_col)
ld c, a
ret
save_under:
call pos_bc
call scr_addr_cr
ld de, under_thief
ld b, 8
.save_row:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .save_row
call pos_bc
call attr_addr_cr
ld a, (hl)
ld (under_thief + 8), a
ret
restore_under:
call pos_bc
call scr_addr_cr
ld de, under_thief
ld b, 8
.restore_row:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .restore_row
call pos_bc
call attr_addr_cr
ld a, (under_thief + 8)
ld (hl), a
ret
draw_thief:
call pos_bc
call attr_addr_cr
ld (hl), THIEF
call pos_bc
call scr_addr_cr
ld de, thief
ld b, 8
.thief_row:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .thief_row
ret
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
palette:
defb '.'
defw shade2_tile
defb FLOOR_ATTR
defb '#'
defw wall_tile
defb WALL_ATTR
defb 'T'
defw torch_tile
defb TORCH_ATTR
defb 'S'
defw statue_tile
defb STATUE_ATTR
defb 'B'
defw banner_tile
defb BANNER_ATTR
defb 'o'
defw rubble_tile
defb RUBBLE_ATTR
defb '+'
defw mark_tile
defb MARK_ATTR
defb 'G'
defw gold_tile
defb GOLD_ATTR
shade_tiles:
defw shade0_tile
defw shade1_tile
defw shade2_tile
defw shade3_tile
defw shade4_tile
room_falloff:
defb 2 ; Hall — broad
defb 1 ; Gallery — medium
defb 0 ; Vault — tight
rooms:
defw room0_state
defb NO_EXIT, NO_EXIT, 1, NO_EXIT
defw room1_state
defb 2, NO_EXIT, NO_EXIT, 0
defw room2_state
defb NO_EXIT, 1, NO_EXIT, NO_EXIT
; The Great Hall — three sconces (top-centre and the two side walls), broadly
; lit; banners flank a statue with rubble at its feet.
room0_template:
defb "###############T################"
defb "#..............................#"
defb "#.......G......................#"
defb "#..............................#"
defb "#..............................#"
defb "B..............S...............B"
defb "#.............ooo..............#"
defb "#..............................#"
defb "T........##..........##........T"
defb "#........##..........##........#"
defb "#..............................#"
defb "#..............................."
defb "#..............................#"
defb "#..............................#"
defb "#........##..........##........#"
defb "#........##..........##........#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#.....................G........#"
defb "#..............................#"
defb "#..............................#"
defb "################################"
; The Gallery — two torches: low on the south wall, and one on the east wall.
room1_template:
defb "###############.################"
defb "#..............................#"
defb "#.....G........................#"
defb "#..............................#"
defb "#..............................T"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "###############.################"
defb "#..............................#"
defb "#..............................#"
defb "...............................#"
defb "#..............................#"
defb "#..............................#"
defb "B..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#........................ooo...#"
defb "#..............................#"
defb "#.......................G......#"
defb "#..............................#"
defb "#..............................#"
defb "###############T################"
; The Vault — one flame, on the altar. Its character is the dark.
room2_template:
defb "################################"
defb "#..............................#"
defb "#.........G....................#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#.............#T##.............#"
defb "#.............####.............#"
defb "#.............####.............#"
defb "#.............####.............#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#..............................#"
defb "#...................G..........#"
defb "#..............................#"
defb "###############.################"
shade0_tile:
defb %00000000
defb %00100010
defb %00000000
defb %00000000
defb %00000000
defb %10001000
defb %00000000
defb %00000000
shade1_tile:
defb %00100010
defb %00000000
defb %10001000
defb %00000000
defb %00100010
defb %00000000
defb %10001000
defb %00000000
shade2_tile:
defb %10101010
defb %01010101
defb %10101010
defb %01010101
defb %10101010
defb %01010101
defb %10101010
defb %01010101
shade3_tile:
defb %10101010
defb %11111111
defb %01010101
defb %11111111
defb %10101010
defb %11111111
defb %01010101
defb %11111111
shade4_tile:
defb %11111111
defb %11101110
defb %11111111
defb %10111011
defb %11111111
defb %11101110
defb %11111111
defb %10111011
wall_tile:
defb %00010001
defb %00000000
defb %01000100
defb %00000000
defb %00010001
defb %00000000
defb %01000100
defb %00000000
torch_tile:
defb %00010000
defb %00111000
defb %00111000
defb %01111100
defb %01111100
defb %01111100
defb %00111000
defb %00010000
statue_tile:
defb %00111100
defb %01111110
defb %00111100
defb %00011000
defb %00011000
defb %00111100
defb %01111110
defb %01111110
banner_tile:
defb %01111110
defb %01111110
defb %01011010
defb %01011010
defb %01111110
defb %01111110
defb %00111100
defb %00011000
rubble_tile:
defb %00000000
defb %01100000
defb %01100000
defb %00000110
defb %00000110
defb %00011000
defb %00011000
defb %00000000
mark_tile:
defb %00000000
defb %00011000
defb %00011000
defb %01111110
defb %01111110
defb %00011000
defb %00011000
defb %00000000
gold_tile:
defb %00000000
defb %00111100
defb %01111110
defb %01111110
defb %01111110
defb %01111110
defb %00111100
defb %00000000
thief:
defb %00011000
defb %00111100
defb %01111110
defb %01111110
defb %01111110
defb %01111110
defb %00111100
defb %00100100
current_room:
defb 0
torch_count:
defb 0
cell_row:
defb 0
cell_col:
defb 0
min_dist:
defb 0
gold_remaining:
defb TOTAL_GOLD
won:
defb 0
thief_col:
defb START_COL
thief_row:
defb START_ROW
tcol:
defb 0
trow:
defb 0
map_ptr:
defw 0
tile_ptr:
defw 0
tile_attr:
defb 0
under_thief:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
torch_list:
defs MAX_TORCHES * 2
room0_state:
defs 768
room1_state:
defs 768
room2_state:
defs 768
end start
The whole loop, end to end — the title and its theme, space to enter, the keep you built across sixteen units, the win when it's cleared, and space back to the title, reset and waiting:
And the win itself — the keep cleared, the door held open back to the start:
That's a game — one you can hand to someone and say finish it.
Try this: a hand-pixelled logo
The ROM font is honest and clear, but a title can be more. Replace the SHADOWKEEP text with a hand-drawn logo — define your own wide letter shapes as tiles, or a small crest, and draw_tile them across the top. It's pixel work, not new code, and it's the difference between "a program" and "a presentation".
Try this: an attract loop
Right now the title waits forever. Make it demo instead: after the theme loops twice with no key, drop into the game with input ignored, walk the thief on a scripted path for a few seconds, then return to the title. Arcade machines did this to draw a crowd. It's a third state in your machine — title → attract → title — and slots in beside the two you have.
Try this: remember the best clear
Add a counter of how many moves (or how few) it took to clear the keep, print it on the win screen, and keep the best across plays. Suddenly there's a reason to play again better — the smallest possible scoreboard, and a glimpse of why a win screen is worth more than a HALT.
When it's wrong, see why
- The second game still shows collected coins gone.
new_gameisn't copying the templates back over the working state — it's the copy, not a redraw, that resets the gold. - The title text is garbage or in the wrong place.
print_char's glyph address is wrong — it's$3D00 + (char - 32) * 8. Check thesub 32and the threeadd hl,hl(×8). - Space does nothing at the title. The key read is
IN A,($7FFE), bit 0 for space. If the theme is long you may wait up to one note; that's expected. No response at all means the bit or port is wrong. - It starts the game the instant you press space, then immediately drops a mark. Space is still held when the game loop begins.
show_titlewaits for release before returning to prevent exactly this — keep that debounce. - The theme stutters or won't loop. Interrupts are on during the title —
show_titleruns withdi; the game loop'seicomes after. End-of-tune ($FF) must jump back to the tune's start, not fall through. - After winning it hangs instead of returning. The
.wonbranch mustcall show_winand thenjr main_title; ifshow_winnever sees a space release it will wait forever — check its release loop.
Before and after
You started this unit with every part built but no game to sit down to — the program ran the keep and nothing wrapped it. You finished with the loop: a title that waits, the keep played, a win when it's cleared, and the title again with a fresh keep. It took no new engine — a handful of labels and jumps for the states, a block-copy of the templates for the reset, and the ROM font for the words. The discipline of always editing the working maps, never the templates, is what made "a fresh game" a single copy. A pile of parts became something a person can finish and play again.
What you've learnt
- A game is a state machine. Title, play, win — each a routine, the transitions just jumps. Structure is deciding what runs next; it needs no framework.
- Replayable means resettable. A fresh game restores state, it doesn't only redraw. Editing the working copy of the maps (never the templates) is what makes the reset a single block-copy.
- The machine is full of tools. The ROM font at
$3D00gives you text for nothing — reach for what's already there before you build your own. - Responsiveness is a design choice. Checking input between notes keeps the title alive without interrupt-driven music. The simplest thing that feels instant is usually right.
- A loop is what makes it a game. Beginning, middle, end, and back to the beginning — the difference between a tech demo and something a person can sit down and finish.
The keep stands — and what comes after
This is the end of Arc 1. You've built, from nothing, a complete cell-based flick-adventure: a multi-room keep you explore, lit by torches with dithered shadow, furnished and given character, heard in footsteps and doors, scored in gold, themed in a single beeper voice, and wrapped in a title-to-win-to-title loop. It's a real game — the kind that could have shipped on a budget label, made the way Atic Atac and Manic Miner were made: not with an advanced engine, but with art, light, sound, design and completeness.
What's deliberately not here — pixel-smooth sprite movement, masked drawing over scenery, isometric depth — is the subject of the games that follow, each introducing one hard rendering technique on the foundation you now have. The keep stands. Next, we make things move.