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

Enemy Waves

Turn one enemy into a wave — parallel arrays and indexed addressing drive several enemies from a single loop, with the Y register and a spawn subroutine doing the repetitive work.

63% of Starfield

One enemy is target practice. A wave — several descending at once, each in its own column — is a game. The naive way to add enemies is to copy the movement code three times with different variables, and it falls apart fast. The real tool is indexed addressing: store each enemy's state in an array, and write the movement once, with the index doing the work. This is the unit where one becomes many.

Where we start

Unit 9 has a single enemy — one enemy_x, one enemy_y, one flash_timer — and a score. Everything below generalises that "one" into "however many."

Milestone 1 — many from one loop

Three enemies need three X positions, three Ys, three flash timers. Instead of naming them all, we lay each set out as a parallel array in zero page — consecutive bytes, one per enemy — so the same index reaches the matching slot in every array:

Indexenemy_x_tblenemy_y_tblflash_tblSprite
0$07$0a$0d2
1$08$0b$0e3
2$09$0c$0f4

lda enemy_x_tbl,x reads the byte at enemy_x_tbl + X — enemy 0's X when X is 0, enemy 1's when X is 1. So the movement code is written once and run inside a loop: ldx #$00, do the work, inx, cpx #$02, bne back. One body, every enemy.

There's a second problem the loop creates. While X tracks which enemy, we still need to address the right sprite registers — and those aren't an array we control, they're fixed VIC-II locations two bytes apart ($d004 for sprite 2, $d006 for sprite 3…). So we use the Y register as a second index, fed from a small lookup table of offsets: ldy sprite_pos_off,x then sta $d000,y. X indexes our data; Y indexes the hardware.

Finally, the spawn logic — set a position, pick a column, restore the colour, write the registers — is now needed in three places (initial setup and two respawn paths). Copying it three times invites bugs, so it becomes a subroutine: jsr spawn_enemy jumps in, rts returns to just after the call. The 6502's stack at $0100 remembers the return address for us. We pass it the start Y in A and the enemy index in X.

Step 1: arrays, an indexed loop, and a spawn subroutine (two enemies)
+111-76
99 bullet_y = $03 ; Bullet Y position
1010 laser_timer = $04 ; Frames of laser pitch-sweep remaining (0 = idle)
1111 laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only)
12-enemy_x = $06 ; Enemy X position
13-enemy_y = $07 ; Enemy Y position
14-flash_timer = $08 ; Frames of hit-flash remaining (0 = idle)
15-score = $09 ; Two-digit score, BCD (one decimal digit per nybble)
12+score = $06 ; Two-digit score, BCD (one decimal digit per nybble)
13+; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
14+enemy_x_tbl = $07 ; 3 bytes ($07,$08,$09): each enemy's X
15+enemy_y_tbl = $0a ; 3 bytes ($0a,$0b,$0c): each enemy's Y
16+flash_tbl = $0d ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer
1617
1718 ; ------------------------------------------------
1819 ; BASIC stub
...
4849 sta $d001 ; Y position
4950 lda #$01
5051 sta $d027 ; Colour (white)
51- lda #%00000101
52- sta $d015 ; Enable sprites 0 (ship) and 2 (enemy)
52+ lda #%00001101
53+ sta $d015 ; Enable sprites 0 (ship), 2 and 3 (two enemies)
5354 lda #$00
5455 sta $d010 ; sprite high-X bits clear (ship starts under X=256)
5556
...
5960 lda #$07
6061 sta $d028 ; Colour (yellow)
6162
62- ; Sprite 2 setup (enemy)
63+ ; Enemy sprites 2 and 3 share the one enemy shape in block 130.
64+ ; Colour and position are set per-enemy by spawn_enemy, below.
6365 lda #130
64- sta $07fa ; Data pointer (block 130 = $2080)
65- lda #$05
66- sta $d029 ; Colour (green)
67- lda #100
68- sta enemy_x
69- sta $d004 ; X position
70- lda #$32
71- sta enemy_y
72- sta $d005 ; Y position (top of the play area)
66+ sta $07fa ; sprite 2 data pointer
67+ sta $07fb ; sprite 3 data pointer
7368
7469 ; Bullet starts inactive
7570 lda #$00
...
9994 lda #$01
10095 sta $d800 ; colour both digits white
10196 sta $d801
97+
98+ ; Spawn the wave at staggered heights (A = start Y, X = enemy index)
99+ lda #$32
100+ ldx #$00
101+ jsr spawn_enemy
102+ lda #$82
103+ ldx #$01
104+ jsr spawn_enemy
102105
103106 ; ------------------------------------------------
104107 ; Game loop — runs once per frame
...
271274
272275 no_bullet:
273276
274- ; --- Update enemy ---
275- ; While the flash timer runs the enemy is frozen white; otherwise it drifts.
276- lda flash_timer
277- beq enemy_move ; not flashing -> normal movement
278- dec flash_timer
279- bne no_respawn ; still flashing -> stay frozen, white
280- ; flash just ended -> restore colour and respawn at the top
281- lda #$05
282- sta $d029 ; back to green
283- lda #$32
284- sta enemy_y
285- sta $d005
286- lda $d012
287- and #$7f
288- clc
289- adc #$30
290- sta enemy_x
291- sta $d004
292- jmp no_respawn
277+ ; --- Update every enemy: one indexed loop does all of them ---
278+ ldx #$00
279+enemy_loop:
280+ lda flash_tbl,x
281+ bne do_flash ; this enemy is mid-flash
293282
294-enemy_move:
295- ; drift down 1 pixel per frame
296- lda enemy_y
283+ ; not flashing: drift down 1 pixel per frame
284+ lda enemy_y_tbl,x
297285 clc
298- adc #$01 ; clc before adc, the addition from the Primer
299- sta enemy_y
300- sta $d005 ; sprite 2 Y
286+ adc #$01 ; clc before adc, the addition from the Primer
287+ sta enemy_y_tbl,x
288+ cmp #$f8 ; off the bottom? (Y >= 248)
289+ bcc update_sprite ; still on screen
290+ lda #$32 ; respawn this enemy at the top, new column
291+ jsr spawn_enemy
292+ jmp next_enemy
301293
302- ; Off the bottom? (Y >= 248) -> respawn at the top in a new column
303- cmp #$f8
304- bcc no_respawn ; Y < 248, still on screen
305- lda #$32
306- sta enemy_y
307- sta $d005 ; back to the top
308- lda $d012 ; raster line — changes constantly, our pseudo-random
309- and #$7f ; range 0-127
310- clc
311- adc #$30 ; shift to 48-175, keeping it within the visible width
312- sta enemy_x
313- sta $d004 ; new X column
314-no_respawn:
294+do_flash:
295+ dec flash_tbl,x
296+ bne update_sprite ; still flashing -> stay frozen, white
297+ lda #$32 ; flash done -> respawn (spawn_enemy restores green)
298+ jsr spawn_enemy
299+ jmp next_enemy
315300
316- ; --- Bullet vs enemy: same row AND same column? ---
301+update_sprite:
302+ ; copy this enemy's position into its VIC-II sprite registers
303+ ldy sprite_pos_off,x
304+ lda enemy_x_tbl,x
305+ sta $d000,y ; sprite X ($d004, $d006, ...)
306+ lda enemy_y_tbl,x
307+ sta $d001,y ; sprite Y ($d005, $d007, ...)
308+
309+next_enemy:
310+ inx
311+ cpx #$02 ; two enemies for now
312+ bne enemy_loop
313+
314+ ; --- Bullet vs the wave: test each enemy until one is hit ---
317315 lda bullet_active
318- beq no_hit ; no bullet in flight, nothing to hit
316+ bne check_collision
317+ jmp no_hit
318+check_collision:
319+ ldx #$00
320+collision_loop:
321+ lda flash_tbl,x
322+ bne next_collision ; skip an enemy that's already exploding
319323
320324 ; Y distance (8-bit subtract wraps, so two ranges count as close)
321325 lda bullet_y
322326 sec
323- sbc enemy_y
327+ sbc enemy_y_tbl,x
324328 cmp #$10
325- bcc y_close ; 0..15 apart: close
329+ bcc check_x ; 0..15 apart: close
326330 cmp #$f0
327- bcc no_hit ; 16..239 apart: too far
328-y_close:
329- ; Same row — now the columns must match too.
331+ bcc next_collision ; 16..239 apart: too far
332+check_x:
330333 ; A bullet in the right portion (9th bit set) is past X=255, far from
331- ; any enemy (they stay under X=176), so rule it out before comparing.
334+ ; any enemy, so rule it out before comparing low bytes.
332335 lda $d010
333336 and #%00000010 ; bullet's 9th X bit (sprite 1)
334- bne no_hit
335-
336- ; X distance (low byte; the bullet's 9th bit is clear here)
337+ bne next_collision
337338 lda $d002
338339 sec
339- sbc enemy_x
340+ sbc enemy_x_tbl,x
340341 cmp #$10
341342 bcc hit_enemy ; 0..15 apart: close
342343 cmp #$f0
343- bcc no_hit ; 16..239 apart: too far
344- ; 240..255: close from the other side -> fall through
344+ bcc next_collision ; 16..239 apart: too far
345+ jmp hit_enemy ; 240..255: close from the other side
346+
347+next_collision:
348+ inx
349+ cpx #$02
350+ bne collision_loop
351+ jmp no_hit
345352
346353 hit_enemy:
347- ; Remove the bullet
354+ ; X = the enemy that was hit. Remove the bullet.
348355 lda #$00
349356 sta bullet_active
350357 lda $d015
351358 and #%11111101 ; sprite 1 (bullet) off
352359 sta $d015
353360
354- ; Flash the enemy white and start an 8-frame timer. The enemy update
355- ; freezes it white until the timer runs out, then restores green and
356- ; respawns it — so the respawn now waits for the flash instead of
357- ; happening instantly.
358- lda #$01
359- sta $d029 ; sprite 2 colour = white
361+ ; Flash THIS enemy white and start its 8-frame timer. The enemy loop
362+ ; freezes it white until the timer runs out, then respawns it.
360363 lda #$08
361- sta flash_timer
364+ sta flash_tbl,x
365+ ldy sprite_colour_off,x
366+ lda #$01
367+ sta $d000,y ; this enemy's colour register = white
362368
363369 ; Explosion sound — SID voice 2, noise waveform (voice 1 keeps the laser)
364370 lda #$00
...
401407 no_hit:
402408
403409 jmp game_loop
410+
411+; ------------------------------------------------
412+; Subroutine: spawn one enemy
413+; A = starting Y, X = enemy index (X is preserved)
414+; ------------------------------------------------
415+spawn_enemy:
416+ sta enemy_y_tbl,x
417+ lda $d012 ; raster line -> pseudo-random column
418+ and #$7f
419+ clc
420+ adc #$30 ; 48-175, inside the visible width
421+ sta enemy_x_tbl,x
422+ lda #$00
423+ sta flash_tbl,x ; not flashing
424+ ldy sprite_colour_off,x
425+ lda #$05
426+ sta $d000,y ; this enemy's colour = green
427+ ldy sprite_pos_off,x
428+ lda enemy_x_tbl,x
429+ sta $d000,y ; sprite X
430+ lda enemy_y_tbl,x
431+ sta $d001,y ; sprite Y
432+ rts
433+
434+; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
435+sprite_pos_off:
436+ !byte $04, $06, $08 ; X offsets: $d004, $d006, $d008
437+sprite_colour_off:
438+ !byte $29, $2a, $2b ; colour offsets: $d029, $d02a, $d02b
404439
405440 ; ------------------------------------------------
406441 ; Sprite data at $2000 (block 128) — ship
The complete step 1 program
; Starfield - Unit 10: Enemy Waves
; Cumulative steps: step-00 (one enemy) -> step-01 (+ a second, via indexed tables) -> step-02 (+ a third: the full wave)
; 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)
score         = $06     ; Two-digit score, BCD (one decimal digit per nybble)
; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
enemy_x_tbl   = $07     ; 3 bytes ($07,$08,$09): each enemy's X
enemy_y_tbl   = $0a     ; 3 bytes ($0a,$0b,$0c): each enemy's Y
flash_tbl     = $0d     ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer

; ------------------------------------------------
; 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 #%00001101
        sta $d015           ; Enable sprites 0 (ship), 2 and 3 (two enemies)
        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)

        ; Enemy sprites 2 and 3 share the one enemy shape in block 130.
        ; Colour and position are set per-enemy by spawn_enemy, below.
        lda #130
        sta $07fa           ; sprite 2 data pointer
        sta $07fb           ; sprite 3 data pointer

        ; 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

        ; Score readout: "00" in the top-left, white. (We've written to screen
        ; RAM since Unit 1's clear loop — now we write characters, not spaces.)
        lda #$00
        sta score
        lda #$30            ; screen code for '0'
        sta $0400           ; tens digit (row 0, col 0)
        sta $0401           ; ones digit (row 0, col 1)
        lda #$01
        sta $d800           ; colour both digits white
        sta $d801

        ; Spawn the wave at staggered heights (A = start Y, X = enemy index)
        lda #$32
        ldx #$00
        jsr spawn_enemy
        lda #$82
        ldx #$01
        jsr spawn_enemy

; ------------------------------------------------
; 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 every enemy: one indexed loop does all of them ---
        ldx #$00
enemy_loop:
        lda flash_tbl,x
        bne do_flash            ; this enemy is mid-flash

        ; not flashing: drift down 1 pixel per frame
        lda enemy_y_tbl,x
        clc
        adc #$01                ; clc before adc, the addition from the Primer
        sta enemy_y_tbl,x
        cmp #$f8                ; off the bottom? (Y >= 248)
        bcc update_sprite       ; still on screen
        lda #$32                ; respawn this enemy at the top, new column
        jsr spawn_enemy
        jmp next_enemy

do_flash:
        dec flash_tbl,x
        bne update_sprite       ; still flashing -> stay frozen, white
        lda #$32                ; flash done -> respawn (spawn_enemy restores green)
        jsr spawn_enemy
        jmp next_enemy

update_sprite:
        ; copy this enemy's position into its VIC-II sprite registers
        ldy sprite_pos_off,x
        lda enemy_x_tbl,x
        sta $d000,y             ; sprite X  ($d004, $d006, ...)
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y  ($d005, $d007, ...)

next_enemy:
        inx
        cpx #$02                ; two enemies for now
        bne enemy_loop

        ; --- Bullet vs the wave: test each enemy until one is hit ---
        lda bullet_active
        bne check_collision
        jmp no_hit
check_collision:
        ldx #$00
collision_loop:
        lda flash_tbl,x
        bne next_collision      ; skip an enemy that's already exploding

        ; Y distance (8-bit subtract wraps, so two ranges count as close)
        lda bullet_y
        sec
        sbc enemy_y_tbl,x
        cmp #$10
        bcc check_x             ; 0..15 apart: close
        cmp #$f0
        bcc next_collision      ; 16..239 apart: too far
check_x:
        ; A bullet in the right portion (9th bit set) is past X=255, far from
        ; any enemy, so rule it out before comparing low bytes.
        lda $d010
        and #%00000010          ; bullet's 9th X bit (sprite 1)
        bne next_collision
        lda $d002
        sec
        sbc enemy_x_tbl,x
        cmp #$10
        bcc hit_enemy           ; 0..15 apart: close
        cmp #$f0
        bcc next_collision      ; 16..239 apart: too far
        jmp hit_enemy           ; 240..255: close from the other side

next_collision:
        inx
        cpx #$02
        bne collision_loop
        jmp no_hit

hit_enemy:
        ; X = the enemy that was hit. Remove the bullet.
        lda #$00
        sta bullet_active
        lda $d015
        and #%11111101          ; sprite 1 (bullet) off
        sta $d015

        ; Flash THIS enemy white and start its 8-frame timer. The enemy loop
        ; freezes it white until the timer runs out, then respawns it.
        lda #$08
        sta flash_tbl,x
        ldy sprite_colour_off,x
        lda #$01
        sta $d000,y             ; this enemy's colour register = white

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

        ; Score one hit. In decimal mode ADC carries at 10, so the byte stays
        ; readable as two decimal digits (BCD) — no conversion needed.
        sed                     ; decimal mode on
        lda score
        clc
        adc #$01
        sta score
        cld                     ; decimal mode off (every later ADC/SBC needs it off)

        ; Refresh the two digits: high nybble -> tens, low nybble -> ones
        lda score
        lsr
        lsr
        lsr
        lsr                     ; high nybble down to 0-9
        clc
        adc #$30                ; to screen code
        sta $0400               ; tens digit
        lda score
        and #$0f                ; low nybble, 0-9
        clc
        adc #$30
        sta $0401               ; ones digit

no_hit:

        jmp game_loop

; ------------------------------------------------
; Subroutine: spawn one enemy
;   A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
        sta enemy_y_tbl,x
        lda $d012               ; raster line -> pseudo-random column
        and #$7f
        clc
        adc #$30                ; 48-175, inside the visible width
        sta enemy_x_tbl,x
        lda #$00
        sta flash_tbl,x         ; not flashing
        ldy sprite_colour_off,x
        lda #$05
        sta $d000,y             ; this enemy's colour = green
        ldy sprite_pos_off,x
        lda enemy_x_tbl,x
        sta $d000,y             ; sprite X
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y
        rts

; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
sprite_pos_off:
        !byte $04, $06, $08     ; X offsets: $d004, $d006, $d008
sprite_colour_off:
        !byte $29, $2a, $2b     ; colour offsets: $d029, $d02a, $d02b

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

It's a big change, but it buys the whole rest of the game. With two enemies started at different heights, the one loop drives both — each drifting, leaving, and respawning independently:

Two enemies, started at different heights and run by a single indexed loop — each drifts and respawns on its own, from the same shared code.

Milestone 2 — the full wave

Here's the payoff of writing it indexed: going from two enemies to three is almost no code. One more sprite enabled, one more data pointer, one more spawn_enemy call at init — and the two loop limits change from #$02 to #$03. The movement and collision bodies don't change at all.

Step 2: one constant and one sprite more — three enemies
+9-5
4949 sta $d001 ; Y position
5050 lda #$01
5151 sta $d027 ; Colour (white)
52- lda #%00001101
53- sta $d015 ; Enable sprites 0 (ship), 2 and 3 (two enemies)
52+ lda #%00011101
53+ sta $d015 ; Enable sprites 0 (ship), 2-4 (three enemies)
5454 lda #$00
5555 sta $d010 ; sprite high-X bits clear (ship starts under X=256)
5656
...
6060 lda #$07
6161 sta $d028 ; Colour (yellow)
6262
63- ; Enemy sprites 2 and 3 share the one enemy shape in block 130.
63+ ; Enemy sprites 2, 3 and 4 share the one enemy shape in block 130.
6464 ; Colour and position are set per-enemy by spawn_enemy, below.
6565 lda #130
6666 sta $07fa ; sprite 2 data pointer
6767 sta $07fb ; sprite 3 data pointer
68+ sta $07fc ; sprite 4 data pointer
6869
6970 ; Bullet starts inactive
7071 lda #$00
...
101102 jsr spawn_enemy
102103 lda #$82
103104 ldx #$01
105+ jsr spawn_enemy
106+ lda #$d2
107+ ldx #$02
104108 jsr spawn_enemy
105109
106110 ; ------------------------------------------------
...
308312
309313 next_enemy:
310314 inx
311- cpx #$02 ; two enemies for now
315+ cpx #$03 ; the full wave of three
312316 bne enemy_loop
313317
314318 ; --- Bullet vs the wave: test each enemy until one is hit ---
...
346350
347351 next_collision:
348352 inx
349- cpx #$02
353+ cpx #$03
350354 bne collision_loop
351355 jmp no_hit
352356
The complete program
; Starfield - Unit 10: Enemy Waves
; Cumulative steps: step-00 (one enemy) -> step-01 (+ a second, via indexed tables) -> step-02 (+ a third: the full wave)
; 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)
score         = $06     ; Two-digit score, BCD (one decimal digit per nybble)
; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
enemy_x_tbl   = $07     ; 3 bytes ($07,$08,$09): each enemy's X
enemy_y_tbl   = $0a     ; 3 bytes ($0a,$0b,$0c): each enemy's Y
flash_tbl     = $0d     ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer

; ------------------------------------------------
; 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 #%00011101
        sta $d015           ; Enable sprites 0 (ship), 2-4 (three enemies)
        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)

        ; Enemy sprites 2, 3 and 4 share the one enemy shape in block 130.
        ; Colour and position are set per-enemy by spawn_enemy, below.
        lda #130
        sta $07fa           ; sprite 2 data pointer
        sta $07fb           ; sprite 3 data pointer
        sta $07fc           ; sprite 4 data pointer

        ; 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

        ; Score readout: "00" in the top-left, white. (We've written to screen
        ; RAM since Unit 1's clear loop — now we write characters, not spaces.)
        lda #$00
        sta score
        lda #$30            ; screen code for '0'
        sta $0400           ; tens digit (row 0, col 0)
        sta $0401           ; ones digit (row 0, col 1)
        lda #$01
        sta $d800           ; colour both digits white
        sta $d801

        ; Spawn the wave at staggered heights (A = start Y, X = enemy index)
        lda #$32
        ldx #$00
        jsr spawn_enemy
        lda #$82
        ldx #$01
        jsr spawn_enemy
        lda #$d2
        ldx #$02
        jsr spawn_enemy

; ------------------------------------------------
; 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 every enemy: one indexed loop does all of them ---
        ldx #$00
enemy_loop:
        lda flash_tbl,x
        bne do_flash            ; this enemy is mid-flash

        ; not flashing: drift down 1 pixel per frame
        lda enemy_y_tbl,x
        clc
        adc #$01                ; clc before adc, the addition from the Primer
        sta enemy_y_tbl,x
        cmp #$f8                ; off the bottom? (Y >= 248)
        bcc update_sprite       ; still on screen
        lda #$32                ; respawn this enemy at the top, new column
        jsr spawn_enemy
        jmp next_enemy

do_flash:
        dec flash_tbl,x
        bne update_sprite       ; still flashing -> stay frozen, white
        lda #$32                ; flash done -> respawn (spawn_enemy restores green)
        jsr spawn_enemy
        jmp next_enemy

update_sprite:
        ; copy this enemy's position into its VIC-II sprite registers
        ldy sprite_pos_off,x
        lda enemy_x_tbl,x
        sta $d000,y             ; sprite X  ($d004, $d006, ...)
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y  ($d005, $d007, ...)

next_enemy:
        inx
        cpx #$03                ; the full wave of three
        bne enemy_loop

        ; --- Bullet vs the wave: test each enemy until one is hit ---
        lda bullet_active
        bne check_collision
        jmp no_hit
check_collision:
        ldx #$00
collision_loop:
        lda flash_tbl,x
        bne next_collision      ; skip an enemy that's already exploding

        ; Y distance (8-bit subtract wraps, so two ranges count as close)
        lda bullet_y
        sec
        sbc enemy_y_tbl,x
        cmp #$10
        bcc check_x             ; 0..15 apart: close
        cmp #$f0
        bcc next_collision      ; 16..239 apart: too far
check_x:
        ; A bullet in the right portion (9th bit set) is past X=255, far from
        ; any enemy, so rule it out before comparing low bytes.
        lda $d010
        and #%00000010          ; bullet's 9th X bit (sprite 1)
        bne next_collision
        lda $d002
        sec
        sbc enemy_x_tbl,x
        cmp #$10
        bcc hit_enemy           ; 0..15 apart: close
        cmp #$f0
        bcc next_collision      ; 16..239 apart: too far
        jmp hit_enemy           ; 240..255: close from the other side

next_collision:
        inx
        cpx #$03
        bne collision_loop
        jmp no_hit

hit_enemy:
        ; X = the enemy that was hit. Remove the bullet.
        lda #$00
        sta bullet_active
        lda $d015
        and #%11111101          ; sprite 1 (bullet) off
        sta $d015

        ; Flash THIS enemy white and start its 8-frame timer. The enemy loop
        ; freezes it white until the timer runs out, then respawns it.
        lda #$08
        sta flash_tbl,x
        ldy sprite_colour_off,x
        lda #$01
        sta $d000,y             ; this enemy's colour register = white

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

        ; Score one hit. In decimal mode ADC carries at 10, so the byte stays
        ; readable as two decimal digits (BCD) — no conversion needed.
        sed                     ; decimal mode on
        lda score
        clc
        adc #$01
        sta score
        cld                     ; decimal mode off (every later ADC/SBC needs it off)

        ; Refresh the two digits: high nybble -> tens, low nybble -> ones
        lda score
        lsr
        lsr
        lsr
        lsr                     ; high nybble down to 0-9
        clc
        adc #$30                ; to screen code
        sta $0400               ; tens digit
        lda score
        and #$0f                ; low nybble, 0-9
        clc
        adc #$30
        sta $0401               ; ones digit

no_hit:

        jmp game_loop

; ------------------------------------------------
; Subroutine: spawn one enemy
;   A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
        sta enemy_y_tbl,x
        lda $d012               ; raster line -> pseudo-random column
        and #$7f
        clc
        adc #$30                ; 48-175, inside the visible width
        sta enemy_x_tbl,x
        lda #$00
        sta flash_tbl,x         ; not flashing
        ldy sprite_colour_off,x
        lda #$05
        sta $d000,y             ; this enemy's colour = green
        ldy sprite_pos_off,x
        lda enemy_x_tbl,x
        sta $d000,y             ; sprite X
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y
        rts

; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
sprite_pos_off:
        !byte $04, $06, $08     ; X offsets: $d004, $d006, $d008
sprite_colour_off:
        !byte $29, $2a, $2b     ; colour offsets: $d029, $d02a, $d02b

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

The collision is a loop now too: it walks the wave and stops at the first enemy the bullet overlaps, so the shot picks off whichever one it meets — and the score counts each. Three enemies descend, and any of them can be shot:

One more constant and an extra sprite turns two into three. The full wave descends, the ship picks enemies off individually, and the score climbs with each kill.

When it's wrong, see why

Indexed bugs usually hit one enemy or all of them — the index is the thing to watch:

  • Enemies missing. A pointer or an enable bit. Sprites 2–4 all point to block 130 ($07fa$07fc = 130), and $d015 must enable bits 2, 3, 4 ($1d for all three plus the ship). A blank slot is almost always the data pointer.
  • Only one enemy moves. The loop isn't looping. Check the tail — inx, then cpx against the count, then bne back to the top. A wrong count or a missing inx processes one and stops.
  • They all pile into the same spot. The stagger or the spawn. The init calls pass different start Ys; spawn_enemy reads the raster for a fresh column each time. If they overlap, the start Ys are equal or the raster read is missing.
  • The score jumps by more than one per hit. The collision loop must stop at the first hit — jmp hit_enemy leaves the loop. Without it, one bullet can register against several enemies in the same pass.
  • The wrong enemy flashes. X has been disturbed. The hit handler writes flash_tbl,x and the colour via sprite_colour_off,x, so X must still hold the index of the enemy that was hit when you get there.

Before and after

We started with a single enemy and the movement written out once for it, and finished with a wave of three driven by the same one loop — the difference between them is a loop count, not three copies of the code. Arrays hold the state, the X register walks them, the Y register reaches the hardware, and a subroutine does the spawning. Adding a fourth enemy is now a five-minute change.

Try this

  • Give them different speeds. Add a speed_tbl of three bytes — say 1, 2, 1 — and load speed_tbl,x instead of the constant #$01 in the drift. A faster enemy is a harder target, and a mixed wave reads as more alive.
  • Add a fourth. Sprite 5 (pointer $07fd, enable bit 5, colour offset $2c), a fourth entry in each array and the offset tables, and #$03 becomes #$04. The machinery already scales — prove it to yourself.

What you've learnt

  • Parallel arrays — one array per attribute, a shared index reaching the matching slot in each; the way to hold many objects' state.
  • Indexed addressinglda table,x writes the body once and lets the index pick the object; a loop runs it for all.
  • The Y register as a second index — X for your data, Y for hardware registers, fed by a small offset lookup table.
  • Subroutinesjsr/rts and the stack at $0100, so shared logic (spawning) lives in one place and is called from many.

What's next

The wave comes at you and you're untouchable — the enemies pass through the ship with no consequence. Next you'll detect enemy-on-ship collision, and the game gains its first way to lose.