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

Direct Hit

Your bullet meets the enemy — software collision detection using coordinate distance checking. The enemy vanishes and respawns at the top.

5% of Starfield

Fire. Hit. The enemy vanishes and reappears at the top. No explosion, no score — just proof that your bullet knows where the enemy is.

Collision Detection

Games need to know when things overlap. The VIC-II has hardware collision registers ($D01E and $D01F) that detect when sprite pixels touch, but they’re awkward for precise game logic — they trigger on pixel overlap, not position proximity, and they latch until read. Software collision is more predictable: compare the X and Y coordinates yourself.

The approach: each frame, if the bullet is active, check whether it’s close enough to the enemy. “Close enough” means both X and Y are within 16 pixels. If both checks pass, it’s a hit.

Checking Distance

        ; --- Check bullet-enemy collision ---
        lda bullet_active
        beq no_hit              ; No bullet, skip

        ; Check Y distance
        lda bullet_y
        sec
        sbc enemy_y
        cmp #$10                ; Within 16 pixels? (positive direction)
        bcc y_close
        cmp #$f0                ; Within 16 pixels? (negative/wrapped)
        bcc no_hit              ; Too far apart
y_close:
        ; Check X distance
        lda $d002               ; Bullet X
        sec
        sbc enemy_x
        cmp #$10                ; Within 16 pixels? (positive direction)
        bcc hit_enemy
        cmp #$f0                ; Within 16 pixels? (negative/wrapped)
        bcc no_hit              ; Too far apart

hit_enemy:
        ; Deactivate bullet
        lda #$00
        sta bullet_active
        lda $d015
        and #%11111101          ; Disable sprite 1 (bullet)
        sta $d015

        ; Respawn enemy at top
        lda #$32
        sta enemy_y
        sta $d005
        lda $d012               ; New random X
        and #$7f
        clc
        adc #$30
        sta enemy_x
        sta $d004

no_hit:

The first line is a guard clause — if bullet_active is zero, skip the entire check. No point comparing positions when there’s nothing to hit with.

Then comes the Y check. SEC / SBC subtracts enemy_y from bullet_y. The result tells us how far apart they are, but there’s a subtlety: subtraction wraps around in 8 bits.

If the bullet is below the enemy (bullet_y > enemy_y), the result is a small positive number — straightforward. If the bullet is above the enemy (bullet_y < enemy_y), the result wraps to a large value near 255.

Both cases can mean “close”:

  • bullet_y = 100, enemy_y = 90 — result is 10. Less than $10 (16), so they’re close.
  • bullet_y = 100, enemy_y = 105 — result is 251 (unsigned). Greater than or equal to $F0 (240), so they’re also close — from the other direction.
  • bullet_y = 100, enemy_y = 50 — result is 50. Neither less than $10 nor greater than $F0. Too far apart.

The two CMP / branch instructions carve out these two “close” ranges. Values 0–15 ($00$0F) and 240–255 ($F0$FF) both mean “within 16 pixels.” Everything in between means “too far.”

The X check works identically, reading the bullet’s sprite X register at $D002 and comparing against enemy_x.

On Hit

When both checks pass, two things happen:

Deactivate the bullet. Clear bullet_active and use AND to turn off bit 1 in the sprite enable register — the same pattern from Unit 3, just in a new context.

Respawn the enemy. Reset Y to $32 (top of screen) and pick a new random X from the raster register — the same respawn pattern from Unit 5. The enemy reappears immediately, ready to drift down again.

No explosion sound yet. No score. That’s Units 7 and 8. This unit is purely about the geometry — proving that two objects can find each other in screen space.

The Complete Code

; Starfield - Unit 6: Direct Hit
; Assemble with: acme -f cbm -o starfield.prg starfield.asm

; ------------------------------------------------
; Zero-page variables
; ------------------------------------------------
bullet_active = $02     ; 0 = no bullet, 1 = active
bullet_y      = $03     ; Bullet Y position
enemy_x       = $04     ; Enemy X position
enemy_y       = $05     ; 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)

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

        ; Enemy starting position
        lda #100
        sta enemy_x
        sta $d004           ; X position
        lda #$32
        sta enemy_y
        sta $d005           ; Y position

        ; Enable sprites 0 and 2 (bullet starts disabled)
        lda #%00000101
        sta $d015

        ; 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 #$09
        sta $d405           ; Attack=0, Decay=9
        lda #$00
        sta $d406           ; Sustain=0, Release=0

!ifdef SCREENSHOT_MODE {
        ; Place a static bullet for screenshot capture
        lda $d000
        sta $d002           ; Bullet X = ship X
        lda #140
        sta $d003           ; Bullet Y mid-screen

        ; Enable all three sprites
        lda #%00000111
        sta $d015
}

; ------------------------------------------------
; Game loop — runs once per frame
; ------------------------------------------------
game_loop:
        ; Wait for the raster beam to reach line 255
-       lda $d012
        cmp #$ff
        bne -

        ; --- Read joystick and move ship ---

        ; UP (bit 0)
        lda $dc00
        and #%00000001
        bne not_up
        dec $d001
        dec $d001
not_up:

        ; DOWN (bit 1)
        lda $dc00
        and #%00000010
        bne not_down
        inc $d001
        inc $d001
not_down:

        ; LEFT (bit 2)
        lda $dc00
        and #%00000100
        bne not_left
        dec $d000
        dec $d000
not_left:

        ; RIGHT (bit 3)
        lda $dc00
        and #%00001000
        bne not_right
        inc $d000
        inc $d000
not_right:

        ; --- Fire button (bit 4) ---
        lda $dc00
        and #%00010000
        bne no_fire         ; Bit is 1 = NOT pressed

        ; Fire is pressed — only spawn if no bullet active
        lda bullet_active
        bne no_fire         ; Already active, skip

        ; Spawn bullet at ship position
        lda $d000           ; Ship X → bullet X
        sta $d002
        lda $d001           ; Ship Y → bullet Y
        sta bullet_y

        ; Enable sprite 1 (keep other sprites enabled)
        lda $d015
        ora #%00000010
        sta $d015

        ; Mark bullet active
        lda #$01
        sta bullet_active

        ; Trigger laser sound (gate off then on to retrigger)
        lda #$20
        sta $d404           ; Sawtooth, gate OFF (reset envelope)
        lda #$21
        sta $d404           ; Sawtooth, gate ON (start sound)

no_fire:

        ; --- Update bullet ---
        lda bullet_active
        beq no_bullet       ; Not active, skip

        ; Move bullet up (4 pixels per frame)
        lda bullet_y
        sec
        sbc #$04
        sta bullet_y
        sta $d003           ; Update sprite 1 Y position

        ; Check if bullet has left the screen (Y < 30)
        cmp #$1e
        bcs no_bullet       ; Y >= 30, still on screen

        ; Deactivate bullet
        lda #$00
        sta bullet_active

        ; Disable sprite 1 (keep other sprites enabled)
        lda $d015
        and #%11111101
        sta $d015

no_bullet:

        ; --- Check bullet-enemy collision ---
        lda bullet_active
        beq no_hit              ; No bullet, skip

        ; Check Y distance
        lda bullet_y
        sec
        sbc enemy_y
        cmp #$10                ; Within 16 pixels? (positive direction)
        bcc y_close
        cmp #$f0                ; Within 16 pixels? (negative/wrapped)
        bcc no_hit              ; Too far apart
y_close:
        ; Check X distance
        lda $d002               ; Bullet X
        sec
        sbc enemy_x
        cmp #$10                ; Within 16 pixels? (positive direction)
        bcc hit_enemy
        cmp #$f0                ; Within 16 pixels? (negative/wrapped)
        bcc no_hit              ; Too far apart

hit_enemy:
        ; Deactivate bullet
        lda #$00
        sta bullet_active
        lda $d015
        and #%11111101          ; Disable sprite 1 (bullet)
        sta $d015

        ; Respawn enemy at top
        lda #$32
        sta enemy_y
        sta $d005
        lda $d012               ; New random X
        and #$7f
        clc
        adc #$30
        sta enemy_x
        sta $d004

no_hit:

        ; --- Update enemy ---
        lda enemy_y
        clc
        adc #$01            ; Move down 1 pixel per frame
        sta enemy_y
        sta $d005           ; Update sprite 2 Y position

        ; Check if enemy has left the screen (Y > 248)
        cmp #$f8
        bcc no_respawn      ; Y < 248, still on screen

        ; Respawn at top with new X position
        lda #$32
        sta enemy_y
        sta $d005           ; Reset Y to top

        lda $d012           ; Pseudo-random from raster position
        and #$7f            ; Range 0-127
        clc
        adc #$30            ; Range 48-175
        sta enemy_x
        sta $d004           ; New X position

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

If It Doesn’t Work

  • Bullet passes through? Check the distance threshold — $10 is 16 pixels. The enemy sprite is roughly 16 pixels wide, so this should catch most overlaps. If sprites are offset, try $14 (20 pixels).
  • Hit detection only works sometimes? Make sure you’re comparing bullet_y (the zero-page variable) with enemy_y. The sprite register $D003 may lag behind by a frame if you read it before writing the updated position.
  • Enemy doesn’t respawn on hit? Check the respawn code runs inside the hit_enemy block, not unconditionally after it. The no_hit label must be after the respawn instructions.
  • Everything dies instantly? Make sure the collision check is between the bullet update and the enemy update, not overlapping with either.

Try This: Adjust the Hit Box

CMP #$10 defines a 16-pixel threshold. You can change the Y and X thresholds independently:

  • CMP #$08 — 8 pixels. Tight. Requires precise aim.
  • CMP #$18 — 24 pixels. Very forgiving, hard to miss.

A taller hit box (bigger Y threshold) with a narrow X threshold rewards horizontal aiming. A wide X threshold with a tight Y threshold rewards timing. Experiment with the feel.

Try This: Count Hits

Add a zero-page variable:

hit_count = $06

In the hit_enemy block, add:

inc hit_count

You can’t display it on screen yet (that’s Unit 8), but you can verify it’s changing by watching address $06 in a monitor or debugger. It’s a preview of what scoring will look like.

What You’ve Learnt

  • Software collision detection — comparing coordinates directly, not relying on hardware sprite collision registers.
  • Unsigned distanceSEC / SBC gives a result where both small values (0–15) and values near 255 (240–255) mean “close.”
  • Nested checks — Y first, then X. Both must pass for a hit.
  • Guard clause — skip the entire check when bullet_active is zero. No wasted cycles, no false positives.

What’s Next

The enemy vanishes instantly — no fanfare, no sound. In Unit 7, the SID chip gives explosions a voice, and the enemy flashes white on impact.