Skip to content

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.

  • Commodore 64
  • Assembly

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:

Index enemy_x_tbl enemy_y_tbl flash_tbl Sprite
0 $07 $0a $0d 2
1 $08 $0b $0e 3
2 $09 $0c $0f 4

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 added−76 removed
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)
Removed: enemy_x       = $06     ; Enemy X position
Removed: enemy_y       = $07     ; Enemy Y position
Removed: flash_timer   = $08     ; Frames of hit-flash remaining (0 = idle)
Removed: score         = $09     ; Two-digit score, BCD (one decimal digit per nybble)
Added: score         = $06     ; Two-digit score, BCD (one decimal digit per nybble)
Added: ; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
Added: enemy_x_tbl   = $07     ; 3 bytes ($07,$08,$09): each enemy's X
Added: enemy_y_tbl   = $0a     ; 3 bytes ($0a,$0b,$0c): each enemy's Y
Added: flash_tbl     = $0d     ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer

; ------------------------------------------------
; BASIC stub
…        sta $d001           ; Y position
        lda #$01
        sta $d027           ; Colour (white)
Removed:         lda #%00000101
Removed:         sta $d015           ; Enable sprites 0 (ship) and 2 (enemy)
Added:         lda #%00001101
Added:         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)

…        lda #$07
        sta $d028           ; Colour (yellow)

Removed:         ; Sprite 2 setup (enemy)
Added:         ; Enemy sprites 2 and 3 share the one enemy shape in block 130.
Added:         ; Colour and position are set per-enemy by spawn_enemy, below.
        lda #130
Removed:         sta $07fa           ; Data pointer (block 130 = $2080)
Removed:         lda #$05
Removed:         sta $d029           ; Colour (green)
Removed:         lda #100
Removed:         sta enemy_x
Removed:         sta $d004           ; X position
Removed:         lda #$32
Removed:         sta enemy_y
Removed:         sta $d005           ; Y position (top of the play area)
Added:         sta $07fa           ; sprite 2 data pointer
Added:         sta $07fb           ; sprite 3 data pointer

        ; Bullet starts inactive
        lda #$00
…        lda #$01
        sta $d800           ; colour both digits white
        sta $d801
Added: 
Added:         ; Spawn the wave at staggered heights (A = start Y, X = enemy index)
Added:         lda #$32
Added:         ldx #$00
Added:         jsr spawn_enemy
Added:         lda #$82
Added:         ldx #$01
Added:         jsr spawn_enemy

; ------------------------------------------------
; Game loop — runs once per frame
…
no_bullet:

Removed:         ; --- Update enemy ---
Removed:         ; While the flash timer runs the enemy is frozen white; otherwise it drifts.
Removed:         lda flash_timer
Removed:         beq enemy_move          ; not flashing -> normal movement
Removed:         dec flash_timer
Removed:         bne no_respawn          ; still flashing -> stay frozen, white
Removed:         ; flash just ended -> restore colour and respawn at the top
Removed:         lda #$05
Removed:         sta $d029               ; back to green
Removed:         lda #$32
Removed:         sta enemy_y
Removed:         sta $d005
Removed:         lda $d012
Removed:         and #$7f
Removed:         clc
Removed:         adc #$30
Removed:         sta enemy_x
Removed:         sta $d004
Removed:         jmp no_respawn
Added:         ; --- Update every enemy: one indexed loop does all of them ---
Added:         ldx #$00
Added: enemy_loop:
Added:         lda flash_tbl,x
Added:         bne do_flash            ; this enemy is mid-flash

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

Removed:         ; Off the bottom? (Y >= 248) -> respawn at the top in a new column
Removed:         cmp #$f8
Removed:         bcc no_respawn      ; Y < 248, still on screen
Removed:         lda #$32
Removed:         sta enemy_y
Removed:         sta $d005           ; back to the top
Removed:         lda $d012           ; raster line — changes constantly, our pseudo-random
Removed:         and #$7f            ; range 0-127
Removed:         clc
Removed:         adc #$30            ; shift to 48-175, keeping it within the visible width
Removed:         sta enemy_x
Removed:         sta $d004           ; new X column
Removed: no_respawn:
Added: do_flash:
Added:         dec flash_tbl,x
Added:         bne update_sprite       ; still flashing -> stay frozen, white
Added:         lda #$32                ; flash done -> respawn (spawn_enemy restores green)
Added:         jsr spawn_enemy
Added:         jmp next_enemy

Removed:         ; --- Bullet vs enemy: same row AND same column? ---
Added: update_sprite:
Added:         ; copy this enemy's position into its VIC-II sprite registers
Added:         ldy sprite_pos_off,x
Added:         lda enemy_x_tbl,x
Added:         sta $d000,y             ; sprite X  ($d004, $d006, ...)
Added:         lda enemy_y_tbl,x
Added:         sta $d001,y             ; sprite Y  ($d005, $d007, ...)
Added: 
Added: next_enemy:
Added:         inx
Added:         cpx #$02                ; two enemies for now
Added:         bne enemy_loop
Added: 
Added:         ; --- Bullet vs the wave: test each enemy until one is hit ---
        lda bullet_active
Removed:         beq no_hit              ; no bullet in flight, nothing to hit
Added:         bne check_collision
Added:         jmp no_hit
Added: check_collision:
Added:         ldx #$00
Added: collision_loop:
Added:         lda flash_tbl,x
Added:         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
Removed:         sbc enemy_y
Added:         sbc enemy_y_tbl,x
        cmp #$10
Removed:         bcc y_close             ; 0..15 apart: close
Added:         bcc check_x             ; 0..15 apart: close
        cmp #$f0
Removed:         bcc no_hit              ; 16..239 apart: too far
Removed: y_close:
Removed:         ; Same row — now the columns must match too.
Added:         bcc next_collision      ; 16..239 apart: too far
Added: check_x:
        ; A bullet in the right portion (9th bit set) is past X=255, far from
Removed:         ; any enemy (they stay under X=176), so rule it out before comparing.
Added:         ; any enemy, so rule it out before comparing low bytes.
        lda $d010
        and #%00000010          ; bullet's 9th X bit (sprite 1)
Removed:         bne no_hit
Removed: 
Removed:         ; X distance (low byte; the bullet's 9th bit is clear here)
Added:         bne next_collision
        lda $d002
        sec
Removed:         sbc enemy_x
Added:         sbc enemy_x_tbl,x
        cmp #$10
        bcc hit_enemy           ; 0..15 apart: close
        cmp #$f0
Removed:         bcc no_hit              ; 16..239 apart: too far
Removed:         ; 240..255: close from the other side -> fall through
Added:         bcc next_collision      ; 16..239 apart: too far
Added:         jmp hit_enemy           ; 240..255: close from the other side
Added: 
Added: next_collision:
Added:         inx
Added:         cpx #$02
Added:         bne collision_loop
Added:         jmp no_hit

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

Removed:         ; Flash the enemy white and start an 8-frame timer. The enemy update
Removed:         ; freezes it white until the timer runs out, then restores green and
Removed:         ; respawns it — so the respawn now waits for the flash instead of
Removed:         ; happening instantly.
Removed:         lda #$01
Removed:         sta $d029               ; sprite 2 colour = white
Added:         ; Flash THIS enemy white and start its 8-frame timer. The enemy loop
Added:         ; freezes it white until the timer runs out, then respawns it.
        lda #$08
Removed:         sta flash_timer
Added:         sta flash_tbl,x
Added:         ldy sprite_colour_off,x
Added:         lda #$01
Added:         sta $d000,y             ; this enemy's colour register = white

        ; Explosion sound — SID voice 2, noise waveform (voice 1 keeps the laser)
        lda #$00
…no_hit:

        jmp game_loop
Added: 
Added: ; ------------------------------------------------
Added: ; Subroutine: spawn one enemy
Added: ;   A = starting Y, X = enemy index (X is preserved)
Added: ; ------------------------------------------------
Added: spawn_enemy:
Added:         sta enemy_y_tbl,x
Added:         lda $d012               ; raster line -> pseudo-random column
Added:         and #$7f
Added:         clc
Added:         adc #$30                ; 48-175, inside the visible width
Added:         sta enemy_x_tbl,x
Added:         lda #$00
Added:         sta flash_tbl,x         ; not flashing
Added:         ldy sprite_colour_off,x
Added:         lda #$05
Added:         sta $d000,y             ; this enemy's colour = green
Added:         ldy sprite_pos_off,x
Added:         lda enemy_x_tbl,x
Added:         sta $d000,y             ; sprite X
Added:         lda enemy_y_tbl,x
Added:         sta $d001,y             ; sprite Y
Added:         rts
Added: 
Added: ; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
Added: sprite_pos_off:
Added:         !byte $04, $06, $08     ; X offsets: $d004, $d006, $d008
Added: sprite_colour_off:
Added:         !byte $29, $2a, $2b     ; colour offsets: $d029, $d02a, $d02b

; ------------------------------------------------
; Sprite data at $2000 (block 128) — ship
The complete step 1 program
step-01.asm
; 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 added−5 removed
        sta $d001           ; Y position
        lda #$01
        sta $d027           ; Colour (white)
Removed:         lda #%00001101
Removed:         sta $d015           ; Enable sprites 0 (ship), 2 and 3 (two enemies)
Added:         lda #%00011101
Added:         sta $d015           ; Enable sprites 0 (ship), 2-4 (three enemies)
        lda #$00
        sta $d010           ; sprite high-X bits clear (ship starts under X=256)

…        lda #$07
        sta $d028           ; Colour (yellow)

Removed:         ; Enemy sprites 2 and 3 share the one enemy shape in block 130.
Added:         ; 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
Added:         sta $07fc           ; sprite 4 data pointer

        ; Bullet starts inactive
        lda #$00
…        jsr spawn_enemy
        lda #$82
        ldx #$01
Added:         jsr spawn_enemy
Added:         lda #$d2
Added:         ldx #$02
        jsr spawn_enemy

; ------------------------------------------------
…
next_enemy:
        inx
Removed:         cpx #$02                ; two enemies for now
Added:         cpx #$03                ; the full wave of three
        bne enemy_loop

        ; --- Bullet vs the wave: test each enemy until one is hit ---
…
next_collision:
        inx
Removed:         cpx #$02
Added:         cpx #$03
        bne collision_loop
        jmp no_hit

The complete program
step-02.asm
; 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 addressing — lda 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.
  • Subroutines — jsr/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.

Run it here

This unit's program, built for you.

Program
step-02.prg
Machine
Commodore 64 (PAL Breadbin)

Download step-02.prg