Skip to content

The Curve

Starfield works; nothing escalates. A wave counter, a speed table and a live fall speed put a difficulty curve in the game; a SID jingle gives the title a voice; and a dwell on both ends of the loop lets the endings land. Then the winnability gate does its work: the turret park — park under the lane, hold fire, never lose — is closed with a real LFSR spawn, full-field 9-bit enemies, and a hunt that leaves no square safe. And finally the game gets a curve worth playing: the swarm grows from three enemies to six over the waves, the hunt tightens late, and a four-digit score can hold a long game.

  • Commodore 64
  • Assembly

Unit 16 closed the loop, and called the game finished. Play it for five minutes and you’ll find the two things the loop is missing. First: the fiftieth enemy falls exactly like the first — there is no curve, nothing that answers skill with pressure. Second: the machine is silent everywhere except mid-fight, and the title — the screen you stare at the longest — says nothing at all. This unit fixes both, and tucks in one piece of justice the state machine owed us from the start.

Where we start

Unit 16’s complete game: title, play, game over, round again. Three enemies fall at one pixel per frame, forever.

Milestone 1 — the lanes quicken

A difficulty curve needs three small ideas, and they are worth keeping apart in your head:

  • wave counts where you are — it starts at 1 and never stops climbing.
  • wave_speed_tbl says what each wave means — one byte per wave, pixels of fall per frame.
  • fall_speed is the live tuning — the one byte the enemy loop reads, every frame.

Ten kills end a wave. advance_wave resets the kill count, bumps the counter, looks up the new speed and pokes it into fall_speed — and that is the whole trick: the enemies never learn that waves exist. The drift code reads its tuning fresh each frame, and the tuning changed while it wasn’t looking. No if wave >= 3 scattered through the loop, no per-wave code at all.

Three different caps live in this milestone, and they are not the same thing. The wave counter never stops. The table index clamps at the last entry — past wave five the game repeats its hardest setting, because an unclamped index would read whatever byte happens to follow the table and call it a speed. And the HUD digit shows 9 forever after. The player’s number tells the truth about how far they got; the physics quietly admits it ran out of new ideas. Conflate those caps and the game ends up lying about one of them.

The wave-up also says so: a bright triangle ding on voice 3, right over the explosion that earned it, and a W readout at the top of the screen — “I died on wave four” finally has a four.

Step 1: a wave counter, a speed table, a live fall speed, and a readout+82 added−2 removed
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop)
Added: ; This step: the wave system.
; Assemble: acme -f cbm -o <step>.prg <step>.asm

; ------------------------------------------------
…frame_count   = $13     ; free-running frame counter (parallax timing)
star_row      = $14     ; 12 stars: row of each   ($14-$1f)
star_col      = $20     ; 12 stars: column of each ($20-$2b)
Added: wave          = $2c     ; wave counter (1, 2, 3, ... — never stops climbing)
Added: kills         = $2d     ; hits this wave; 10 advances the wave
Added: fall_speed    = $2e     ; pixels per frame the enemies fall — poked by advance_wave
; $fb/$fc: scratch pointer used by the star routines

; ------------------------------------------------
…        lda flash_tbl,x
        bne do_flash            ; this enemy is mid-flash

Removed:         ; not flashing: drift down 1 pixel per frame
Added:         ; not flashing: drift down at the wave's pace
        lda enemy_y_tbl,x
        clc
Removed:         adc #$01                ; clc before adc, the addition from the Primer
Added:         adc fall_speed          ; the wave's tuning, read fresh every frame
        sta enemy_y_tbl,x
        cmp #$f8                ; off the bottom? (Y >= 248)
        bcc update_sprite       ; still on screen
…        clc
        adc #$30
        sta $0401               ; ones digit
Added: 
Added:         ; Ten hits clear the wave: the lanes quicken
Added:         inc kills
Added:         lda kills
Added:         cmp #10
Added:         bcc no_hit
Added:         jsr advance_wave

no_hit:

…        sta $0427
        lda #$01
        sta $d827
Added:         ; Wave 1: counter, kills, the live speed, and the readout
Added:         lda #$01
Added:         sta wave
Added:         lda #$00
Added:         sta kills
Added:         lda wave_speed_tbl      ; first table entry
Added:         sta fall_speed
Added:         jsr draw_wave
        ; state = playing
        lda #$01
        sta state
Added:         rts
Added: 
Added: ; ------------------------------------------------
Added: ; Subroutine: advance_wave — ten kills: quicken the lanes, say so
Added: ;   Three different caps live here, and they are not the same thing:
Added: ;   the COUNTER never stops, the table INDEX clamps at the last row,
Added: ;   and the DIGIT shows 9 forever after. The player's number tells the
Added: ;   truth; the physics admits it ran out of new ideas.
Added: ; ------------------------------------------------
Added: advance_wave:
Added:         lda #$00
Added:         sta kills
Added:         inc wave
Added:         ; table index = wave - 1, clamped to the last entry
Added:         ldy wave
Added:         dey
Added:         cpy #WAVE_TOP
Added:         bcc wave_speed_ok
Added:         ldy #WAVE_TOP
Added: wave_speed_ok:
Added:         lda wave_speed_tbl,y
Added:         sta fall_speed
Added:         jsr draw_wave
Added:         ; The chirp — voice 3, a bright triangle ding over the explosion
Added:         lda #$00
Added:         sta $d40e               ; voice 3 frequency low
Added:         lda #$40
Added:         sta $d40f               ; voice 3 frequency high (a high ding)
Added:         lda #$09
Added:         sta $d413               ; attack 0, decay 9
Added:         lda #$00
Added:         sta $d414               ; sustain 0, release 0
Added:         lda #$10
Added:         sta $d412               ; triangle, gate OFF (reset the envelope)
Added:         lda #$11
Added:         sta $d412               ; triangle, gate ON
Added:         rts
Added: 
Added: ; ------------------------------------------------
Added: ; Subroutine: draw_wave — "W" and the wave digit, top centre
Added: ;   The digit caps at 9; the wave itself keeps counting.
Added: ; ------------------------------------------------
Added: draw_wave:
Added:         lda #$17                ; W
Added:         sta $0413
Added:         lda wave
Added:         cmp #$0a
Added:         bcc wave_digit_ok
Added:         lda #$09                ; show 9 from here on
Added: wave_digit_ok:
Added:         clc
Added:         adc #$30                ; to screen code
Added:         sta $0414
Added:         lda #$01                ; white
Added:         sta $d813
Added:         sta $d814
        rts
Added: 
Added: ; Pixels per frame, one entry per wave; the last entry is the wall
Added: WAVE_TOP = 4                    ; last index of the table below
Added: wave_speed_tbl:
Added:         !byte 1, 2, 2, 3, 3

; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
The complete program
step-01.asm
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop)
; This step: the wave system.
; 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
state         = $10     ; 0 = title, 1 = playing, 2 = game over
lives         = $11     ; lives remaining (starts at 3)
death_timer   = $12     ; frames of post-hit flash (and, in step 2, invulnerability)
frame_count   = $13     ; free-running frame counter (parallax timing)
star_row      = $14     ; 12 stars: row of each   ($14-$1f)
star_col      = $20     ; 12 stars: column of each ($20-$2b)
wave          = $2c     ; wave counter (1, 2, 3, ... — never stops climbing)
kills         = $2d     ; hits this wave; 10 advances the wave
fall_speed    = $2e     ; pixels per frame the enemies fall — poked by advance_wave
; $fb/$fc: scratch pointer used by the star routines

; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00

; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
start:
        ; --- One-time hardware setup (runs once, not per game) ---
        lda #$00
        sta $d020           ; border black
        sta $d021           ; background black
        sta $d010           ; ship 9th X bit clear

        ; Fixed sprite colours
        lda #$01
        sta $d027           ; ship white
        lda #$07
        sta $d028           ; bullet yellow

        ; SID voice 1 — the laser
        lda #$0f
        sta $d418           ; volume to maximum
        lda #$00
        sta $d400
        lda #$10
        sta $d401
        lda #$06
        sta $d405
        lda #$00
        sta $d406

        ; Star positions (drawn by enter_title / enter_game)
        sta frame_count     ; A is still 0
        ldx #$00
init_star_loop:
        lda star_init_row,x
        sta star_row,x
        lda star_init_col,x
        sta star_col,x
        inx
        cpx #12
        bne init_star_loop

        ; Open on the title screen
        jsr enter_title

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

        ; --- Parallax starfield: scrolls in every state (title, play, over) ---
        inc frame_count
        ldx #$00
star_loop:
        jsr erase_star
        ; Does THIS star move this frame? Near (0-3) every frame, mid (4-7)
        ; every 2nd frame, far (8-11) every 4th frame.
        cpx #$04
        bcc star_do_move        ; near layer: always
        cpx #$08
        bcc star_mid            ; mid layer
        ; far layer: only when the low two frame bits are clear (1 in 4)
        lda frame_count
        and #%00000011
        bne star_move_done
        beq star_do_move
star_mid:
        lda frame_count
        and #%00000001          ; every other frame
        bne star_move_done
star_do_move:
        inc star_row,x          ; one row down
        lda star_row,x
        cmp #25
        bcc star_move_done
        lda #$00                ; past the bottom -> wrap to the top
        sta star_row,x
star_move_done:
        jsr draw_star
        inx
        cpx #12
        bne star_loop

        ; --- State machine: title (0) / playing (1) / game over (2) ---
        lda state
        beq title_state
        cmp #$02
        beq over_state
        jmp game_active             ; 1 = playing

title_state:
        jsr show_title              ; repaint, in case a star scrolled across it
        lda $dc00
        and #%00010000              ; fire button (bit 4)
        bne loop_again              ; not pressed — wait on the title
        jsr enter_game              ; fire -> start a game
loop_again:
        jmp game_loop

over_state:
        jsr show_game_over          ; repaint over any star damage
        lda $dc00
        and #%00010000
        bne loop_again
        jsr enter_title             ; fire -> back to the title screen
        jmp game_loop

game_active:

        ; --- 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 at the wave's pace
        lda enemy_y_tbl,x
        clc
        adc fall_speed          ; the wave's tuning, read fresh every frame
        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

        ; Ten hits clear the wave: the lanes quicken
        inc kills
        lda kills
        cmp #10
        bcc no_hit
        jsr advance_wave

no_hit:

        ; --- Ship vs the wave: has any enemy reached the ship? ---
        ; ...but not while the life-lost flash runs — the ship is invulnerable
        lda death_timer
        beq do_ship_collision   ; not flashing -> run the check
        jmp no_ship_hit         ; flashing -> skip it (jmp, the target is far)
do_ship_collision:
        ldx #$00
ship_collision_loop:
        lda flash_tbl,x
        bne next_ship_check     ; ignore an exploding enemy
        ; Y distance: ship Y ($d001) vs this enemy's Y
        lda $d001
        sec
        sbc enemy_y_tbl,x
        cmp #$10
        bcc check_ship_x
        cmp #$f0
        bcc next_ship_check
check_ship_x:
        ; ship past X=255 (9th bit set) is far from any enemy — rule it out
        lda $d010
        and #%00000001          ; ship's 9th X bit (sprite 0)
        bne next_ship_check
        lda $d000
        sec
        sbc enemy_x_tbl,x
        cmp #$10
        bcc ship_hit
        cmp #$f0
        bcc next_ship_check
        jmp ship_hit            ; 240..255: close from the other side

next_ship_check:
        inx
        cpx #$03
        bne ship_collision_loop
        jmp no_ship_hit

ship_hit:
        ; Lose a life and update the readout
        dec lives
        lda lives
        clc
        adc #$30
        sta $0427               ; lives digit, top-right

        lda lives
        bne life_lost           ; lives remain -> respawn and play on

        ; Out of lives -> game over state (the dispatch handles the freeze)
        lda #$02
        sta state
        sta $d027               ; ship turns red ($02 = red, reused here)
        jsr show_game_over
        jmp death_sound

life_lost:
        ; Respawn the ship at its start position
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda $d010
        and #%11111110          ; clear the ship's 9th bit (back under X=256)
        sta $d010

        ; Start the life-lost flash (step 2 makes it an invulnerability window too)
        lda #90
        sta death_timer

death_sound:
        ; Death sound — SID voice 3 (plays on every death)
        lda #$00
        sta $d40e               ; voice 3 frequency low
        lda #$10
        sta $d40f               ; voice 3 frequency high
        lda #$0a
        sta $d413               ; attack 0, decay 10 (a long, slow fade)
        lda #$00
        sta $d414               ; sustain 0, release 0
        lda #$20
        sta $d412               ; sawtooth, gate OFF (reset the envelope)
        lda #$21
        sta $d412               ; sawtooth, gate ON (trigger)

no_ship_hit:

        ; --- Life-lost flash: while the timer runs, blink the border ---
        lda death_timer
        beq flash_done
        dec death_timer
        lda death_timer
        and #%00001000          ; bit 3 toggles every 8 frames
        bne flash_bright
        lda #$00                ; dark phase
        sta $d020
        jmp flash_tick
flash_bright:
        lda #$02                ; bright phase (red border)
        sta $d020
flash_tick:
        lda death_timer
        bne flash_done
        lda #$00                ; just expired -> border back to black
        sta $d020
flash_done:

        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

; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
;   Row 12 x 40 + 16 = 496 = $1f0, so screen RAM $05f0, colour RAM $d9f0
; ------------------------------------------------
show_game_over:
        lda #$07            ; G
        sta $05f0
        lda #$01            ; A
        sta $05f1
        lda #$0d            ; M
        sta $05f2
        lda #$05            ; E
        sta $05f3
        lda #$20            ; (space)
        sta $05f4
        lda #$0f            ; O
        sta $05f5
        lda #$16            ; V
        sta $05f6
        lda #$05            ; E
        sta $05f7
        lda #$12            ; R
        sta $05f8
        ; colour the nine cells white ($d9f0..$d9f8)
        lda #$01
        ldx #$00
-       sta $d9f0,x
        inx
        cpx #$09
        bne -
        rts

; ------------------------------------------------
; Subroutine: clear_and_stars — wipe the screen, then repaint every star
; ------------------------------------------------
clear_and_stars:
        ldx #$00
cas_clear:
        lda #$20
        sta $0400,x
        sta $0500,x
        sta $0600,x
        sta $0700,x
        inx
        bne cas_clear
        ldx #$00
cas_draw:
        jsr draw_star
        inx
        cpx #12
        bne cas_draw
        rts

; ------------------------------------------------
; Subroutine: enter_title — show the title, hide the game, state = 0
; ------------------------------------------------
enter_title:
        jsr clear_and_stars
        lda #$00
        sta $d015               ; all sprites off: the title has no ship or wave
        sta $d020               ; border black
        jsr show_title
        lda #$00
        sta state               ; 0 = title
        rts

; ------------------------------------------------
; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
        jsr clear_and_stars
        ; The sprite data pointers live in screen RAM ($07f8+), so the clear just
        ; wiped them — set them here, after the clear, or the sprites show garbage.
        lda #128
        sta $07f8           ; ship
        lda #129
        sta $07f9           ; bullet
        lda #130
        sta $07fa           ; enemy 0
        sta $07fb           ; enemy 1
        sta $07fc           ; enemy 2
        ; Ship at its start, white (it may have gone red on game over)
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda #$01
        sta $d027
        lda #$00
        sta $d010
        ; Enable ship + three enemies (the bullet stays off)
        lda #%00011101
        sta $d015
        ; Spawn the wave at staggered heights
        lda #$32
        ldx #$00
        jsr spawn_enemy
        lda #$82
        ldx #$01
        jsr spawn_enemy
        lda #$d2
        ldx #$02
        jsr spawn_enemy
        ; Reset per-game state
        lda #$00
        sta bullet_active
        sta death_timer
        sta $d020               ; border black
        sta score
        ; Score "00", white
        lda #$30
        sta $0400
        sta $0401
        lda #$01
        sta $d800
        sta $d801
        ; Lives "3", white
        lda #$03
        sta lives
        lda #$33
        sta $0427
        lda #$01
        sta $d827
        ; Wave 1: counter, kills, the live speed, and the readout
        lda #$01
        sta wave
        lda #$00
        sta kills
        lda wave_speed_tbl      ; first table entry
        sta fall_speed
        jsr draw_wave
        ; state = playing
        lda #$01
        sta state
        rts

; ------------------------------------------------
; Subroutine: advance_wave — ten kills: quicken the lanes, say so
;   Three different caps live here, and they are not the same thing:
;   the COUNTER never stops, the table INDEX clamps at the last row,
;   and the DIGIT shows 9 forever after. The player's number tells the
;   truth; the physics admits it ran out of new ideas.
; ------------------------------------------------
advance_wave:
        lda #$00
        sta kills
        inc wave
        ; table index = wave - 1, clamped to the last entry
        ldy wave
        dey
        cpy #WAVE_TOP
        bcc wave_speed_ok
        ldy #WAVE_TOP
wave_speed_ok:
        lda wave_speed_tbl,y
        sta fall_speed
        jsr draw_wave
        ; The chirp — voice 3, a bright triangle ding over the explosion
        lda #$00
        sta $d40e               ; voice 3 frequency low
        lda #$40
        sta $d40f               ; voice 3 frequency high (a high ding)
        lda #$09
        sta $d413               ; attack 0, decay 9
        lda #$00
        sta $d414               ; sustain 0, release 0
        lda #$10
        sta $d412               ; triangle, gate OFF (reset the envelope)
        lda #$11
        sta $d412               ; triangle, gate ON
        rts

; ------------------------------------------------
; Subroutine: draw_wave — "W" and the wave digit, top centre
;   The digit caps at 9; the wave itself keeps counting.
; ------------------------------------------------
draw_wave:
        lda #$17                ; W
        sta $0413
        lda wave
        cmp #$0a
        bcc wave_digit_ok
        lda #$09                ; show 9 from here on
wave_digit_ok:
        clc
        adc #$30                ; to screen code
        sta $0414
        lda #$01                ; white
        sta $d813
        sta $d814
        rts

; Pixels per frame, one entry per wave; the last entry is the wall
WAVE_TOP = 4                    ; last index of the table below
wave_speed_tbl:
        !byte 1, 2, 2, 3, 3

; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
; ------------------------------------------------
show_title:
        lda #$13            ; S
        sta $05a0
        lda #$14            ; T
        sta $05a1
        lda #$01            ; A
        sta $05a2
        lda #$12            ; R
        sta $05a3
        lda #$06            ; F
        sta $05a4
        lda #$09            ; I
        sta $05a5
        lda #$05            ; E
        sta $05a6
        lda #$0c            ; L
        sta $05a7
        lda #$04            ; D
        sta $05a8
        lda #$01            ; white
        ldx #$00
-       sta $d9a0,x
        inx
        cpx #$09
        bne -
        lda #$10            ; P
        sta $063f
        lda #$12            ; R
        sta $0640
        lda #$05            ; E
        sta $0641
        lda #$13            ; S
        sta $0642
        lda #$13            ; S
        sta $0643
        lda #$20            ; (space)
        sta $0644
        lda #$06            ; F
        sta $0645
        lda #$09            ; I
        sta $0646
        lda #$12            ; R
        sta $0647
        lda #$05            ; E
        sta $0648
        lda #$0f            ; light grey
        ldx #$00
-       sta $da3f,x
        inx
        cpx #$0a
        bne -
        rts

; ------------------------------------------------
; Subroutine: erase_star  (X = star index)
;   blanks the star's current cell back to a space
; ------------------------------------------------
erase_star:
        ldy star_row,x
        lda row_addr_lo,y       ; point $fb/$fc at the start of this star's row
        sta $fb
        lda row_addr_hi,y
        sta $fc
        ldy star_col,x          ; Y = the column offset along that row
        lda #$20                ; a space
        sta ($fb),y             ; "finger on the boxes" — pointer + Y offset
        rts

; ------------------------------------------------
; Subroutine: draw_star  (X = star index)
;   writes the star's character + colour at its (row, col)
; ------------------------------------------------
draw_star:
        ldy star_row,x
        lda row_addr_lo,y
        sta $fb                 ; row start, low byte
        lda row_addr_hi,y
        sta $fc                 ; row start, high byte
        ldy star_col,x          ; Y = column
        lda star_char_tbl,x
        sta ($fb),y             ; STA ($fb),Y -> the screen-RAM cell
        ; screen RAM $04xx-$07xx maps to colour RAM $d8xx-$dbxx: high byte + $d4
        lda $fc
        clc
        adc #$d4
        sta $fc
        lda star_colour_tbl,x
        sta ($fb),y             ; same column offset, now into colour RAM
        rts

; ------------------------------------------------
; Star data tables
; ------------------------------------------------
; Screen-RAM start address of each row (row x 40 + $0400), rows 0-24
row_addr_lo:
        !byte $00,$28,$50,$78,$a0,$c8,$f0,$18
        !byte $40,$68,$90,$b8,$e0,$08,$30,$58
        !byte $80,$a8,$d0,$f8,$20,$48,$70,$98,$c0
row_addr_hi:
        !byte $04,$04,$04,$04,$04,$04,$04,$05
        !byte $05,$05,$05,$05,$05,$06,$06,$06
        !byte $06,$06,$06,$06,$07,$07,$07,$07,$07

; 12 stars. Columns avoid 0, 1 and 39 — the score and lives cells.
star_init_row:
        !byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
star_init_col:
        !byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
; Appearance reinforces the depth: near = bright white '*', far = dim grey '.'
star_char_tbl:
        !byte $2a,$2a,$2a,$2a, $2a,$2a,$2a,$2a, $2e,$2e,$2e,$2e
star_colour_tbl:
        !byte $01,$01,$01,$01, $0f,$0f,$0f,$0f, $0b,$0b,$0b,$0b

; ------------------------------------------------
; 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   ;
Mid-game: the white ship holding a column on the left, green enemies falling, the score and lives readouts at the corners and a white W2 wave readout at the top centre.
Wave two, mid-fight: the W readout at the top centre, and every enemy on screen falling twice as fast as it did a minute ago.

Here is the moment the curve bends — the tenth kill’s explosion with the wave-up chirp riding on top of it:

SID voices 2 and 3 · noise and triangle
The tenth kill — explosion, chirp, and a faster game

Milestone 2 — the band, and a beat of stillness

The title music is the same table idea pointed at the SID. A melody is rows of pitch and duration; jingle_tick runs once per title frame, counts the current note down, and serves the next row when it expires. A pitch of zero is a rest — gate the voice off and keep counting, because silence is a note too — and $ff loops back to the top.

Notice how little the 6510 does. On the Spectrum, a beeper tune owns the processor — every half-cycle of every note is a CPU loop, and the game stops while music plays. Here the SID generates the waveform between player calls. A sounding row makes four register writes: frequency high, frequency low, gate off and gate on. A rest writes gate off; other calls still count down the timer. The starfield can continue while the SID sounds, provided the game meets its update budget. Compare the sequencer contracts before adapting the table or timer. That is what a sound chip buys you over a sound bit — and the tune is Twinkle Twinkle, Little Star, because what else would a starfield play?

One catch worth meeting now: voice 1 already belongs to the laser. The title lends it to the band — enter_title installs a softer envelope, enter_game gates the voice off and hands the laser its envelope back. One voice, two owners, and the transitions are exactly where the handover lives. Miss half of it and the laser comes back sounding like a flute.

SID voice 1 · triangle wave
The title jingle — one loop of Twinkle Twinkle on voice 1

The last change is the smallest and the most felt. In Unit 16, dying with the fire button held — which is how everyone dies — blew straight through the ending: game over read the held button, snapped to the title, the title read the same held button, and a new game started before the old one’s death tone finished. Three screens in three frames. The fix is ui_lock: entering game over loads it with a second of frames, entering the title with half a second, and neither screen believes the fire button until the lock runs out. The pause is not dead time — it is the punctuation that lets you read your own ending.

Step 2: a table-driven SID jingle, and a dwell on both ends of the loop+90 added−1 removed
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop)
Removed: ; This step: the wave system.
Added: ; This step: a SID title jingle, and a dwell on both ends of the loop.
; Assemble: acme -f cbm -o <step>.prg <step>.asm

; ------------------------------------------------
…wave          = $2c     ; wave counter (1, 2, 3, ... — never stops climbing)
kills         = $2d     ; hits this wave; 10 advances the wave
fall_speed    = $2e     ; pixels per frame the enemies fall — poked by advance_wave
Added: jingle_idx    = $2f     ; which jingle table entry is sounding
Added: jingle_timer  = $30     ; frames left on the current note
Added: ui_lock       = $31     ; frames before fire counts on the title / game-over screens
; $fb/$fc: scratch pointer used by the star routines

; ------------------------------------------------
…
title_state:
        jsr show_title              ; repaint, in case a star scrolled across it
Added:         jsr jingle_tick             ; the band plays; the SID holds each note itself
Added:         lda ui_lock                 ; a beat before fire counts —
Added:         beq title_ready             ; the press that ended the last screen
Added:         dec ui_lock                 ; must not start this one
Added:         jmp game_loop
Added: title_ready:
        lda $dc00
        and #%00010000              ; fire button (bit 4)
        bne loop_again              ; not pressed — wait on the title
…
over_state:
        jsr show_game_over          ; repaint over any star damage
Added:         lda ui_lock                 ; the dwell: let the ending land
Added:         beq over_ready
Added:         dec ui_lock
Added:         jmp game_loop
Added: over_ready:
        lda $dc00
        and #%00010000
        bne loop_again
…        lda #$02
        sta state
        sta $d027               ; ship turns red ($02 = red, reused here)
Added:         lda #60
Added:         sta ui_lock             ; the dwell — stillness before fire counts again
        jsr show_game_over
        jmp death_sound

…        sta $d015               ; all sprites off: the title has no ship or wave
        sta $d020               ; border black
        jsr show_title
Added:         lda #25
Added:         sta ui_lock             ; half a second before fire is believed
Added:         ; Voice 1 becomes the band: a softer envelope than the laser's
Added:         lda #$18
Added:         sta $d405               ; attack 1, decay 8
Added:         lda #$a0
Added:         sta $d406               ; sustain 10, release 0
Added:         lda #$00
Added:         sta jingle_idx          ; start the tune from the top,
Added:         lda #$01
Added:         sta jingle_timer        ; first note due on the next tick
        lda #$00
        sta state               ; 0 = title
        rts
…; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
Added:         ; The band stops: gate voice 1 off and hand it back to the laser
Added:         lda #$10
Added:         sta $d404               ; triangle, gate OFF
Added:         lda #$06
Added:         sta $d405               ; the laser's envelope again
Added:         lda #$00
Added:         sta $d406
        jsr clear_and_stars
        ; The sprite data pointers live in screen RAM ($07f8+), so the clear just
        ; wiped them — set them here, after the clear, or the sprites show garbage.
…WAVE_TOP = 4                    ; last index of the table below
wave_speed_tbl:
        !byte 1, 2, 2, 3, 3
Added: 
Added: ; ------------------------------------------------
Added: ; Subroutine: jingle_tick — one frame of title music
Added: ;   A melody is a table: a pitch (two bytes) and a duration in frames.
Added: ;   The SID holds each note by itself — this routine only does anything
Added: ;   when the current note's time is up. Pitch high byte 0 is a rest
Added: ;   (silence is a note too); $ff loops back to the top.
Added: ; ------------------------------------------------
Added: jingle_tick:
Added:         dec jingle_timer
Added:         beq jingle_next
Added:         rts
Added: jingle_next:
Added:         ldx jingle_idx
Added:         lda jingle_hi,x
Added:         cmp #$ff                ; the loop marker
Added:         bne jingle_play
Added:         lda #$00                ; back to the top
Added:         sta jingle_idx
Added:         ldx #$00
Added:         lda jingle_hi,x
Added: jingle_play:
Added:         bne jingle_note
Added:         ; a rest: gate off, keep counting
Added:         lda #$10
Added:         sta $d404
Added:         jmp jingle_clock
Added: jingle_note:
Added:         sta $d401               ; pitch, high byte
Added:         lda jingle_lo,x
Added:         sta $d400               ; pitch, low byte
Added:         lda #$10
Added:         sta $d404               ; gate off first — retrigger the envelope
Added:         lda #$11
Added:         sta $d404               ; triangle, gate on
Added: jingle_clock:
Added:         lda jingle_dur,x
Added:         sta jingle_timer
Added:         inc jingle_idx
Added:         rts
Added: 
Added: ; Twinkle Twinkle, Little Star — fourteen notes, a breath, and round
Added: ; again. A starfield needs no other tune. (PAL SID pitch values.)
Added: jingle_lo:
Added:         !byte $67,$67,$13,$13,$45,$45,$13
Added:         !byte $3b,$3b,$ed,$ed,$88,$88,$67
Added:         !byte $00,$00
Added: jingle_hi:
Added:         !byte $11,$11,$1a,$1a,$1d,$1d,$1a
Added:         !byte $17,$17,$15,$15,$13,$13,$11
Added:         !byte $00,$ff
Added: jingle_dur:
Added:         !byte 20,20,20,20,20,20,40
Added:         !byte 20,20,20,20,20,20,40
Added:         !byte 35,1

; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
The complete program
step-02.asm
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop)
; This step: a SID title jingle, and a dwell on both ends of the loop.
; 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
state         = $10     ; 0 = title, 1 = playing, 2 = game over
lives         = $11     ; lives remaining (starts at 3)
death_timer   = $12     ; frames of post-hit flash (and, in step 2, invulnerability)
frame_count   = $13     ; free-running frame counter (parallax timing)
star_row      = $14     ; 12 stars: row of each   ($14-$1f)
star_col      = $20     ; 12 stars: column of each ($20-$2b)
wave          = $2c     ; wave counter (1, 2, 3, ... — never stops climbing)
kills         = $2d     ; hits this wave; 10 advances the wave
fall_speed    = $2e     ; pixels per frame the enemies fall — poked by advance_wave
jingle_idx    = $2f     ; which jingle table entry is sounding
jingle_timer  = $30     ; frames left on the current note
ui_lock       = $31     ; frames before fire counts on the title / game-over screens
; $fb/$fc: scratch pointer used by the star routines

; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00

; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
start:
        ; --- One-time hardware setup (runs once, not per game) ---
        lda #$00
        sta $d020           ; border black
        sta $d021           ; background black
        sta $d010           ; ship 9th X bit clear

        ; Fixed sprite colours
        lda #$01
        sta $d027           ; ship white
        lda #$07
        sta $d028           ; bullet yellow

        ; SID voice 1 — the laser
        lda #$0f
        sta $d418           ; volume to maximum
        lda #$00
        sta $d400
        lda #$10
        sta $d401
        lda #$06
        sta $d405
        lda #$00
        sta $d406

        ; Star positions (drawn by enter_title / enter_game)
        sta frame_count     ; A is still 0
        ldx #$00
init_star_loop:
        lda star_init_row,x
        sta star_row,x
        lda star_init_col,x
        sta star_col,x
        inx
        cpx #12
        bne init_star_loop

        ; Open on the title screen
        jsr enter_title

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

        ; --- Parallax starfield: scrolls in every state (title, play, over) ---
        inc frame_count
        ldx #$00
star_loop:
        jsr erase_star
        ; Does THIS star move this frame? Near (0-3) every frame, mid (4-7)
        ; every 2nd frame, far (8-11) every 4th frame.
        cpx #$04
        bcc star_do_move        ; near layer: always
        cpx #$08
        bcc star_mid            ; mid layer
        ; far layer: only when the low two frame bits are clear (1 in 4)
        lda frame_count
        and #%00000011
        bne star_move_done
        beq star_do_move
star_mid:
        lda frame_count
        and #%00000001          ; every other frame
        bne star_move_done
star_do_move:
        inc star_row,x          ; one row down
        lda star_row,x
        cmp #25
        bcc star_move_done
        lda #$00                ; past the bottom -> wrap to the top
        sta star_row,x
star_move_done:
        jsr draw_star
        inx
        cpx #12
        bne star_loop

        ; --- State machine: title (0) / playing (1) / game over (2) ---
        lda state
        beq title_state
        cmp #$02
        beq over_state
        jmp game_active             ; 1 = playing

title_state:
        jsr show_title              ; repaint, in case a star scrolled across it
        jsr jingle_tick             ; the band plays; the SID holds each note itself
        lda ui_lock                 ; a beat before fire counts —
        beq title_ready             ; the press that ended the last screen
        dec ui_lock                 ; must not start this one
        jmp game_loop
title_ready:
        lda $dc00
        and #%00010000              ; fire button (bit 4)
        bne loop_again              ; not pressed — wait on the title
        jsr enter_game              ; fire -> start a game
loop_again:
        jmp game_loop

over_state:
        jsr show_game_over          ; repaint over any star damage
        lda ui_lock                 ; the dwell: let the ending land
        beq over_ready
        dec ui_lock
        jmp game_loop
over_ready:
        lda $dc00
        and #%00010000
        bne loop_again
        jsr enter_title             ; fire -> back to the title screen
        jmp game_loop

game_active:

        ; --- 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 at the wave's pace
        lda enemy_y_tbl,x
        clc
        adc fall_speed          ; the wave's tuning, read fresh every frame
        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

        ; Ten hits clear the wave: the lanes quicken
        inc kills
        lda kills
        cmp #10
        bcc no_hit
        jsr advance_wave

no_hit:

        ; --- Ship vs the wave: has any enemy reached the ship? ---
        ; ...but not while the life-lost flash runs — the ship is invulnerable
        lda death_timer
        beq do_ship_collision   ; not flashing -> run the check
        jmp no_ship_hit         ; flashing -> skip it (jmp, the target is far)
do_ship_collision:
        ldx #$00
ship_collision_loop:
        lda flash_tbl,x
        bne next_ship_check     ; ignore an exploding enemy
        ; Y distance: ship Y ($d001) vs this enemy's Y
        lda $d001
        sec
        sbc enemy_y_tbl,x
        cmp #$10
        bcc check_ship_x
        cmp #$f0
        bcc next_ship_check
check_ship_x:
        ; ship past X=255 (9th bit set) is far from any enemy — rule it out
        lda $d010
        and #%00000001          ; ship's 9th X bit (sprite 0)
        bne next_ship_check
        lda $d000
        sec
        sbc enemy_x_tbl,x
        cmp #$10
        bcc ship_hit
        cmp #$f0
        bcc next_ship_check
        jmp ship_hit            ; 240..255: close from the other side

next_ship_check:
        inx
        cpx #$03
        bne ship_collision_loop
        jmp no_ship_hit

ship_hit:
        ; Lose a life and update the readout
        dec lives
        lda lives
        clc
        adc #$30
        sta $0427               ; lives digit, top-right

        lda lives
        bne life_lost           ; lives remain -> respawn and play on

        ; Out of lives -> game over state (the dispatch handles the freeze)
        lda #$02
        sta state
        sta $d027               ; ship turns red ($02 = red, reused here)
        lda #60
        sta ui_lock             ; the dwell — stillness before fire counts again
        jsr show_game_over
        jmp death_sound

life_lost:
        ; Respawn the ship at its start position
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda $d010
        and #%11111110          ; clear the ship's 9th bit (back under X=256)
        sta $d010

        ; Start the life-lost flash (step 2 makes it an invulnerability window too)
        lda #90
        sta death_timer

death_sound:
        ; Death sound — SID voice 3 (plays on every death)
        lda #$00
        sta $d40e               ; voice 3 frequency low
        lda #$10
        sta $d40f               ; voice 3 frequency high
        lda #$0a
        sta $d413               ; attack 0, decay 10 (a long, slow fade)
        lda #$00
        sta $d414               ; sustain 0, release 0
        lda #$20
        sta $d412               ; sawtooth, gate OFF (reset the envelope)
        lda #$21
        sta $d412               ; sawtooth, gate ON (trigger)

no_ship_hit:

        ; --- Life-lost flash: while the timer runs, blink the border ---
        lda death_timer
        beq flash_done
        dec death_timer
        lda death_timer
        and #%00001000          ; bit 3 toggles every 8 frames
        bne flash_bright
        lda #$00                ; dark phase
        sta $d020
        jmp flash_tick
flash_bright:
        lda #$02                ; bright phase (red border)
        sta $d020
flash_tick:
        lda death_timer
        bne flash_done
        lda #$00                ; just expired -> border back to black
        sta $d020
flash_done:

        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

; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
;   Row 12 x 40 + 16 = 496 = $1f0, so screen RAM $05f0, colour RAM $d9f0
; ------------------------------------------------
show_game_over:
        lda #$07            ; G
        sta $05f0
        lda #$01            ; A
        sta $05f1
        lda #$0d            ; M
        sta $05f2
        lda #$05            ; E
        sta $05f3
        lda #$20            ; (space)
        sta $05f4
        lda #$0f            ; O
        sta $05f5
        lda #$16            ; V
        sta $05f6
        lda #$05            ; E
        sta $05f7
        lda #$12            ; R
        sta $05f8
        ; colour the nine cells white ($d9f0..$d9f8)
        lda #$01
        ldx #$00
-       sta $d9f0,x
        inx
        cpx #$09
        bne -
        rts

; ------------------------------------------------
; Subroutine: clear_and_stars — wipe the screen, then repaint every star
; ------------------------------------------------
clear_and_stars:
        ldx #$00
cas_clear:
        lda #$20
        sta $0400,x
        sta $0500,x
        sta $0600,x
        sta $0700,x
        inx
        bne cas_clear
        ldx #$00
cas_draw:
        jsr draw_star
        inx
        cpx #12
        bne cas_draw
        rts

; ------------------------------------------------
; Subroutine: enter_title — show the title, hide the game, state = 0
; ------------------------------------------------
enter_title:
        jsr clear_and_stars
        lda #$00
        sta $d015               ; all sprites off: the title has no ship or wave
        sta $d020               ; border black
        jsr show_title
        lda #25
        sta ui_lock             ; half a second before fire is believed
        ; Voice 1 becomes the band: a softer envelope than the laser's
        lda #$18
        sta $d405               ; attack 1, decay 8
        lda #$a0
        sta $d406               ; sustain 10, release 0
        lda #$00
        sta jingle_idx          ; start the tune from the top,
        lda #$01
        sta jingle_timer        ; first note due on the next tick
        lda #$00
        sta state               ; 0 = title
        rts

; ------------------------------------------------
; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
        ; The band stops: gate voice 1 off and hand it back to the laser
        lda #$10
        sta $d404               ; triangle, gate OFF
        lda #$06
        sta $d405               ; the laser's envelope again
        lda #$00
        sta $d406
        jsr clear_and_stars
        ; The sprite data pointers live in screen RAM ($07f8+), so the clear just
        ; wiped them — set them here, after the clear, or the sprites show garbage.
        lda #128
        sta $07f8           ; ship
        lda #129
        sta $07f9           ; bullet
        lda #130
        sta $07fa           ; enemy 0
        sta $07fb           ; enemy 1
        sta $07fc           ; enemy 2
        ; Ship at its start, white (it may have gone red on game over)
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda #$01
        sta $d027
        lda #$00
        sta $d010
        ; Enable ship + three enemies (the bullet stays off)
        lda #%00011101
        sta $d015
        ; Spawn the wave at staggered heights
        lda #$32
        ldx #$00
        jsr spawn_enemy
        lda #$82
        ldx #$01
        jsr spawn_enemy
        lda #$d2
        ldx #$02
        jsr spawn_enemy
        ; Reset per-game state
        lda #$00
        sta bullet_active
        sta death_timer
        sta $d020               ; border black
        sta score
        ; Score "00", white
        lda #$30
        sta $0400
        sta $0401
        lda #$01
        sta $d800
        sta $d801
        ; Lives "3", white
        lda #$03
        sta lives
        lda #$33
        sta $0427
        lda #$01
        sta $d827
        ; Wave 1: counter, kills, the live speed, and the readout
        lda #$01
        sta wave
        lda #$00
        sta kills
        lda wave_speed_tbl      ; first table entry
        sta fall_speed
        jsr draw_wave
        ; state = playing
        lda #$01
        sta state
        rts

; ------------------------------------------------
; Subroutine: advance_wave — ten kills: quicken the lanes, say so
;   Three different caps live here, and they are not the same thing:
;   the COUNTER never stops, the table INDEX clamps at the last row,
;   and the DIGIT shows 9 forever after. The player's number tells the
;   truth; the physics admits it ran out of new ideas.
; ------------------------------------------------
advance_wave:
        lda #$00
        sta kills
        inc wave
        ; table index = wave - 1, clamped to the last entry
        ldy wave
        dey
        cpy #WAVE_TOP
        bcc wave_speed_ok
        ldy #WAVE_TOP
wave_speed_ok:
        lda wave_speed_tbl,y
        sta fall_speed
        jsr draw_wave
        ; The chirp — voice 3, a bright triangle ding over the explosion
        lda #$00
        sta $d40e               ; voice 3 frequency low
        lda #$40
        sta $d40f               ; voice 3 frequency high (a high ding)
        lda #$09
        sta $d413               ; attack 0, decay 9
        lda #$00
        sta $d414               ; sustain 0, release 0
        lda #$10
        sta $d412               ; triangle, gate OFF (reset the envelope)
        lda #$11
        sta $d412               ; triangle, gate ON
        rts

; ------------------------------------------------
; Subroutine: draw_wave — "W" and the wave digit, top centre
;   The digit caps at 9; the wave itself keeps counting.
; ------------------------------------------------
draw_wave:
        lda #$17                ; W
        sta $0413
        lda wave
        cmp #$0a
        bcc wave_digit_ok
        lda #$09                ; show 9 from here on
wave_digit_ok:
        clc
        adc #$30                ; to screen code
        sta $0414
        lda #$01                ; white
        sta $d813
        sta $d814
        rts

; Pixels per frame, one entry per wave; the last entry is the wall
WAVE_TOP = 4                    ; last index of the table below
wave_speed_tbl:
        !byte 1, 2, 2, 3, 3

; ------------------------------------------------
; Subroutine: jingle_tick — one frame of title music
;   A melody is a table: a pitch (two bytes) and a duration in frames.
;   The SID holds each note by itself — this routine only does anything
;   when the current note's time is up. Pitch high byte 0 is a rest
;   (silence is a note too); $ff loops back to the top.
; ------------------------------------------------
jingle_tick:
        dec jingle_timer
        beq jingle_next
        rts
jingle_next:
        ldx jingle_idx
        lda jingle_hi,x
        cmp #$ff                ; the loop marker
        bne jingle_play
        lda #$00                ; back to the top
        sta jingle_idx
        ldx #$00
        lda jingle_hi,x
jingle_play:
        bne jingle_note
        ; a rest: gate off, keep counting
        lda #$10
        sta $d404
        jmp jingle_clock
jingle_note:
        sta $d401               ; pitch, high byte
        lda jingle_lo,x
        sta $d400               ; pitch, low byte
        lda #$10
        sta $d404               ; gate off first — retrigger the envelope
        lda #$11
        sta $d404               ; triangle, gate on
jingle_clock:
        lda jingle_dur,x
        sta jingle_timer
        inc jingle_idx
        rts

; Twinkle Twinkle, Little Star — fourteen notes, a breath, and round
; again. A starfield needs no other tune. (PAL SID pitch values.)
jingle_lo:
        !byte $67,$67,$13,$13,$45,$45,$13
        !byte $3b,$3b,$ed,$ed,$88,$88,$67
        !byte $00,$00
jingle_hi:
        !byte $11,$11,$1a,$1a,$1d,$1d,$1a
        !byte $17,$17,$15,$15,$13,$13,$11
        !byte $00,$ff
jingle_dur:
        !byte 20,20,20,20,20,20,40
        !byte 20,20,20,20,20,20,40
        !byte 35,1

; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
; ------------------------------------------------
show_title:
        lda #$13            ; S
        sta $05a0
        lda #$14            ; T
        sta $05a1
        lda #$01            ; A
        sta $05a2
        lda #$12            ; R
        sta $05a3
        lda #$06            ; F
        sta $05a4
        lda #$09            ; I
        sta $05a5
        lda #$05            ; E
        sta $05a6
        lda #$0c            ; L
        sta $05a7
        lda #$04            ; D
        sta $05a8
        lda #$01            ; white
        ldx #$00
-       sta $d9a0,x
        inx
        cpx #$09
        bne -
        lda #$10            ; P
        sta $063f
        lda #$12            ; R
        sta $0640
        lda #$05            ; E
        sta $0641
        lda #$13            ; S
        sta $0642
        lda #$13            ; S
        sta $0643
        lda #$20            ; (space)
        sta $0644
        lda #$06            ; F
        sta $0645
        lda #$09            ; I
        sta $0646
        lda #$12            ; R
        sta $0647
        lda #$05            ; E
        sta $0648
        lda #$0f            ; light grey
        ldx #$00
-       sta $da3f,x
        inx
        cpx #$0a
        bne -
        rts

; ------------------------------------------------
; Subroutine: erase_star  (X = star index)
;   blanks the star's current cell back to a space
; ------------------------------------------------
erase_star:
        ldy star_row,x
        lda row_addr_lo,y       ; point $fb/$fc at the start of this star's row
        sta $fb
        lda row_addr_hi,y
        sta $fc
        ldy star_col,x          ; Y = the column offset along that row
        lda #$20                ; a space
        sta ($fb),y             ; "finger on the boxes" — pointer + Y offset
        rts

; ------------------------------------------------
; Subroutine: draw_star  (X = star index)
;   writes the star's character + colour at its (row, col)
; ------------------------------------------------
draw_star:
        ldy star_row,x
        lda row_addr_lo,y
        sta $fb                 ; row start, low byte
        lda row_addr_hi,y
        sta $fc                 ; row start, high byte
        ldy star_col,x          ; Y = column
        lda star_char_tbl,x
        sta ($fb),y             ; STA ($fb),Y -> the screen-RAM cell
        ; screen RAM $04xx-$07xx maps to colour RAM $d8xx-$dbxx: high byte + $d4
        lda $fc
        clc
        adc #$d4
        sta $fc
        lda star_colour_tbl,x
        sta ($fb),y             ; same column offset, now into colour RAM
        rts

; ------------------------------------------------
; Star data tables
; ------------------------------------------------
; Screen-RAM start address of each row (row x 40 + $0400), rows 0-24
row_addr_lo:
        !byte $00,$28,$50,$78,$a0,$c8,$f0,$18
        !byte $40,$68,$90,$b8,$e0,$08,$30,$58
        !byte $80,$a8,$d0,$f8,$20,$48,$70,$98,$c0
row_addr_hi:
        !byte $04,$04,$04,$04,$04,$04,$04,$05
        !byte $05,$05,$05,$05,$05,$06,$06,$06
        !byte $06,$06,$06,$06,$07,$07,$07,$07,$07

; 12 stars. Columns avoid 0, 1 and 39 — the score and lives cells.
star_init_row:
        !byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
star_init_col:
        !byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
; Appearance reinforces the depth: near = bright white '*', far = dim grey '.'
star_char_tbl:
        !byte $2a,$2a,$2a,$2a, $2a,$2a,$2a,$2a, $2e,$2e,$2e,$2e
star_colour_tbl:
        !byte $01,$01,$01,$01, $0f,$0f,$0f,$0f, $0b,$0b,$0b,$0b

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

Milestone 3 — the turret park, closed

Starfield looked finished at step 2. But finished has a second test on this course — the one the winnability gate asks of every game: can it be lost by a player who’s actually trying to win? Drive Starfield the way a bored, clever player does, and it can’t.

Park the ship under the enemies’ lane, hold fire, never move. Every enemy funnels into your bullet stream and dies; you score freely and you are immortal. You needn’t even aim — fly to the right third of the screen and stop, and enemies never reach there at all. Two ways to switch the danger off, and both are one bug wearing two coats.

The bug is where the enemies come from. The shipped spawn picks a column from the raster line:

Code
        lda $d012           ; the raster line, as "randomness"
        and #$7f
        adc #$30            ; X = 48..175

It reads at the same instant every time — respawns fire at a fixed point in the frame, so the beam has barely moved — so every enemy appears in a band about a dozen pixels wide, narrower than the 16-pixel hit box. One parked, firing ship covers the whole band. And that band (48–175) is far narrower than the ship’s own travel (out past 300): everything to the right is a place no enemy can go.

The design-stress pass named it and its fix in one breath: give the spawn a real random source, spread it across the whole field, and make no square safe.

A real random column — the LFSR

We replace the repeated raster sample with a linear-feedback shift register — one byte walking a maximal 255-long pseudo-random cycle, one step per spawn:

Code
advance_rng:
        lda rng_seed
        asl                 ; shift left; the bit that falls out -> carry
        bcc +
        eor #$1d            ; a 1 fell out: fold the tap back in
+       sta rng_seed
        rts

The routine advances one byte of state per spawn. A non-zero seed walks all 255 non-zero byte values before repeating. Seeding from the raster once at game entry can vary the starting point, but two starts can choose the same seed. The same seed and call order reproduce the sequence; this generator is predictable, not a security tool.

Enemies across the whole field — the 9th bit

To threaten the sky the ship can reach, an enemy has to cross X=255 — and a sprite’s X is 9 bits: the low eight in its position register, the ninth over in $d010. You met that bit in the Primer, on the ship. Now the enemies use it too: an enemy_xhi_tbl byte each, written into $d010 beside the low X, and both collision tests taught to compare the full nine bits instead of skipping the right half. The enemies can now spawn on both sides of X=255. The mapping is 24 + b + floor(b/8). For an arbitrary byte 0–255 that would span 24–310, as the source comment describes. Our LFSR never supplies zero, so its actual spawn set contains 255 distinct columns from 25 through 310, with gaps. For example, b=7 gives X=31 and b=8 gives X=33: X=32 is skipped. It is not a uniform choice among all screen columns.

Predict whether X=24 can appear as a spawn, and whether that makes it a permanently safe place to park. The first answer comes from the mapping; the second also depends on enemy width, collisions and the hunt movement below. LFSR range mapping explains why reaching a useful range does not establish fairness.

Three green enemies at widely different X positions across the whole width of the screen, the white ship near the bottom.
The LFSR spawn: the three enemies scatter across the whole field, past the old X=175 ceiling. No single column to park under, and no right-side corner the danger can't reach.

No square is safe — the hunt

Spreading the enemies stops you scoring from a park, but a still ship can still hide: shoot the one lane it sits under, let the rest fall wide. So the last piece makes the enemies come to you. Every other frame, each enemy drifts one pixel toward the ship’s column:

Code
drift_enemy_x:
        ; step this enemy's 9-bit X one pixel toward the ship's 9-bit X
        ...
        bmi .drift_right    ; enemy left of the ship -> move right
        bne .drift_left     ; enemy right of the ship -> move left
        ...

Slow enough that a moving ship shakes them; fatal to one that doesn’t. Parking is a death sentence now: the enemies converge on your column from across the field, and with one bullet in the air at a time a still ship can’t clear them all.

The shipped game: the ship sitting at the bottom centre, three lives, enemies drifting in a lane away from it.
The shipped game, thirty seconds of parking and holding fire: three lives, untouched. The turret park.
The fixed game: the ship at the bottom centre with enemies converging on its column from above, lives down to 2.
The fixed game, the same thirty seconds of the same lazy play: a life already gone, the wave bearing down. Sitting still is no longer a strategy — the game can be lost.
Step 3: an LFSR spawn, full-field 9-bit enemies, and a hunt that leaves no square safe+154 added−25 removed
; Starfield - Unit 17: The Curve
Removed: ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop)
Removed: ; This step: a SID title jingle, and a dwell on both ends of the loop.
Added: ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop) -> step-03 (+ a real spawn RNG, full-field enemies, and homing: close the turret-park dominant strategy and the right-side no-death zone the design-stress pass found)
Added: ; This step: replace the raster-seeded enemy column with an 8-bit LFSR, spread enemies across the whole 9-bit field the ship can reach, make both collision tests 9-bit-aware, and have enemies home toward the ship's column so no parked position is safe — the fail state is reachable again.
; Assemble: acme -f cbm -o <step>.prg <step>.asm

; ------------------------------------------------
…jingle_idx    = $2f     ; which jingle table entry is sounding
jingle_timer  = $30     ; frames left on the current note
ui_lock       = $31     ; frames before fire counts on the title / game-over screens
Added: ; --- step 3: a real spawn RNG, and enemies across the whole field ---
Added: rng_seed      = $32     ; 8-bit LFSR state — the enemy-column pseudo-random source
Added: enemy_xhi_tbl = $33     ; 3 bytes ($33-$35): each enemy's 9th X bit (0 or 1)
Added: col_tmp       = $36     ; scratch: the spawn column's low byte mid-calculation
Added: dx_lo         = $37     ; scratch: 9-bit collision delta, low byte
Added: dx_hi         = $38     ; scratch: 9-bit collision delta, high byte
; $fb/$fc: scratch pointer used by the star routines

; ------------------------------------------------
…        lda flash_tbl,x
        bne do_flash            ; this enemy is mid-flash

Removed:         ; not flashing: drift down at the wave's pace
Added:         ; not flashing: fall at the wave's pace, then home toward the ship's
Added:         ; column every other frame — a moving ship still shakes them, a still one
Added:         ; gets run down. This is what makes the fail state reachable: no parked
Added:         ; position is safe once the enemies come to you.
        lda enemy_y_tbl,x
        clc
        adc fall_speed          ; the wave's tuning, read fresh every frame
        sta enemy_y_tbl,x
Added:         lda frame_count
Added:         lsr                     ; bit 0 -> carry
Added:         bcc +                   ; drift only on odd frames (~0.5 px/frame sideways)
Added:         jsr drift_enemy_x
Added: +       lda 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
…        sta $d000,y             ; sprite X  ($d004, $d006, ...)
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y  ($d005, $d007, ...)
Removed: 
Added:         jsr set_enemy_hibit     ; reflect this enemy's 9th X bit into $d010
next_enemy:
        inx
        cpx #$03                ; the full wave of three
…        cmp #$f0
        bcc next_collision      ; 16..239 apart: too far
check_x:
Removed:         ; A bullet in the right portion (9th bit set) is past X=255, far from
Removed:         ; any enemy, so rule it out before comparing low bytes.
Removed:         lda $d010
Removed:         and #%00000010          ; bullet's 9th X bit (sprite 1)
Removed:         bne next_collision
Added:         ; 9-bit |bulletX - enemyX| < 16. Enemies now range the whole field, so a
Added:         ; right-side bullet can no longer be ruled out by its 9th bit — compare the
Added:         ; full 9-bit X of both (low byte, then the high bit, keeping the borrow).
        lda $d002
        sec
Removed:         sbc enemy_x_tbl,x
Removed:         cmp #$10
Removed:         bcc hit_enemy           ; 0..15 apart: close
Added:         sbc enemy_x_tbl,x       ; low byte; carry holds the borrow
Added:         sta dx_lo
Added:         lda $d010
Added:         and #%00000010          ; bullet's 9th X bit (sprite 1)
Added:         beq bcol_hi0
Added:         lda #$01
Added: bcol_hi0:
Added:         sbc enemy_xhi_tbl,x     ; high byte, minus the borrow from the low
Added:         sta dx_hi
Added:         beq bcol_pos            ; delta 0..255: near only if the low byte is < 16
Added:         cmp #$ff
Added:         bne next_collision      ; delta < -255 or > 255: far
Added:         lda dx_lo               ; delta -256..-1: near from above (-16..-1)
        cmp #$f0
Removed:         bcc next_collision      ; 16..239 apart: too far
Removed:         jmp hit_enemy           ; 240..255: close from the other side
Added:         bcs hit_enemy
Added:         jmp next_collision
Added: bcol_pos:
Added:         lda dx_lo
Added:         cmp #$10
Added:         bcc hit_enemy           ; 0..15 apart: a hit
Added:         jmp next_collision

next_collision:
        inx
…        cmp #$f0
        bcc next_ship_check
check_ship_x:
Removed:         ; ship past X=255 (9th bit set) is far from any enemy — rule it out
Removed:         lda $d010
Removed:         and #%00000001          ; ship's 9th X bit (sprite 0)
Removed:         bne next_ship_check
Added:         ; 9-bit |shipX - enemyX| < 16. The ship's whole travel is reachable now,
Added:         ; so we compare full 9-bit X instead of skipping the right half.
        lda $d000
        sec
        sbc enemy_x_tbl,x
Added:         sta dx_lo
Added:         lda $d010
Added:         and #%00000001          ; ship's 9th X bit (sprite 0)
Added:         beq scol_hi0
Added:         lda #$01
Added: scol_hi0:
Added:         sbc enemy_xhi_tbl,x
Added:         sta dx_hi
Added:         beq scol_pos
Added:         cmp #$ff
Added:         bne next_ship_check
Added:         lda dx_lo
Added:         cmp #$f0
Added:         bcs ship_hit
Added:         jmp next_ship_check
Added: scol_pos:
Added:         lda dx_lo
        cmp #$10
        bcc ship_hit
Removed:         cmp #$f0
Removed:         bcc next_ship_check
Removed:         jmp ship_hit            ; 240..255: close from the other side
Added:         jmp next_ship_check

next_ship_check:
        inx
…; ------------------------------------------------
spawn_enemy:
        sta enemy_y_tbl,x
Removed:         lda $d012               ; raster line -> pseudo-random column
Removed:         and #$7f
Added:         ; A real per-spawn column. Advance the LFSR and spread its byte across
Added:         ; the whole field the ship can reach: b + b/8 lifts 0..255 to 0..286, and
Added:         ; +24 lands it at X = 24..310 as a 9-bit value (low byte + a 9th bit), so
Added:         ; the three lanes scatter and none sits outside the ship's travel.
Added:         jsr advance_rng
Added:         lda rng_seed
Added:         sta col_tmp
Added:         lsr
Added:         lsr
Added:         lsr                     ; b >> 3  (0..31)
        clc
Removed:         adc #$30                ; 48-175, inside the visible width
Added:         adc col_tmp             ; b + b/8  (0..286); carry = the 9th bit
Added:         sta enemy_x_tbl,x
Added:         lda #$00
Added:         adc #$00                ; capture the carry as the high bit
Added:         sta enemy_xhi_tbl,x
Added:         lda enemy_x_tbl,x
Added:         clc
Added:         adc #24                 ; shift the range up off the left edge
        sta enemy_x_tbl,x
Added:         lda enemy_xhi_tbl,x
Added:         adc #$00                ; carry ripples into the 9th bit
Added:         sta enemy_xhi_tbl,x
        lda #$00
        sta flash_tbl,x         ; not flashing
        ldy sprite_colour_off,x
…        sta $d000,y             ; this enemy's colour = green
        ldy sprite_pos_off,x
        lda enemy_x_tbl,x
Removed:         sta $d000,y             ; sprite X
Added:         sta $d000,y             ; sprite X (low 8 bits)
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y
Added:         jsr set_enemy_hibit     ; and its 9th X bit
Added:         rts
Added: 
Added: ; advance_rng — one step of an 8-bit Galois LFSR (tap $1d, maximal length 255).
Added: ; Shift left; if a 1 fell out, fold the tap back in. From any non-zero seed it
Added: ; never reaches 0, so the spawn-column source never gets stuck.
Added: advance_rng:
Added:         lda rng_seed
Added:         asl
Added:         bcc +
Added:         eor #$1d
Added: +       sta rng_seed
Added:         rts
Added: 
Added: ; set_enemy_hibit — reflect enemy_xhi_tbl,x into this enemy's 9th X bit in $d010
Added: ; (enemies are sprites 2,3,4 -> $d010 bits 2,3,4). X = enemy index, preserved.
Added: set_enemy_hibit:
Added:         lda enemy_d010_bit,x
Added:         ldy enemy_xhi_tbl,x
Added:         bne +
Added:         eor #$ff                ; clear this sprite's bit
Added:         and $d010
Added:         sta $d010
Added:         rts
Added: +       ora $d010               ; set this sprite's bit
Added:         sta $d010
Added:         rts
Added: enemy_d010_bit:
Added:         !byte $04, $08, $10     ; sprite 2, 3, 4 high-X bits
Added: 
Added: ; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X.
Added: ; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and
Added: ; steps the enemy one pixel the short way; equal columns hold.
Added: drift_enemy_x:
Added:         lda enemy_x_tbl,x
Added:         sec
Added:         sbc $d000               ; (enemy - ship) low; carry holds the borrow
Added:         sta dx_lo
Added:         lda $d010
Added:         and #%00000001          ; ship's 9th X bit
Added:         sta dx_hi               ; ship high (temp)
Added:         lda enemy_xhi_tbl,x
Added:         sbc dx_hi               ; A = (enemy - ship) high; N = sign
Added:         bmi .drift_right        ; enemy left of ship -> move right
Added:         bne .drift_left         ; enemy far right of ship -> move left
Added:         lda dx_lo
Added:         beq .drift_done         ; same column -> hold
Added: .drift_left:
Added:         lda enemy_x_tbl,x
Added:         sec
Added:         sbc #$01
Added:         sta enemy_x_tbl,x
Added:         lda enemy_xhi_tbl,x
Added:         sbc #$00
Added:         sta enemy_xhi_tbl,x
Added:         rts
Added: .drift_right:
Added:         lda enemy_x_tbl,x
Added:         clc
Added:         adc #$01
Added:         sta enemy_x_tbl,x
Added:         lda enemy_xhi_tbl,x
Added:         adc #$00
Added:         sta enemy_xhi_tbl,x
Added: .drift_done:
        rts

; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
…        ; Enable ship + three enemies (the bullet stays off)
        lda #%00011101
        sta $d015
Added:         ; Seed the column LFSR from the raster the instant play begins, forced
Added:         ; non-zero (the LFSR locks at 0) — so each game's spawn run differs while
Added:         ; the LFSR keeps the columns spread within a run.
Added:         lda $d012
Added:         ora #$01
Added:         sta rng_seed
        ; Spawn the wave at staggered heights
        lda #$32
        ldx #$00
The complete corrected program
step-03.asm
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop) -> step-03 (+ a real spawn RNG, full-field enemies, and homing: close the turret-park dominant strategy and the right-side no-death zone the design-stress pass found)
; This step: replace the raster-seeded enemy column with an 8-bit LFSR, spread enemies across the whole 9-bit field the ship can reach, make both collision tests 9-bit-aware, and have enemies home toward the ship's column so no parked position is safe — the fail state is reachable again.
; 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
state         = $10     ; 0 = title, 1 = playing, 2 = game over
lives         = $11     ; lives remaining (starts at 3)
death_timer   = $12     ; frames of post-hit flash (and, in step 2, invulnerability)
frame_count   = $13     ; free-running frame counter (parallax timing)
star_row      = $14     ; 12 stars: row of each   ($14-$1f)
star_col      = $20     ; 12 stars: column of each ($20-$2b)
wave          = $2c     ; wave counter (1, 2, 3, ... — never stops climbing)
kills         = $2d     ; hits this wave; 10 advances the wave
fall_speed    = $2e     ; pixels per frame the enemies fall — poked by advance_wave
jingle_idx    = $2f     ; which jingle table entry is sounding
jingle_timer  = $30     ; frames left on the current note
ui_lock       = $31     ; frames before fire counts on the title / game-over screens
; --- step 3: a real spawn RNG, and enemies across the whole field ---
rng_seed      = $32     ; 8-bit LFSR state — the enemy-column pseudo-random source
enemy_xhi_tbl = $33     ; 3 bytes ($33-$35): each enemy's 9th X bit (0 or 1)
col_tmp       = $36     ; scratch: the spawn column's low byte mid-calculation
dx_lo         = $37     ; scratch: 9-bit collision delta, low byte
dx_hi         = $38     ; scratch: 9-bit collision delta, high byte
; $fb/$fc: scratch pointer used by the star routines

; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00

; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
start:
        ; --- One-time hardware setup (runs once, not per game) ---
        lda #$00
        sta $d020           ; border black
        sta $d021           ; background black
        sta $d010           ; ship 9th X bit clear

        ; Fixed sprite colours
        lda #$01
        sta $d027           ; ship white
        lda #$07
        sta $d028           ; bullet yellow

        ; SID voice 1 — the laser
        lda #$0f
        sta $d418           ; volume to maximum
        lda #$00
        sta $d400
        lda #$10
        sta $d401
        lda #$06
        sta $d405
        lda #$00
        sta $d406

        ; Star positions (drawn by enter_title / enter_game)
        sta frame_count     ; A is still 0
        ldx #$00
init_star_loop:
        lda star_init_row,x
        sta star_row,x
        lda star_init_col,x
        sta star_col,x
        inx
        cpx #12
        bne init_star_loop

        ; Open on the title screen
        jsr enter_title

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

        ; --- Parallax starfield: scrolls in every state (title, play, over) ---
        inc frame_count
        ldx #$00
star_loop:
        jsr erase_star
        ; Does THIS star move this frame? Near (0-3) every frame, mid (4-7)
        ; every 2nd frame, far (8-11) every 4th frame.
        cpx #$04
        bcc star_do_move        ; near layer: always
        cpx #$08
        bcc star_mid            ; mid layer
        ; far layer: only when the low two frame bits are clear (1 in 4)
        lda frame_count
        and #%00000011
        bne star_move_done
        beq star_do_move
star_mid:
        lda frame_count
        and #%00000001          ; every other frame
        bne star_move_done
star_do_move:
        inc star_row,x          ; one row down
        lda star_row,x
        cmp #25
        bcc star_move_done
        lda #$00                ; past the bottom -> wrap to the top
        sta star_row,x
star_move_done:
        jsr draw_star
        inx
        cpx #12
        bne star_loop

        ; --- State machine: title (0) / playing (1) / game over (2) ---
        lda state
        beq title_state
        cmp #$02
        beq over_state
        jmp game_active             ; 1 = playing

title_state:
        jsr show_title              ; repaint, in case a star scrolled across it
        jsr jingle_tick             ; the band plays; the SID holds each note itself
        lda ui_lock                 ; a beat before fire counts —
        beq title_ready             ; the press that ended the last screen
        dec ui_lock                 ; must not start this one
        jmp game_loop
title_ready:
        lda $dc00
        and #%00010000              ; fire button (bit 4)
        bne loop_again              ; not pressed — wait on the title
        jsr enter_game              ; fire -> start a game
loop_again:
        jmp game_loop

over_state:
        jsr show_game_over          ; repaint over any star damage
        lda ui_lock                 ; the dwell: let the ending land
        beq over_ready
        dec ui_lock
        jmp game_loop
over_ready:
        lda $dc00
        and #%00010000
        bne loop_again
        jsr enter_title             ; fire -> back to the title screen
        jmp game_loop

game_active:

        ; --- 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: fall at the wave's pace, then home toward the ship's
        ; column every other frame — a moving ship still shakes them, a still one
        ; gets run down. This is what makes the fail state reachable: no parked
        ; position is safe once the enemies come to you.
        lda enemy_y_tbl,x
        clc
        adc fall_speed          ; the wave's tuning, read fresh every frame
        sta enemy_y_tbl,x
        lda frame_count
        lsr                     ; bit 0 -> carry
        bcc +                   ; drift only on odd frames (~0.5 px/frame sideways)
        jsr drift_enemy_x
+       lda 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, ...)
        jsr set_enemy_hibit     ; reflect this enemy's 9th X bit into $d010
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:
        ; 9-bit |bulletX - enemyX| < 16. Enemies now range the whole field, so a
        ; right-side bullet can no longer be ruled out by its 9th bit — compare the
        ; full 9-bit X of both (low byte, then the high bit, keeping the borrow).
        lda $d002
        sec
        sbc enemy_x_tbl,x       ; low byte; carry holds the borrow
        sta dx_lo
        lda $d010
        and #%00000010          ; bullet's 9th X bit (sprite 1)
        beq bcol_hi0
        lda #$01
bcol_hi0:
        sbc enemy_xhi_tbl,x     ; high byte, minus the borrow from the low
        sta dx_hi
        beq bcol_pos            ; delta 0..255: near only if the low byte is < 16
        cmp #$ff
        bne next_collision      ; delta < -255 or > 255: far
        lda dx_lo               ; delta -256..-1: near from above (-16..-1)
        cmp #$f0
        bcs hit_enemy
        jmp next_collision
bcol_pos:
        lda dx_lo
        cmp #$10
        bcc hit_enemy           ; 0..15 apart: a hit
        jmp next_collision

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

        ; Ten hits clear the wave: the lanes quicken
        inc kills
        lda kills
        cmp #10
        bcc no_hit
        jsr advance_wave

no_hit:

        ; --- Ship vs the wave: has any enemy reached the ship? ---
        ; ...but not while the life-lost flash runs — the ship is invulnerable
        lda death_timer
        beq do_ship_collision   ; not flashing -> run the check
        jmp no_ship_hit         ; flashing -> skip it (jmp, the target is far)
do_ship_collision:
        ldx #$00
ship_collision_loop:
        lda flash_tbl,x
        bne next_ship_check     ; ignore an exploding enemy
        ; Y distance: ship Y ($d001) vs this enemy's Y
        lda $d001
        sec
        sbc enemy_y_tbl,x
        cmp #$10
        bcc check_ship_x
        cmp #$f0
        bcc next_ship_check
check_ship_x:
        ; 9-bit |shipX - enemyX| < 16. The ship's whole travel is reachable now,
        ; so we compare full 9-bit X instead of skipping the right half.
        lda $d000
        sec
        sbc enemy_x_tbl,x
        sta dx_lo
        lda $d010
        and #%00000001          ; ship's 9th X bit (sprite 0)
        beq scol_hi0
        lda #$01
scol_hi0:
        sbc enemy_xhi_tbl,x
        sta dx_hi
        beq scol_pos
        cmp #$ff
        bne next_ship_check
        lda dx_lo
        cmp #$f0
        bcs ship_hit
        jmp next_ship_check
scol_pos:
        lda dx_lo
        cmp #$10
        bcc ship_hit
        jmp next_ship_check

next_ship_check:
        inx
        cpx #$03
        bne ship_collision_loop
        jmp no_ship_hit

ship_hit:
        ; Lose a life and update the readout
        dec lives
        lda lives
        clc
        adc #$30
        sta $0427               ; lives digit, top-right

        lda lives
        bne life_lost           ; lives remain -> respawn and play on

        ; Out of lives -> game over state (the dispatch handles the freeze)
        lda #$02
        sta state
        sta $d027               ; ship turns red ($02 = red, reused here)
        lda #60
        sta ui_lock             ; the dwell — stillness before fire counts again
        jsr show_game_over
        jmp death_sound

life_lost:
        ; Respawn the ship at its start position
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda $d010
        and #%11111110          ; clear the ship's 9th bit (back under X=256)
        sta $d010

        ; Start the life-lost flash (step 2 makes it an invulnerability window too)
        lda #90
        sta death_timer

death_sound:
        ; Death sound — SID voice 3 (plays on every death)
        lda #$00
        sta $d40e               ; voice 3 frequency low
        lda #$10
        sta $d40f               ; voice 3 frequency high
        lda #$0a
        sta $d413               ; attack 0, decay 10 (a long, slow fade)
        lda #$00
        sta $d414               ; sustain 0, release 0
        lda #$20
        sta $d412               ; sawtooth, gate OFF (reset the envelope)
        lda #$21
        sta $d412               ; sawtooth, gate ON (trigger)

no_ship_hit:

        ; --- Life-lost flash: while the timer runs, blink the border ---
        lda death_timer
        beq flash_done
        dec death_timer
        lda death_timer
        and #%00001000          ; bit 3 toggles every 8 frames
        bne flash_bright
        lda #$00                ; dark phase
        sta $d020
        jmp flash_tick
flash_bright:
        lda #$02                ; bright phase (red border)
        sta $d020
flash_tick:
        lda death_timer
        bne flash_done
        lda #$00                ; just expired -> border back to black
        sta $d020
flash_done:

        jmp game_loop

; ------------------------------------------------
; Subroutine: spawn one enemy
;   A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
        sta enemy_y_tbl,x
        ; A real per-spawn column. Advance the LFSR and spread its byte across
        ; the whole field the ship can reach: b + b/8 lifts 0..255 to 0..286, and
        ; +24 lands it at X = 24..310 as a 9-bit value (low byte + a 9th bit), so
        ; the three lanes scatter and none sits outside the ship's travel.
        jsr advance_rng
        lda rng_seed
        sta col_tmp
        lsr
        lsr
        lsr                     ; b >> 3  (0..31)
        clc
        adc col_tmp             ; b + b/8  (0..286); carry = the 9th bit
        sta enemy_x_tbl,x
        lda #$00
        adc #$00                ; capture the carry as the high bit
        sta enemy_xhi_tbl,x
        lda enemy_x_tbl,x
        clc
        adc #24                 ; shift the range up off the left edge
        sta enemy_x_tbl,x
        lda enemy_xhi_tbl,x
        adc #$00                ; carry ripples into the 9th bit
        sta enemy_xhi_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 (low 8 bits)
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y
        jsr set_enemy_hibit     ; and its 9th X bit
        rts

; advance_rng — one step of an 8-bit Galois LFSR (tap $1d, maximal length 255).
; Shift left; if a 1 fell out, fold the tap back in. From any non-zero seed it
; never reaches 0, so the spawn-column source never gets stuck.
advance_rng:
        lda rng_seed
        asl
        bcc +
        eor #$1d
+       sta rng_seed
        rts

; set_enemy_hibit — reflect enemy_xhi_tbl,x into this enemy's 9th X bit in $d010
; (enemies are sprites 2,3,4 -> $d010 bits 2,3,4). X = enemy index, preserved.
set_enemy_hibit:
        lda enemy_d010_bit,x
        ldy enemy_xhi_tbl,x
        bne +
        eor #$ff                ; clear this sprite's bit
        and $d010
        sta $d010
        rts
+       ora $d010               ; set this sprite's bit
        sta $d010
        rts
enemy_d010_bit:
        !byte $04, $08, $10     ; sprite 2, 3, 4 high-X bits

; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X.
; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and
; steps the enemy one pixel the short way; equal columns hold.
drift_enemy_x:
        lda enemy_x_tbl,x
        sec
        sbc $d000               ; (enemy - ship) low; carry holds the borrow
        sta dx_lo
        lda $d010
        and #%00000001          ; ship's 9th X bit
        sta dx_hi               ; ship high (temp)
        lda enemy_xhi_tbl,x
        sbc dx_hi               ; A = (enemy - ship) high; N = sign
        bmi .drift_right        ; enemy left of ship -> move right
        bne .drift_left         ; enemy far right of ship -> move left
        lda dx_lo
        beq .drift_done         ; same column -> hold
.drift_left:
        lda enemy_x_tbl,x
        sec
        sbc #$01
        sta enemy_x_tbl,x
        lda enemy_xhi_tbl,x
        sbc #$00
        sta enemy_xhi_tbl,x
        rts
.drift_right:
        lda enemy_x_tbl,x
        clc
        adc #$01
        sta enemy_x_tbl,x
        lda enemy_xhi_tbl,x
        adc #$00
        sta enemy_xhi_tbl,x
.drift_done:
        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

; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
;   Row 12 x 40 + 16 = 496 = $1f0, so screen RAM $05f0, colour RAM $d9f0
; ------------------------------------------------
show_game_over:
        lda #$07            ; G
        sta $05f0
        lda #$01            ; A
        sta $05f1
        lda #$0d            ; M
        sta $05f2
        lda #$05            ; E
        sta $05f3
        lda #$20            ; (space)
        sta $05f4
        lda #$0f            ; O
        sta $05f5
        lda #$16            ; V
        sta $05f6
        lda #$05            ; E
        sta $05f7
        lda #$12            ; R
        sta $05f8
        ; colour the nine cells white ($d9f0..$d9f8)
        lda #$01
        ldx #$00
-       sta $d9f0,x
        inx
        cpx #$09
        bne -
        rts

; ------------------------------------------------
; Subroutine: clear_and_stars — wipe the screen, then repaint every star
; ------------------------------------------------
clear_and_stars:
        ldx #$00
cas_clear:
        lda #$20
        sta $0400,x
        sta $0500,x
        sta $0600,x
        sta $0700,x
        inx
        bne cas_clear
        ldx #$00
cas_draw:
        jsr draw_star
        inx
        cpx #12
        bne cas_draw
        rts

; ------------------------------------------------
; Subroutine: enter_title — show the title, hide the game, state = 0
; ------------------------------------------------
enter_title:
        jsr clear_and_stars
        lda #$00
        sta $d015               ; all sprites off: the title has no ship or wave
        sta $d020               ; border black
        jsr show_title
        lda #25
        sta ui_lock             ; half a second before fire is believed
        ; Voice 1 becomes the band: a softer envelope than the laser's
        lda #$18
        sta $d405               ; attack 1, decay 8
        lda #$a0
        sta $d406               ; sustain 10, release 0
        lda #$00
        sta jingle_idx          ; start the tune from the top,
        lda #$01
        sta jingle_timer        ; first note due on the next tick
        lda #$00
        sta state               ; 0 = title
        rts

; ------------------------------------------------
; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
        ; The band stops: gate voice 1 off and hand it back to the laser
        lda #$10
        sta $d404               ; triangle, gate OFF
        lda #$06
        sta $d405               ; the laser's envelope again
        lda #$00
        sta $d406
        jsr clear_and_stars
        ; The sprite data pointers live in screen RAM ($07f8+), so the clear just
        ; wiped them — set them here, after the clear, or the sprites show garbage.
        lda #128
        sta $07f8           ; ship
        lda #129
        sta $07f9           ; bullet
        lda #130
        sta $07fa           ; enemy 0
        sta $07fb           ; enemy 1
        sta $07fc           ; enemy 2
        ; Ship at its start, white (it may have gone red on game over)
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda #$01
        sta $d027
        lda #$00
        sta $d010
        ; Enable ship + three enemies (the bullet stays off)
        lda #%00011101
        sta $d015
        ; Seed the column LFSR from the raster the instant play begins, forced
        ; non-zero (the LFSR locks at 0) — so each game's spawn run differs while
        ; the LFSR keeps the columns spread within a run.
        lda $d012
        ora #$01
        sta rng_seed
        ; Spawn the wave at staggered heights
        lda #$32
        ldx #$00
        jsr spawn_enemy
        lda #$82
        ldx #$01
        jsr spawn_enemy
        lda #$d2
        ldx #$02
        jsr spawn_enemy
        ; Reset per-game state
        lda #$00
        sta bullet_active
        sta death_timer
        sta $d020               ; border black
        sta score
        ; Score "00", white
        lda #$30
        sta $0400
        sta $0401
        lda #$01
        sta $d800
        sta $d801
        ; Lives "3", white
        lda #$03
        sta lives
        lda #$33
        sta $0427
        lda #$01
        sta $d827
        ; Wave 1: counter, kills, the live speed, and the readout
        lda #$01
        sta wave
        lda #$00
        sta kills
        lda wave_speed_tbl      ; first table entry
        sta fall_speed
        jsr draw_wave
        ; state = playing
        lda #$01
        sta state
        rts

; ------------------------------------------------
; Subroutine: advance_wave — ten kills: quicken the lanes, say so
;   Three different caps live here, and they are not the same thing:
;   the COUNTER never stops, the table INDEX clamps at the last row,
;   and the DIGIT shows 9 forever after. The player's number tells the
;   truth; the physics admits it ran out of new ideas.
; ------------------------------------------------
advance_wave:
        lda #$00
        sta kills
        inc wave
        ; table index = wave - 1, clamped to the last entry
        ldy wave
        dey
        cpy #WAVE_TOP
        bcc wave_speed_ok
        ldy #WAVE_TOP
wave_speed_ok:
        lda wave_speed_tbl,y
        sta fall_speed
        jsr draw_wave
        ; The chirp — voice 3, a bright triangle ding over the explosion
        lda #$00
        sta $d40e               ; voice 3 frequency low
        lda #$40
        sta $d40f               ; voice 3 frequency high (a high ding)
        lda #$09
        sta $d413               ; attack 0, decay 9
        lda #$00
        sta $d414               ; sustain 0, release 0
        lda #$10
        sta $d412               ; triangle, gate OFF (reset the envelope)
        lda #$11
        sta $d412               ; triangle, gate ON
        rts

; ------------------------------------------------
; Subroutine: draw_wave — "W" and the wave digit, top centre
;   The digit caps at 9; the wave itself keeps counting.
; ------------------------------------------------
draw_wave:
        lda #$17                ; W
        sta $0413
        lda wave
        cmp #$0a
        bcc wave_digit_ok
        lda #$09                ; show 9 from here on
wave_digit_ok:
        clc
        adc #$30                ; to screen code
        sta $0414
        lda #$01                ; white
        sta $d813
        sta $d814
        rts

; Pixels per frame, one entry per wave; the last entry is the wall
WAVE_TOP = 4                    ; last index of the table below
wave_speed_tbl:
        !byte 1, 2, 2, 3, 3

; ------------------------------------------------
; Subroutine: jingle_tick — one frame of title music
;   A melody is a table: a pitch (two bytes) and a duration in frames.
;   The SID holds each note by itself — this routine only does anything
;   when the current note's time is up. Pitch high byte 0 is a rest
;   (silence is a note too); $ff loops back to the top.
; ------------------------------------------------
jingle_tick:
        dec jingle_timer
        beq jingle_next
        rts
jingle_next:
        ldx jingle_idx
        lda jingle_hi,x
        cmp #$ff                ; the loop marker
        bne jingle_play
        lda #$00                ; back to the top
        sta jingle_idx
        ldx #$00
        lda jingle_hi,x
jingle_play:
        bne jingle_note
        ; a rest: gate off, keep counting
        lda #$10
        sta $d404
        jmp jingle_clock
jingle_note:
        sta $d401               ; pitch, high byte
        lda jingle_lo,x
        sta $d400               ; pitch, low byte
        lda #$10
        sta $d404               ; gate off first — retrigger the envelope
        lda #$11
        sta $d404               ; triangle, gate on
jingle_clock:
        lda jingle_dur,x
        sta jingle_timer
        inc jingle_idx
        rts

; Twinkle Twinkle, Little Star — fourteen notes, a breath, and round
; again. A starfield needs no other tune. (PAL SID pitch values.)
jingle_lo:
        !byte $67,$67,$13,$13,$45,$45,$13
        !byte $3b,$3b,$ed,$ed,$88,$88,$67
        !byte $00,$00
jingle_hi:
        !byte $11,$11,$1a,$1a,$1d,$1d,$1a
        !byte $17,$17,$15,$15,$13,$13,$11
        !byte $00,$ff
jingle_dur:
        !byte 20,20,20,20,20,20,40
        !byte 20,20,20,20,20,20,40
        !byte 35,1

; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
; ------------------------------------------------
show_title:
        lda #$13            ; S
        sta $05a0
        lda #$14            ; T
        sta $05a1
        lda #$01            ; A
        sta $05a2
        lda #$12            ; R
        sta $05a3
        lda #$06            ; F
        sta $05a4
        lda #$09            ; I
        sta $05a5
        lda #$05            ; E
        sta $05a6
        lda #$0c            ; L
        sta $05a7
        lda #$04            ; D
        sta $05a8
        lda #$01            ; white
        ldx #$00
-       sta $d9a0,x
        inx
        cpx #$09
        bne -
        lda #$10            ; P
        sta $063f
        lda #$12            ; R
        sta $0640
        lda #$05            ; E
        sta $0641
        lda #$13            ; S
        sta $0642
        lda #$13            ; S
        sta $0643
        lda #$20            ; (space)
        sta $0644
        lda #$06            ; F
        sta $0645
        lda #$09            ; I
        sta $0646
        lda #$12            ; R
        sta $0647
        lda #$05            ; E
        sta $0648
        lda #$0f            ; light grey
        ldx #$00
-       sta $da3f,x
        inx
        cpx #$0a
        bne -
        rts

; ------------------------------------------------
; Subroutine: erase_star  (X = star index)
;   blanks the star's current cell back to a space
; ------------------------------------------------
erase_star:
        ldy star_row,x
        lda row_addr_lo,y       ; point $fb/$fc at the start of this star's row
        sta $fb
        lda row_addr_hi,y
        sta $fc
        ldy star_col,x          ; Y = the column offset along that row
        lda #$20                ; a space
        sta ($fb),y             ; "finger on the boxes" — pointer + Y offset
        rts

; ------------------------------------------------
; Subroutine: draw_star  (X = star index)
;   writes the star's character + colour at its (row, col)
; ------------------------------------------------
draw_star:
        ldy star_row,x
        lda row_addr_lo,y
        sta $fb                 ; row start, low byte
        lda row_addr_hi,y
        sta $fc                 ; row start, high byte
        ldy star_col,x          ; Y = column
        lda star_char_tbl,x
        sta ($fb),y             ; STA ($fb),Y -> the screen-RAM cell
        ; screen RAM $04xx-$07xx maps to colour RAM $d8xx-$dbxx: high byte + $d4
        lda $fc
        clc
        adc #$d4
        sta $fc
        lda star_colour_tbl,x
        sta ($fb),y             ; same column offset, now into colour RAM
        rts

; ------------------------------------------------
; Star data tables
; ------------------------------------------------
; Screen-RAM start address of each row (row x 40 + $0400), rows 0-24
row_addr_lo:
        !byte $00,$28,$50,$78,$a0,$c8,$f0,$18
        !byte $40,$68,$90,$b8,$e0,$08,$30,$58
        !byte $80,$a8,$d0,$f8,$20,$48,$70,$98,$c0
row_addr_hi:
        !byte $04,$04,$04,$04,$04,$04,$04,$05
        !byte $05,$05,$05,$05,$05,$06,$06,$06
        !byte $06,$06,$06,$06,$07,$07,$07,$07,$07

; 12 stars. Columns avoid 0, 1 and 39 — the score and lives cells.
star_init_row:
        !byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
star_init_col:
        !byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
; Appearance reinforces the depth: near = bright white '*', far = dim grey '.'
star_char_tbl:
        !byte $2a,$2a,$2a,$2a, $2a,$2a,$2a,$2a, $2e,$2e,$2e,$2e
star_colour_tbl:
        !byte $01,$01,$01,$01, $0f,$0f,$0f,$0f, $0b,$0b,$0b,$0b

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

None of this was invented for a lesson — the turret park shipped, and a scripted “park and hold fire” run found it exactly the way the winnability gate is meant to: by playing to break the game and watching it break. step-03 is Starfield with the hole closed — the LFSR earns its place, and the game finally can’t be won by refusing to play it.

Milestone 4 — a curve that keeps bending

Closing the turret park made Starfield a game you can lose. It didn’t make it a game worth a long sitting. Three enemies, a field of black, a difficulty that stops climbing at wave 5 — the design-stress pass named that too: minute ten is identical to minute two. The lanes quicken, then run out of ideas and stop. So this last step gives the game a curve that keeps bending.

The swarm grows

The speed table ran out of room; the sprite hardware did not. The ship is sprite 0, the bullet sprite 1, the enemies 2, 3 and 4 — which leaves sprites 5, 6 and 7 idle. The escalation the game was missing sat in the VIC-II the whole time: more enemies.

Turning three enemies into a variable count is the parallel-array pattern you’ve used since Unit 1, one size larger. The four enemy arrays — X, Y, flash, the 9th bit — widen from three bytes to six, and every loop that walked #$03 now walks enemy_count:

Code
        inx
        cpx enemy_count         ; every active enemy this wave (3..6)
        bne enemy_loop

advance_wave grows that count — one more enemy every other wave, up to six — switching on the next sprite and dropping it in from the top:

Code
        lda enemy_count
        cmp #$06
        bcs no_grow             ; already six
        tax                     ; X = the new enemy's index
        inc enemy_count
        lda enemy_d010_bit,x    ; its sprite's enable bit
        ora $d015               ; switch the sprite on
        sta $d015
        lda #$32
        jsr spawn_enemy         ; drop it in, LFSR column
Wave 1: three green enemies spread across the screen, the ship near the bottom, score 0001.
Wave one, where the curve begins: three enemies, scattered by the LFSR.
Wave 9: six green enemies spread across the field homing on the ship, the readout at W9, score 0016.
Nine waves on: six enemies — the idle sprites 5, 6 and 7 pressed into service — and a score four digits deep. The field is no longer empty, and the pressure hasn't stopped rising.

The hunt tightens

The count caps at six around wave six, and the speed table topped out at five. Past that the game would go flat again — so the homing becomes the late escalator. Early on, enemies drift toward your column every other frame (a gentle half-pixel); from wave seven, three frames in four — firmer, a pull you feel, without the lunge that collapses the swarm onto you at once:

Code
        lda wave
        cmp #$07
        bcc drift_slow          ; before wave 7: every other frame (~0.5 px)
        lda frame_count
        and #$03
        bne drift_now           ; wave 7+: three frames in four (~0.75 px)

A score that can hold a long game

A game you can play for nine waves needs a scoreboard that counts that high. The old score was one BCD byte — two digits, wrapping silently at a hundred. Now it is two bytes, four digits, the carry rippling from the low pair to the high:

Code
        sed
        lda score
        clc
        adc #$01
        sta score
        lda score_hi
        adc #$00                ; the carry ripples up into the top two digits
        sta score_hi
        cld

Four digits is room for 9,999 kills — more than any sitting will spend, which is the point: the number never lies to you again.

Step 4: enemy count as a difficulty axis, a tightening hunt, and a four-digit score+109 added−41 removed
; Starfield - Unit 17: The Curve
Removed: ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop) -> step-03 (+ a real spawn RNG, full-field enemies, and homing: close the turret-park dominant strategy and the right-side no-death zone the design-stress pass found)
Removed: ; This step: replace the raster-seeded enemy column with an 8-bit LFSR, spread enemies across the whole 9-bit field the ship can reach, make both collision tests 9-bit-aware, and have enemies home toward the ship's column so no parked position is safe — the fail state is reachable again.
Added: ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves) -> step-02 (+ a SID title jingle and a dwell) -> step-03 (+ LFSR spawn, full-field 9-bit enemies, and homing: close the turret park + no-death zone) -> step-04 (+ a real curve: the swarm grows 3->6 over waves, the hunt tightens late-game, and the score widens to four digits so a long game is trackable)
Added: ; This step: make the enemy count a difficulty axis (parallel arrays widened from 3 to 6, spawned from the free sprites 5-7 as waves climb), tighten the hunt from wave 7 (homing three frames in four instead of one in two), and carry a 2-byte BCD score — the curve keeps bending past the point the speed table gave up.
; Assemble: acme -f cbm -o <step>.prg <step>.asm

; ------------------------------------------------
…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: score         = $06     ; Two-digit score, BCD (one decimal digit per nybble)
Removed: ; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
Removed: enemy_x_tbl   = $07     ; 3 bytes ($07,$08,$09): each enemy's X
Removed: enemy_y_tbl   = $0a     ; 3 bytes ($0a,$0b,$0c): each enemy's Y
Removed: flash_tbl     = $0d     ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer
Added: score         = $06     ; score low byte, BCD (tens:ones)
Added: score_hi      = $08     ; score high byte, BCD (thousands:hundreds) — freed with the arrays
Added: ; Enemies are 6-wide parallel arrays now, moved to absolute memory (the data block
Added: ; down by the sprite tables) so up to six fit; index 0..5 picks the enemy.
Added: enemy_count   = $07     ; how many enemies are active this wave (3, rising to 6)
state         = $10     ; 0 = title, 1 = playing, 2 = game over
lives         = $11     ; lives remaining (starts at 3)
death_timer   = $12     ; frames of post-hit flash (and, in step 2, invulnerability)
…ui_lock       = $31     ; frames before fire counts on the title / game-over screens
; --- step 3: a real spawn RNG, and enemies across the whole field ---
rng_seed      = $32     ; 8-bit LFSR state — the enemy-column pseudo-random source
Removed: enemy_xhi_tbl = $33     ; 3 bytes ($33-$35): each enemy's 9th X bit (0 or 1)
col_tmp       = $36     ; scratch: the spawn column's low byte mid-calculation
dx_lo         = $37     ; scratch: 9-bit collision delta, low byte
dx_hi         = $38     ; scratch: 9-bit collision delta, high byte
…        clc
        adc fall_speed          ; the wave's tuning, read fresh every frame
        sta enemy_y_tbl,x
Added:         lda wave
Added:         cmp #$07
Added:         bcc drift_slow          ; before wave 7: home every other frame (~0.5 px)
Added:         ; wave 7+: three frames in four (~0.75 px) — a firmer pull, not a lunge
Added:         lda frame_count
Added:         and #$03
Added:         bne drift_now           ; drift on 3 of every 4 frames
Added:         beq drift_skip          ; rest on the 4th
Added: drift_slow:
        lda frame_count
        lsr                     ; bit 0 -> carry
Removed:         bcc +                   ; drift only on odd frames (~0.5 px/frame sideways)
Added:         bcc drift_skip          ; earlier waves -> home every other frame
Added: drift_now:
        jsr drift_enemy_x
Removed: +       lda enemy_y_tbl,x
Added: drift_skip:
Added:         lda 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 set_enemy_hibit     ; reflect this enemy's 9th X bit into $d010
next_enemy:
        inx
Removed:         cpx #$03                ; the full wave of three
Added:         cpx enemy_count         ; every active enemy this wave (3..6)
        bne enemy_loop

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

…        clc
        adc #$01
        sta score
Added:         lda score_hi
Added:         adc #$00                ; the carry out of the low two digits ripples up
Added:         sta score_hi
        cld                     ; decimal mode off (every later ADC/SBC needs it off)
Removed: 
Removed:         ; Refresh the two digits: high nybble -> tens, low nybble -> ones
Removed:         lda score
Removed:         lsr
Removed:         lsr
Removed:         lsr
Removed:         lsr                     ; high nybble down to 0-9
Removed:         clc
Removed:         adc #$30                ; to screen code
Removed:         sta $0400               ; tens digit
Removed:         lda score
Removed:         and #$0f                ; low nybble, 0-9
Removed:         clc
Removed:         adc #$30
Removed:         sta $0401               ; ones digit
Added:         jsr draw_score          ; refresh all four digits

        ; Ten hits clear the wave: the lanes quicken
        inc kills
…
next_ship_check:
        inx
Removed:         cpx #$03
Added:         cpx enemy_count
        bne ship_collision_loop
        jmp no_ship_hit

…        sta $d010
        rts
enemy_d010_bit:
Removed:         !byte $04, $08, $10     ; sprite 2, 3, 4 high-X bits
Added:         !byte $04, $08, $10, $20, $40, $80  ; sprites 2-7 high-X bits

; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X.
; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and
….drift_done:
        rts

Removed: ; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
Added: ; Per-enemy VIC-II register offsets (sprites 2-7)
sprite_pos_off:
Removed:         !byte $04, $06, $08     ; X offsets: $d004, $d006, $d008
Added:         !byte $04, $06, $08, $0a, $0c, $0e  ; X offsets: $d004..$d00e
sprite_colour_off:
Removed:         !byte $29, $2a, $2b     ; colour offsets: $d029, $d02a, $d02b
Added:         !byte $29, $2a, $2b, $2c, $2d, $2e  ; colour offsets: $d029..$d02e
Added: 
Added: ; The enemy parallel arrays, six wide, in absolute RAM (moved out of zero page so
Added: ; six fit). Spawn fills them; the loops walk 0..enemy_count-1.
Added: enemy_x_tbl:   !fill 6, 0
Added: enemy_y_tbl:   !fill 6, 0
Added: flash_tbl:     !fill 6, 0
Added: enemy_xhi_tbl: !fill 6, 0
Added: 
Added: ; staggered spawn heights for up to six enemies
Added: spawn_y_tbl:   !byte $32, $82, $d2, $5a, $aa, $28

; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
…        sta $07fa           ; enemy 0
        sta $07fb           ; enemy 1
        sta $07fc           ; enemy 2
Added:         sta $07fd           ; enemy 3
Added:         sta $07fe           ; enemy 4
Added:         sta $07ff           ; enemy 5
        ; Ship at its start, white (it may have gone red on game over)
        lda #172
        sta $d000
…        sta $d027
        lda #$00
        sta $d010
Added:         ; Start with three enemies; the wave grows this toward six.
Added:         lda #$03
Added:         sta enemy_count
        ; Enable ship + three enemies (the bullet stays off)
        lda #%00011101
        sta $d015
…        lda $d012
        ora #$01
        sta rng_seed
Removed:         ; Spawn the wave at staggered heights
Removed:         lda #$32
Added:         ; Spawn the starting wave at staggered heights
        ldx #$00
Removed:         jsr spawn_enemy
Removed:         lda #$82
Removed:         ldx #$01
Removed:         jsr spawn_enemy
Removed:         lda #$d2
Removed:         ldx #$02
Added: .spawn_wave:
Added:         lda spawn_y_tbl,x
        jsr spawn_enemy
Added:         inx
Added:         cpx enemy_count
Added:         bne .spawn_wave
        ; Reset per-game state
        lda #$00
        sta bullet_active
        sta death_timer
        sta $d020               ; border black
        sta score
Removed:         ; Score "00", white
Added:         sta score_hi
Added:         ; Score "0000", white
        lda #$30
        sta $0400
        sta $0401
Added:         sta $0402
Added:         sta $0403
        lda #$01
        sta $d800
        sta $d801
Added:         sta $d802
Added:         sta $d803
        ; Lives "3", white
        lda #$03
        sta lives
…        lda #$00
        sta kills
        inc wave
Added:         ; Grow the swarm: every other wave, add one enemy until six are on screen.
Added:         ; This is the escalation axis the speed table ran out of — the field keeps
Added:         ; filling after the lanes stop quickening.
Added:         lda wave
Added:         and #$01
Added:         bne no_grow             ; odd wave -> no new enemy this time
Added:         lda enemy_count
Added:         cmp #$06
Added:         bcs no_grow             ; already six
Added:         tax                     ; X = the new enemy's index (= the current count)
Added:         inc enemy_count
Added:         lda enemy_d010_bit,x    ; sprite (2+X)'s enable bit is the same byte
Added:         ora $d015
Added:         sta $d015               ; switch the new sprite on
Added:         lda #$32
Added:         jsr spawn_enemy         ; drop it in at the top, LFSR column
Added: no_grow:
        ; table index = wave - 1, clamped to the last entry
        ldy wave
        dey
…        sta $d412               ; triangle, gate OFF (reset the envelope)
        lda #$11
        sta $d412               ; triangle, gate ON
Added:         rts
Added: 
Added: ; ------------------------------------------------
Added: ; Subroutine: draw_score — four BCD digits at the top-left ($0400-$0403)
Added: ;   thousands:hundreds live in score_hi, tens:ones in score
Added: ; ------------------------------------------------
Added: draw_score:
Added:         lda score_hi
Added:         lsr
Added:         lsr
Added:         lsr
Added:         lsr
Added:         clc
Added:         adc #$30
Added:         sta $0400               ; thousands
Added:         lda score_hi
Added:         and #$0f
Added:         clc
Added:         adc #$30
Added:         sta $0401               ; hundreds
Added:         lda score
Added:         lsr
Added:         lsr
Added:         lsr
Added:         lsr
Added:         clc
Added:         adc #$30
Added:         sta $0402               ; tens
Added:         lda score
Added:         and #$0f
Added:         clc
Added:         adc #$30
Added:         sta $0403               ; ones
        rts

; ------------------------------------------------
The complete program
step-04.asm
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves) -> step-02 (+ a SID title jingle and a dwell) -> step-03 (+ LFSR spawn, full-field 9-bit enemies, and homing: close the turret park + no-death zone) -> step-04 (+ a real curve: the swarm grows 3->6 over waves, the hunt tightens late-game, and the score widens to four digits so a long game is trackable)
; This step: make the enemy count a difficulty axis (parallel arrays widened from 3 to 6, spawned from the free sprites 5-7 as waves climb), tighten the hunt from wave 7 (homing three frames in four instead of one in two), and carry a 2-byte BCD score — the curve keeps bending past the point the speed table gave up.
; 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     ; score low byte, BCD (tens:ones)
score_hi      = $08     ; score high byte, BCD (thousands:hundreds) — freed with the arrays
; Enemies are 6-wide parallel arrays now, moved to absolute memory (the data block
; down by the sprite tables) so up to six fit; index 0..5 picks the enemy.
enemy_count   = $07     ; how many enemies are active this wave (3, rising to 6)
state         = $10     ; 0 = title, 1 = playing, 2 = game over
lives         = $11     ; lives remaining (starts at 3)
death_timer   = $12     ; frames of post-hit flash (and, in step 2, invulnerability)
frame_count   = $13     ; free-running frame counter (parallax timing)
star_row      = $14     ; 12 stars: row of each   ($14-$1f)
star_col      = $20     ; 12 stars: column of each ($20-$2b)
wave          = $2c     ; wave counter (1, 2, 3, ... — never stops climbing)
kills         = $2d     ; hits this wave; 10 advances the wave
fall_speed    = $2e     ; pixels per frame the enemies fall — poked by advance_wave
jingle_idx    = $2f     ; which jingle table entry is sounding
jingle_timer  = $30     ; frames left on the current note
ui_lock       = $31     ; frames before fire counts on the title / game-over screens
; --- step 3: a real spawn RNG, and enemies across the whole field ---
rng_seed      = $32     ; 8-bit LFSR state — the enemy-column pseudo-random source
col_tmp       = $36     ; scratch: the spawn column's low byte mid-calculation
dx_lo         = $37     ; scratch: 9-bit collision delta, low byte
dx_hi         = $38     ; scratch: 9-bit collision delta, high byte
; $fb/$fc: scratch pointer used by the star routines

; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00

; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
start:
        ; --- One-time hardware setup (runs once, not per game) ---
        lda #$00
        sta $d020           ; border black
        sta $d021           ; background black
        sta $d010           ; ship 9th X bit clear

        ; Fixed sprite colours
        lda #$01
        sta $d027           ; ship white
        lda #$07
        sta $d028           ; bullet yellow

        ; SID voice 1 — the laser
        lda #$0f
        sta $d418           ; volume to maximum
        lda #$00
        sta $d400
        lda #$10
        sta $d401
        lda #$06
        sta $d405
        lda #$00
        sta $d406

        ; Star positions (drawn by enter_title / enter_game)
        sta frame_count     ; A is still 0
        ldx #$00
init_star_loop:
        lda star_init_row,x
        sta star_row,x
        lda star_init_col,x
        sta star_col,x
        inx
        cpx #12
        bne init_star_loop

        ; Open on the title screen
        jsr enter_title

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

        ; --- Parallax starfield: scrolls in every state (title, play, over) ---
        inc frame_count
        ldx #$00
star_loop:
        jsr erase_star
        ; Does THIS star move this frame? Near (0-3) every frame, mid (4-7)
        ; every 2nd frame, far (8-11) every 4th frame.
        cpx #$04
        bcc star_do_move        ; near layer: always
        cpx #$08
        bcc star_mid            ; mid layer
        ; far layer: only when the low two frame bits are clear (1 in 4)
        lda frame_count
        and #%00000011
        bne star_move_done
        beq star_do_move
star_mid:
        lda frame_count
        and #%00000001          ; every other frame
        bne star_move_done
star_do_move:
        inc star_row,x          ; one row down
        lda star_row,x
        cmp #25
        bcc star_move_done
        lda #$00                ; past the bottom -> wrap to the top
        sta star_row,x
star_move_done:
        jsr draw_star
        inx
        cpx #12
        bne star_loop

        ; --- State machine: title (0) / playing (1) / game over (2) ---
        lda state
        beq title_state
        cmp #$02
        beq over_state
        jmp game_active             ; 1 = playing

title_state:
        jsr show_title              ; repaint, in case a star scrolled across it
        jsr jingle_tick             ; the band plays; the SID holds each note itself
        lda ui_lock                 ; a beat before fire counts —
        beq title_ready             ; the press that ended the last screen
        dec ui_lock                 ; must not start this one
        jmp game_loop
title_ready:
        lda $dc00
        and #%00010000              ; fire button (bit 4)
        bne loop_again              ; not pressed — wait on the title
        jsr enter_game              ; fire -> start a game
loop_again:
        jmp game_loop

over_state:
        jsr show_game_over          ; repaint over any star damage
        lda ui_lock                 ; the dwell: let the ending land
        beq over_ready
        dec ui_lock
        jmp game_loop
over_ready:
        lda $dc00
        and #%00010000
        bne loop_again
        jsr enter_title             ; fire -> back to the title screen
        jmp game_loop

game_active:

        ; --- 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: fall at the wave's pace, then home toward the ship's
        ; column every other frame — a moving ship still shakes them, a still one
        ; gets run down. This is what makes the fail state reachable: no parked
        ; position is safe once the enemies come to you.
        lda enemy_y_tbl,x
        clc
        adc fall_speed          ; the wave's tuning, read fresh every frame
        sta enemy_y_tbl,x
        lda wave
        cmp #$07
        bcc drift_slow          ; before wave 7: home every other frame (~0.5 px)
        ; wave 7+: three frames in four (~0.75 px) — a firmer pull, not a lunge
        lda frame_count
        and #$03
        bne drift_now           ; drift on 3 of every 4 frames
        beq drift_skip          ; rest on the 4th
drift_slow:
        lda frame_count
        lsr                     ; bit 0 -> carry
        bcc drift_skip          ; earlier waves -> home every other frame
drift_now:
        jsr drift_enemy_x
drift_skip:
        lda 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, ...)
        jsr set_enemy_hibit     ; reflect this enemy's 9th X bit into $d010
next_enemy:
        inx
        cpx enemy_count         ; every active enemy this wave (3..6)
        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:
        ; 9-bit |bulletX - enemyX| < 16. Enemies now range the whole field, so a
        ; right-side bullet can no longer be ruled out by its 9th bit — compare the
        ; full 9-bit X of both (low byte, then the high bit, keeping the borrow).
        lda $d002
        sec
        sbc enemy_x_tbl,x       ; low byte; carry holds the borrow
        sta dx_lo
        lda $d010
        and #%00000010          ; bullet's 9th X bit (sprite 1)
        beq bcol_hi0
        lda #$01
bcol_hi0:
        sbc enemy_xhi_tbl,x     ; high byte, minus the borrow from the low
        sta dx_hi
        beq bcol_pos            ; delta 0..255: near only if the low byte is < 16
        cmp #$ff
        bne next_collision      ; delta < -255 or > 255: far
        lda dx_lo               ; delta -256..-1: near from above (-16..-1)
        cmp #$f0
        bcs hit_enemy
        jmp next_collision
bcol_pos:
        lda dx_lo
        cmp #$10
        bcc hit_enemy           ; 0..15 apart: a hit
        jmp next_collision

next_collision:
        inx
        cpx enemy_count
        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
        lda score_hi
        adc #$00                ; the carry out of the low two digits ripples up
        sta score_hi
        cld                     ; decimal mode off (every later ADC/SBC needs it off)
        jsr draw_score          ; refresh all four digits

        ; Ten hits clear the wave: the lanes quicken
        inc kills
        lda kills
        cmp #10
        bcc no_hit
        jsr advance_wave

no_hit:

        ; --- Ship vs the wave: has any enemy reached the ship? ---
        ; ...but not while the life-lost flash runs — the ship is invulnerable
        lda death_timer
        beq do_ship_collision   ; not flashing -> run the check
        jmp no_ship_hit         ; flashing -> skip it (jmp, the target is far)
do_ship_collision:
        ldx #$00
ship_collision_loop:
        lda flash_tbl,x
        bne next_ship_check     ; ignore an exploding enemy
        ; Y distance: ship Y ($d001) vs this enemy's Y
        lda $d001
        sec
        sbc enemy_y_tbl,x
        cmp #$10
        bcc check_ship_x
        cmp #$f0
        bcc next_ship_check
check_ship_x:
        ; 9-bit |shipX - enemyX| < 16. The ship's whole travel is reachable now,
        ; so we compare full 9-bit X instead of skipping the right half.
        lda $d000
        sec
        sbc enemy_x_tbl,x
        sta dx_lo
        lda $d010
        and #%00000001          ; ship's 9th X bit (sprite 0)
        beq scol_hi0
        lda #$01
scol_hi0:
        sbc enemy_xhi_tbl,x
        sta dx_hi
        beq scol_pos
        cmp #$ff
        bne next_ship_check
        lda dx_lo
        cmp #$f0
        bcs ship_hit
        jmp next_ship_check
scol_pos:
        lda dx_lo
        cmp #$10
        bcc ship_hit
        jmp next_ship_check

next_ship_check:
        inx
        cpx enemy_count
        bne ship_collision_loop
        jmp no_ship_hit

ship_hit:
        ; Lose a life and update the readout
        dec lives
        lda lives
        clc
        adc #$30
        sta $0427               ; lives digit, top-right

        lda lives
        bne life_lost           ; lives remain -> respawn and play on

        ; Out of lives -> game over state (the dispatch handles the freeze)
        lda #$02
        sta state
        sta $d027               ; ship turns red ($02 = red, reused here)
        lda #60
        sta ui_lock             ; the dwell — stillness before fire counts again
        jsr show_game_over
        jmp death_sound

life_lost:
        ; Respawn the ship at its start position
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda $d010
        and #%11111110          ; clear the ship's 9th bit (back under X=256)
        sta $d010

        ; Start the life-lost flash (step 2 makes it an invulnerability window too)
        lda #90
        sta death_timer

death_sound:
        ; Death sound — SID voice 3 (plays on every death)
        lda #$00
        sta $d40e               ; voice 3 frequency low
        lda #$10
        sta $d40f               ; voice 3 frequency high
        lda #$0a
        sta $d413               ; attack 0, decay 10 (a long, slow fade)
        lda #$00
        sta $d414               ; sustain 0, release 0
        lda #$20
        sta $d412               ; sawtooth, gate OFF (reset the envelope)
        lda #$21
        sta $d412               ; sawtooth, gate ON (trigger)

no_ship_hit:

        ; --- Life-lost flash: while the timer runs, blink the border ---
        lda death_timer
        beq flash_done
        dec death_timer
        lda death_timer
        and #%00001000          ; bit 3 toggles every 8 frames
        bne flash_bright
        lda #$00                ; dark phase
        sta $d020
        jmp flash_tick
flash_bright:
        lda #$02                ; bright phase (red border)
        sta $d020
flash_tick:
        lda death_timer
        bne flash_done
        lda #$00                ; just expired -> border back to black
        sta $d020
flash_done:

        jmp game_loop

; ------------------------------------------------
; Subroutine: spawn one enemy
;   A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
        sta enemy_y_tbl,x
        ; A real per-spawn column. Advance the LFSR and spread its byte across
        ; the whole field the ship can reach: b + b/8 lifts 0..255 to 0..286, and
        ; +24 lands it at X = 24..310 as a 9-bit value (low byte + a 9th bit), so
        ; the three lanes scatter and none sits outside the ship's travel.
        jsr advance_rng
        lda rng_seed
        sta col_tmp
        lsr
        lsr
        lsr                     ; b >> 3  (0..31)
        clc
        adc col_tmp             ; b + b/8  (0..286); carry = the 9th bit
        sta enemy_x_tbl,x
        lda #$00
        adc #$00                ; capture the carry as the high bit
        sta enemy_xhi_tbl,x
        lda enemy_x_tbl,x
        clc
        adc #24                 ; shift the range up off the left edge
        sta enemy_x_tbl,x
        lda enemy_xhi_tbl,x
        adc #$00                ; carry ripples into the 9th bit
        sta enemy_xhi_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 (low 8 bits)
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y
        jsr set_enemy_hibit     ; and its 9th X bit
        rts

; advance_rng — one step of an 8-bit Galois LFSR (tap $1d, maximal length 255).
; Shift left; if a 1 fell out, fold the tap back in. From any non-zero seed it
; never reaches 0, so the spawn-column source never gets stuck.
advance_rng:
        lda rng_seed
        asl
        bcc +
        eor #$1d
+       sta rng_seed
        rts

; set_enemy_hibit — reflect enemy_xhi_tbl,x into this enemy's 9th X bit in $d010
; (enemies are sprites 2,3,4 -> $d010 bits 2,3,4). X = enemy index, preserved.
set_enemy_hibit:
        lda enemy_d010_bit,x
        ldy enemy_xhi_tbl,x
        bne +
        eor #$ff                ; clear this sprite's bit
        and $d010
        sta $d010
        rts
+       ora $d010               ; set this sprite's bit
        sta $d010
        rts
enemy_d010_bit:
        !byte $04, $08, $10, $20, $40, $80  ; sprites 2-7 high-X bits

; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X.
; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and
; steps the enemy one pixel the short way; equal columns hold.
drift_enemy_x:
        lda enemy_x_tbl,x
        sec
        sbc $d000               ; (enemy - ship) low; carry holds the borrow
        sta dx_lo
        lda $d010
        and #%00000001          ; ship's 9th X bit
        sta dx_hi               ; ship high (temp)
        lda enemy_xhi_tbl,x
        sbc dx_hi               ; A = (enemy - ship) high; N = sign
        bmi .drift_right        ; enemy left of ship -> move right
        bne .drift_left         ; enemy far right of ship -> move left
        lda dx_lo
        beq .drift_done         ; same column -> hold
.drift_left:
        lda enemy_x_tbl,x
        sec
        sbc #$01
        sta enemy_x_tbl,x
        lda enemy_xhi_tbl,x
        sbc #$00
        sta enemy_xhi_tbl,x
        rts
.drift_right:
        lda enemy_x_tbl,x
        clc
        adc #$01
        sta enemy_x_tbl,x
        lda enemy_xhi_tbl,x
        adc #$00
        sta enemy_xhi_tbl,x
.drift_done:
        rts

; Per-enemy VIC-II register offsets (sprites 2-7)
sprite_pos_off:
        !byte $04, $06, $08, $0a, $0c, $0e  ; X offsets: $d004..$d00e
sprite_colour_off:
        !byte $29, $2a, $2b, $2c, $2d, $2e  ; colour offsets: $d029..$d02e

; The enemy parallel arrays, six wide, in absolute RAM (moved out of zero page so
; six fit). Spawn fills them; the loops walk 0..enemy_count-1.
enemy_x_tbl:   !fill 6, 0
enemy_y_tbl:   !fill 6, 0
flash_tbl:     !fill 6, 0
enemy_xhi_tbl: !fill 6, 0

; staggered spawn heights for up to six enemies
spawn_y_tbl:   !byte $32, $82, $d2, $5a, $aa, $28

; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
;   Row 12 x 40 + 16 = 496 = $1f0, so screen RAM $05f0, colour RAM $d9f0
; ------------------------------------------------
show_game_over:
        lda #$07            ; G
        sta $05f0
        lda #$01            ; A
        sta $05f1
        lda #$0d            ; M
        sta $05f2
        lda #$05            ; E
        sta $05f3
        lda #$20            ; (space)
        sta $05f4
        lda #$0f            ; O
        sta $05f5
        lda #$16            ; V
        sta $05f6
        lda #$05            ; E
        sta $05f7
        lda #$12            ; R
        sta $05f8
        ; colour the nine cells white ($d9f0..$d9f8)
        lda #$01
        ldx #$00
-       sta $d9f0,x
        inx
        cpx #$09
        bne -
        rts

; ------------------------------------------------
; Subroutine: clear_and_stars — wipe the screen, then repaint every star
; ------------------------------------------------
clear_and_stars:
        ldx #$00
cas_clear:
        lda #$20
        sta $0400,x
        sta $0500,x
        sta $0600,x
        sta $0700,x
        inx
        bne cas_clear
        ldx #$00
cas_draw:
        jsr draw_star
        inx
        cpx #12
        bne cas_draw
        rts

; ------------------------------------------------
; Subroutine: enter_title — show the title, hide the game, state = 0
; ------------------------------------------------
enter_title:
        jsr clear_and_stars
        lda #$00
        sta $d015               ; all sprites off: the title has no ship or wave
        sta $d020               ; border black
        jsr show_title
        lda #25
        sta ui_lock             ; half a second before fire is believed
        ; Voice 1 becomes the band: a softer envelope than the laser's
        lda #$18
        sta $d405               ; attack 1, decay 8
        lda #$a0
        sta $d406               ; sustain 10, release 0
        lda #$00
        sta jingle_idx          ; start the tune from the top,
        lda #$01
        sta jingle_timer        ; first note due on the next tick
        lda #$00
        sta state               ; 0 = title
        rts

; ------------------------------------------------
; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
        ; The band stops: gate voice 1 off and hand it back to the laser
        lda #$10
        sta $d404               ; triangle, gate OFF
        lda #$06
        sta $d405               ; the laser's envelope again
        lda #$00
        sta $d406
        jsr clear_and_stars
        ; The sprite data pointers live in screen RAM ($07f8+), so the clear just
        ; wiped them — set them here, after the clear, or the sprites show garbage.
        lda #128
        sta $07f8           ; ship
        lda #129
        sta $07f9           ; bullet
        lda #130
        sta $07fa           ; enemy 0
        sta $07fb           ; enemy 1
        sta $07fc           ; enemy 2
        sta $07fd           ; enemy 3
        sta $07fe           ; enemy 4
        sta $07ff           ; enemy 5
        ; Ship at its start, white (it may have gone red on game over)
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda #$01
        sta $d027
        lda #$00
        sta $d010
        ; Start with three enemies; the wave grows this toward six.
        lda #$03
        sta enemy_count
        ; Enable ship + three enemies (the bullet stays off)
        lda #%00011101
        sta $d015
        ; Seed the column LFSR from the raster the instant play begins, forced
        ; non-zero (the LFSR locks at 0) — so each game's spawn run differs while
        ; the LFSR keeps the columns spread within a run.
        lda $d012
        ora #$01
        sta rng_seed
        ; Spawn the starting wave at staggered heights
        ldx #$00
.spawn_wave:
        lda spawn_y_tbl,x
        jsr spawn_enemy
        inx
        cpx enemy_count
        bne .spawn_wave
        ; Reset per-game state
        lda #$00
        sta bullet_active
        sta death_timer
        sta $d020               ; border black
        sta score
        sta score_hi
        ; Score "0000", white
        lda #$30
        sta $0400
        sta $0401
        sta $0402
        sta $0403
        lda #$01
        sta $d800
        sta $d801
        sta $d802
        sta $d803
        ; Lives "3", white
        lda #$03
        sta lives
        lda #$33
        sta $0427
        lda #$01
        sta $d827
        ; Wave 1: counter, kills, the live speed, and the readout
        lda #$01
        sta wave
        lda #$00
        sta kills
        lda wave_speed_tbl      ; first table entry
        sta fall_speed
        jsr draw_wave
        ; state = playing
        lda #$01
        sta state
        rts

; ------------------------------------------------
; Subroutine: advance_wave — ten kills: quicken the lanes, say so
;   Three different caps live here, and they are not the same thing:
;   the COUNTER never stops, the table INDEX clamps at the last row,
;   and the DIGIT shows 9 forever after. The player's number tells the
;   truth; the physics admits it ran out of new ideas.
; ------------------------------------------------
advance_wave:
        lda #$00
        sta kills
        inc wave
        ; Grow the swarm: every other wave, add one enemy until six are on screen.
        ; This is the escalation axis the speed table ran out of — the field keeps
        ; filling after the lanes stop quickening.
        lda wave
        and #$01
        bne no_grow             ; odd wave -> no new enemy this time
        lda enemy_count
        cmp #$06
        bcs no_grow             ; already six
        tax                     ; X = the new enemy's index (= the current count)
        inc enemy_count
        lda enemy_d010_bit,x    ; sprite (2+X)'s enable bit is the same byte
        ora $d015
        sta $d015               ; switch the new sprite on
        lda #$32
        jsr spawn_enemy         ; drop it in at the top, LFSR column
no_grow:
        ; table index = wave - 1, clamped to the last entry
        ldy wave
        dey
        cpy #WAVE_TOP
        bcc wave_speed_ok
        ldy #WAVE_TOP
wave_speed_ok:
        lda wave_speed_tbl,y
        sta fall_speed
        jsr draw_wave
        ; The chirp — voice 3, a bright triangle ding over the explosion
        lda #$00
        sta $d40e               ; voice 3 frequency low
        lda #$40
        sta $d40f               ; voice 3 frequency high (a high ding)
        lda #$09
        sta $d413               ; attack 0, decay 9
        lda #$00
        sta $d414               ; sustain 0, release 0
        lda #$10
        sta $d412               ; triangle, gate OFF (reset the envelope)
        lda #$11
        sta $d412               ; triangle, gate ON
        rts

; ------------------------------------------------
; Subroutine: draw_score — four BCD digits at the top-left ($0400-$0403)
;   thousands:hundreds live in score_hi, tens:ones in score
; ------------------------------------------------
draw_score:
        lda score_hi
        lsr
        lsr
        lsr
        lsr
        clc
        adc #$30
        sta $0400               ; thousands
        lda score_hi
        and #$0f
        clc
        adc #$30
        sta $0401               ; hundreds
        lda score
        lsr
        lsr
        lsr
        lsr
        clc
        adc #$30
        sta $0402               ; tens
        lda score
        and #$0f
        clc
        adc #$30
        sta $0403               ; ones
        rts

; ------------------------------------------------
; Subroutine: draw_wave — "W" and the wave digit, top centre
;   The digit caps at 9; the wave itself keeps counting.
; ------------------------------------------------
draw_wave:
        lda #$17                ; W
        sta $0413
        lda wave
        cmp #$0a
        bcc wave_digit_ok
        lda #$09                ; show 9 from here on
wave_digit_ok:
        clc
        adc #$30                ; to screen code
        sta $0414
        lda #$01                ; white
        sta $d813
        sta $d814
        rts

; Pixels per frame, one entry per wave; the last entry is the wall
WAVE_TOP = 4                    ; last index of the table below
wave_speed_tbl:
        !byte 1, 2, 2, 3, 3

; ------------------------------------------------
; Subroutine: jingle_tick — one frame of title music
;   A melody is a table: a pitch (two bytes) and a duration in frames.
;   The SID holds each note by itself — this routine only does anything
;   when the current note's time is up. Pitch high byte 0 is a rest
;   (silence is a note too); $ff loops back to the top.
; ------------------------------------------------
jingle_tick:
        dec jingle_timer
        beq jingle_next
        rts
jingle_next:
        ldx jingle_idx
        lda jingle_hi,x
        cmp #$ff                ; the loop marker
        bne jingle_play
        lda #$00                ; back to the top
        sta jingle_idx
        ldx #$00
        lda jingle_hi,x
jingle_play:
        bne jingle_note
        ; a rest: gate off, keep counting
        lda #$10
        sta $d404
        jmp jingle_clock
jingle_note:
        sta $d401               ; pitch, high byte
        lda jingle_lo,x
        sta $d400               ; pitch, low byte
        lda #$10
        sta $d404               ; gate off first — retrigger the envelope
        lda #$11
        sta $d404               ; triangle, gate on
jingle_clock:
        lda jingle_dur,x
        sta jingle_timer
        inc jingle_idx
        rts

; Twinkle Twinkle, Little Star — fourteen notes, a breath, and round
; again. A starfield needs no other tune. (PAL SID pitch values.)
jingle_lo:
        !byte $67,$67,$13,$13,$45,$45,$13
        !byte $3b,$3b,$ed,$ed,$88,$88,$67
        !byte $00,$00
jingle_hi:
        !byte $11,$11,$1a,$1a,$1d,$1d,$1a
        !byte $17,$17,$15,$15,$13,$13,$11
        !byte $00,$ff
jingle_dur:
        !byte 20,20,20,20,20,20,40
        !byte 20,20,20,20,20,20,40
        !byte 35,1

; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
; ------------------------------------------------
show_title:
        lda #$13            ; S
        sta $05a0
        lda #$14            ; T
        sta $05a1
        lda #$01            ; A
        sta $05a2
        lda #$12            ; R
        sta $05a3
        lda #$06            ; F
        sta $05a4
        lda #$09            ; I
        sta $05a5
        lda #$05            ; E
        sta $05a6
        lda #$0c            ; L
        sta $05a7
        lda #$04            ; D
        sta $05a8
        lda #$01            ; white
        ldx #$00
-       sta $d9a0,x
        inx
        cpx #$09
        bne -
        lda #$10            ; P
        sta $063f
        lda #$12            ; R
        sta $0640
        lda #$05            ; E
        sta $0641
        lda #$13            ; S
        sta $0642
        lda #$13            ; S
        sta $0643
        lda #$20            ; (space)
        sta $0644
        lda #$06            ; F
        sta $0645
        lda #$09            ; I
        sta $0646
        lda #$12            ; R
        sta $0647
        lda #$05            ; E
        sta $0648
        lda #$0f            ; light grey
        ldx #$00
-       sta $da3f,x
        inx
        cpx #$0a
        bne -
        rts

; ------------------------------------------------
; Subroutine: erase_star  (X = star index)
;   blanks the star's current cell back to a space
; ------------------------------------------------
erase_star:
        ldy star_row,x
        lda row_addr_lo,y       ; point $fb/$fc at the start of this star's row
        sta $fb
        lda row_addr_hi,y
        sta $fc
        ldy star_col,x          ; Y = the column offset along that row
        lda #$20                ; a space
        sta ($fb),y             ; "finger on the boxes" — pointer + Y offset
        rts

; ------------------------------------------------
; Subroutine: draw_star  (X = star index)
;   writes the star's character + colour at its (row, col)
; ------------------------------------------------
draw_star:
        ldy star_row,x
        lda row_addr_lo,y
        sta $fb                 ; row start, low byte
        lda row_addr_hi,y
        sta $fc                 ; row start, high byte
        ldy star_col,x          ; Y = column
        lda star_char_tbl,x
        sta ($fb),y             ; STA ($fb),Y -> the screen-RAM cell
        ; screen RAM $04xx-$07xx maps to colour RAM $d8xx-$dbxx: high byte + $d4
        lda $fc
        clc
        adc #$d4
        sta $fc
        lda star_colour_tbl,x
        sta ($fb),y             ; same column offset, now into colour RAM
        rts

; ------------------------------------------------
; Star data tables
; ------------------------------------------------
; Screen-RAM start address of each row (row x 40 + $0400), rows 0-24
row_addr_lo:
        !byte $00,$28,$50,$78,$a0,$c8,$f0,$18
        !byte $40,$68,$90,$b8,$e0,$08,$30,$58
        !byte $80,$a8,$d0,$f8,$20,$48,$70,$98,$c0
row_addr_hi:
        !byte $04,$04,$04,$04,$04,$04,$04,$05
        !byte $05,$05,$05,$05,$05,$06,$06,$06
        !byte $06,$06,$06,$06,$07,$07,$07,$07,$07

; 12 stars. Columns avoid 0, 1 and 39 — the score and lives cells.
star_init_row:
        !byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
star_init_col:
        !byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
; Appearance reinforces the depth: near = bright white '*', far = dim grey '.'
star_char_tbl:
        !byte $2a,$2a,$2a,$2a, $2a,$2a,$2a,$2a, $2e,$2e,$2e,$2e
star_colour_tbl:
        !byte $01,$01,$01,$01, $0f,$0f,$0f,$0f, $0b,$0b,$0b,$0b

; ------------------------------------------------
; 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 design-stress pass asked for a second escalation axis once the speed table gave out. Enemy count is that axis, the tightening hunt is a third, and the score finally counts high enough to prove you climbed. Starfield isn’t just un-broken now — it’s a game with somewhere to go.

Restart investigation: does game three begin like game one?

A title screen returning is useful evidence, but it does not prove that the next game starts cleanly. Use the optional restart companion with step 4. It builds a baseline, one deliberately faulty copy and a repair. Keep the original game as our working checkpoint.

We call the real entry and wave-growth routines in a controlled test. After each of the first two entries, five calls to advance_wave grow the swarm to six. The fixture also supplies a non-zero score, one remaining life, an active bullet and seven kills. It then returns to the title and enters the game again. These are prepared test conditions, not three played-through games.

Before running, predict the state immediately after each enter_game:

State First, second and third entry should each have
Enemy count 3
Score, including its high byte 0000
Lives 3
Wave, kills, fall speed 1, 0, 1
Bullet active 0
Sprite enable bits Ship and first three enemies on; bullet off

The faulty copy omits just sta enemy_count from enter_game. The fixture explicitly sets the count to three once before the first entry. That makes the first faulty game look correct without relying on initial RAM contents. Deleting the store in an ordinary launch could fail on the first game instead.

Run the baseline, then the faulty copy. Record all three displayed enemy counts. Does a correct first entry establish that restart works? Choose a hypothesis: a wrong starting count, a wrong wave-growth rule, or a missing per-game reset. What evidence would distinguish them?

Hint 1: follow the lifetime of the count

Find the store in enter_game before the spawn loop. Then find the increment in advance_wave. Does entering the title restore that value? Which value does the next spawn loop use if the store is missing?

Hint 2: compare a rule with its presentation

enter_game still resets wave to 1 and enables the first three enemy sprites. Neither operation changes enemy_count. Inspect the recorded count as well as the readout and sprite-enable bits.

Explain the evidence and check the repair

The baseline reports 3, 3, 3. The faulty copy reports 3, 6, 6: the first entry borrows the fixture’s initial value, then the larger count survives each restart. Wave 1 and three enabled sprites do not make the logical count three. The later loops can still process the extra entries.

Restore sta enemy_count after lda #$03, before spawning the starting wave. Rebuild and repeat the three entries. Expect 3, 3, 3, and check the other fields in the table too. The companion records them in memory and includes a native verification script; repairing the count must not undo score, lives, wave or bullet initialisation.

Changing the wave table, hiding more sprites or setting the count only at launch would not repair this lifetime error. The count belongs to each play session, so each new session must establish it.

Explain the fault without naming the missing instruction: which old state crossed a boundary where it no longer belonged? Then identify another field in the entry table with the same lifetime. Keep random-call order and sound handover out of this investigation; one reliable fault is enough. Return to the original step-04 game after the experiment.

Position investigation: repeat the choices, find the first difference

After the restart investigation, use the optional fixed-seed companion. It makes twelve calls to the existing spawn routine with seed 1, always using enemy 0, which is sprite 2. There are no joystick, movement or collision updates between calls. The same launch input schedule runs each copy. We have fixed the starting state and call order as well as the seed; this is a small routine experiment, not a recorded playthrough.

One deliberately faulty copy omits the call to set_enemy_hibit at the end of spawn_enemy. The random generator and coordinate calculation are unchanged. Predict whether the first error will appear in the random byte, the logical coordinate or the sprite registers. Run the baseline and fault, then compare their records in order instead of hunting for an arbitrary difference on screen.

Each record contains the random byte, logical X low/high bytes, $d004 and $d010. The screen shows the last three records in hexadecimal. The companion records all twelve for inspection. Initially $d010=$ab: sprite 2’s high bit is clear, while several other bits are set so we can check that the helper preserves them. Its mask for this sprite is $04.

Call Random byte (decimal) Calculate 24 + b + floor(b/8) Predict low byte / high byte
10 116 Write our result Write before running
11 232 Write our result Write before running
12 205 Write our result Write before running
Hint 1: find where the representations separate

For X above 255, subtract 256 to find its low byte. Compare the logical high byte with bit 2 of $d010. A correct random byte does not prove that its position reached the display correctly.

Hint 2: follow the next spawn too

The same sprite is reused. After setting its high bit, what must happen when its next coordinate is below 256? What would happen if we left the bit set, or wrote the logical high byte over all of $d010?

Compare the first divergence and repair

The coordinates are 154, 285, 254. Calls 1–10 agree. On call 11, both programs calculate X=285: low byte 29 ($1d), high byte 1. Both write 29 to $d004. The working helper changes $d010 from $ab to $af; the faulty copy leaves it at $ab. The register-selected position is therefore 29, not 285. This is a handoff fault, not a different random choice.

Restore the original jsr set_enemy_hibit. On call 12, the helper must also clear sprite 2’s high bit, returning $d010 to $ab for X=254. Leaving it set would select X=510. The other sprite bits must survive both operations.

The companion’s checks compare all twelve random bytes and coordinates, verify the register values on both sides of 255, and repeat the baseline from a cold start. The repair reproduces the baseline PRG byte for byte. A single plausible sprite position would be weaker evidence than this ordered trace.

Explain the first wrong value and which routine owns the transfer. Then explain why changing the seed would hide or move this failure without repairing it. Keep the random calls in their original order for this investigation. We will change that variable separately; a seed alone cannot guarantee reproducibility. Return to the original step-04 game afterwards.

Call-order investigation: who gets the next byte?

Now keep the position handling correct. The optional call-order companion reuses the fixed-seed fixture and adds one cosmetic draw for a border colour. This decoration belongs to the experiment, not the normal game’s starfield. Both versions consume thirteen bytes: twelve for enemies and one for decoration. We move the cosmetic draw from just before the first spawn to just after it.

Keep seed 1, the initial state and the launch inputs identical. Before running, assign the first four bytes—2, 4, 8, 16—to their consumers. Use 24 + b + floor(b/8) to predict the first two enemy positions. Will every later spawn differ, or can the two traces agree again?

Run both copies. The display shows their first three spawn records in hexadecimal; the companion also logs each draw as a consumer ID and its byte. We can inspect those values without distinguishing the border colours. Compare the raw random sequence and the consumers: matching seeds alone do not answer both questions.

Hint 1: label the draws before calculating positions

In one version the order begins decoration, enemy, enemy. In the other it begins enemy, decoration, enemy. Each request advances the same generator once.

Hint 2: count what has happened before spawn two

How many bytes has each version consumed by then? There are no collision or movement updates between these calls that could introduce another difference.

Compare the consumers and explain the result
Draw Decoration before spawn 1 Decoration after spawn 1
Byte 2 Decoration Spawn 1: X=26
Byte 4 Spawn 1: X=28 Decoration
Byte 8 Spawn 2: X=33 Spawn 2: X=33
Byte 16 Spawn 3: X=42 Spawn 3: X=42

The generator produces exactly the same sequence. The first enemy receives a different byte because decoration takes a different turn. Unlike the previous fault, the logical position and sprite registers agree in both versions.

Spawns 2–12 agree: both versions have consumed two bytes before spawn 2. That does not mean a real game would automatically recover the same history. A changed first position could affect a collision, score or later random calls. Our isolated fixture excludes those consequences so we can see this one cause.

Explain what we should do if a harmless-looking decoration edit changes an important test scenario. Restoring the old call order restores these choices; accepting a deliberate behaviour change means recording a new baseline. A separate random state for decoration is another possible design when that independence is needed, but it brings another seed and reset responsibility. We do not need a new generator or replay engine to investigate this example.

Finish by describing the evidence a bug report needs beyond its seed: which build, initial state, inputs and random consumers produced the result? Return to the original step-04 game afterwards. These three investigations isolate a reset fault, a representation handoff and a change in who consumes the data.

When it’s wrong, see why

  • The jingle plays its first note and holds it forever. jingle_timer isn’t being reloaded, or jingle_idx never increments — the tick serves one row and then keeps agreeing with itself.
  • The jingle keeps playing into the game. enter_game must gate voice 1 off. The SID holds notes by itself — that is the feature — so nobody stops the band except you.
  • The laser sounds soft and woolly after a game. The envelope handover is half-done: enter_title installed the band’s attack/decay and enter_game never restored the laser’s. Write both registers back.
  • The wave never advances. kills isn’t reset in advance_wave (it sails past 10 and the cmp #10 / bcc never matches again), or the increment isn’t in the hit path at all.
  • Wave six is absurdly, unplayably fast. The table index isn’t clamped — past the end of wave_speed_tbl the lookup reads neighbouring bytes as speeds. WAVE_TOP is the wall.
  • Fire still skips the game-over screen. ui_lock has to be loaded where the state changes — in the out-of-lives branch — not in show_game_over, which repaints every frame and would reload the lock forever.
  • A parked, firing ship never dies. The spawn column is seeded from the raster at a fixed point in the frame, so enemies cluster in a band tighter than the hit box and a still ship clears it. Give the spawn a real LFSR, spread it across the whole 9-bit field, and home the enemies toward the ship — a park has to be punishable.
  • Enemies vanish at the right edge, or never reach it. The spawn X tops out at 175 and the collision test skips the ship’s 9th-bit half. Carry enemies past X=255 with a per-enemy high bit and compare the full nine bits, or the right of the screen is a no-death zone.
  • The swarm never grows past three. A loop still walks #$03 instead of enemy_count, or advance_wave never increments the count. Widen the arrays to six and count with the variable.
  • A new enemy shows as garbage, or not at all. Its sprite bit in $d015 wasn’t switched on, or its shape pointer at $07fd-$07ff wasn’t set. Enable the bit and set the pointer before the enemy can appear.
  • The score still snaps back to zero. The high byte isn’t carried — the adc #$00 into score_hi is missing, so the low pair wraps at a hundred as before.

Before and after

We started with a finished loop that treated every minute the same and ended with a game that pushes back: waves that quicken on a schedule the data owns, a title with a voice, endings with room to land. None of it touched the systems built in Units 1–16 — the curve is a poked byte, the music is a visited table, the dwell is a counted-down lock. That is what the architecture was for. And when the winnability gate went looking, it found a game that pushed back only if you let it — a turret park that made losing optional — so a real random spawn, full-field enemies and a hunt closed the last hole: now the pushing-back is not optional. Then, with the game finally fair, we gave it somewhere to go — a swarm that grows into the idle sprites, a hunt that tightens late, a score that counts a long run — the curve bending on past the point the speed table admitted defeat.

Try this

  • Act on the press, not the hold. ui_lock slows a held button down; a latch ignores it entirely — keep last frame’s fire bit, act only on the 1→0 edge. It’s the full fix Unit 16’s “Try this” promised, and every screen with a button wants it.
  • Bend the curve. Make the table longer and gentler: 1, 1, 2, 2, 2, 3 with a wave every fifteen kills. Difficulty design is now a data-entry job — that was the point.
  • Transpose the jingle. Double every pitch value and the tune jumps an octave (on the SID, bigger number means higher note — the opposite of the Spectrum’s delay loops). NOTE*2 arithmetic is the assembler’s job, not yours.
  • A two-note chirp. The wave-up ding is one gate of voice 3. Make it a quick low-high pair — two pokes and a few frames apart — and it starts to sound like a promotion instead of a doorbell.

What you’ve learnt

  • Escalation is data — a counter, a table, and one live byte the game reads fresh every frame; the systems never know what wave it is.
  • Three caps, three jobs — the counter tells the truth, the index clamps for safety, the digit caps for display. Keeping them separate keeps the game honest.
  • A sequencer is a table and a tick — pitch, duration, rest-as-a-note, a loop marker; the SID holds the notes so the game keeps running.
  • Shared voices change hands at transitions — the title lends the laser’s voice to the band; both halves of the handover live in enter_title and enter_game.
  • Endings need dwell — a lock the ending screens count down before believing the button; the pause is part of the design.
  • A game is proven by trying to break it — a scripted “park and hold fire” run found a dominant strategy the finished game shipped with. Randomness you can stand under isn’t random; a field the danger can’t reach isn’t a field. A real LFSR, full-field 9-bit enemies, and a hunt made losing possible again.
  • Difficulty is more than one axis — when the speed table topped out, enemy count and homing pressure carried the curve on. Escalation lives in the hardware you haven’t used yet (three idle sprites) and the behaviour you can tighten.
  • The parallel-array pattern scales — three enemies to six is the same arrays one size wider and one variable in place of a literal; the loops never learn how many there are.

What’s next

Now Starfield is finished — and finished means losable and worth replaying: opening music, a curve that keeps bending past where the speed table gave out, a swarm that grows into the idle sprites, endings that land, a four-digit number for the run you died on, and no square left to switch the danger off. The vocabulary list from Unit 16 gains its last entries: escalation as data, music as a table, patience as a lock, a random source you can’t out-park, and difficulty spread across more axes than one.

Whatever you build next on this machine starts from all of it.

Run it here

This unit's program, built for you.

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

Download step-04.prg