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

Direct Hit

Make the bullet and enemy notice each other — software collision by comparing coordinates on both axes, with the unsigned-distance trick and a ninth-bit guard.

44% of Starfield

The enemy drifts down, your bullet flies up, and they pass straight through each other as if neither existed. They don't yet share any code that says "are you touching?" This unit adds that question — collision detection — and the answer makes the first real interaction in the game: a shot that meets the enemy destroys it.

Where we start

Unit 6's enemy comes at you and respawns forever; Unit 5's bullet fires and climbs. Neither knows the other is there.

The white ship at the bottom and the green enemy partway down the screen, in different columns — a bullet fired now would pass the enemy without effect.

The VIC-II does have hardware collision registers ($d01e, $d01f), but they're awkward for game logic: they report which sprites overlapped, not by how much, and they latch until you read them. Comparing coordinates ourselves is more predictable and teaches the idea cleanly. Each frame, if a bullet is flying, we ask: is it close enough to the enemy to count as a hit?

Milestone 1 — same row

"Close" is a distance, and distance is a subtraction: bullet_y − enemy_y. But these are unsigned bytes, and subtraction wraps — which is the one trick this whole unit turns on. When the bullet is just below the enemy the result is a small positive number; when it's just above, the result wraps round near 255. Both mean close — from opposite sides:

Situationbullet_y − enemy_yReading
bullet 10 below the enemy10015 → close
bullet 5 above the enemy251wraps high, 240255 → close
bullet 50 below the enemy5016239 → too far

So two compares carve out the two "close" bands: under $10 (0–15), or $f0 and over (240–255). Anything between is too far apart. For this first step we check only Y — are they on the same row? — and on a match we remove the bullet and respawn the enemy:

Step 1: a hit is any bullet on the enemy's row
+35
278278 sta enemy_x
279279 sta $d004 ; new X column
280280 no_respawn:
281+
282+ ; --- Bullet vs enemy: same row? (Y distance only, for now) ---
283+ lda bullet_active
284+ beq no_hit ; no bullet in flight, nothing to hit
285+
286+ ; how far apart vertically? (8-bit subtract wraps, so two ranges count)
287+ lda bullet_y
288+ sec
289+ sbc enemy_y
290+ cmp #$10
291+ bcc hit_enemy ; 0..15 apart: close
292+ cmp #$f0
293+ bcc no_hit ; 16..239 apart: too far
294+ ; 240..255: close from the other side -> fall through
295+
296+hit_enemy:
297+ ; Remove the bullet
298+ lda #$00
299+ sta bullet_active
300+ lda $d015
301+ and #%11111101 ; sprite 1 (bullet) off
302+ sta $d015
303+
304+ ; Respawn the enemy at the top in a new column
305+ lda #$32
306+ sta enemy_y
307+ sta $d005
308+ lda $d012
309+ and #$7f
310+ clc
311+ adc #$30
312+ sta enemy_x
313+ sta $d004
314+
315+no_hit:
281316
282317 jmp game_loop
283318
The complete step 1 program
; Starfield - Unit 7: Direct Hit
; Cumulative steps: step-00 (ship + enemy) -> step-01 (+ same-row hit: Y only) -> step-02 (+ same-column too: real collision)
; 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

; ------------------------------------------------
; 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: 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? (Y distance only, for now) ---
        lda bullet_active
        beq no_hit              ; no bullet in flight, nothing to hit

        ; how far apart vertically? (8-bit subtract wraps, so two ranges count)
        lda bullet_y
        sec
        sbc enemy_y
        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

        ; Respawn the enemy at the top in a new column
        lda #$32
        sta enemy_y
        sta $d005
        lda $d012
        and #$7f
        clc
        adc #$30
        sta enemy_x
        sta $d004

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   ;

A guard comes first: if bullet_active is zero there's nothing to test, so we skip the whole block — no wasted work, no false hits from a stale position. Run it and the flaw is obvious. Fire straight up the middle and the enemy dies even though the bullet is nowhere near it horizontally:

Y-only is too eager: the bullet rises up the centre while the enemy sits well to the left — yet the enemy vanishes the instant the bullet reaches its row, then respawns.

Checking one axis answers "same height?" — not "same place?". A row is the whole width of the screen.

Milestone 2 — same column too

A real hit needs both: same row and same column. We add the identical distance test on X, reading the bullet's sprite-X register $d002 against enemy_x.

One wrinkle the bullet picked up in Unit 4: it can carry a ninth X bit. The enemy always sits under X=176, so a bullet whose ninth bit is set is past X=255 — a screen-width away, never a real hit. If we compared low bytes alone, such a shot could falsely match an enemy on the left. So before the X compare we rule it out: ninth bit set on the bullet means no hit.

Step 2: require the column to match, ninth-bit-aware
+18-2
279279 sta $d004 ; new X column
280280 no_respawn:
281281
282- ; --- Bullet vs enemy: same row? (Y distance only, for now) ---
282+ ; --- Bullet vs enemy: same row AND same column? ---
283283 lda bullet_active
284284 beq no_hit ; no bullet in flight, nothing to hit
285285
286- ; how far apart vertically? (8-bit subtract wraps, so two ranges count)
286+ ; Y distance (8-bit subtract wraps, so two ranges count as close)
287287 lda bullet_y
288288 sec
289289 sbc enemy_y
290+ cmp #$10
291+ bcc y_close ; 0..15 apart: close
292+ cmp #$f0
293+ bcc no_hit ; 16..239 apart: too far
294+y_close:
295+ ; Same row — now the columns must match too.
296+ ; A bullet in the right portion (9th bit set) is past X=255, far from
297+ ; any enemy (they stay under X=176), so rule it out before comparing.
298+ lda $d010
299+ and #%00000010 ; bullet's 9th X bit (sprite 1)
300+ bne no_hit
301+
302+ ; X distance (low byte; the bullet's 9th bit is clear here)
303+ lda $d002
304+ sec
305+ sbc enemy_x
290306 cmp #$10
291307 bcc hit_enemy ; 0..15 apart: close
292308 cmp #$f0
The complete program
; Starfield - Unit 7: Direct Hit
; Cumulative steps: step-00 (ship + enemy) -> step-01 (+ same-row hit: Y only) -> step-02 (+ same-column too: real collision)
; 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

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

        ; Respawn the enemy at the top in a new column
        lda #$32
        sta enemy_y
        sta $d005
        lda $d012
        and #$7f
        clc
        adc #$30
        sta enemy_x
        sta $d004

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   ;

Now the centred shot — same one that killed before — sails right past the enemy in its own column:

With the column check in place, the centred shot passes the enemy's row harmlessly — different column, no hit. The enemy drifts on.

Line the ship up under the enemy first, and the same fire button finally connects:

Same row, same column: a real hit. The bullet rises into the enemy, which is destroyed and respawns at the top.

When it's wrong, see why

Collision bugs are quiet — nothing crashes, the hit just lands wrong. Read the positions and the flags:

  • Bullet passes through a dead-centre shot. A threshold or an axis is off. $10 is 16 pixels, about the sprite's width; widen it to $14 if your sprites sit a little apart. And confirm both compares run — a missing X test reverts you to Milestone 1's behaviour.
  • The enemy dies wherever you fire. The X check isn't gating the hit — the Y match is reaching hit_enemy on its own. The Y-close branch must fall into the X test, not straight into the hit.
  • Phantom kills when firing from the right. The ninth-bit guard. Read $d010: if the bullet's bit (bit 1) is set, its real X is 256-plus and it can't touch a left-side enemy — that shot must score no hit.
  • Hit registers but the enemy doesn't reappear. The respawn lives inside hit_enemy; if no_hit sits above it, the reset is skipped. Walk enemy_y — it should snap back to $32 on a hit.

Before and after

We started with a bullet and an enemy that ignored each other, and finished with a shot that destroys the enemy when — and only when — it genuinely overlaps it on both axes. The geometry is all that's here: no sound, no score, no flash. Just two objects finding each other in screen space.

Try this

  • Resize the hit box. cmp #$10 sets a 16-pixel reach on each axis, and you can tune them apart. #$08 demands precise aim; #$18 is forgiving. A tall, narrow box rewards lining up the column; a short, wide one rewards timing the row. The feel is yours to set.
  • Count the kills. Add hit_count = $08 to the zero-page list (the bytes up to $07 are taken), and inc hit_count inside hit_enemy. You can't show it on screen yet — that's the scoring unit — but you can watch address $08 climb in a monitor. It's a preview of where this is heading.

What you've learnt

  • Software collision — comparing coordinates directly, not leaning on the VIC-II's latch-and-overlap registers.
  • Unsigned distance — one subtraction, where both 015 and 240255 mean "close," because the byte wraps.
  • Two axes, both required — a row match alone hits the whole screen width; the column check is what makes it a real overlap.
  • A ninth-bit guard — a bullet past X=255 can't touch a left-side enemy, so rule it out before comparing low bytes.
  • The guard clause — skip the whole test when no bullet is flying.

What's next

The enemy blinks out of existence with no fanfare at all. Next you'll sell the hit — a white flash on the enemy and an explosion from the SID, the difference between "it worked" and "it felt good."