Skip to content
Game 1 Unit 12 of 128 1 hr learning time

Beeper Sound

Toggle bit 4 of port $FE in a timed loop to make the speaker vibrate. A rising three-note tone plays when treasure is collected — the game's first sound.

9% of Shadowkeep

The 48K Spectrum has no sound chip. It has a beeper — a single speaker connected to one bit of an I/O port. Bit 4 of port $FE. Set it high and the speaker cone pushes out. Set it low and it pulls back. Toggle it fast enough and the speaker vibrates. Vibration is sound.

That’s it. One bit. Every sound the 48K Spectrum makes — every explosion, every melody, every loading screech — comes from toggling one bit at the right speed.

How the Beeper Works

Port $FE controls several things. Bits 0–2 set the border colour (you’ve been using these since Unit 4). Bit 4 controls the speaker. The other bits have other purposes.

To make a tone:

  1. Set bit 4 high — LD A, $10 / OUT ($FE), A
  2. Wait a precise delay
  3. Set bit 4 low — XOR $10 / OUT ($FE), A
  4. Wait the same delay
  5. Repeat

The delay controls the pitch. Short delay = fast toggling = high pitch. Long delay = slow toggling = low pitch. The duration (number of repetitions) controls how long the note lasts.

XOR — Toggle a Bit

XOR $10 flips bit 4 without touching any other bits. XOR (exclusive OR) follows this rule: if the mask bit is 1, the result bit flips. If the mask bit is 0, the result bit stays.

  A:        00010000    (bit 4 is 1 — speaker on)
  XOR $10:  00010000    (mask: flip bit 4)
  Result:   00000000    (bit 4 is 0 — speaker off)

  A:        00000000    (bit 4 is 0 — speaker off)
  XOR $10:  00010000    (mask: flip bit 4)
  Result:   00010000    (bit 4 is 1 — speaker on)

Two XORs bring A back to its original value. This is why XOR is used for toggling — it’s its own inverse.

Compare with AND (which clears bits) and OR (which sets bits). XOR is the toggling operator. You’ve now met all three bitwise operations:

OperationEffectUsed for
ANDClear bits to 0Extract fields (INK colour)
ORSet bits to 1Combine flags
XORFlip bitsToggle (speaker, animation)

The Beep Subroutine

; Generate a tone on the Spectrum's beeper.
; Bit 4 of port $FE controls the speaker. Toggle it high and low
; in a loop — the delay between toggles sets the pitch.

; Entry: HL = duration (number of cycles), DE = pitch (delay per half-cycle)

beep:
            ld      a, $10              ; Bit 4 high — speaker on
.on:        out     ($fe), a            ; Push speaker cone out
            ld      b, e                ; Delay counter = pitch
.delay1:    djnz    .delay1             ; Wait

            xor     $10                 ; Toggle bit 4 — speaker off
            out     ($fe), a            ; Pull speaker cone back
            ld      b, e                ; Same delay
.delay2:    djnz    .delay2             ; Wait

            xor     $10                 ; Toggle bit 4 — speaker on again
            dec     hl                  ; One cycle done
            ld      a, h
            or      l                   ; HL = 0?
            ld      a, $10              ; Reload speaker-on value
            jr      nz, .on             ; More cycles — continue

            xor     a                   ; A = 0 — speaker off, border black
            out     ($fe), a
            ret

The subroutine takes two parameters:

  • HL = duration — how many wave cycles to play
  • E = pitch — the DJNZ delay per half-cycle (lower = higher pitch)

Each iteration toggles the speaker twice (on, off) with a delay in between. That’s one complete wave cycle. DEC HL counts down the duration. When HL reaches zero, the tone stops.

Testing HL for Zero

The Z80 has no single instruction to test whether HL is zero. DEC HL doesn’t set flags (unlike DEC A). The idiom is:

            dec     hl
            ld      a, h
            or      l               ; Z set only if both H and L are 0

OR L combines H and L — if both are zero, the result is zero and Z is set. If either has any bits set, the result is non-zero. This three-instruction pattern appears whenever you need to test a 16-bit counter.

Why LD A, $10 After the Zero Test?

The OR L instruction modifies A (it contains H OR L). But the loop needs A to hold $10 for the next OUT. LD A, $10 reloads it — and crucially, LD doesn’t affect flags. The Z flag from OR L survives the reload, so the JR NZ check still works correctly.

A Rising Collect Sound

Three notes, each higher than the last:

            ld      hl, 80              ; Duration
            ld      e, 40               ; Low pitch
            call    beep
            ld      hl, 80
            ld      e, 30               ; Medium pitch
            call    beep
            ld      hl, 80
            ld      e, 20               ; High pitch
            call    beep

The pitch values (40, 30, 20) are DJNZ delay counts. Lower delay means faster toggling and a higher note. The three calls play in sequence — a quick ascending chirp that says “got it.”

The sound plays inside check_collect, after the treasure counter is incremented. The game pauses briefly for the sound (the beeper is synchronous — the CPU does nothing else while generating the tone). For a short chirp this is barely noticeable.

The Complete Code

; ============================================================================
; SHADOWKEEP — Unit 12: Beeper Sound
; ============================================================================
; The Spectrum's beeper is one bit — bit 4 of port $FE. Toggle it high
; and low in a timed loop and the speaker vibrates. The delay between
; toggles controls the pitch. Shorter delay = higher pitch.
;
; XOR $10 flips bit 4 without touching the other bits. Two toggles
; (high then low) make one complete wave cycle. Repeat for duration.
;
; A short rising tone plays when treasure is collected. The game has
; sound for the first time.
; ============================================================================

            org     32768

; Attribute values
WALL        equ     $09             ; PAPER 1 (blue) + INK 1
FLOOR       equ     $38             ; PAPER 7 (white) + INK 0
TREASURE    equ     $70             ; BRIGHT + PAPER 6 (yellow) + INK 0
HAZARD      equ     $90             ; FLASH + PAPER 2 (red) + INK 0
PLAYER      equ     $3a             ; PAPER 7 (white) + INK 2 (red)

; Collision
WALL_INK    equ     1               ; INK colour that means "wall"

; Room
ROOM_TOP    equ     10
ROOM_LEFT   equ     12
ROOM_WIDTH  equ     9
ROOM_HEIGHT equ     5
ROW_SKIP    equ     23              ; 32 - ROOM_WIDTH
TOTAL_TREASURE equ  3               ; Treasures in the room

; Screen addresses for starting position (row 12, col 13)
START_ROW   equ     12
START_COL   equ     13
START_SCR   equ     $488d
START_ATT   equ     $598d

; Score display position (row 23, col 10)
SCORE_SCR   equ     $50ea           ; Screen bitmap: row 23, col 10
SCORE_ATT   equ     $5aea           ; Attribute: row 23, col 10
SCORE_LEN   equ     12              ; "TREASURE n/3" = 12 characters

; Keyboard rows
KEY_ROW_QT  equ     $fb             ; Q, W, E, R, T
KEY_ROW_AG  equ     $fd             ; A, S, D, F, G
KEY_ROW_PY  equ     $df             ; P, O, I, U, Y

; ROM font
FONT_BASE   equ     $3c00           ; Character set in ROM

; ----------------------------------------------------------------------------
; Entry point
; ----------------------------------------------------------------------------

start:
            ld      a, 0
            out     ($fe), a

            ; Clear screen
            ld      hl, $4000
            ld      de, $4001
            ld      bc, 6911
            ld      (hl), 0
            ldir

            ; ==================================================================
            ; Draw room from data table
            ; ==================================================================

            ld      hl, $594c           ; Attribute address: row 10, col 12
            ld      de, room_data
            ld      c, ROOM_HEIGHT

.row:       ld      b, ROOM_WIDTH
.cell:      ld      a, (de)
            ld      (hl), a
            inc     de
            inc     hl
            djnz    .cell

            push    de
            ld      de, ROW_SKIP
            add     hl, de
            pop     de
            dec     c
            jr      nz, .row

            ; ==================================================================
            ; Draw the player at starting position
            ; ==================================================================

            ld      a, (START_ATT)
            ld      (player_under), a

            ld      hl, START_SCR
            ld      de, player_gfx
            ld      b, 8
.initdraw:  ld      a, (de)
            ld      (hl), a
            inc     de
            inc     h
            djnz    .initdraw

            ld      a, PLAYER
            ld      (START_ATT), a

            ; Set attributes for score line
            ld      hl, SCORE_ATT
            ld      a, $47              ; BRIGHT + INK 7 (white on black)
            ld      b, SCORE_LEN
.sattr:     ld      (hl), a
            inc     hl
            djnz    .sattr

            call    print_score         ; Show initial status

            ; ==================================================================
            ; Main loop
            ; ==================================================================

.loop:      halt

            ; --- Erase player at current position ---
            ld      hl, (player_scr)
            ld      b, 8
            ld      a, 0
.erase:     ld      (hl), a
            inc     h
            djnz    .erase

            ld      hl, (player_att)
            ld      a, (player_under)
            ld      (hl), a

            ; --- Check Q (up) ---
            ld      a, KEY_ROW_QT
            in      a, ($fe)
            bit     0, a
            jr      nz, .not_q

            ld      hl, (player_att)
            ld      de, $ffe0           ; -32 (one row up)
            add     hl, de
            ld      a, (hl)
            and     $07
            cp      WALL_INK
            jr      z, .not_q

            call    check_collect

            ld      (player_att), hl

            ld      hl, (player_scr)
            ld      de, $ffe0
            add     hl, de
            ld      (player_scr), hl

            ld      a, (player_row)
            dec     a
            ld      (player_row), a

.not_q:
            ; --- Check A (down) ---
            ld      a, KEY_ROW_AG
            in      a, ($fe)
            bit     0, a
            jr      nz, .not_a

            ld      hl, (player_att)
            ld      de, 32
            add     hl, de
            ld      a, (hl)
            and     $07
            cp      WALL_INK
            jr      z, .not_a

            call    check_collect

            ld      (player_att), hl

            ld      hl, (player_scr)
            ld      de, 32
            add     hl, de
            ld      (player_scr), hl

            ld      a, (player_row)
            inc     a
            ld      (player_row), a

.not_a:
            ; --- Check O (left) ---
            ld      a, KEY_ROW_PY
            in      a, ($fe)
            bit     1, a
            jr      nz, .not_o

            ld      hl, (player_att)
            dec     hl
            ld      a, (hl)
            and     $07
            cp      WALL_INK
            jr      z, .not_o

            call    check_collect

            ld      (player_att), hl

            ld      hl, (player_scr)
            dec     hl
            ld      (player_scr), hl

            ld      a, (player_col)
            dec     a
            ld      (player_col), a

.not_o:
            ; --- Check P (right) ---
            ld      a, KEY_ROW_PY
            in      a, ($fe)
            bit     0, a
            jr      nz, .not_p

            ld      hl, (player_att)
            inc     hl
            ld      a, (hl)
            and     $07
            cp      WALL_INK
            jr      z, .not_p

            call    check_collect

            ld      (player_att), hl

            ld      hl, (player_scr)
            inc     hl
            ld      (player_scr), hl

            ld      a, (player_col)
            inc     a
            ld      (player_col), a

.not_p:
            ; --- Draw player at current position ---
            ld      hl, (player_scr)
            ld      de, player_gfx
            ld      b, 8
.draw:      ld      a, (de)
            ld      (hl), a
            inc     de
            inc     h
            djnz    .draw

            ld      hl, (player_att)
            ld      a, PLAYER
            ld      (hl), a

            ; --- Update score display ---
            call    print_score

            ; --- Border shows completion ---
            ld      a, (treasure_count)
            cp      TOTAL_TREASURE
            jr      nz, .not_all
            ld      a, 4                ; Green border — all collected
            out     ($fe), a
            jp      .loop
.not_all:
            ld      a, 0                ; Black border
            out     ($fe), a

            jp      .loop

; ============================================================================
; Subroutines
; ============================================================================

; ----------------------------------------------------------------------------
; check_collect — save target cell, check for treasure, collect if found
; Entry: HL = target attribute address
; Exit:  HL preserved, player_under updated
; ----------------------------------------------------------------------------
check_collect:
            ld      a, (hl)
            ld      (player_under), a
            bit     6, a                ; BRIGHT = treasure?
            ret     z                   ; No — done
            res     6, a                ; Clear BRIGHT (collected)
            ld      (player_under), a
            push    hl
            ld      hl, treasure_count
            inc     (hl)
            pop     hl

            ; Play collect sound — short rising tone
            push    hl
            push    de
            ld      hl, 80              ; Duration (cycles)
            ld      e, 40               ; Pitch (delay — lower = higher)
            call    beep
            ld      hl, 80
            ld      e, 30               ; Higher pitch
            call    beep
            ld      hl, 80
            ld      e, 20               ; Highest pitch
            call    beep
            pop     de
            pop     hl
            ret

; ----------------------------------------------------------------------------
; beep — generate a tone on the speaker
; Entry: HL = duration (number of wave cycles), E = pitch (delay per half)
; Exit:  speaker off, border black
; Destroys: A, B, HL
; ----------------------------------------------------------------------------
beep:
            ld      a, $10              ; Bit 4 high — speaker on
.on:        out     ($fe), a            ; Push speaker cone out
            ld      b, e                ; Delay counter = pitch
.delay1:    djnz    .delay1             ; Wait

            xor     $10                 ; Toggle bit 4
            out     ($fe), a            ; Pull speaker cone back
            ld      b, e                ; Same delay
.delay2:    djnz    .delay2             ; Wait

            xor     $10                 ; Toggle bit 4 back
            dec     hl                  ; One cycle done
            ld      a, h
            or      l                   ; HL = 0?
            ld      a, $10              ; Reload (doesn't affect flags)
            jr      nz, .on             ; More cycles — continue

            xor     a                   ; A = 0 — speaker off, border black
            out     ($fe), a
            ret

; ----------------------------------------------------------------------------
; print_score — display "TREASURE n/3" at row 23
; Destroys: A, BC, DE, HL
; ----------------------------------------------------------------------------
print_score:
            ld      de, SCORE_SCR

            ld      hl, score_text
            call    print_str

            ld      a, (treasure_count)
            add     a, '0'
            call    print_char

            ld      a, '/'
            call    print_char

            ld      a, TOTAL_TREASURE
            add     a, '0'
            call    print_char

            ret

; ----------------------------------------------------------------------------
; print_str — print null-terminated string at screen address DE
; Entry: HL = string address, DE = screen address
; Exit:  HL past null terminator, DE advanced past last character
; ----------------------------------------------------------------------------
print_str:
            ld      a, (hl)
            or      a
            ret     z
            push    hl
            call    print_char
            pop     hl
            inc     hl
            jr      print_str

; ----------------------------------------------------------------------------
; print_char — draw one character to screen memory using ROM font
; Entry: A = character (32-127), DE = screen address (pixel row 0)
; Exit:  DE advanced to next column (E incremented)
; ----------------------------------------------------------------------------
print_char:
            push    de

            ld      l, a
            ld      h, 0
            add     hl, hl
            add     hl, hl
            add     hl, hl
            ld      bc, FONT_BASE
            add     hl, bc

            ld      b, 8
.pchar:     ld      a, (hl)
            ld      (de), a
            inc     hl
            inc     d
            djnz    .pchar

            pop     de
            inc     e
            ret

; ============================================================================
; Room data — one byte per cell
; ============================================================================

room_data:
            db      WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL
            db      WALL, FLOOR, WALL, FLOOR, FLOOR, FLOOR, TREASURE, FLOOR, WALL
            db      WALL, FLOOR, TREASURE, FLOOR, FLOOR, FLOOR, WALL, FLOOR, WALL
            db      WALL, FLOOR, FLOOR, FLOOR, HAZARD, FLOOR, FLOOR, TREASURE, WALL
            db      WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL

; ============================================================================
; String data
; ============================================================================

score_text: db      "TREASURE ", 0

; ============================================================================
; Player data
; ============================================================================

player_gfx: db      $18, $3c, $7e, $ff
            db      $ff, $7e, $3c, $18

player_row: db      START_ROW
player_col: db      START_COL

player_scr: dw      START_SCR
player_att: dw      START_ATT

player_under:
            db      FLOOR

treasure_count:
            db      0

            end     start

Shadowkeep Unit 12

The screen looks the same as Unit 11 — the new feature is audio. Walk onto a bright yellow treasure cell and you’ll hear a rising three-note chirp as the treasure is collected. The border may flash briefly during the sound (the OUT ($FE) also writes to the border bits).

Try This: Different Sounds

Change the pitch and duration for different effects:

            ; Low thud (for hitting a wall)
            ld      hl, 30
            ld      e, 80               ; Low pitch, short
            call    beep

            ; Alarm (for touching a hazard)
            ld      hl, 200
            ld      e, 15               ; High pitch, long
            call    beep

Experiment with values. Pitch below 10 is barely audible (too fast). Pitch above 100 sounds like a low rumble. Duration of 50–100 is a quick blip; 500+ is a sustained tone.

Try This: Simple Melody

Play a sequence of notes with different pitches:

            ld      hl, 150
            ld      e, 48               ; C-ish
            call    beep
            ld      hl, 150
            ld      e, 36               ; E-ish
            call    beep
            ld      hl, 150
            ld      e, 24               ; G-ish
            call    beep

The pitch values don’t correspond to exact musical notes (that requires precise frequency calculation), but relative values produce recognisable intervals. Commercial Spectrum games used lookup tables to map musical notes to exact delay values.

Try This: Border Flash

The border changes colour during sound because OUT ($FE) writes both the speaker (bit 4) and border (bits 0–2). To keep the border black during sound, mask the border bits:

            ld      a, $10              ; Speaker on, border black (bits 0-2 = 0)

To make the border flash with the sound (a visual effect), set the border bits:

            ld      a, $14              ; Speaker on, border green (bits 0-2 = 4)

Many Spectrum games used coloured borders during loading and sound effects as a visual flourish.

If It Doesn’t Work

  • No sound? Check that OUT ($FE), A is present and that A has bit 4 set ($10). Without the OUT, the speaker doesn’t toggle.
  • Constant tone that never stops? Check the duration counter. DEC HL followed by LD A, H / OR L tests for zero. If the JR NZ condition is wrong, the loop runs forever.
  • Sound is too quiet or too loud? The Spectrum beeper has one volume. If you can’t hear it, check your emulator’s audio settings.
  • Game freezes during sound? The beeper is synchronous — the CPU generates the tone in a tight loop. Long durations (thousands of cycles) will pause the game noticeably. Keep collect sounds short (80–150 cycles per note).
  • Border flashes during sound? Expected behaviour. OUT ($FE) writes to both speaker and border. Set bits 0–2 to 0 in the speaker value to keep the border black.

What You’ve Learnt

  • Beeper output — bit 4 of port $FE controls the speaker. Toggle it in a timed loop to produce a tone. Delay = pitch, repetitions = duration.
  • XOR $10 — toggle bit 4 without affecting other bits. XOR flips where the mask is 1, preserves where it’s 0. Two XORs return to the original value.
  • Testing HL for zeroDEC HL doesn’t set flags. Use LD A, H / OR L to test if both bytes are zero. A three-instruction idiom.
  • LD doesn’t affect flagsLD A, $10 after OR L preserves the Z flag. This lets you reload a register without losing a condition.
  • Synchronous sound — the CPU generates the tone directly. While the beeper is active, nothing else happens. Keep sounds short for responsive gameplay.

What’s Next

The room has no exit — there’s nowhere to go after collecting the treasure. In Unit 13, you’ll add an exit door marked by a specific attribute value. When the player stands on it, the room is complete. This is the first win condition — the goal that makes Shadowkeep a game.