Direct Hit
Your bullet meets the enemy — software collision detection using coordinate distance checking. The enemy vanishes and respawns at the top.
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
$10nor 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 —
$10is 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) withenemy_y. The sprite register$D003may 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_enemyblock, not unconditionally after it. Theno_hitlabel 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 distance —
SEC/SBCgives 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_activeis 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.