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

A Theme in One Voice

Write music on one bit. Build a note-table player — a stream of pitch/duration pairs driving the beep primitive — and compose a short, solemn theme in D minor for the keep. One voice, no harmony: the melody carries the whole mood alone.

94% of Shadowkeep

The keep is a complete game — but it opens in silence. A game announces itself; it has a theme, the few seconds of music that tell you what you're about to play. This unit gives the keep one, on the only instrument we have: the single-bit beeper from Unit 13, asked now to carry not a footstep but a tune.

What you'll hear by the end

The Hall, lit and waiting, two coins of gold on its floor — the screen you've built, now opening to a short theme played as the keep comes into view.
The same Hall — and a still can't carry what's new. As the keep opens, a short D-minor phrase plays: one voice, rising and resolving home, the keep introducing itself before you take a step.

The screen is the Hall as before — but now, as the keep opens, a short solemn phrase plays: a melody in D minor, rising to a held note and falling home, then a brief answer. A couple of seconds, one voice, then the game begins. It's the sound of the keep introducing itself. (Load it and listen — the still is silent, the keep is not.)

Music from a note table

In Unit 13 we toggled the beeper by hand for each effect. Music needs more notes than that, so we write a tiny player that reads a tune from data. The tune is just bytes — pairs of (pitch, duration):

play_tune:
.pt_next:
            ld      a, (hl)          ; pitch
            inc     hl
            cp      $FF
            ret     z                ; $FF -> end of tune
            ld      c, a             ; pitch -> C (beep delay)
            ld      a, (hl)          ; duration
            inc     hl
            ld      d, a             ; duration -> D (how long to hold it)
            ld      a, c
            or      a
            jr      z, .pt_rest      ; pitch 0 -> a rest
            ; ... play the note, then a small gap, then the next ...

A $FF pitch ends the tune; a 0 pitch is a rest; anything else is a note. To change the music you change the data, never the player — exactly like the room maps drive the level. The player is the engine; the theme is content.

A long note from a short counter

One beep can only run 255 cycles — far too short for a held, solemn note. So a note is beep repeated in chunks, the duration byte saying how many:

play_note:                           ; C = pitch, D = duration in chunks
.pn_loop:
            ld      b, 24            ; one chunk = 24 cycles of the tone
            push    de
            call    beep             ; B cycles at pitch C
            pop     de
            dec     d
            jr      nz, .pn_loop
            ret

Stack a handful of chunks and a note breathes for a third of a second; a couple and it's a quick passing tone. Duration becomes rhythm.

Composing for one voice

The hard part isn't the code — it's that the beeper is monophonic. One bit, one note at a time: no chords, no bass under the melody, no harmony to hide behind. The tune has to carry rhythm, mood and identity entirely on its own line. So we write a small, deliberate phrase in D minor — dark, solemn, the keep before the dark:

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)
            ; ... a breath, then a short answer, then home ...
            defb    $FF

It rises through the D-minor triad to a held A, then steps back down and resolves home to D — a shape you can hum, which is the whole job of a theme. Play it once as the keep opens; then hand over to the game.

Milestone — give the keep a theme

play_tune walks a table of (pitch, duration) pairs — $FF ends it, 0 is a rest — calling beep for each note. Because one beep runs at most 255 cycles, play_note stacks the tone in chunks so a note can breathe for a third of a second; the duration byte is the chunk count, and that's your rhythm. The theme itself is a short D-minor phrase, one voice carrying the whole mood — and di/ei hand the CPU wholly to the music while it plays, then back to the loop.

Step 1: a note-table player and a one-voice D-minor theme
+90-1
11 ; Shadowkeep — Unit 15: A Theme in One Voice
22 ; Cumulative build; every step runs on its own. Narrative: the unit page.
3-; step-00 = Unit 14's end: a complete game — explore, collect, win — but silent at the open.
3+; step-01 adds a note-table player and a short D-minor theme that plays as the keep opens.
44
55 org 32768
66
...
5454 call draw_room
5555 call save_under
5656 call draw_thief
57+
58+ di ; clean audio: no 50Hz tick over the tune
59+ ld hl, theme
60+ call play_tune
5761
5862 im 1
5963 ei
...
553557 ld a, 1
554558 ld (won), a
555559 call sfx_win
560+ ret
561+
562+; ----------------------------------------------------------------------------
563+; The music player — a note-table interpreter.
564+;
565+; play_tune — HL points at a stream of (pitch, duration) byte pairs:
566+; pitch $FF -> end of tune (return).
567+; pitch 0 -> rest: silence for `duration` chunks.
568+; else -> a note: tone at delay `pitch`, held for `duration` chunks.
569+; A short gap is inserted after every note so they articulate instead of
570+; slurring into one another.
571+; ----------------------------------------------------------------------------
572+play_tune:
573+.pt_next:
574+ ld a, (hl) ; pitch
575+ inc hl
576+ cp $FF
577+ ret z ; end of tune
578+ ld c, a ; pitch -> C
579+ ld a, (hl) ; duration
580+ inc hl
581+ ld d, a ; duration -> D (chunk count)
582+ ld a, c
583+ or a
584+ jr z, .pt_rest
585+ push hl
586+ call play_note ; C = pitch, D = chunks
587+ pop hl
588+ jr .pt_gap
589+.pt_rest:
590+ push hl
591+ call rest_chunks ; D chunks of silence
592+ pop hl
593+.pt_gap:
594+ push hl
595+ ld d, 1 ; one chunk of silence between notes
596+ call rest_chunks
597+ pop hl
598+ jr .pt_next
599+
600+; play_note — C = pitch (beep delay), D = duration in chunks. A note is just
601+; the beep primitive run for D fixed-size chunks, so a note can be long even
602+; though one beep's cycle count (B) is a single byte.
603+play_note:
604+.pn_loop:
605+ ld b, 24 ; one chunk = 24 square-wave cycles
606+ push de
607+ call beep ; B cycles at pitch C
608+ pop de
609+ dec d
610+ jr nz, .pn_loop
611+ ret
612+
613+; rest_chunks — D chunks of silence, timed to roughly match a note chunk so
614+; rests sit in the rhythm.
615+rest_chunks:
616+.rc_loop:
617+ ld b, 24
618+.rc_inner:
619+ ld e, 75
620+.rc_wait:
621+ dec e
622+ jr nz, .rc_wait
623+ djnz .rc_inner
624+ dec d
625+ jr nz, .rc_loop
556626 ret
627+
628+; The theme — a short, solemn phrase in D minor. Pitch values are beep delay
629+; constants (larger = lower); durations are chunk counts. One voice, no
630+; harmony: the melody carries the whole mood by itself.
631+theme:
632+ defb 75, 8 ; D5
633+ defb 63, 8 ; F5
634+ defb 50, 8 ; A5
635+ defb 50, 12 ; A5 (held)
636+ defb 56, 8 ; G5
637+ defb 63, 8 ; F5
638+ defb 66, 8 ; E5
639+ defb 75, 16 ; D5 (resolve)
640+ defb 0, 6 ; breath
641+ defb 56, 8 ; G5
642+ defb 63, 8 ; F5
643+ defb 50, 8 ; A5
644+ defb 75, 16 ; D5 (home)
645+ defb $FF ; end
557646
558647 pos_bc:
559648 ld a, (thief_row)
The complete program
; Shadowkeep — Unit 15: A Theme in One Voice
; Cumulative build; every step runs on its own. Narrative: the unit page.
; step-01 adds a note-table player and a short D-minor theme that plays as the keep opens.

            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

            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      a, START_COL
            ld      (thief_col), a
            ld      a, START_ROW
            ld      (thief_row), a

            call    draw_room
            call    save_under
            call    draw_thief

            di                       ; clean audio: no 50Hz tick over the tune
            ld      hl, theme
            call    play_tune

            im      1
            ei
.loop:
            halt
            ld      a, (won)
            or      a
            jr      nz, .loop        ; keep won: freeze on the last coin
            call    player_step
            call    mark_step
            jr      .loop

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 theme plays as the keep opens — one voice, rising through the D-minor triad to a held note, then stepping home:

ZX Spectrum beeper · port $FE bit 4 · note-table player
The keep's theme — a short D-minor phrase on one bit of beeper

Then the game begins as before — explore, collect, win. The keep now has a voice that's its own, not a generic blip.

A note on interrupts. We di (disable interrupts) before the theme and only im 1 / ei afterwards, for the game loop. The 50 Hz interrupt would otherwise tick across every note and roughen the tune. Music wants the CPU's full attention; the game loop wants the interrupt back for halt. Each gets what it needs.

Try this: change the key

Shift every pitch value up or down by the same proportion and the whole theme transposes — higher and brighter, or lower and grimmer. The shape (the intervals between notes) is the melody; the absolute pitches are just where you pin it. Try it lower for a darker keep.

Try this: a faster theme

Halve every duration. The phrase turns urgent, hurried — wrong for a brooding keep, but you can hear instantly how much rhythm carries the mood, independent of the notes. Then slow it past the original and feel it become a dirge. Tempo is a dial on emotion.

Try this: a second voice (the illusion)

True two-part harmony is beyond one bit — but you can fake it by alternating two pitches fast within a chunk, so the ear hears a chord-like shimmer. Sketch it on two notes. It's how advanced Spectrum music squeezed apparent polyphony from a single speaker — and a glimpse of where beeper craft can go.

When it's wrong, see why

  • One long tone instead of a tune. The player isn't advancing HL, or every note has the same pitch. Each pair must inc hl twice; check the pitches in the data differ from each other.
  • The tune never ends / the game never starts. No $FF terminator, or the end check is wrong. The first byte of each pair is tested against $FF before it's used as a pitch.
  • All notes the same length. The duration byte isn't reaching play_note, or D is overwritten. Each note's second byte is its chunk count.
  • Rough, buzzy tune. Interrupts are still on — di before play_tune. (Don't forget to im 1 / ei afterwards, or the game loop's halt will hang.)
  • Rests make noise. A rest should leave the speaker bit alone; if it clicks, rest_chunks is toggling the port. It must only wait.

Before and after

You started with a game that opened in silence and finished with one that announces itself — a short D-minor phrase as the keep comes up. The instrument never changed: it's the same one-bit beeper from Unit 13, now reading a tune from a note table instead of being toggled by hand. A note is beep stacked in chunks; a melody is a table of pitch/duration pairs; the player never changes and the tune is data. The hard part was composition, not code — one voice, no harmony, the melody carrying rhythm and mood alone. The keep got a signature.

What you've learnt

  • Music is data plus a player. A note table of pitch/duration pairs, walked by a tiny interpreter calling beep — the player never changes, the tune is content.
  • Duration is chunks; chunks are rhythm. Repeating a short beep makes a long note, and the count of repeats is how you compose rhythm from a one-byte counter.
  • One voice carries everything. With no harmony to lean on, the melody alone must hold rhythm, mood and identity. That constraint is beeper composition.
  • Give the CPU to the music. di for the tune, ei for the loop — each part of the program gets the machine on the terms it needs.

What's next

Theme, sound, gold, a win — every piece is built. Unit 16, "The Keep Stands," assembles them into a whole: a title screen with the theme looping under it, press a key to enter, the game you play, a victory when the keep is cleared, and back to the title. The complete loop — the moment the keep stops being a demo and becomes a game you can sit down and finish.