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

Hazards and Lives

The FLASH hazard cell finally hurts. BIT 7 detects danger, DEC (HL) counts down lives, and the player resets to start on contact. Three lives — then game over.

12% of Shadowkeep

The red flashing cell has been in the room since Unit 8. It looks dangerous — FLASH makes it alternate between red and black. But walking onto it does nothing. The hazard is decoration. That changes now.

Step on the hazard and you lose a life. Three lives, shown on the score line. When they’re gone, it’s game over. The room has consequences for the first time.

BIT 7 — Testing FLASH

You’ve used BIT 6 to detect BRIGHT (treasure). The FLASH bit is bit 7 — one position higher. Same instruction, different bit number:

            ld      a, (player_under)
            bit     7, a                ; FLASH = hazard?
            jr      z, .not_hazard      ; No FLASH — safe

The pattern is identical to treasure detection. Read the attribute, test a single bit, branch on the result. The attribute byte encodes the entire game world — each bit has a meaning:

BitNameTestMeaning
7FLASHBIT 7Hazard — lose a life
6BRIGHTBIT 6Treasure — collect it
5-3PAPERAND $38Background colour
2-0INKAND $07Foreground colour (wall check)

Four different checks on one byte. No lookup tables, no sprite collision boxes. The Spectrum’s attribute system is the collision system.

When to Check

The hazard check happens after the player is drawn at their new position. If player_under has FLASH set, the player stepped on danger:

; Hazard detection — BIT 7 tests the FLASH bit
;
; FLASH (bit 7) means danger. After drawing the player,
; check what's underneath. If it's flashing, the player
; stepped on a hazard.

            ld      a, (player_under)
            bit     7, a                ; FLASH = hazard?
            jr      z, .not_hazard

            ; Erase player from hazard cell
            ld      hl, (player_scr)
            ld      b, 8
            ld      a, 0
.derase:    ld      (hl), a
            inc     h
            djnz    .derase

            ld      hl, (player_att)
            ld      a, (player_under)
            ld      (hl), a             ; Restore hazard attribute

            ; Lose a life
            ld      hl, lives
            dec     (hl)                ; DEC (HL) sets flags!
            jp      z, .game_over       ; Z = lives reached zero

            ; ... reset player to start ...

The sequence: erase the player from the hazard cell (restore its attribute), then decrement lives. This order matters — the hazard cell needs its FLASH attribute back before the player vanishes, otherwise the flashing stops.

DEC (HL) Sets Flags

; DEC (HL) vs DEC HL — flags
;
; DEC HL (16-bit) does NOT set flags. You need the
; three-instruction idiom: DEC HL / LD A,H / OR L.
;
; DEC (HL) (8-bit memory) DOES set flags. Z is set
; when the byte at HL reaches zero. One instruction
; to decrement and test.

            ld      hl, lives
            dec     (hl)                ; Decrement byte, set Z if zero
            jr      z, .game_over       ; Lives exhausted

; Compare:
;   DEC HL   — 16-bit, no flags    (Unit 12: testing HL counter)
;   DEC (HL) — 8-bit memory, flags (this unit: testing lives)
;   DEC A    — 8-bit register, flags

DEC (HL) decrements the byte at address HL and sets the Z flag if the result is zero. One instruction to decrement and test. This is unlike DEC HL (the 16-bit version from Unit 12) which does NOT set flags and needs the LD A, H / OR L idiom.

The distinction matters. The Z80 has two kinds of DEC:

  • DEC r and DEC (HL) — 8-bit, sets flags (Z, S, H, N)
  • DEC rr — 16-bit register pair, does NOT set flags

The same pattern applies to INC. INC (HL) (used for treasure count in Unit 10) also sets flags, though we didn’t need them then.

Death and Reset

When the player touches a hazard:

  1. Erase — clear the player’s bitmap from the hazard cell, restore the hazard attribute
  2. DecrementDEC (HL) on the lives counter
  3. Check zeroJP Z, .game_over if no lives remain
  4. Sound — a short descending two-note tone (the opposite of the collect chirp)
  5. Reset — restore all position variables to their starting values
            ; Reset to start position
            ld      a, START_ROW
            ld      (player_row), a
            ld      a, START_COL
            ld      (player_col), a
            ld      hl, START_SCR
            ld      (player_scr), hl
            ld      hl, START_ATT
            ld      (player_att), hl

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

Six variables need resetting: row, column, screen address, attribute address, and player_under. Miss any one and the player appears at the wrong position or corrupts a cell. The reset is verbose but simple — load constant, store to variable, repeat.

The last line reads the current value at START_ATT and saves it as player_under. This is the same pattern used during initialisation — save what’s underneath before the player is drawn on top.

Game Over

When lives reaches zero, the game ends differently from victory:

.game_over:
            ; Four descending notes — the opposite of the victory fanfare
            ld      hl, 100
            ld      e, 18               ; High
            call    beep
            ld      hl, 100
            ld      e, 25               ; Mid-high
            call    beep
            ld      hl, 100
            ld      e, 35               ; Mid-low
            call    beep
            ld      hl, 300
            ld      e, 50               ; Sustained low
            call    beep

The descending fanfare mirrors the ascending victory sound. Same structure (four notes, last one sustained), reversed direction. The border turns red instead of green. “GAME OVER!” replaces the score line.

Two ways to leave the main loop, two endings:

EndingConditionSoundBorderMessage
VictoryExit + all treasureAscendingGreenROOM COMPLETE!
Game overLives = 0DescendingRedGAME OVER!

Lives on the Score Line

The score display now shows both treasure and lives:

            ; "TREASURE 0/3  L:3"
            ld      hl, score_text      ; "TREASURE "
            call    print_str
            ; ... treasure count ...
            ld      hl, lives_text      ; "L:"
            call    print_str
            ld      a, (lives)
            add     a, '0'
            call    print_char

print_score prints 17 characters total. The SCORE_LEN constant (which sets how many attribute cells get the white-on-black treatment) is now 18 to cover the full width.

The victory and game over messages are padded with spaces to overwrite all 17 characters cleanly. Without padding, leftover characters from the score would remain visible.

The Complete Code

; ============================================================================
; SHADOWKEEP — Unit 15: Hazards and Lives
; ============================================================================
; The FLASH hazard cell finally bites. Step on it and you lose a life.
; Three lives, shown on the score line. When they're gone, the game is
; over. The room has stakes for the first time.
;
; BIT 7 tests the FLASH bit — the same pattern as BIT 6 for BRIGHT.
; DEC (HL) decrements the lives counter and sets flags in one
; instruction. Unlike DEC HL (16-bit, no flags), DEC (HL) is an
; 8-bit memory operation that sets Z when the result is zero.
; ============================================================================

            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
EXIT        equ     $28             ; PAPER 5 (cyan) + 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
START_LIVES equ     3

; 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     18              ; Wide enough for "TREASURE 0/3  L:3"

; 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

            ; --- Check hazard ---
            ld      a, (player_under)
            bit     7, a                ; FLASH = hazard?
            jr      z, .not_hazard

            ; Erase player from hazard cell
            ld      hl, (player_scr)
            ld      b, 8
            ld      a, 0
.derase:    ld      (hl), a
            inc     h
            djnz    .derase

            ld      hl, (player_att)
            ld      a, (player_under)
            ld      (hl), a             ; Restore hazard attribute

            ; Lose a life
            ld      hl, lives
            dec     (hl)                ; DEC (HL) sets Z flag!
            jp      z, .game_over

            ; Death sound — short descending tone
            ld      hl, 80
            ld      e, 20               ; High
            call    beep
            ld      hl, 80
            ld      e, 40               ; Low
            call    beep

            ; Reset to start position
            ld      a, START_ROW
            ld      (player_row), a
            ld      a, START_COL
            ld      (player_col), a
            ld      hl, START_SCR
            ld      (player_scr), hl
            ld      hl, START_ATT
            ld      (player_att), hl

            ; Save what's under start position
            ld      a, (START_ATT)
            ld      (player_under), a

            call    print_score         ; Show reduced lives

            jp      .loop

.not_hazard:
            ; --- Check win condition ---
            ld      a, (player_under)
            cp      EXIT                ; Standing on the exit?
            jr      nz, .not_on_exit

            ld      a, (treasure_count)
            cp      TOTAL_TREASURE      ; All treasures collected?
            jr      z, .room_complete

.not_on_exit:
            ; --- Border shows progress ---
            ld      a, (treasure_count)
            cp      TOTAL_TREASURE
            jr      nz, .not_all
            ld      a, 4                ; Green border — door is open
            out     ($fe), a
            jp      .loop
.not_all:
            ld      a, 0                ; Black border
            out     ($fe), a

            jp      .loop

            ; ==================================================================
            ; Room complete — victory sequence
            ; ==================================================================

.room_complete:
            ; Victory fanfare — four ascending notes
            ld      hl, 100
            ld      e, 45               ; Low
            call    beep
            ld      hl, 100
            ld      e, 35               ; Mid
            call    beep
            ld      hl, 100
            ld      e, 25               ; High
            call    beep
            ld      hl, 300
            ld      e, 18               ; Sustained high
            call    beep

            ; Overwrite score line with victory message
            ld      de, SCORE_SCR
            ld      hl, win_text
            call    print_str

            ; Green border — permanent
            ld      a, 4
            out     ($fe), a

            ; Halt forever
.victory:   halt
            jr      .victory

            ; ==================================================================
            ; Game over — death sequence
            ; ==================================================================

.game_over:
            ; Game over sound — four descending notes
            ld      hl, 100
            ld      e, 18               ; High
            call    beep
            ld      hl, 100
            ld      e, 25               ; Mid-high
            call    beep
            ld      hl, 100
            ld      e, 35               ; Mid-low
            call    beep
            ld      hl, 300
            ld      e, 50               ; Sustained low
            call    beep

            ; Overwrite score line with game over message
            ld      de, SCORE_SCR
            ld      hl, lose_text
            call    print_str

            ; Red border — permanent
            ld      a, 2
            out     ($fe), a

            ; Halt forever
.dead:      halt
            jr      .dead

; ============================================================================
; 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  L:n" 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

            ld      a, ' '
            call    print_char
            ld      a, ' '
            call    print_char

            ld      hl, lives_text
            call    print_str

            ld      a, (lives)
            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
; ============================================================================
;
;   W W W W W W W W W
;   W . W . . . T . W      T = treasure
;   W . T . . . W . W      H = hazard
;   W . . . H . . T W      X = exit
;   W W W W W X W W W
;

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, EXIT, WALL, WALL, WALL

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

score_text: db      "TREASURE ", 0
lives_text: db      "L:", 0
win_text:   db      "ROOM COMPLETE!   ", 0
lose_text:  db      "   GAME OVER!    ", 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

lives:      db      START_LIVES

            end     start

Shadowkeep Unit 15

The score line now reads “TREASURE 0/3 L:3”. Walk onto the flashing red hazard cell and you’ll hear a descending tone, lose a life (L:2), and reappear at the starting position. Lose all three lives and the game ends with “GAME OVER!” and a red border. Collect all treasure and reach the exit to win.

Try This: More Hazards

Add more hazard cells to make the room harder:

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

Three hazards instead of one. The player must weave between them to collect treasure and reach the exit. With only three lives, every move towards the hazards is a risk.

Try This: Extra Lives

Change the starting lives and display:

START_LIVES equ     5               ; More forgiving

With more lives, the room is easier. With fewer (try 1), every hazard touch is fatal. The balance between lives and hazard placement defines the difficulty.

Try This: Invincibility Frames

After dying, the player is vulnerable immediately. Add a brief grace period:

            ; After reset, set an invincibility counter
            ld      a, 30               ; 30 frames = 0.6 seconds
            ld      (invuln), a

            ; In hazard check, skip if invulnerable
            ld      a, (invuln)
            or      a
            jr      nz, .not_hazard     ; Still invulnerable

            ; In main loop, decrement counter each frame
            ld      a, (invuln)
            or      a
            jr      z, .no_invuln
            dec     a
            ld      (invuln), a
.no_invuln:

This prevents the player from dying repeatedly on the same hazard. A common pattern in action games — a moment of safety after taking damage.

If It Doesn’t Work

  • Hazard doesn’t hurt? Check that BIT 7, A is testing player_under, not the target cell before movement. The hazard check happens after the player is drawn at the new position.
  • Lives don’t decrease? Check DEC (HL) where HL points to lives. If HL points elsewhere, you’re decrementing the wrong byte.
  • Player doesn’t reset? All six position variables must be restored: player_row, player_col, player_scr, player_att, and player_under. Miss one and the player appears at the wrong position.
  • Game over triggers immediately? Make sure lives starts at START_LIVES (3), not 0. Check that DEC (HL) is followed by JP Z (not JR Z — the game over code is too far for a relative jump).
  • Score line shows wrong lives? Check that lives is decremented with DEC (HL), not DEC A. The counter must be in memory for print_score to read it later.

What You’ve Learnt

  • BIT 7 for FLASH — hazard detection, completing the attribute bit tests: BIT 6 = treasure, BIT 7 = hazard, AND $07 = wall.
  • DEC (HL) sets flags — 8-bit memory decrement sets Z when the result is zero. Unlike DEC HL (16-bit, no flags), DEC (HL) combines decrement and test in one instruction.
  • JP vs JRJP Z when the target is far away (game over code), JR Z when it’s close. JR is smaller (2 bytes vs 3) but limited to -128/+127 bytes.
  • Death and reset — erase the player, restore the cell, reset six position variables, save what’s under the new position. The same initialisation pattern, repeated on death.
  • Two exit conditions — the main loop can end in victory or game over. Two paths out, two endings, two sounds. The game has both success and failure.

What’s Next

Unit 16 is the final unit of Phase 1. The single room is playable — you can win or lose. But there are rough edges: the player starts on top of existing content, there’s no title screen, and restarting requires reloading. Unit 16 integrates everything into a polished single-room experience.