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

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.

100% of Shadowkeep

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 title screen: a black field with the word SHADOWKEEP centred in bright white, and below it PRESS SPACE TO ENTER.
The keep opens not into the Hall but into a title — its name in the ROM font, a prompt, the theme looping underneath. Press space and you're in; clear the keep and you're shown the door back here.

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.

Step 1: a title-to-play-to-win-to-title state machine
+166-12
11 ; Shadowkeep — Unit 16: The Keep Stands
22 ; 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.
44
55 org 32768
66
...
3030 start:
3131 ld a, 0
3232 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
3352
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:
3459 ld hl, room0_template
3560 ld de, room0_state
3661 ld bc, 768
...
4671
4772 xor a
4873 ld (current_room), a
74+ ld (won), a
75+ ld a, TOTAL_GOLD
76+ ld (gold_remaining), a
4977 ld a, START_COL
5078 ld (thief_col), a
5179 ld a, START_ROW
...
5482 call draw_room
5583 call save_under
5684 call draw_thief
85+ ret
5786
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:
59104 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
61136
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)
67184 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
72226
73227 mark_step:
74228 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:

Title to play to win to title. Space enters the keep; the cleared-keep state draws THE KEEP STANDS; space returns to the title, every coin restored by the template copy in new_game. A whole game, beginning to end to beginning. (The win is triggered here for the capture; in play it's the last coin that does it.)

And the win itself — the keep cleared, the door held open back to the start:

The win screen: a black field with THE KEEP STANDS centred in bright white, and below it PRESS SPACE TO RETURN.
THE KEEP STANDS — the keep cleared, the same ROM font, and the prompt that closes the loop: press space, and the title waits again with a fresh keep.

That's a game — one you can hand to someone and say finish it.

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_game isn'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 the sub 32 and the three add 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_title waits 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_title runs with di; the game loop's ei comes after. End-of-tune ($FF) must jump back to the tune's start, not fall through.
  • After winning it hangs instead of returning. The .won branch must call show_win and then jr main_title; if show_win never 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 $3D00 gives 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.