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.
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.
| 11 | 11 | laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only) | |
| 12 | 12 | enemy_x = $06 ; Enemy X position | |
| 13 | 13 | enemy_y = $07 ; Enemy Y position | |
| 14 | + | flash_timer = $08 ; Frames of hit-flash remaining (0 = idle) | |
| 14 | 15 | | |
| 15 | 16 | ; ------------------------------------------------ | |
| 16 | 17 | ; BASIC stub | |
| ... | |||
| 258 | 259 | | |
| 259 | 260 | no_bullet: | |
| 260 | 261 | | |
| 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 | |
| 262 | 284 | lda enemy_y | |
| 263 | 285 | clc | |
| 264 | 286 | adc #$01 ; clc before adc, the addition from the Primer | |
| ... | |||
| 317 | 339 | and #%11111101 ; sprite 1 (bullet) off | |
| 318 | 340 | sta $d015 | |
| 319 | 341 | | |
| 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 | |
| 330 | 350 | | |
| 331 | 351 | no_hit: | |
| 332 | 352 | |
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.
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:
| Register | Voice 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:
| 347 | 347 | sta $d029 ; sprite 2 colour = white | |
| 348 | 348 | lda #$08 | |
| 349 | 349 | 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) | |
| 350 | 364 | | |
| 351 | 365 | no_hit: | |
| 352 | 366 | |
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:
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
$d029should become$01(white); whenflash_timerexpires 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_timertest must come before the movement code, or the enemy steps once before freezing. - No boom. Walk the voice-2 chain: master volume
$d418non-zero (set once at startup), and the gate at$d40btoggling$80→$81. A lone$81won'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 = $02is a subterranean rumble;$18is 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_timeris 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.