Skip to content
Game 1 Unit 8 of 16 1 hr learning time

Explosion

Sell the hit — flash the enemy white with a frame-counter freeze, then fire an explosion on the SID's second voice while the laser still rings.

50% of Starfield

The collision works, but the hit is hollow: the enemy just blinks out of existence, in silence, and a fresh one slides down. It functions — and it feels like nothing. This unit is about the gap between "it works" and "it feels good." We sell the kill two ways: the enemy flashes white and freezes for a beat, and the SID fires an explosion that booms over the laser.

Where we start

Unit 7 ends with an instant, silent disappearance — the bullet meets the enemy and it's gone the same frame, no acknowledgement. Everything below adds feedback to that one moment.

Milestone 1 — a flash you can see

A hit should register on screen for long enough to notice. So instead of respawning the enemy the instant it's hit, we turn it white and hold it there for a few frames, frozen, before it respawns. That hold is the first real frame counter — a pattern you'll reach for constantly:

Set a value; subtract one each frame; act when it reaches zero.

Here flash_timer starts at 8. While it's non-zero the enemy update freezes the enemy and counts the timer down; the frame it hits zero, we restore the green colour and respawn. Eight frames at 50 Hz is about 160 ms — long enough to read as a flash, short enough to feel snappy.

Step 1: flash white and freeze, then respawn
+31-11
1111 laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only)
1212 enemy_x = $06 ; Enemy X position
1313 enemy_y = $07 ; Enemy Y position
14+flash_timer = $08 ; Frames of hit-flash remaining (0 = idle)
1415
1516 ; ------------------------------------------------
1617 ; BASIC stub
...
258259
259260 no_bullet:
260261
261- ; --- Update enemy: drift down 1 pixel per frame ---
262+ ; --- Update enemy ---
263+ ; While the flash timer runs the enemy is frozen white; otherwise it drifts.
264+ lda flash_timer
265+ beq enemy_move ; not flashing -> normal movement
266+ dec flash_timer
267+ bne no_respawn ; still flashing -> stay frozen, white
268+ ; flash just ended -> restore colour and respawn at the top
269+ lda #$05
270+ sta $d029 ; back to green
271+ lda #$32
272+ sta enemy_y
273+ sta $d005
274+ lda $d012
275+ and #$7f
276+ clc
277+ adc #$30
278+ sta enemy_x
279+ sta $d004
280+ jmp no_respawn
281+
282+enemy_move:
283+ ; drift down 1 pixel per frame
262284 lda enemy_y
263285 clc
264286 adc #$01 ; clc before adc, the addition from the Primer
...
317339 and #%11111101 ; sprite 1 (bullet) off
318340 sta $d015
319341
320- ; Respawn the enemy at the top in a new column
321- lda #$32
322- sta enemy_y
323- sta $d005
324- lda $d012
325- and #$7f
326- clc
327- adc #$30
328- sta enemy_x
329- sta $d004
342+ ; Flash the enemy white and start an 8-frame timer. The enemy update
343+ ; freezes it white until the timer runs out, then restores green and
344+ ; respawns it — so the respawn now waits for the flash instead of
345+ ; happening instantly.
346+ lda #$01
347+ sta $d029 ; sprite 2 colour = white
348+ lda #$08
349+ sta flash_timer
330350
331351 no_hit:
332352
The complete step 1 program
; Starfield - Unit 8: Explosion
; Cumulative steps: step-00 (instant hit) -> step-01 (+ white flash + freeze timer) -> step-02 (+ explosion 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)
enemy_x       = $06     ; Enemy X position
enemy_y       = $07     ; Enemy Y position
flash_timer   = $08     ; Frames of hit-flash remaining (0 = idle)

; ------------------------------------------------
; 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 #%00000101
        sta $d015           ; Enable sprites 0 (ship) and 2 (enemy)
        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)

        ; Sprite 2 setup (enemy)
        lda #130
        sta $07fa           ; Data pointer (block 130 = $2080)
        lda #$05
        sta $d029           ; Colour (green)
        lda #100
        sta enemy_x
        sta $d004           ; X position
        lda #$32
        sta enemy_y
        sta $d005           ; Y position (top of the play area)

        ; 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

; ------------------------------------------------
; 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 -

        ; --- 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 enemy ---
        ; While the flash timer runs the enemy is frozen white; otherwise it drifts.
        lda flash_timer
        beq enemy_move          ; not flashing -> normal movement
        dec flash_timer
        bne no_respawn          ; still flashing -> stay frozen, white
        ; flash just ended -> restore colour and respawn at the top
        lda #$05
        sta $d029               ; back to green
        lda #$32
        sta enemy_y
        sta $d005
        lda $d012
        and #$7f
        clc
        adc #$30
        sta enemy_x
        sta $d004
        jmp no_respawn

enemy_move:
        ; drift down 1 pixel per frame
        lda enemy_y
        clc
        adc #$01            ; clc before adc, the addition from the Primer
        sta enemy_y
        sta $d005           ; sprite 2 Y

        ; Off the bottom? (Y >= 248) -> respawn at the top in a new column
        cmp #$f8
        bcc no_respawn      ; Y < 248, still on screen
        lda #$32
        sta enemy_y
        sta $d005           ; back to the top
        lda $d012           ; raster line — changes constantly, our pseudo-random
        and #$7f            ; range 0-127
        clc
        adc #$30            ; shift to 48-175, keeping it within the visible width
        sta enemy_x
        sta $d004           ; new X column
no_respawn:

        ; --- Bullet vs enemy: same row AND same column? ---
        lda bullet_active
        beq no_hit              ; no bullet in flight, nothing to hit

        ; Y distance (8-bit subtract wraps, so two ranges count as close)
        lda bullet_y
        sec
        sbc enemy_y
        cmp #$10
        bcc y_close             ; 0..15 apart: close
        cmp #$f0
        bcc no_hit              ; 16..239 apart: too far
y_close:
        ; Same row — now the columns must match too.
        ; A bullet in the right portion (9th bit set) is past X=255, far from
        ; any enemy (they stay under X=176), so rule it out before comparing.
        lda $d010
        and #%00000010          ; bullet's 9th X bit (sprite 1)
        bne no_hit

        ; X distance (low byte; the bullet's 9th bit is clear here)
        lda $d002
        sec
        sbc enemy_x
        cmp #$10
        bcc hit_enemy           ; 0..15 apart: close
        cmp #$f0
        bcc no_hit              ; 16..239 apart: too far
        ; 240..255: close from the other side -> fall through

hit_enemy:
        ; Remove the bullet
        lda #$00
        sta bullet_active
        lda $d015
        and #%11111101          ; sprite 1 (bullet) off
        sta $d015

        ; Flash the enemy white and start an 8-frame timer. The enemy update
        ; freezes it white until the timer runs out, then restores green and
        ; respawns it — so the respawn now waits for the flash instead of
        ; happening instantly.
        lda #$01
        sta $d029               ; sprite 2 colour = white
        lda #$08
        sta flash_timer

no_hit:

        jmp game_loop

; ------------------------------------------------
; 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   ;

Two edits make it work. The hit no longer respawns — it writes $01 (white) to the enemy's colour register $d029 and sets flash_timer. And the enemy update gains a guard at the top: while the timer runs, freeze and count down; when it expires, restore green ($05) and respawn. The freeze check sits before the movement, so the enemy stops dead the moment it's hit, instead of drifting through its own death.

On a hit the enemy snaps from green to white and holds still for eight frames before respawning — the kill lands with a visible beat instead of blinking out.

Milestone 2 — an explosion you can hear

You met the SID on voice 1 for the laser. It has three voices, fully independent — so we leave the laser on voice 1 and give the explosion its own, voice 2. Its registers are the same set, just offset seven bytes up:

RegisterVoice 1 (laser)Voice 2 (explosion)
Frequency low$d400$d407
Frequency high$d401$d408
Control (waveform + gate)$d404$d40b
Attack / Decay$d405$d40c
Sustain / Release$d406$d40d

An explosion isn't a note — it's a crash, so we pick the noise waveform: bit 7 of the control register. With a low frequency it reads as a bass rumble. Then it's the same gate dance as the laser — write the waveform with the gate off, then on, to retrigger the envelope:

$D40B — Voice 2 control 7 Noise 1 6 Pulse 0 5 Saw 0 4 Tri 0 3 Test 0 2 Ring 0 1 Sync 0 0 Gate 1
Noise (bit 7) + gate on (bit 0) = $81. Write $80 first to clear the gate, then $81, so each hit retriggers the burst.
Step 2: fire the explosion on voice 2
+14
347347 sta $d029 ; sprite 2 colour = white
348348 lda #$08
349349 sta flash_timer
350+
351+ ; Explosion sound — SID voice 2, noise waveform (voice 1 keeps the laser)
352+ lda #$00
353+ sta $d407 ; voice 2 frequency low
354+ lda #$08
355+ sta $d408 ; voice 2 frequency high (a low rumble)
356+ lda #$09
357+ sta $d40c ; attack 0, decay 9
358+ lda #$00
359+ sta $d40d ; sustain 0, release 0
360+ lda #$80
361+ sta $d40b ; noise, gate OFF (reset the envelope)
362+ lda #$81
363+ sta $d40b ; noise, gate ON (trigger the burst)
350364
351365 no_hit:
352366
The complete program
; Starfield - Unit 8: Explosion
; Cumulative steps: step-00 (instant hit) -> step-01 (+ white flash + freeze timer) -> step-02 (+ explosion 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)
enemy_x       = $06     ; Enemy X position
enemy_y       = $07     ; Enemy Y position
flash_timer   = $08     ; Frames of hit-flash remaining (0 = idle)

; ------------------------------------------------
; 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 #%00000101
        sta $d015           ; Enable sprites 0 (ship) and 2 (enemy)
        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)

        ; Sprite 2 setup (enemy)
        lda #130
        sta $07fa           ; Data pointer (block 130 = $2080)
        lda #$05
        sta $d029           ; Colour (green)
        lda #100
        sta enemy_x
        sta $d004           ; X position
        lda #$32
        sta enemy_y
        sta $d005           ; Y position (top of the play area)

        ; 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

; ------------------------------------------------
; 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 -

        ; --- 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 enemy ---
        ; While the flash timer runs the enemy is frozen white; otherwise it drifts.
        lda flash_timer
        beq enemy_move          ; not flashing -> normal movement
        dec flash_timer
        bne no_respawn          ; still flashing -> stay frozen, white
        ; flash just ended -> restore colour and respawn at the top
        lda #$05
        sta $d029               ; back to green
        lda #$32
        sta enemy_y
        sta $d005
        lda $d012
        and #$7f
        clc
        adc #$30
        sta enemy_x
        sta $d004
        jmp no_respawn

enemy_move:
        ; drift down 1 pixel per frame
        lda enemy_y
        clc
        adc #$01            ; clc before adc, the addition from the Primer
        sta enemy_y
        sta $d005           ; sprite 2 Y

        ; Off the bottom? (Y >= 248) -> respawn at the top in a new column
        cmp #$f8
        bcc no_respawn      ; Y < 248, still on screen
        lda #$32
        sta enemy_y
        sta $d005           ; back to the top
        lda $d012           ; raster line — changes constantly, our pseudo-random
        and #$7f            ; range 0-127
        clc
        adc #$30            ; shift to 48-175, keeping it within the visible width
        sta enemy_x
        sta $d004           ; new X column
no_respawn:

        ; --- Bullet vs enemy: same row AND same column? ---
        lda bullet_active
        beq no_hit              ; no bullet in flight, nothing to hit

        ; Y distance (8-bit subtract wraps, so two ranges count as close)
        lda bullet_y
        sec
        sbc enemy_y
        cmp #$10
        bcc y_close             ; 0..15 apart: close
        cmp #$f0
        bcc no_hit              ; 16..239 apart: too far
y_close:
        ; Same row — now the columns must match too.
        ; A bullet in the right portion (9th bit set) is past X=255, far from
        ; any enemy (they stay under X=176), so rule it out before comparing.
        lda $d010
        and #%00000010          ; bullet's 9th X bit (sprite 1)
        bne no_hit

        ; X distance (low byte; the bullet's 9th bit is clear here)
        lda $d002
        sec
        sbc enemy_x
        cmp #$10
        bcc hit_enemy           ; 0..15 apart: close
        cmp #$f0
        bcc no_hit              ; 16..239 apart: too far
        ; 240..255: close from the other side -> fall through

hit_enemy:
        ; Remove the bullet
        lda #$00
        sta bullet_active
        lda $d015
        and #%11111101          ; sprite 1 (bullet) off
        sta $d015

        ; Flash the enemy white and start an 8-frame timer. The enemy update
        ; freezes it white until the timer runs out, then restores green and
        ; respawns it — so the respawn now waits for the flash instead of
        ; happening instantly.
        lda #$01
        sta $d029               ; sprite 2 colour = white
        lda #$08
        sta flash_timer

        ; 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)

no_hit:

        jmp game_loop

; ------------------------------------------------
; 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   ;

Because the voices are independent, the laser doesn't have to finish first. Fire, and the pew rings on voice 1; the instant the shot connects, the boom lands on voice 2 — both sounding together:

SID 6581 · recorded from a real hit
The shot pews on voice 1, then the hit booms on voice 2 — two voices at once

That layering is the point of a multi-voice chip: the laser and the explosion occupy different voices, so one never cuts the other off. The same one-byte volume at $d418 carries both.

When it's wrong, see why

The flash and the boom fail independently — check them apart:

  • No flash. The colour writes are the suspects. On a hit $d029 should become $01 (white); when flash_timer expires it should return to $05 (green). If it sticks white, the restore isn't running; if it never whitens, the hit isn't writing the colour.
  • The enemy drifts during its death. The freeze guard is in the wrong place. The flash_timer test must come before the movement code, or the enemy steps once before freezing.
  • No boom. Walk the voice-2 chain: master volume $d418 non-zero (set once at startup), and the gate at $d40b toggling $80$81. A lone $81 won't retrigger if the gate was already set.
  • A boom, but wrong or stepping on the laser. Check you wrote $d407$d40d (voice 2), not $d400$d406 (voice 1). They're identical layouts seven bytes apart, and hitting voice 1 by mistake overwrites the laser mid-pew.

Before and after

We started with a hit that blinked out in silence and finished with one that flashes white, holds for a beat, and booms — the same collision, now with the feedback that makes destroying something feel like destroying something. None of it changes what the code does; all of it changes how the moment lands.

Try this

  • Reshape the boom. Voice 2's frequency and decay set its character. $d408 = $02 is a subterranean rumble; $18 is more of a crackle. A longer decay ($d40c = $0f) lets it linger; a short one ($03) snaps it off. Find the explosion that fits your game.
  • Time the flash. flash_timer is 8 frames. Drop it to 4 for a quick blink, raise it to 16 for a heavier hit. Too long and the game feels like it's pausing on every kill — feel where the line is.

What you've learnt

  • Game feel — the same hit, made to land: feedback in the medium the player can see and hear, not just in the logic.
  • The frame-counter pattern — set a value, decrement each frame, act at zero. Flash windows, animation, delays — all the same shape.
  • A timed visual effect — a temporary colour change with a countdown, and freezing movement while it runs.
  • SID voice 2 — an independent channel, its registers seven bytes up from voice 1, sounding at the same time.
  • The noise waveform — bit 7 of the control register, the raw material for explosions, static, and percussion.

What's next

Every hit now flashes and booms, but the screen keeps no tally — destroy ten enemies and there's nothing to show for it. Next you'll write a score to the screen directly, poking digits into screen RAM.