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

Three Lives

The player starts with three lives. Each spike hit or obstacle collision costs one. Run out and the game freezes. A shared damage subroutine keeps the logic clean.

10% of Dash

Until now, touching a spike or the obstacle resets the player instantly — annoying, but no consequence. You can walk into spikes forever and nothing changes. This unit adds lives. The player starts with three. Each hit costs one. Run out and the game freezes — game over.

The key change: both the obstacle collision and the hazard check need the same response — deduct a life, reset position, play a sound. Rather than duplicating that logic in two places, a single take_damage subroutine handles it. Both callers use JSR take_damage.

The Lives Variable

Two new zero-page variables track the player’s state:

lives:      .res 1
game_over:  .res 1

At startup, lives is set to START_LIVES (3) and game_over to 0:

START_LIVES    = 3
    lda #0
    sta score
    sta game_over
    lda #START_LIVES
    sta lives

Displaying Lives

The lives count appears on the right side of the HUD, mirroring the score on the left. During setup, the initial “3” is written to the nametable at $203C (row 1, column 28):

    lda #$20
    sta PPUADDR
    lda #$3C
    sta PPUADDR             ; $203C (row 1, col 28) — lives
    lda #(DIGIT_ZERO + START_LIVES)
    sta PPUDATA

The expression DIGIT_ZERO + START_LIVES evaluates to tile 8 — the digit “3” (tile 5 is “0”, tile 6 is “1”, and so on).

The NMI handler updates both values every frame:

    ; --- Update score display ---
    bit PPUSTATUS
    lda #$20
    sta PPUADDR
    lda #$22
    sta PPUADDR             ; $2022 (row 1, col 2)
    lda score
    clc
    adc #DIGIT_ZERO
    sta PPUDATA

    ; --- Update lives display ---
    lda #$20
    sta PPUADDR
    lda #$3C
    sta PPUADDR             ; $203C (row 1, col 28)
    lda lives
    clc
    adc #DIGIT_ZERO
    sta PPUDATA

    ; --- Reset scroll ---
    lda #0
    sta PPUSCROLL
    sta PPUSCROLL

Two consecutive nametable writes in NMI. After the score write, the PPUADDR toggle is already in the “first write” state — so the lives address pair works without another bit PPUSTATUS. The scroll reset at the end is essential because every PPUADDR write corrupts the scroll register.

The take_damage Subroutine

; -----------------------------------------------------------------------------
; take_damage: Deduct a life and handle the result
; Effect: Decrements lives. If lives remain, resets player and plays sound.
;         If no lives remain, sets game_over flag and hides the player.
; -----------------------------------------------------------------------------
take_damage:
    lda lives
    beq @done               ; Already dead — ignore

    dec lives
    bne @still_alive

    ; --- Game over ---
    lda #1
    sta game_over
    lda #$EF
    sta player_y            ; Will propagate to OAM, hiding sprite
    rts

@still_alive:
    ; --- Reset player position ---
    lda #PLAYER_X
    sta player_x
    lda #PLAYER_Y
    sta player_y
    lda #0
    sta vel_y
    lda #1
    sta on_ground

    ; --- Damage sound (pulse channel — harsh buzz) ---
    lda #%00111100          ; Duty 12.5%, volume 12
    sta SQ1_VOL
    lda #%00000000          ; No sweep
    sta SQ1_SWEEP
    lda #$80                ; Timer low — low pitch
    sta SQ1_LO
    lda #$01                ; Timer high=1, length counter
    sta SQ1_HI

@done:
    rts

The subroutine starts with a guard: lda lives / beq @done. If lives is already zero (somehow called twice in one frame), it does nothing. This prevents the counter from wrapping to 255.

dec lives subtracts one. If the result isn’t zero, there are lives remaining — reset the player and play the damage sound. If the result is zero, it’s game over: set the game_over flag and hide the player by moving it to Y position $EF.

The damage sound is the same harsh pulse buzz from Unit 12. The position reset is the same code that used to be inline. The difference is that now it’s in one place, called from two.

Calling take_damage

Both the obstacle collision and the hazard check now end with jsr take_damage instead of inline reset code:

    ; --- Collision with obstacle ---
    ...
    jsr take_damage

@no_collide:

    ; --- Check for hazard tiles ---
    ...
    jsr take_damage

@no_hazard:

The obstacle collision runs before the hazard check. This matters: when take_damage resets the player to the start position (column 7), the hazard check runs next — but column 7 has ground tiles, not spikes. No double damage.

Game Over

When the last life is lost, game_over is set to 1. The main loop checks this at the very top:

main_loop:
    lda nmi_flag
    beq main_loop
    lda #0
    sta nmi_flag

    ; --- Game over check ---
    lda game_over
    bne main_loop           ; Frozen — wait for next frame

If game_over is non-zero, the code jumps straight back to waiting for the next NMI. No input is read, no physics run, no collisions check. The game freezes.

But NMI still fires every frame. OAM DMA still runs. The HUD still updates. The screen doesn’t blank — it just stops moving. The “0” in the lives display tells the player what happened.

The Complete Code

; =============================================================================
; DASH - Unit 13: Three Lives
; =============================================================================
; The player starts with three lives. Spikes and the obstacle each cost a life.
; A shared take_damage subroutine handles the response. Lives display on the
; nametable alongside the score.
; =============================================================================

; -----------------------------------------------------------------------------
; NES Hardware Addresses
; -----------------------------------------------------------------------------
PPUCTRL   = $2000
PPUMASK   = $2001
PPUSTATUS = $2002
OAMADDR   = $2003
PPUSCROLL = $2005
PPUADDR   = $2006
PPUDATA   = $2007
OAMDMA    = $4014
JOYPAD1   = $4016

; -----------------------------------------------------------------------------
; APU Registers
; -----------------------------------------------------------------------------
SQ1_VOL    = $4000
SQ1_SWEEP  = $4001
SQ1_LO     = $4002
SQ1_HI     = $4003
TRI_LINEAR = $4008
TRI_LO     = $400A
TRI_HI     = $400B
APU_STATUS = $4015

; -----------------------------------------------------------------------------
; Button Masks
; -----------------------------------------------------------------------------
BTN_A      = %10000000
BTN_B      = %01000000
BTN_SELECT = %00100000
BTN_START  = %00010000
BTN_UP     = %00001000
BTN_DOWN   = %00000100
BTN_LEFT   = %00000010
BTN_RIGHT  = %00000001

; -----------------------------------------------------------------------------
; Game Constants
; -----------------------------------------------------------------------------
PLAYER_X       = 60
PLAYER_Y       = 200
PLAYER_TILE    = 1
RIGHT_WALL     = 248
FLOOR_Y        = 200
GRAVITY        = 1
JUMP_VEL       = $F6
OBSTACLE_TILE  = 2
OBSTACLE_SPEED = 2
GROUND_TILE    = 3
COIN_TILE      = 4
DIGIT_ZERO     = 5             ; First digit tile (0-9 are tiles 5-14)
SPIKE_TILE     = 15
START_LIVES    = 3

; -----------------------------------------------------------------------------
; Memory
; -----------------------------------------------------------------------------
.segment "ZEROPAGE"
player_x:   .res 1
player_y:   .res 1
vel_y:      .res 1
buttons:    .res 1
nmi_flag:   .res 1
on_ground:  .res 1
obstacle_x: .res 1
tile_ptr:   .res 2
score:      .res 1
lives:      .res 1
game_over:  .res 1

.segment "OAM"
oam_buffer: .res 256

.segment "BSS"

; =============================================================================
; iNES Header
; =============================================================================
.segment "HEADER"
    .byte "NES", $1A
    .byte 2
    .byte 1
    .byte $01
    .byte $00
    .byte 0,0,0,0,0,0,0,0

; =============================================================================
; Code
; =============================================================================
.segment "CODE"

; --- Reset ---
reset:
    sei
    cld
    ldx #$40
    stx $4017
    ldx #$FF
    txs
    inx
    stx PPUCTRL
    stx PPUMASK
    stx $4010
    stx APU_STATUS

@vblank1:
    bit PPUSTATUS
    bpl @vblank1

    lda #0
@clear_ram:
    sta $0000, x
    sta $0100, x
    sta $0200, x
    sta $0300, x
    sta $0400, x
    sta $0500, x
    sta $0600, x
    sta $0700, x
    inx
    bne @clear_ram

@vblank2:
    bit PPUSTATUS
    bpl @vblank2

    ; --- Load palette ---
    bit PPUSTATUS
    lda #$3F
    sta PPUADDR
    lda #$00
    sta PPUADDR

    ldx #0
@load_palette:
    lda palette_data, x
    sta PPUDATA
    inx
    cpx #32
    bne @load_palette

    ; --- Clear nametable 0 ---
    bit PPUSTATUS
    lda #$20
    sta PPUADDR
    lda #$00
    sta PPUADDR

    lda #0
    ldy #4
    ldx #0
@clear_nt:
    sta PPUDATA
    dex
    bne @clear_nt
    dey
    bne @clear_nt

    ; --- Write ground tiles (rows 26-29) ---
    bit PPUSTATUS
    lda #$23
    sta PPUADDR
    lda #$40
    sta PPUADDR

    lda #GROUND_TILE
    ldx #128
@write_ground:
    sta PPUDATA
    dex
    bne @write_ground

    ; --- Write spike tiles (row 26, columns 10-11) ---
    bit PPUSTATUS
    lda #$23
    sta PPUADDR
    lda #$4A
    sta PPUADDR
    lda #SPIKE_TILE
    sta PPUDATA
    sta PPUDATA

    ; --- Write platform tiles (row 20, columns 12-19) ---
    bit PPUSTATUS
    lda #$22
    sta PPUADDR
    lda #$8C
    sta PPUADDR

    lda #GROUND_TILE
    ldx #8
@write_platform:
    sta PPUDATA
    dex
    bne @write_platform

    ; --- Write wall tiles (rows 24-25, columns 22-23) ---
    bit PPUSTATUS
    lda #$23
    sta PPUADDR
    lda #$16
    sta PPUADDR
    lda #GROUND_TILE
    sta PPUDATA
    sta PPUDATA

    bit PPUSTATUS
    lda #$23
    sta PPUADDR
    lda #$36
    sta PPUADDR
    lda #GROUND_TILE
    sta PPUDATA
    sta PPUDATA

    ; --- Write initial HUD ---
    bit PPUSTATUS
    lda #$20
    sta PPUADDR
    lda #$22
    sta PPUADDR             ; $2022 (row 1, col 2) — score
    lda #DIGIT_ZERO
    sta PPUDATA

    lda #$20
    sta PPUADDR
    lda #$3C
    sta PPUADDR             ; $203C (row 1, col 28) — lives
    lda #(DIGIT_ZERO + START_LIVES)
    sta PPUDATA

    ; --- Set attributes ---
    bit PPUSTATUS
    lda #$23
    sta PPUADDR
    lda #$E8
    sta PPUADDR

    ldx #0
@write_attrs:
    lda attr_data, x
    sta PPUDATA
    inx
    cpx #24
    bne @write_attrs

    ; --- Set up player sprite (OAM entry 0) ---
    lda #PLAYER_Y
    sta oam_buffer+0
    lda #PLAYER_TILE
    sta oam_buffer+1
    lda #0
    sta oam_buffer+2
    lda #PLAYER_X
    sta oam_buffer+3

    ; Set up obstacle sprite (OAM entry 1)
    lda #FLOOR_Y
    sta oam_buffer+4
    lda #OBSTACLE_TILE
    sta oam_buffer+5
    lda #1
    sta oam_buffer+6
    lda #255
    sta oam_buffer+7

    ; Set up collectible sprites (OAM entries 2-4)
    lda #152
    sta oam_buffer+8
    lda #COIN_TILE
    sta oam_buffer+9
    lda #2
    sta oam_buffer+10
    lda #128
    sta oam_buffer+11

    lda #FLOOR_Y
    sta oam_buffer+12
    lda #COIN_TILE
    sta oam_buffer+13
    lda #2
    sta oam_buffer+14
    lda #200
    sta oam_buffer+15

    lda #168
    sta oam_buffer+16
    lda #COIN_TILE
    sta oam_buffer+17
    lda #2
    sta oam_buffer+18
    lda #32
    sta oam_buffer+19

    ; Initialise game state
    lda #PLAYER_X
    sta player_x
    lda #PLAYER_Y
    sta player_y
    lda #0
    sta vel_y
    sta score
    sta game_over
    lda #START_LIVES
    sta lives
    lda #1
    sta on_ground
    lda #255
    sta obstacle_x

    ; Hide other sprites (entries 5-63)
    lda #$EF
    ldx #20
@hide_sprites:
    sta oam_buffer, x
    inx
    bne @hide_sprites

    ; Enable APU channels: pulse 1 + triangle
    lda #%00000101
    sta APU_STATUS

    ; Reset scroll position
    bit PPUSTATUS
    lda #0
    sta PPUSCROLL
    sta PPUSCROLL

    ; Enable rendering
    lda #%10000000
    sta PPUCTRL
    lda #%00011110
    sta PPUMASK

; =============================================================================
; Main Loop
; =============================================================================
main_loop:
    lda nmi_flag
    beq main_loop
    lda #0
    sta nmi_flag

    ; --- Game over check ---
    lda game_over
    bne main_loop           ; Frozen — wait for next frame

    ; --- Read controller ---
    lda #1
    sta JOYPAD1
    lda #0
    sta JOYPAD1

    ldx #8
@read_pad:
    lda JOYPAD1
    lsr a
    rol buttons
    dex
    bne @read_pad

    ; --- Jump check ---
    lda buttons
    and #BTN_A
    beq @no_jump
    lda on_ground
    beq @no_jump
    lda #JUMP_VEL
    sta vel_y
    lda #0
    sta on_ground

    ; Play jump sound (pulse channel)
    lda #%10111000
    sta SQ1_VOL
    lda #%10111001
    sta SQ1_SWEEP
    lda #$C8
    sta SQ1_LO
    lda #$00
    sta SQ1_HI

@no_jump:

    ; --- Move left (with wall check) ---
    lda buttons
    and #BTN_LEFT
    beq @not_left
    lda player_x
    beq @not_left

    lda player_y
    clc
    adc #4
    lsr
    lsr
    lsr
    tax

    lda level_rows_lo, x
    sta tile_ptr
    lda level_rows_hi, x
    sta tile_ptr+1

    lda player_x
    sec
    sbc #1
    lsr
    lsr
    lsr
    tay

    lda (tile_ptr), y
    bne @not_left

    dec player_x
@not_left:

    ; --- Move right (with wall check) ---
    lda buttons
    and #BTN_RIGHT
    beq @not_right
    lda player_x
    cmp #RIGHT_WALL
    bcs @not_right

    lda player_y
    clc
    adc #4
    lsr
    lsr
    lsr
    tax

    lda level_rows_lo, x
    sta tile_ptr
    lda level_rows_hi, x
    sta tile_ptr+1

    lda player_x
    clc
    adc #8
    lsr
    lsr
    lsr
    tay

    lda (tile_ptr), y
    bne @not_right

    inc player_x
@not_right:

    ; --- Apply gravity ---
    lda vel_y
    clc
    adc #GRAVITY
    sta vel_y

    ; --- Apply velocity to Y position ---
    lda player_y
    clc
    adc vel_y
    sta player_y

    ; --- Tile collision (vertical) ---
    lda vel_y
    bmi @no_floor

    lda player_y
    clc
    adc #8
    lsr
    lsr
    lsr
    tax

    cpx #30
    bcs @on_solid

    lda level_rows_lo, x
    sta tile_ptr
    lda level_rows_hi, x
    sta tile_ptr+1

    lda player_x
    clc
    adc #4
    lsr
    lsr
    lsr
    tay

    lda (tile_ptr), y
    beq @no_floor

@on_solid:
    lda player_y
    clc
    adc #8
    and #%11111000
    sec
    sbc #8
    sta player_y
    lda #0
    sta vel_y
    lda #1
    sta on_ground
    jmp @done_floor

@no_floor:
    lda #0
    sta on_ground

@done_floor:

    ; --- Check collectibles ---
    ldx #8
    jsr check_collect
    ldx #12
    jsr check_collect
    ldx #16
    jsr check_collect

    ; --- Move obstacle ---
    lda obstacle_x
    sec
    sbc #OBSTACLE_SPEED
    sta obstacle_x

    ; --- Collision with obstacle ---
    lda on_ground
    beq @no_collide

    lda player_y
    cmp #(FLOOR_Y - 7)
    bcc @no_collide

    lda obstacle_x
    cmp #240
    bcs @no_collide

    lda player_x
    clc
    adc #8
    cmp obstacle_x
    bcc @no_collide
    beq @no_collide

    lda obstacle_x
    clc
    adc #8
    cmp player_x
    bcc @no_collide
    beq @no_collide

    jsr take_damage

@no_collide:

    ; --- Check for hazard tiles ---
    lda on_ground
    beq @no_hazard

    lda player_y
    clc
    adc #8
    lsr
    lsr
    lsr
    tax

    cpx #30
    bcs @no_hazard

    lda level_rows_lo, x
    sta tile_ptr
    lda level_rows_hi, x
    sta tile_ptr+1

    lda player_x
    clc
    adc #4
    lsr
    lsr
    lsr
    tay

    lda (tile_ptr), y
    cmp #SPIKE_TILE
    bne @no_hazard

    jsr take_damage

@no_hazard:

    ; --- Update sprite positions ---
    lda player_y
    sta oam_buffer+0
    lda player_x
    sta oam_buffer+3

    lda #FLOOR_Y
    sta oam_buffer+4
    lda obstacle_x
    sta oam_buffer+7

    jmp main_loop

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

; -----------------------------------------------------------------------------
; check_collect: Check if the player overlaps a collectible sprite
; Input:  X = OAM buffer offset (8, 12, or 16)
; Effect: If overlapping, hides the sprite, increments score, plays sound
; -----------------------------------------------------------------------------
check_collect:
    lda oam_buffer, x
    cmp #$EF
    beq @done

    lda player_y
    clc
    adc #8
    cmp oam_buffer, x
    bcc @done
    beq @done

    lda oam_buffer, x
    clc
    adc #8
    cmp player_y
    bcc @done
    beq @done

    lda player_x
    clc
    adc #8
    cmp oam_buffer+3, x
    bcc @done
    beq @done

    lda oam_buffer+3, x
    clc
    adc #8
    cmp player_x
    bcc @done
    beq @done

    lda #$EF
    sta oam_buffer, x
    inc score

    lda #%00011000
    sta TRI_LINEAR
    lda #$29
    sta TRI_LO
    lda #$00
    sta TRI_HI

@done:
    rts

; -----------------------------------------------------------------------------
; take_damage: Deduct a life and handle the result
; Effect: Decrements lives. If lives remain, resets player and plays sound.
;         If no lives remain, sets game_over flag and hides the player.
; -----------------------------------------------------------------------------
take_damage:
    lda lives
    beq @done               ; Already dead — ignore

    dec lives
    bne @still_alive

    ; --- Game over ---
    lda #1
    sta game_over
    lda #$EF
    sta player_y            ; Will propagate to OAM, hiding sprite
    rts

@still_alive:
    ; --- Reset player position ---
    lda #PLAYER_X
    sta player_x
    lda #PLAYER_Y
    sta player_y
    lda #0
    sta vel_y
    lda #1
    sta on_ground

    ; --- Damage sound (pulse channel — harsh buzz) ---
    lda #%00111100          ; Duty 12.5%, volume 12
    sta SQ1_VOL
    lda #%00000000          ; No sweep
    sta SQ1_SWEEP
    lda #$80                ; Timer low — low pitch
    sta SQ1_LO
    lda #$01                ; Timer high=1, length counter
    sta SQ1_HI

@done:
    rts

; =============================================================================
; NMI Handler
; =============================================================================
nmi:
    pha
    txa
    pha
    tya
    pha

    ; --- OAM DMA ---
    lda #0
    sta OAMADDR
    lda #>oam_buffer
    sta OAMDMA

    ; --- Update score display ---
    bit PPUSTATUS
    lda #$20
    sta PPUADDR
    lda #$22
    sta PPUADDR             ; $2022 (row 1, col 2)
    lda score
    clc
    adc #DIGIT_ZERO
    sta PPUDATA

    ; --- Update lives display ---
    lda #$20
    sta PPUADDR
    lda #$3C
    sta PPUADDR             ; $203C (row 1, col 28)
    lda lives
    clc
    adc #DIGIT_ZERO
    sta PPUDATA

    ; --- Reset scroll ---
    lda #0
    sta PPUSCROLL
    sta PPUSCROLL

    lda #1
    sta nmi_flag

    pla
    tay
    pla
    tax
    pla
    rti

irq:
    rti

; =============================================================================
; Data
; =============================================================================

palette_data:
    .byte $0F, $00, $10, $20
    .byte $0F, $09, $19, $29
    .byte $0F, $00, $10, $20
    .byte $0F, $00, $10, $20
    .byte $0F, $30, $16, $27
    .byte $0F, $16, $27, $30
    .byte $0F, $28, $38, $30
    .byte $0F, $30, $16, $27

attr_data:
    .byte $00, $00, $00, $05, $05, $00, $00, $00
    .byte $50, $50, $50, $50, $50, $54, $50, $50
    .byte $05, $05, $05, $05, $05, $05, $05, $05

; -----------------------------------------------------------------------------
; Level Data
; -----------------------------------------------------------------------------
level_empty_row:
    .byte 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0
    .byte 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0

level_platform_row:
    .byte 0,0,0,0, 0,0,0,0, 0,0,0,0, 3,3,3,3
    .byte 3,3,3,3, 0,0,0,0, 0,0,0,0, 0,0,0,0

level_wall_row:
    .byte 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0
    .byte 0,0,0,0, 0,0,3,3, 0,0,0,0, 0,0,0,0

level_ground_row:
    .byte 3,3,3,3, 3,3,3,3, 3,3,3,3, 3,3,3,3
    .byte 3,3,3,3, 3,3,3,3, 3,3,3,3, 3,3,3,3

level_ground_spike_row:
    .byte 3,3,3,3, 3,3,3,3, 3,3,15,15, 3,3,3,3
    .byte 3,3,3,3, 3,3,3,3, 3,3,3,3, 3,3,3,3

; Row pointer tables
level_rows_lo:
    .byte <level_empty_row      ; Row 0
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row      ; Row 10
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_platform_row   ; Row 20
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_wall_row       ; Row 24
    .byte <level_wall_row
    .byte <level_ground_spike_row ; Row 26
    .byte <level_ground_row
    .byte <level_ground_row
    .byte <level_ground_row

level_rows_hi:
    .byte >level_empty_row      ; Row 0
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row      ; Row 10
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_platform_row   ; Row 20
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_wall_row       ; Row 24
    .byte >level_wall_row
    .byte >level_ground_spike_row ; Row 26
    .byte >level_ground_row
    .byte >level_ground_row
    .byte >level_ground_row

; =============================================================================
; Vectors
; =============================================================================
.segment "VECTORS"
    .word nmi
    .word reset
    .word irq

; =============================================================================
; CHR-ROM
; =============================================================================
.segment "CHARS"

; Tile 0: Empty
.byte $00,$00,$00,$00,$00,$00,$00,$00
.byte $00,$00,$00,$00,$00,$00,$00,$00

; Tile 1: Running figure
.byte %00110000,%00110000,%01111000,%00110000
.byte %00110000,%00101000,%01000100,%01000100
.byte $00,$00,$00,$00,$00,$00,$00,$00

; Tile 2: Diamond obstacle
.byte %00011000,%00111100,%01111110,%11111111
.byte %11111111,%01111110,%00111100,%00011000
.byte $00,$00,$00,$00,$00,$00,$00,$00

; Tile 3: Ground block
.byte $FF,$FF,$FF,$FF,$FF,$FF,$FF,$FF  ; Plane 0
.byte $FF,$00,$00,$00,$00,$00,$00,$00  ; Plane 1

; Tile 4: Coin
.byte $3C,$7E,$FF,$FF,$FF,$FF,$7E,$3C  ; Plane 0
.byte $00,$00,$00,$00,$00,$00,$00,$00  ; Plane 1

; Tiles 5-14: Digits 0-9 (both planes = colour 3)
.byte $70,$88,$88,$88,$88,$88,$70,$00  ; Tile 5: 0
.byte $70,$88,$88,$88,$88,$88,$70,$00
.byte $20,$60,$20,$20,$20,$20,$70,$00  ; Tile 6: 1
.byte $20,$60,$20,$20,$20,$20,$70,$00
.byte $70,$88,$08,$30,$40,$80,$F8,$00  ; Tile 7: 2
.byte $70,$88,$08,$30,$40,$80,$F8,$00
.byte $70,$88,$08,$30,$08,$88,$70,$00  ; Tile 8: 3
.byte $70,$88,$08,$30,$08,$88,$70,$00
.byte $10,$30,$50,$90,$F8,$10,$10,$00  ; Tile 9: 4
.byte $10,$30,$50,$90,$F8,$10,$10,$00
.byte $F8,$80,$F0,$08,$08,$88,$70,$00  ; Tile 10: 5
.byte $F8,$80,$F0,$08,$08,$88,$70,$00
.byte $30,$40,$80,$F0,$88,$88,$70,$00  ; Tile 11: 6
.byte $30,$40,$80,$F0,$88,$88,$70,$00
.byte $F8,$08,$10,$20,$20,$20,$20,$00  ; Tile 12: 7
.byte $F8,$08,$10,$20,$20,$20,$20,$00
.byte $70,$88,$88,$70,$88,$88,$70,$00  ; Tile 13: 8
.byte $70,$88,$88,$70,$88,$88,$70,$00
.byte $70,$88,$88,$78,$08,$10,$60,$00  ; Tile 14: 9
.byte $70,$88,$88,$78,$08,$10,$60,$00

; Tile 15: Spikes
.byte $18,$18,$3C,$3C,$7E,$7E,$FF,$FF  ; Plane 0
.byte $18,$18,$3C,$3C,$7E,$7E,$FF,$FF  ; Plane 1

.res 8192 - 256, $00

Dash Unit 13

Score on the left, lives on the right. Three lives to start. The spikes and obstacle are both lethal now — each hit costs a life and resets the player to the start. Lose all three and the game freezes.

Try This: Flash on Damage

When the player takes damage, briefly change the background colour to red. Write $16 (red) to palette address $3F00 in the NMI handler when a damaged flag is set, then clear the flag after one frame. The screen flashes red for a single frame — a visual punch to accompany the sound.

Try This: Invincibility Frames

After taking damage, make the player invincible for 60 frames (one second). Set a invincible counter on damage, decrement it each frame, and skip the hazard/obstacle checks while it’s non-zero. Flicker the player sprite by toggling bit 5 of the OAM attribute byte (priority) each frame during invincibility.

Try This: Game Over Text

When game_over is set, write “GAME OVER” to the nametable (row 14, centred). You’ll need letter tiles in CHR-ROM — add tiles for G, A, M, E, O, V, R. Write them to VRAM during NMI when game_over transitions from 0 to 1.

If It Doesn’t Work

  • Lives don’t decrease? Check that both collision paths call jsr take_damage, not the old inline reset code. The subroutine must be reachable — make sure the label take_damage: exists and isn’t inside another subroutine’s scope.
  • Lives show the wrong number? The initial nametable write must use DIGIT_ZERO + START_LIVES, not just START_LIVES. Tile 3 is the ground block, not the digit “3”.
  • Player loses two lives at once? Make sure the obstacle collision runs before the hazard check. After take_damage resets the player to column 7, the hazard check should find ground (tile 3) at that position, not spikes.
  • Game doesn’t freeze on game over? The lda game_over / bne main_loop check must be at the top of the main loop, after nmi_flag but before any game logic. If it’s further down, some logic still runs.
  • Lives display doesn’t update? The NMI handler must write to $203C (not $2022 — that’s the score). Check both PPUADDR bytes: $20 then $3C.

What You’ve Learnt

  • Lives system — a counter that decrements on damage. Zero means game over. The game_over flag freezes the main loop while NMI continues.
  • Shared subroutinestake_damage is called from two different collision checks. One subroutine, one place to maintain. JSR/RTS makes this natural.
  • Guard clauseslda lives / beq @done prevents decrementing past zero. Always check the precondition before modifying state.
  • Execution order matters — obstacle collision before hazard check prevents double damage. The player’s reset position has no spikes, so the second check passes safely.
  • Multiple nametable writes in NMI — two PPUADDR/PPUDATA sequences back to back. The PPUADDR toggle tracks correctly without extra bit PPUSTATUS calls between them.

What’s Next

Three lives and game over — the game has real stakes now. But once those lives are gone, the only option is to reset the console. In Unit 14, the game gets a proper restart: after game over, pressing Start begins a fresh run with the score and lives reset.