Player Death
Make the ship killable — detect enemy-on-ship collision, freeze the game with a game-over flag, and play a death tone on the SID's third and final voice.
The wave descends on you and you fly straight through it, untouched. There's no way to lose — which means there's nothing at stake, and the enemies are scenery. This unit gives them teeth: when an enemy reaches the ship, the ship dies, the game freezes, and the SID's last voice marks the moment. Suddenly you have to play carefully.
Where we start
Unit 10 has a wave of three enemies you can shoot, a score, and a ship that nothing can hurt. We add the consequence.
Milestone 1 — the enemies can kill you
Detecting an enemy hitting the ship is the same geometry you already wrote for the bullet — a distance check on both axes — just with the ship's position in place of the bullet's. We walk the wave with the same indexed loop, comparing each enemy against the ship at $d000/$d001. The one new wrinkle: the ship can roam past X=255 (its ninth bit), and an enemy never does, so a ship in the right portion is ruled out before the low-byte compare — the guard you met in Unit 7, now on the ship.
When an enemy connects, we don't respawn it — we end the game. A single byte, game_over, becomes the game's state: set it to 1, turn the ship red, and at the top of the loop check it. If it's set, the loop does nothing but wait — every other system (input, movement, collision) is skipped, so the screen holds exactly as it was at the moment of death.
| 14 | 14 | enemy_x_tbl = $07 ; 3 bytes ($07,$08,$09): each enemy's X | |
| 15 | 15 | enemy_y_tbl = $0a ; 3 bytes ($0a,$0b,$0c): each enemy's Y | |
| 16 | 16 | flash_tbl = $0d ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer | |
| 17 | + | game_over = $10 ; 0 = playing, 1 = the ship has been hit | |
| 17 | 18 | | |
| 18 | 19 | ; ------------------------------------------------ | |
| 19 | 20 | ; BASIC stub | |
| ... | |||
| 106 | 107 | lda #$d2 | |
| 107 | 108 | ldx #$02 | |
| 108 | 109 | jsr spawn_enemy | |
| 110 | + | | |
| 111 | + | ; The game starts alive | |
| 112 | + | lda #$00 | |
| 113 | + | sta game_over | |
| 109 | 114 | | |
| 110 | 115 | ; ------------------------------------------------ | |
| 111 | 116 | ; Game loop — runs once per frame | |
| ... | |||
| 116 | 121 | - lda $d012 | |
| 117 | 122 | cmp #$ff | |
| 118 | 123 | bne - | |
| 124 | + | | |
| 125 | + | ; If the game is over, freeze: keep syncing to the raster, do nothing else | |
| 126 | + | lda game_over | |
| 127 | + | bne game_loop | |
| 119 | 128 | | |
| 120 | 129 | ; --- Read joystick and move ship --- | |
| 121 | 130 | | |
| ... | |||
| 409 | 418 | sta $0401 ; ones digit | |
| 410 | 419 | | |
| 411 | 420 | no_hit: | |
| 421 | + | | |
| 422 | + | ; --- Ship vs the wave: has any enemy reached the ship? --- | |
| 423 | + | ldx #$00 | |
| 424 | + | ship_collision_loop: | |
| 425 | + | lda flash_tbl,x | |
| 426 | + | bne next_ship_check ; ignore an exploding enemy | |
| 427 | + | ; Y distance: ship Y ($d001) vs this enemy's Y | |
| 428 | + | lda $d001 | |
| 429 | + | sec | |
| 430 | + | sbc enemy_y_tbl,x | |
| 431 | + | cmp #$10 | |
| 432 | + | bcc check_ship_x | |
| 433 | + | cmp #$f0 | |
| 434 | + | bcc next_ship_check | |
| 435 | + | check_ship_x: | |
| 436 | + | ; ship past X=255 (9th bit set) is far from any enemy — rule it out | |
| 437 | + | lda $d010 | |
| 438 | + | and #%00000001 ; ship's 9th X bit (sprite 0) | |
| 439 | + | bne next_ship_check | |
| 440 | + | lda $d000 | |
| 441 | + | sec | |
| 442 | + | sbc enemy_x_tbl,x | |
| 443 | + | cmp #$10 | |
| 444 | + | bcc ship_hit | |
| 445 | + | cmp #$f0 | |
| 446 | + | bcc next_ship_check | |
| 447 | + | jmp ship_hit ; 240..255: close from the other side | |
| 448 | + | | |
| 449 | + | next_ship_check: | |
| 450 | + | inx | |
| 451 | + | cpx #$03 | |
| 452 | + | bne ship_collision_loop | |
| 453 | + | jmp no_ship_hit | |
| 454 | + | | |
| 455 | + | ship_hit: | |
| 456 | + | lda #$01 | |
| 457 | + | sta game_over ; the loop freezes from next frame | |
| 458 | + | lda #$02 | |
| 459 | + | sta $d027 ; ship turns red | |
| 460 | + | | |
| 461 | + | no_ship_hit: | |
| 412 | 462 | | |
| 413 | 463 | jmp game_loop | |
| 414 | 464 | |
The complete step 1 program
; Starfield - Unit 11: Player Death
; Cumulative steps: step-00 (untouchable ship) -> step-01 (+ enemies can kill you: game over) -> step-02 (+ a death sound)
; Assemble: acme -f cbm -o <step>.prg <step>.asm
; ------------------------------------------------
; Zero-page variables
; ------------------------------------------------
bullet_active = $02 ; 0 = no bullet, 1 = active
bullet_y = $03 ; Bullet Y position
laser_timer = $04 ; Frames of laser pitch-sweep remaining (0 = idle)
laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only)
score = $06 ; Two-digit score, BCD (one decimal digit per nybble)
; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
enemy_x_tbl = $07 ; 3 bytes ($07,$08,$09): each enemy's X
enemy_y_tbl = $0a ; 3 bytes ($0a,$0b,$0c): each enemy's Y
flash_tbl = $0d ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer
game_over = $10 ; 0 = playing, 1 = the ship has been hit
; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00
; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
; Black screen
lda #$00
sta $d020 ; Border colour
sta $d021 ; Background colour
; Clear the screen
ldx #$00
- lda #$20
sta $0400,x
sta $0500,x
sta $0600,x
sta $0700,x
inx
bne -
; Sprite 0 setup (ship)
lda #128
sta $07f8 ; Data pointer (block 128 = $2000)
lda #172
sta $d000 ; X position
lda #220
sta $d001 ; Y position
lda #$01
sta $d027 ; Colour (white)
lda #%00011101
sta $d015 ; Enable sprites 0 (ship), 2-4 (three enemies)
lda #$00
sta $d010 ; sprite high-X bits clear (ship starts under X=256)
; Sprite 1 setup (bullet)
lda #129
sta $07f9 ; Data pointer (block 129 = $2040)
lda #$07
sta $d028 ; Colour (yellow)
; Enemy sprites 2, 3 and 4 share the one enemy shape in block 130.
; Colour and position are set per-enemy by spawn_enemy, below.
lda #130
sta $07fa ; sprite 2 data pointer
sta $07fb ; sprite 3 data pointer
sta $07fc ; sprite 4 data pointer
; Bullet starts inactive
lda #$00
sta bullet_active
; SID setup — voice 1 laser sound
lda #$0f
sta $d418 ; Volume to maximum
lda #$00
sta $d400 ; Frequency low byte
lda #$10
sta $d401 ; Frequency high byte ($1000 = mid-high pitch)
lda #$06
sta $d405 ; Attack=0, Decay=6 (a short, snappy fall)
lda #$00
sta $d406 ; Sustain=0, Release=0
; Score readout: "00" in the top-left, white. (We've written to screen
; RAM since Unit 1's clear loop — now we write characters, not spaces.)
lda #$00
sta score
lda #$30 ; screen code for '0'
sta $0400 ; tens digit (row 0, col 0)
sta $0401 ; ones digit (row 0, col 1)
lda #$01
sta $d800 ; colour both digits white
sta $d801
; Spawn the wave at staggered heights (A = start Y, X = enemy index)
lda #$32
ldx #$00
jsr spawn_enemy
lda #$82
ldx #$01
jsr spawn_enemy
lda #$d2
ldx #$02
jsr spawn_enemy
; The game starts alive
lda #$00
sta game_over
; ------------------------------------------------
; Game loop — runs once per frame
; ------------------------------------------------
game_loop:
; Wait for the raster beam to reach line 255
; This syncs our code to the display (~50Hz PAL)
- lda $d012
cmp #$ff
bne -
; If the game is over, freeze: keep syncing to the raster, do nothing else
lda game_over
bne game_loop
; --- Read joystick and move ship ---
; UP (bit 0) — clamp to Y >= 50
lda $dc00 ; Read joystick port 2
and #%00000001 ; Isolate bit 0
bne not_up ; Bit is 1 = NOT pressed (active low)
lda $d001
cmp #52 ; 50 + room for a 2-pixel move
bcc not_up ; already at the top — don't move
dec $d001 ; Move ship up (decrease Y)
dec $d001 ; 2 pixels per frame
not_up:
; DOWN (bit 1) — clamp to Y <= 234
lda $dc00
and #%00000010
bne not_down
lda $d001
cmp #233 ; 234 - room for a 2-pixel move
bcs not_down ; already at the bottom — don't move
inc $d001 ; Move ship down (increase Y)
inc $d001
not_down:
; LEFT (bit 2) — 9-bit X, clamp to X >= 24
lda $dc00
and #%00000100
bne not_left
lda $d010
and #$01
bne left_ok ; high bit set: X >= 256, always safe to go left
lda $d000
cmp #26 ; 24 + room for a 2-pixel move
bcc not_left ; already at the left edge — don't move
left_ok:
; before each step, flip the 9th bit when X is about to wrap $00 -> $ff
lda $d000
bne +
lda $d010
eor #$01 ; the eor bit-flip from the Primer, on sprite 0's high X bit
sta $d010
+ dec $d000
lda $d000
bne +
lda $d010
eor #$01
sta $d010
+ dec $d000
not_left:
; RIGHT (bit 3) — 9-bit X, clamp to X <= 320
lda $dc00
and #%00001000
bne not_right
lda $d010
and #$01
beq right_ok ; high bit clear: X < 256, always safe to go right
lda $d000
cmp #63 ; (320 - 256) - room for a 2-pixel move
bcs not_right ; already at the right edge — don't move
right_ok:
; after each step, flip the 9th bit when X wraps $ff -> $00
inc $d000
bne +
lda $d010
eor #$01
sta $d010
+ inc $d000
bne +
lda $d010
eor #$01
sta $d010
+
not_right:
; --- Fire button (bit 4) ---
lda $dc00
and #%00010000
bne no_fire ; Bit is 1 = NOT pressed
; Only spawn if no bullet is already flying
lda bullet_active
bne no_fire
; Spawn the bullet at the ship's position
lda $d000 ; Ship X (low byte) -> bullet X
sta $d002
lda $d001 ; Ship Y -> bullet Y
sta bullet_y
; Copy the ship's 9th X bit (bit 0) to the bullet's (bit 1),
; so a shot fired from the right half spawns under the ship
lda $d010
and #%11111101 ; clear the bullet's 9th bit first
sta $d010
lda $d010
and #$01 ; the ship's 9th bit
asl ; shift it into the bullet's position (bit 1)
ora $d010
sta $d010
; Enable sprite 1 (keep sprite 0 enabled)
lda $d015
ora #%00000010
sta $d015
lda #$01
sta bullet_active
; Trigger laser sound: start the pitch high, gate off then on
lda #$40
sta laser_freq ; start high
sta $d401 ; SID frequency high byte
lda #$20
sta $d404 ; Sawtooth, gate OFF (reset envelope)
lda #$21
sta $d404 ; Sawtooth, gate ON (start sound)
lda #$0a
sta laser_timer ; sweep down over 10 frames
no_fire:
; --- Laser pitch sweep: the 'pew' ---
; Drop the pitch a little each frame while the sweep is running.
; We keep our own copy because SID frequency registers are write-only.
lda laser_timer
beq no_sweep
lda laser_freq
sec
sbc #$06
sta laser_freq
sta $d401 ; write the new pitch to the SID
dec laser_timer
no_sweep:
; --- Update the bullet ---
lda bullet_active
beq no_bullet
; Move it up 4 pixels a frame
lda bullet_y
sec
sbc #$04
sta bullet_y
sta $d003 ; sprite 1 Y
; Gone off the top? (Y < 30) -> remove it
cmp #$1e
bcs no_bullet
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; disable sprite 1, keep sprite 0
sta $d015
lda $d010
and #%11111101 ; clear the bullet's 9th bit
sta $d010
no_bullet:
; --- Update every enemy: one indexed loop does all of them ---
ldx #$00
enemy_loop:
lda flash_tbl,x
bne do_flash ; this enemy is mid-flash
; not flashing: drift down 1 pixel per frame
lda enemy_y_tbl,x
clc
adc #$01 ; clc before adc, the addition from the Primer
sta enemy_y_tbl,x
cmp #$f8 ; off the bottom? (Y >= 248)
bcc update_sprite ; still on screen
lda #$32 ; respawn this enemy at the top, new column
jsr spawn_enemy
jmp next_enemy
do_flash:
dec flash_tbl,x
bne update_sprite ; still flashing -> stay frozen, white
lda #$32 ; flash done -> respawn (spawn_enemy restores green)
jsr spawn_enemy
jmp next_enemy
update_sprite:
; copy this enemy's position into its VIC-II sprite registers
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X ($d004, $d006, ...)
lda enemy_y_tbl,x
sta $d001,y ; sprite Y ($d005, $d007, ...)
next_enemy:
inx
cpx #$03 ; the full wave of three
bne enemy_loop
; --- Bullet vs the wave: test each enemy until one is hit ---
lda bullet_active
bne check_collision
jmp no_hit
check_collision:
ldx #$00
collision_loop:
lda flash_tbl,x
bne next_collision ; skip an enemy that's already exploding
; Y distance (8-bit subtract wraps, so two ranges count as close)
lda bullet_y
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_x ; 0..15 apart: close
cmp #$f0
bcc next_collision ; 16..239 apart: too far
check_x:
; A bullet in the right portion (9th bit set) is past X=255, far from
; any enemy, so rule it out before comparing low bytes.
lda $d010
and #%00000010 ; bullet's 9th X bit (sprite 1)
bne next_collision
lda $d002
sec
sbc enemy_x_tbl,x
cmp #$10
bcc hit_enemy ; 0..15 apart: close
cmp #$f0
bcc next_collision ; 16..239 apart: too far
jmp hit_enemy ; 240..255: close from the other side
next_collision:
inx
cpx #$03
bne collision_loop
jmp no_hit
hit_enemy:
; X = the enemy that was hit. Remove the bullet.
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; sprite 1 (bullet) off
sta $d015
; Flash THIS enemy white and start its 8-frame timer. The enemy loop
; freezes it white until the timer runs out, then respawns it.
lda #$08
sta flash_tbl,x
ldy sprite_colour_off,x
lda #$01
sta $d000,y ; this enemy's colour register = white
; Explosion sound — SID voice 2, noise waveform (voice 1 keeps the laser)
lda #$00
sta $d407 ; voice 2 frequency low
lda #$08
sta $d408 ; voice 2 frequency high (a low rumble)
lda #$09
sta $d40c ; attack 0, decay 9
lda #$00
sta $d40d ; sustain 0, release 0
lda #$80
sta $d40b ; noise, gate OFF (reset the envelope)
lda #$81
sta $d40b ; noise, gate ON (trigger the burst)
; Score one hit. In decimal mode ADC carries at 10, so the byte stays
; readable as two decimal digits (BCD) — no conversion needed.
sed ; decimal mode on
lda score
clc
adc #$01
sta score
cld ; decimal mode off (every later ADC/SBC needs it off)
; Refresh the two digits: high nybble -> tens, low nybble -> ones
lda score
lsr
lsr
lsr
lsr ; high nybble down to 0-9
clc
adc #$30 ; to screen code
sta $0400 ; tens digit
lda score
and #$0f ; low nybble, 0-9
clc
adc #$30
sta $0401 ; ones digit
no_hit:
; --- Ship vs the wave: has any enemy reached the ship? ---
ldx #$00
ship_collision_loop:
lda flash_tbl,x
bne next_ship_check ; ignore an exploding enemy
; Y distance: ship Y ($d001) vs this enemy's Y
lda $d001
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_ship_x
cmp #$f0
bcc next_ship_check
check_ship_x:
; ship past X=255 (9th bit set) is far from any enemy — rule it out
lda $d010
and #%00000001 ; ship's 9th X bit (sprite 0)
bne next_ship_check
lda $d000
sec
sbc enemy_x_tbl,x
cmp #$10
bcc ship_hit
cmp #$f0
bcc next_ship_check
jmp ship_hit ; 240..255: close from the other side
next_ship_check:
inx
cpx #$03
bne ship_collision_loop
jmp no_ship_hit
ship_hit:
lda #$01
sta game_over ; the loop freezes from next frame
lda #$02
sta $d027 ; ship turns red
no_ship_hit:
jmp game_loop
; ------------------------------------------------
; Subroutine: spawn one enemy
; A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
sta enemy_y_tbl,x
lda $d012 ; raster line -> pseudo-random column
and #$7f
clc
adc #$30 ; 48-175, inside the visible width
sta enemy_x_tbl,x
lda #$00
sta flash_tbl,x ; not flashing
ldy sprite_colour_off,x
lda #$05
sta $d000,y ; this enemy's colour = green
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X
lda enemy_y_tbl,x
sta $d001,y ; sprite Y
rts
; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
sprite_pos_off:
!byte $04, $06, $08 ; X offsets: $d004, $d006, $d008
sprite_colour_off:
!byte $29, $2a, $2b ; colour offsets: $d029, $d02a, $d02b
; ------------------------------------------------
; Sprite data at $2000 (block 128) — ship
; ------------------------------------------------
*= $2000
!byte $00,$18,$00 ; ##
!byte $00,$3c,$00 ; ####
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$7e,$00 ; ######
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $03,$ff,$c0 ; ############
!byte $07,$ff,$e0 ; ##############
!byte $07,$ff,$e0 ; ##############
!byte $07,$e7,$e0 ; ###..####..###
!byte $03,$c3,$c0 ; ##....##....##
!byte $01,$ff,$80 ; ##########
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $00,$db,$00 ; ##.##.##
!byte $00,$db,$00 ; ##.##.##
!byte $00,$66,$00 ; ##..##
!byte $00,$24,$00 ; #..#
!byte $00,$00,$00 ;
; ------------------------------------------------
; Sprite data at $2040 (block 129) — bullet
; ------------------------------------------------
*= $2040
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
; ------------------------------------------------
; Sprite data at $2080 (block 130) — enemy
; ------------------------------------------------
*= $2080
!byte $00,$66,$00 ; ##..##
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$db,$00 ; ##.##.##
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $01,$7e,$80 ; #.######.#
!byte $01,$3c,$80 ; #..####..#
!byte $00,$a5,$00 ; #.#..#.#
!byte $01,$81,$80 ; ##......##
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
That lda game_over / bne game_loop at the top of the loop is the whole of "game over" — a flag checked once a frame that gates everything after it. Touch an enemy and the game stops dead:
Milestone 2 — the sound of dying
The SID has three voices. Voice 1 is the laser, voice 2 the explosion — and the third has been sitting idle. Death is its moment: a low sawtooth note with a long, slow decay, gated once as the ship dies and left to ring out.
Each voice is a block of seven registers, so voice 3 starts seven bytes above voice 2 ($d40e, where voice 1 was $d400 and voice 2 $d407). The layout inside the block is identical — but a miscounted offset is the classic silent-SID bug:
| Voice 3 register | Address |
|---|---|
| Frequency low / high | $d40e / $d40f |
| Control (waveform + gate) | $d412 |
| Attack / Decay | $d413 |
| Sustain / Release | $d414 |
| 457 | 457 | sta game_over ; the loop freezes from next frame | |
| 458 | 458 | lda #$02 | |
| 459 | 459 | sta $d027 ; ship turns red | |
| 460 | + | | |
| 461 | + | ; Death sound — SID voice 3, the third and last voice | |
| 462 | + | lda #$00 | |
| 463 | + | sta $d40e ; voice 3 frequency low | |
| 464 | + | lda #$10 | |
| 465 | + | sta $d40f ; voice 3 frequency high | |
| 466 | + | lda #$0a | |
| 467 | + | sta $d413 ; attack 0, decay 10 (a long, slow fade) | |
| 468 | + | lda #$00 | |
| 469 | + | sta $d414 ; sustain 0, release 0 | |
| 470 | + | lda #$20 | |
| 471 | + | sta $d412 ; sawtooth, gate OFF (reset the envelope) | |
| 472 | + | lda #$21 | |
| 473 | + | sta $d412 ; sawtooth, gate ON (trigger) | |
| 460 | 474 | | |
| 461 | 475 | no_ship_hit: | |
| 462 | 476 | |
The complete program
; Starfield - Unit 11: Player Death
; Cumulative steps: step-00 (untouchable ship) -> step-01 (+ enemies can kill you: game over) -> step-02 (+ a death sound)
; Assemble: acme -f cbm -o <step>.prg <step>.asm
; ------------------------------------------------
; Zero-page variables
; ------------------------------------------------
bullet_active = $02 ; 0 = no bullet, 1 = active
bullet_y = $03 ; Bullet Y position
laser_timer = $04 ; Frames of laser pitch-sweep remaining (0 = idle)
laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only)
score = $06 ; Two-digit score, BCD (one decimal digit per nybble)
; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
enemy_x_tbl = $07 ; 3 bytes ($07,$08,$09): each enemy's X
enemy_y_tbl = $0a ; 3 bytes ($0a,$0b,$0c): each enemy's Y
flash_tbl = $0d ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer
game_over = $10 ; 0 = playing, 1 = the ship has been hit
; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00
; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
; Black screen
lda #$00
sta $d020 ; Border colour
sta $d021 ; Background colour
; Clear the screen
ldx #$00
- lda #$20
sta $0400,x
sta $0500,x
sta $0600,x
sta $0700,x
inx
bne -
; Sprite 0 setup (ship)
lda #128
sta $07f8 ; Data pointer (block 128 = $2000)
lda #172
sta $d000 ; X position
lda #220
sta $d001 ; Y position
lda #$01
sta $d027 ; Colour (white)
lda #%00011101
sta $d015 ; Enable sprites 0 (ship), 2-4 (three enemies)
lda #$00
sta $d010 ; sprite high-X bits clear (ship starts under X=256)
; Sprite 1 setup (bullet)
lda #129
sta $07f9 ; Data pointer (block 129 = $2040)
lda #$07
sta $d028 ; Colour (yellow)
; Enemy sprites 2, 3 and 4 share the one enemy shape in block 130.
; Colour and position are set per-enemy by spawn_enemy, below.
lda #130
sta $07fa ; sprite 2 data pointer
sta $07fb ; sprite 3 data pointer
sta $07fc ; sprite 4 data pointer
; Bullet starts inactive
lda #$00
sta bullet_active
; SID setup — voice 1 laser sound
lda #$0f
sta $d418 ; Volume to maximum
lda #$00
sta $d400 ; Frequency low byte
lda #$10
sta $d401 ; Frequency high byte ($1000 = mid-high pitch)
lda #$06
sta $d405 ; Attack=0, Decay=6 (a short, snappy fall)
lda #$00
sta $d406 ; Sustain=0, Release=0
; Score readout: "00" in the top-left, white. (We've written to screen
; RAM since Unit 1's clear loop — now we write characters, not spaces.)
lda #$00
sta score
lda #$30 ; screen code for '0'
sta $0400 ; tens digit (row 0, col 0)
sta $0401 ; ones digit (row 0, col 1)
lda #$01
sta $d800 ; colour both digits white
sta $d801
; Spawn the wave at staggered heights (A = start Y, X = enemy index)
lda #$32
ldx #$00
jsr spawn_enemy
lda #$82
ldx #$01
jsr spawn_enemy
lda #$d2
ldx #$02
jsr spawn_enemy
; The game starts alive
lda #$00
sta game_over
; ------------------------------------------------
; Game loop — runs once per frame
; ------------------------------------------------
game_loop:
; Wait for the raster beam to reach line 255
; This syncs our code to the display (~50Hz PAL)
- lda $d012
cmp #$ff
bne -
; If the game is over, freeze: keep syncing to the raster, do nothing else
lda game_over
bne game_loop
; --- Read joystick and move ship ---
; UP (bit 0) — clamp to Y >= 50
lda $dc00 ; Read joystick port 2
and #%00000001 ; Isolate bit 0
bne not_up ; Bit is 1 = NOT pressed (active low)
lda $d001
cmp #52 ; 50 + room for a 2-pixel move
bcc not_up ; already at the top — don't move
dec $d001 ; Move ship up (decrease Y)
dec $d001 ; 2 pixels per frame
not_up:
; DOWN (bit 1) — clamp to Y <= 234
lda $dc00
and #%00000010
bne not_down
lda $d001
cmp #233 ; 234 - room for a 2-pixel move
bcs not_down ; already at the bottom — don't move
inc $d001 ; Move ship down (increase Y)
inc $d001
not_down:
; LEFT (bit 2) — 9-bit X, clamp to X >= 24
lda $dc00
and #%00000100
bne not_left
lda $d010
and #$01
bne left_ok ; high bit set: X >= 256, always safe to go left
lda $d000
cmp #26 ; 24 + room for a 2-pixel move
bcc not_left ; already at the left edge — don't move
left_ok:
; before each step, flip the 9th bit when X is about to wrap $00 -> $ff
lda $d000
bne +
lda $d010
eor #$01 ; the eor bit-flip from the Primer, on sprite 0's high X bit
sta $d010
+ dec $d000
lda $d000
bne +
lda $d010
eor #$01
sta $d010
+ dec $d000
not_left:
; RIGHT (bit 3) — 9-bit X, clamp to X <= 320
lda $dc00
and #%00001000
bne not_right
lda $d010
and #$01
beq right_ok ; high bit clear: X < 256, always safe to go right
lda $d000
cmp #63 ; (320 - 256) - room for a 2-pixel move
bcs not_right ; already at the right edge — don't move
right_ok:
; after each step, flip the 9th bit when X wraps $ff -> $00
inc $d000
bne +
lda $d010
eor #$01
sta $d010
+ inc $d000
bne +
lda $d010
eor #$01
sta $d010
+
not_right:
; --- Fire button (bit 4) ---
lda $dc00
and #%00010000
bne no_fire ; Bit is 1 = NOT pressed
; Only spawn if no bullet is already flying
lda bullet_active
bne no_fire
; Spawn the bullet at the ship's position
lda $d000 ; Ship X (low byte) -> bullet X
sta $d002
lda $d001 ; Ship Y -> bullet Y
sta bullet_y
; Copy the ship's 9th X bit (bit 0) to the bullet's (bit 1),
; so a shot fired from the right half spawns under the ship
lda $d010
and #%11111101 ; clear the bullet's 9th bit first
sta $d010
lda $d010
and #$01 ; the ship's 9th bit
asl ; shift it into the bullet's position (bit 1)
ora $d010
sta $d010
; Enable sprite 1 (keep sprite 0 enabled)
lda $d015
ora #%00000010
sta $d015
lda #$01
sta bullet_active
; Trigger laser sound: start the pitch high, gate off then on
lda #$40
sta laser_freq ; start high
sta $d401 ; SID frequency high byte
lda #$20
sta $d404 ; Sawtooth, gate OFF (reset envelope)
lda #$21
sta $d404 ; Sawtooth, gate ON (start sound)
lda #$0a
sta laser_timer ; sweep down over 10 frames
no_fire:
; --- Laser pitch sweep: the 'pew' ---
; Drop the pitch a little each frame while the sweep is running.
; We keep our own copy because SID frequency registers are write-only.
lda laser_timer
beq no_sweep
lda laser_freq
sec
sbc #$06
sta laser_freq
sta $d401 ; write the new pitch to the SID
dec laser_timer
no_sweep:
; --- Update the bullet ---
lda bullet_active
beq no_bullet
; Move it up 4 pixels a frame
lda bullet_y
sec
sbc #$04
sta bullet_y
sta $d003 ; sprite 1 Y
; Gone off the top? (Y < 30) -> remove it
cmp #$1e
bcs no_bullet
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; disable sprite 1, keep sprite 0
sta $d015
lda $d010
and #%11111101 ; clear the bullet's 9th bit
sta $d010
no_bullet:
; --- Update every enemy: one indexed loop does all of them ---
ldx #$00
enemy_loop:
lda flash_tbl,x
bne do_flash ; this enemy is mid-flash
; not flashing: drift down 1 pixel per frame
lda enemy_y_tbl,x
clc
adc #$01 ; clc before adc, the addition from the Primer
sta enemy_y_tbl,x
cmp #$f8 ; off the bottom? (Y >= 248)
bcc update_sprite ; still on screen
lda #$32 ; respawn this enemy at the top, new column
jsr spawn_enemy
jmp next_enemy
do_flash:
dec flash_tbl,x
bne update_sprite ; still flashing -> stay frozen, white
lda #$32 ; flash done -> respawn (spawn_enemy restores green)
jsr spawn_enemy
jmp next_enemy
update_sprite:
; copy this enemy's position into its VIC-II sprite registers
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X ($d004, $d006, ...)
lda enemy_y_tbl,x
sta $d001,y ; sprite Y ($d005, $d007, ...)
next_enemy:
inx
cpx #$03 ; the full wave of three
bne enemy_loop
; --- Bullet vs the wave: test each enemy until one is hit ---
lda bullet_active
bne check_collision
jmp no_hit
check_collision:
ldx #$00
collision_loop:
lda flash_tbl,x
bne next_collision ; skip an enemy that's already exploding
; Y distance (8-bit subtract wraps, so two ranges count as close)
lda bullet_y
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_x ; 0..15 apart: close
cmp #$f0
bcc next_collision ; 16..239 apart: too far
check_x:
; A bullet in the right portion (9th bit set) is past X=255, far from
; any enemy, so rule it out before comparing low bytes.
lda $d010
and #%00000010 ; bullet's 9th X bit (sprite 1)
bne next_collision
lda $d002
sec
sbc enemy_x_tbl,x
cmp #$10
bcc hit_enemy ; 0..15 apart: close
cmp #$f0
bcc next_collision ; 16..239 apart: too far
jmp hit_enemy ; 240..255: close from the other side
next_collision:
inx
cpx #$03
bne collision_loop
jmp no_hit
hit_enemy:
; X = the enemy that was hit. Remove the bullet.
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; sprite 1 (bullet) off
sta $d015
; Flash THIS enemy white and start its 8-frame timer. The enemy loop
; freezes it white until the timer runs out, then respawns it.
lda #$08
sta flash_tbl,x
ldy sprite_colour_off,x
lda #$01
sta $d000,y ; this enemy's colour register = white
; Explosion sound — SID voice 2, noise waveform (voice 1 keeps the laser)
lda #$00
sta $d407 ; voice 2 frequency low
lda #$08
sta $d408 ; voice 2 frequency high (a low rumble)
lda #$09
sta $d40c ; attack 0, decay 9
lda #$00
sta $d40d ; sustain 0, release 0
lda #$80
sta $d40b ; noise, gate OFF (reset the envelope)
lda #$81
sta $d40b ; noise, gate ON (trigger the burst)
; Score one hit. In decimal mode ADC carries at 10, so the byte stays
; readable as two decimal digits (BCD) — no conversion needed.
sed ; decimal mode on
lda score
clc
adc #$01
sta score
cld ; decimal mode off (every later ADC/SBC needs it off)
; Refresh the two digits: high nybble -> tens, low nybble -> ones
lda score
lsr
lsr
lsr
lsr ; high nybble down to 0-9
clc
adc #$30 ; to screen code
sta $0400 ; tens digit
lda score
and #$0f ; low nybble, 0-9
clc
adc #$30
sta $0401 ; ones digit
no_hit:
; --- Ship vs the wave: has any enemy reached the ship? ---
ldx #$00
ship_collision_loop:
lda flash_tbl,x
bne next_ship_check ; ignore an exploding enemy
; Y distance: ship Y ($d001) vs this enemy's Y
lda $d001
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_ship_x
cmp #$f0
bcc next_ship_check
check_ship_x:
; ship past X=255 (9th bit set) is far from any enemy — rule it out
lda $d010
and #%00000001 ; ship's 9th X bit (sprite 0)
bne next_ship_check
lda $d000
sec
sbc enemy_x_tbl,x
cmp #$10
bcc ship_hit
cmp #$f0
bcc next_ship_check
jmp ship_hit ; 240..255: close from the other side
next_ship_check:
inx
cpx #$03
bne ship_collision_loop
jmp no_ship_hit
ship_hit:
lda #$01
sta game_over ; the loop freezes from next frame
lda #$02
sta $d027 ; ship turns red
; Death sound — SID voice 3, the third and last voice
lda #$00
sta $d40e ; voice 3 frequency low
lda #$10
sta $d40f ; voice 3 frequency high
lda #$0a
sta $d413 ; attack 0, decay 10 (a long, slow fade)
lda #$00
sta $d414 ; sustain 0, release 0
lda #$20
sta $d412 ; sawtooth, gate OFF (reset the envelope)
lda #$21
sta $d412 ; sawtooth, gate ON (trigger)
no_ship_hit:
jmp game_loop
; ------------------------------------------------
; Subroutine: spawn one enemy
; A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
sta enemy_y_tbl,x
lda $d012 ; raster line -> pseudo-random column
and #$7f
clc
adc #$30 ; 48-175, inside the visible width
sta enemy_x_tbl,x
lda #$00
sta flash_tbl,x ; not flashing
ldy sprite_colour_off,x
lda #$05
sta $d000,y ; this enemy's colour = green
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X
lda enemy_y_tbl,x
sta $d001,y ; sprite Y
rts
; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
sprite_pos_off:
!byte $04, $06, $08 ; X offsets: $d004, $d006, $d008
sprite_colour_off:
!byte $29, $2a, $2b ; colour offsets: $d029, $d02a, $d02b
; ------------------------------------------------
; Sprite data at $2000 (block 128) — ship
; ------------------------------------------------
*= $2000
!byte $00,$18,$00 ; ##
!byte $00,$3c,$00 ; ####
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$7e,$00 ; ######
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $03,$ff,$c0 ; ############
!byte $07,$ff,$e0 ; ##############
!byte $07,$ff,$e0 ; ##############
!byte $07,$e7,$e0 ; ###..####..###
!byte $03,$c3,$c0 ; ##....##....##
!byte $01,$ff,$80 ; ##########
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $00,$db,$00 ; ##.##.##
!byte $00,$db,$00 ; ##.##.##
!byte $00,$66,$00 ; ##..##
!byte $00,$24,$00 ; #..#
!byte $00,$00,$00 ;
; ------------------------------------------------
; Sprite data at $2040 (block 129) — bullet
; ------------------------------------------------
*= $2040
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
; ------------------------------------------------
; Sprite data at $2080 (block 130) — enemy
; ------------------------------------------------
*= $2080
!byte $00,$66,$00 ; ##..##
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$db,$00 ; ##.##.##
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $01,$7e,$80 ; #.######.#
!byte $01,$3c,$80 ; #..####..#
!byte $00,$a5,$00 ; #.#..#.#
!byte $01,$81,$80 ; ##......##
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
It's the same gate dance as the laser and the explosion — waveform with the gate off, then on — written to voice 3's control register. Now the death has a voice:
With all three voices now spoken for — laser, explosion, death — the SID is fully in play, each event on its own channel, none cutting another off.
When it's wrong, see why
Death bugs are either "never happens" or "happens wrong" — the flag and the offsets tell you which:
- The ship is still invincible. The collision loop isn't reaching
ship_hit. Check it walks all three enemies (cpx #$03), compares against$d000/$d001, and that the ninth-bit guard isn't rejecting every frame ($d010bit 0 should be 0 while the ship is in the left portion). - The game doesn't freeze. The
game_overcheck is misplaced or the flag isn't set. It must be read at the top of the loop, before input — andship_hitmust write 1 to it. Watchgame_over: it should flip to 1 exactly once, on contact. - Silent death. The voice-3 offsets. This is the trap: voice 3's control is
$d412, attack/decay$d413, sustain/release$d414— not$d411. Gate to the wrong register and the envelope never starts. Read the values you're writing against the table above; a gate written to$d411(a pulse-width byte) makes no sound at all. - The ship dies the instant it boots. An enemy spawned on top of it. The init staggers the wave's start heights; if one starts at the ship's row in the ship's column, it collides on frame one. Spread the start positions.
Before and after
We started with a ship the wave couldn't touch and finished with one that dies on contact — the game's first fail state. A one-byte flag holds the game's life-or-death status, the freeze is a single check at the top of the loop, and the SID's third voice gives the moment weight. The stakes are real now: every pass of the wave is a threat.
Try this
- A different end. The ship turns red (
$02). Try flashing the border instead — write the death colour to$d020— or both, for a louder failure. The fail state should be unmissable. - Tune the dirge. Voice 3's pitch (
$d40e/$d40f) and decay ($d413) shape the death. Lower and longer feels like a slow collapse; higher and shorter, a sharp end. Match it to how the game should feel when you lose.
What you've learnt
- A game-state flag — one byte (
game_over) that the loop checks each frame to gate every system at once. - Freezing the game — a single branch at the top of the loop that skips input, movement, and collision when the game is over.
- Ship-enemy collision — the bullet's distance check reused with the ship's position, ninth-bit guard included.
- SID voice 3 — the third channel, seven bytes above voice 2, and the offset trap that silences it when miscounted.
What's next
The game freezes on death, but it just sits there — no word to the player, no way back. Next you'll print a GAME OVER message and let a key press restart the wave.