Skip to content
Game 1Unit 17 of 171 hr learning time

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.

100% of Starfield

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-2
11 ; Starfield - Unit 17: The Curve
22 ; 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)
3+; This step: the wave system.
34 ; Assemble: acme -f cbm -o <step>.prg <step>.asm
45
56 ; ------------------------------------------------
...
2021 frame_count = $13 ; free-running frame counter (parallax timing)
2122 star_row = $14 ; 12 stars: row of each ($14-$1f)
2223 star_col = $20 ; 12 stars: column of each ($20-$2b)
24+wave = $2c ; wave counter (1, 2, 3, ... — never stops climbing)
25+kills = $2d ; hits this wave; 10 advances the wave
26+fall_speed = $2e ; pixels per frame the enemies fall — poked by advance_wave
2327 ; $fb/$fc: scratch pointer used by the star routines
2428
2529 ; ------------------------------------------------
...
308312 lda flash_tbl,x
309313 bne do_flash ; this enemy is mid-flash
310314
311- ; not flashing: drift down 1 pixel per frame
315+ ; not flashing: drift down at the wave's pace
312316 lda enemy_y_tbl,x
313317 clc
314- adc #$01 ; clc before adc, the addition from the Primer
318+ adc fall_speed ; the wave's tuning, read fresh every frame
315319 sta enemy_y_tbl,x
316320 cmp #$f8 ; off the bottom? (Y >= 248)
317321 bcc update_sprite ; still on screen
...
431435 clc
432436 adc #$30
433437 sta $0401 ; ones digit
438+
439+ ; Ten hits clear the wave: the lanes quicken
440+ inc kills
441+ lda kills
442+ cmp #10
443+ bcc no_hit
444+ jsr advance_wave
434445
435446 no_hit:
436447
...
695706 sta $0427
696707 lda #$01
697708 sta $d827
709+ ; Wave 1: counter, kills, the live speed, and the readout
710+ lda #$01
711+ sta wave
712+ lda #$00
713+ sta kills
714+ lda wave_speed_tbl ; first table entry
715+ sta fall_speed
716+ jsr draw_wave
698717 ; state = playing
699718 lda #$01
700719 sta state
720+ rts
721+
722+; ------------------------------------------------
723+; Subroutine: advance_wave — ten kills: quicken the lanes, say so
724+; Three different caps live here, and they are not the same thing:
725+; the COUNTER never stops, the table INDEX clamps at the last row,
726+; and the DIGIT shows 9 forever after. The player's number tells the
727+; truth; the physics admits it ran out of new ideas.
728+; ------------------------------------------------
729+advance_wave:
730+ lda #$00
731+ sta kills
732+ inc wave
733+ ; table index = wave - 1, clamped to the last entry
734+ ldy wave
735+ dey
736+ cpy #WAVE_TOP
737+ bcc wave_speed_ok
738+ ldy #WAVE_TOP
739+wave_speed_ok:
740+ lda wave_speed_tbl,y
741+ sta fall_speed
742+ jsr draw_wave
743+ ; The chirp — voice 3, a bright triangle ding over the explosion
744+ lda #$00
745+ sta $d40e ; voice 3 frequency low
746+ lda #$40
747+ sta $d40f ; voice 3 frequency high (a high ding)
748+ lda #$09
749+ sta $d413 ; attack 0, decay 9
750+ lda #$00
751+ sta $d414 ; sustain 0, release 0
752+ lda #$10
753+ sta $d412 ; triangle, gate OFF (reset the envelope)
754+ lda #$11
755+ sta $d412 ; triangle, gate ON
756+ rts
757+
758+; ------------------------------------------------
759+; Subroutine: draw_wave — "W" and the wave digit, top centre
760+; The digit caps at 9; the wave itself keeps counting.
761+; ------------------------------------------------
762+draw_wave:
763+ lda #$17 ; W
764+ sta $0413
765+ lda wave
766+ cmp #$0a
767+ bcc wave_digit_ok
768+ lda #$09 ; show 9 from here on
769+wave_digit_ok:
770+ clc
771+ adc #$30 ; to screen code
772+ sta $0414
773+ lda #$01 ; white
774+ sta $d813
775+ sta $d814
701776 rts
777+
778+; Pixels per frame, one entry per wave; the last entry is the wall
779+WAVE_TOP = 4 ; last index of the table below
780+wave_speed_tbl:
781+ !byte 1, 2, 2, 3, 3
702782
703783 ; ------------------------------------------------
704784 ; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
The complete program
; 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, in Gloaming, 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 holds each note by itself: jingle_tick touches the chip twice per note, at the start and at the end, and the starfield scrolls on regardless. 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-1
11 ; Starfield - Unit 17: The Curve
22 ; 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)
3-; This step: the wave system.
3+; This step: a SID title jingle, and a dwell on both ends of the loop.
44 ; Assemble: acme -f cbm -o <step>.prg <step>.asm
55
66 ; ------------------------------------------------
...
2424 wave = $2c ; wave counter (1, 2, 3, ... — never stops climbing)
2525 kills = $2d ; hits this wave; 10 advances the wave
2626 fall_speed = $2e ; pixels per frame the enemies fall — poked by advance_wave
27+jingle_idx = $2f ; which jingle table entry is sounding
28+jingle_timer = $30 ; frames left on the current note
29+ui_lock = $31 ; frames before fire counts on the title / game-over screens
2730 ; $fb/$fc: scratch pointer used by the star routines
2831
2932 ; ------------------------------------------------
...
128131
129132 title_state:
130133 jsr show_title ; repaint, in case a star scrolled across it
134+ jsr jingle_tick ; the band plays; the SID holds each note itself
135+ lda ui_lock ; a beat before fire counts —
136+ beq title_ready ; the press that ended the last screen
137+ dec ui_lock ; must not start this one
138+ jmp game_loop
139+title_ready:
131140 lda $dc00
132141 and #%00010000 ; fire button (bit 4)
133142 bne loop_again ; not pressed — wait on the title
...
137146
138147 over_state:
139148 jsr show_game_over ; repaint over any star damage
149+ lda ui_lock ; the dwell: let the ending land
150+ beq over_ready
151+ dec ui_lock
152+ jmp game_loop
153+over_ready:
140154 lda $dc00
141155 and #%00010000
142156 bne loop_again
...
498512 lda #$02
499513 sta state
500514 sta $d027 ; ship turns red ($02 = red, reused here)
515+ lda #60
516+ sta ui_lock ; the dwell — stillness before fire counts again
501517 jsr show_game_over
502518 jmp death_sound
503519
...
645661 sta $d015 ; all sprites off: the title has no ship or wave
646662 sta $d020 ; border black
647663 jsr show_title
664+ lda #25
665+ sta ui_lock ; half a second before fire is believed
666+ ; Voice 1 becomes the band: a softer envelope than the laser's
667+ lda #$18
668+ sta $d405 ; attack 1, decay 8
669+ lda #$a0
670+ sta $d406 ; sustain 10, release 0
671+ lda #$00
672+ sta jingle_idx ; start the tune from the top,
673+ lda #$01
674+ sta jingle_timer ; first note due on the next tick
648675 lda #$00
649676 sta state ; 0 = title
650677 rts
...
653680 ; Subroutine: enter_game — set up a fresh game, state = 1
654681 ; ------------------------------------------------
655682 enter_game:
683+ ; The band stops: gate voice 1 off and hand it back to the laser
684+ lda #$10
685+ sta $d404 ; triangle, gate OFF
686+ lda #$06
687+ sta $d405 ; the laser's envelope again
688+ lda #$00
689+ sta $d406
656690 jsr clear_and_stars
657691 ; The sprite data pointers live in screen RAM ($07f8+), so the clear just
658692 ; wiped them — set them here, after the clear, or the sprites show garbage.
...
779813 WAVE_TOP = 4 ; last index of the table below
780814 wave_speed_tbl:
781815 !byte 1, 2, 2, 3, 3
816+
817+; ------------------------------------------------
818+; Subroutine: jingle_tick — one frame of title music
819+; A melody is a table: a pitch (two bytes) and a duration in frames.
820+; The SID holds each note by itself — this routine only does anything
821+; when the current note's time is up. Pitch high byte 0 is a rest
822+; (silence is a note too); $ff loops back to the top.
823+; ------------------------------------------------
824+jingle_tick:
825+ dec jingle_timer
826+ beq jingle_next
827+ rts
828+jingle_next:
829+ ldx jingle_idx
830+ lda jingle_hi,x
831+ cmp #$ff ; the loop marker
832+ bne jingle_play
833+ lda #$00 ; back to the top
834+ sta jingle_idx
835+ ldx #$00
836+ lda jingle_hi,x
837+jingle_play:
838+ bne jingle_note
839+ ; a rest: gate off, keep counting
840+ lda #$10
841+ sta $d404
842+ jmp jingle_clock
843+jingle_note:
844+ sta $d401 ; pitch, high byte
845+ lda jingle_lo,x
846+ sta $d400 ; pitch, low byte
847+ lda #$10
848+ sta $d404 ; gate off first — retrigger the envelope
849+ lda #$11
850+ sta $d404 ; triangle, gate on
851+jingle_clock:
852+ lda jingle_dur,x
853+ sta jingle_timer
854+ inc jingle_idx
855+ rts
856+
857+; Twinkle Twinkle, Little Star — fourteen notes, a breath, and round
858+; again. A starfield needs no other tune. (PAL SID pitch values.)
859+jingle_lo:
860+ !byte $67,$67,$13,$13,$45,$45,$13
861+ !byte $3b,$3b,$ed,$ed,$88,$88,$67
862+ !byte $00,$00
863+jingle_hi:
864+ !byte $11,$11,$1a,$1a,$1d,$1d,$1a
865+ !byte $17,$17,$15,$15,$13,$13,$11
866+ !byte $00,$ff
867+jingle_dur:
868+ !byte 20,20,20,20,20,20,40
869+ !byte 20,20,20,20,20,20,40
870+ !byte 35,1
782871
783872 ; ------------------------------------------------
784873 ; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
The complete program
; 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:

        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

Raster-as-random was a fair beginner shortcut; the grown-up version is a linear-feedback shift register — one byte walking a maximal 255-long pseudo-random cycle, one step per spawn:

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

Eight bytes, no state but its own, and no pattern you can stand under. Seeded from the raster once at the start of each game, it gives every game a different run while keeping the columns scattered within a run.

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 safe corner is gone — an enemy can be anywhere the ship can.

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:

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-25
11 ; Starfield - Unit 17: The Curve
2-; 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)
3-; This step: a SID title jingle, and a dwell on both ends of the loop.
2+; 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)
3+; 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.
44 ; Assemble: acme -f cbm -o <step>.prg <step>.asm
55
66 ; ------------------------------------------------
...
2727 jingle_idx = $2f ; which jingle table entry is sounding
2828 jingle_timer = $30 ; frames left on the current note
2929 ui_lock = $31 ; frames before fire counts on the title / game-over screens
30+; --- step 3: a real spawn RNG, and enemies across the whole field ---
31+rng_seed = $32 ; 8-bit LFSR state — the enemy-column pseudo-random source
32+enemy_xhi_tbl = $33 ; 3 bytes ($33-$35): each enemy's 9th X bit (0 or 1)
33+col_tmp = $36 ; scratch: the spawn column's low byte mid-calculation
34+dx_lo = $37 ; scratch: 9-bit collision delta, low byte
35+dx_hi = $38 ; scratch: 9-bit collision delta, high byte
3036 ; $fb/$fc: scratch pointer used by the star routines
3137
3238 ; ------------------------------------------------
...
326332 lda flash_tbl,x
327333 bne do_flash ; this enemy is mid-flash
328334
329- ; not flashing: drift down at the wave's pace
335+ ; not flashing: fall at the wave's pace, then home toward the ship's
336+ ; column every other frame — a moving ship still shakes them, a still one
337+ ; gets run down. This is what makes the fail state reachable: no parked
338+ ; position is safe once the enemies come to you.
330339 lda enemy_y_tbl,x
331340 clc
332341 adc fall_speed ; the wave's tuning, read fresh every frame
333342 sta enemy_y_tbl,x
343+ lda frame_count
344+ lsr ; bit 0 -> carry
345+ bcc + ; drift only on odd frames (~0.5 px/frame sideways)
346+ jsr drift_enemy_x
347++ lda enemy_y_tbl,x
334348 cmp #$f8 ; off the bottom? (Y >= 248)
335349 bcc update_sprite ; still on screen
336350 lda #$32 ; respawn this enemy at the top, new column
...
351365 sta $d000,y ; sprite X ($d004, $d006, ...)
352366 lda enemy_y_tbl,x
353367 sta $d001,y ; sprite Y ($d005, $d007, ...)
354-
368+ jsr set_enemy_hibit ; reflect this enemy's 9th X bit into $d010
355369 next_enemy:
356370 inx
357371 cpx #$03 ; the full wave of three
...
376390 cmp #$f0
377391 bcc next_collision ; 16..239 apart: too far
378392 check_x:
379- ; A bullet in the right portion (9th bit set) is past X=255, far from
380- ; any enemy, so rule it out before comparing low bytes.
381- lda $d010
382- and #%00000010 ; bullet's 9th X bit (sprite 1)
383- bne next_collision
393+ ; 9-bit |bulletX - enemyX| < 16. Enemies now range the whole field, so a
394+ ; right-side bullet can no longer be ruled out by its 9th bit — compare the
395+ ; full 9-bit X of both (low byte, then the high bit, keeping the borrow).
384396 lda $d002
385397 sec
386- sbc enemy_x_tbl,x
387- cmp #$10
388- bcc hit_enemy ; 0..15 apart: close
398+ sbc enemy_x_tbl,x ; low byte; carry holds the borrow
399+ sta dx_lo
400+ lda $d010
401+ and #%00000010 ; bullet's 9th X bit (sprite 1)
402+ beq bcol_hi0
403+ lda #$01
404+bcol_hi0:
405+ sbc enemy_xhi_tbl,x ; high byte, minus the borrow from the low
406+ sta dx_hi
407+ beq bcol_pos ; delta 0..255: near only if the low byte is < 16
408+ cmp #$ff
409+ bne next_collision ; delta < -255 or > 255: far
410+ lda dx_lo ; delta -256..-1: near from above (-16..-1)
389411 cmp #$f0
390- bcc next_collision ; 16..239 apart: too far
391- jmp hit_enemy ; 240..255: close from the other side
412+ bcs hit_enemy
413+ jmp next_collision
414+bcol_pos:
415+ lda dx_lo
416+ cmp #$10
417+ bcc hit_enemy ; 0..15 apart: a hit
418+ jmp next_collision
392419
393420 next_collision:
394421 inx
...
478505 cmp #$f0
479506 bcc next_ship_check
480507 check_ship_x:
481- ; ship past X=255 (9th bit set) is far from any enemy — rule it out
482- lda $d010
483- and #%00000001 ; ship's 9th X bit (sprite 0)
484- bne next_ship_check
508+ ; 9-bit |shipX - enemyX| < 16. The ship's whole travel is reachable now,
509+ ; so we compare full 9-bit X instead of skipping the right half.
485510 lda $d000
486511 sec
487512 sbc enemy_x_tbl,x
513+ sta dx_lo
514+ lda $d010
515+ and #%00000001 ; ship's 9th X bit (sprite 0)
516+ beq scol_hi0
517+ lda #$01
518+scol_hi0:
519+ sbc enemy_xhi_tbl,x
520+ sta dx_hi
521+ beq scol_pos
522+ cmp #$ff
523+ bne next_ship_check
524+ lda dx_lo
525+ cmp #$f0
526+ bcs ship_hit
527+ jmp next_ship_check
528+scol_pos:
529+ lda dx_lo
488530 cmp #$10
489531 bcc ship_hit
490- cmp #$f0
491- bcc next_ship_check
492- jmp ship_hit ; 240..255: close from the other side
532+ jmp next_ship_check
493533
494534 next_ship_check:
495535 inx
...
576616 ; ------------------------------------------------
577617 spawn_enemy:
578618 sta enemy_y_tbl,x
579- lda $d012 ; raster line -> pseudo-random column
580- and #$7f
619+ ; A real per-spawn column. Advance the LFSR and spread its byte across
620+ ; the whole field the ship can reach: b + b/8 lifts 0..255 to 0..286, and
621+ ; +24 lands it at X = 24..310 as a 9-bit value (low byte + a 9th bit), so
622+ ; the three lanes scatter and none sits outside the ship's travel.
623+ jsr advance_rng
624+ lda rng_seed
625+ sta col_tmp
626+ lsr
627+ lsr
628+ lsr ; b >> 3 (0..31)
581629 clc
582- adc #$30 ; 48-175, inside the visible width
630+ adc col_tmp ; b + b/8 (0..286); carry = the 9th bit
631+ sta enemy_x_tbl,x
632+ lda #$00
633+ adc #$00 ; capture the carry as the high bit
634+ sta enemy_xhi_tbl,x
635+ lda enemy_x_tbl,x
636+ clc
637+ adc #24 ; shift the range up off the left edge
583638 sta enemy_x_tbl,x
639+ lda enemy_xhi_tbl,x
640+ adc #$00 ; carry ripples into the 9th bit
641+ sta enemy_xhi_tbl,x
584642 lda #$00
585643 sta flash_tbl,x ; not flashing
586644 ldy sprite_colour_off,x
...
588646 sta $d000,y ; this enemy's colour = green
589647 ldy sprite_pos_off,x
590648 lda enemy_x_tbl,x
591- sta $d000,y ; sprite X
649+ sta $d000,y ; sprite X (low 8 bits)
592650 lda enemy_y_tbl,x
593651 sta $d001,y ; sprite Y
652+ jsr set_enemy_hibit ; and its 9th X bit
653+ rts
654+
655+; advance_rng — one step of an 8-bit Galois LFSR (tap $1d, maximal length 255).
656+; Shift left; if a 1 fell out, fold the tap back in. From any non-zero seed it
657+; never reaches 0, so the spawn-column source never gets stuck.
658+advance_rng:
659+ lda rng_seed
660+ asl
661+ bcc +
662+ eor #$1d
663++ sta rng_seed
664+ rts
665+
666+; set_enemy_hibit — reflect enemy_xhi_tbl,x into this enemy's 9th X bit in $d010
667+; (enemies are sprites 2,3,4 -> $d010 bits 2,3,4). X = enemy index, preserved.
668+set_enemy_hibit:
669+ lda enemy_d010_bit,x
670+ ldy enemy_xhi_tbl,x
671+ bne +
672+ eor #$ff ; clear this sprite's bit
673+ and $d010
674+ sta $d010
675+ rts
676++ ora $d010 ; set this sprite's bit
677+ sta $d010
678+ rts
679+enemy_d010_bit:
680+ !byte $04, $08, $10 ; sprite 2, 3, 4 high-X bits
681+
682+; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X.
683+; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and
684+; steps the enemy one pixel the short way; equal columns hold.
685+drift_enemy_x:
686+ lda enemy_x_tbl,x
687+ sec
688+ sbc $d000 ; (enemy - ship) low; carry holds the borrow
689+ sta dx_lo
690+ lda $d010
691+ and #%00000001 ; ship's 9th X bit
692+ sta dx_hi ; ship high (temp)
693+ lda enemy_xhi_tbl,x
694+ sbc dx_hi ; A = (enemy - ship) high; N = sign
695+ bmi .drift_right ; enemy left of ship -> move right
696+ bne .drift_left ; enemy far right of ship -> move left
697+ lda dx_lo
698+ beq .drift_done ; same column -> hold
699+.drift_left:
700+ lda enemy_x_tbl,x
701+ sec
702+ sbc #$01
703+ sta enemy_x_tbl,x
704+ lda enemy_xhi_tbl,x
705+ sbc #$00
706+ sta enemy_xhi_tbl,x
707+ rts
708+.drift_right:
709+ lda enemy_x_tbl,x
710+ clc
711+ adc #$01
712+ sta enemy_x_tbl,x
713+ lda enemy_xhi_tbl,x
714+ adc #$00
715+ sta enemy_xhi_tbl,x
716+.drift_done:
594717 rts
595718
596719 ; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
...
710833 ; Enable ship + three enemies (the bullet stays off)
711834 lda #%00011101
712835 sta $d015
836+ ; Seed the column LFSR from the raster the instant play begins, forced
837+ ; non-zero (the LFSR locks at 0) — so each game's spawn run differs while
838+ ; the LFSR keeps the columns spread within a run.
839+ lda $d012
840+ ora #$01
841+ sta rng_seed
713842 ; Spawn the wave at staggered heights
714843 lda #$32
715844 ldx #$00
The complete corrected program
; 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:

        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:

        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:

        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:

        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-41
11 ; Starfield - Unit 17: The Curve
2-; 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)
3-; 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.
2+; 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)
3+; 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.
44 ; Assemble: acme -f cbm -o <step>.prg <step>.asm
55
66 ; ------------------------------------------------
...
1010 bullet_y = $03 ; Bullet Y position
1111 laser_timer = $04 ; Frames of laser pitch-sweep remaining (0 = idle)
1212 laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only)
13-score = $06 ; Two-digit score, BCD (one decimal digit per nybble)
14-; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
15-enemy_x_tbl = $07 ; 3 bytes ($07,$08,$09): each enemy's X
16-enemy_y_tbl = $0a ; 3 bytes ($0a,$0b,$0c): each enemy's Y
17-flash_tbl = $0d ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer
13+score = $06 ; score low byte, BCD (tens:ones)
14+score_hi = $08 ; score high byte, BCD (thousands:hundreds) — freed with the arrays
15+; Enemies are 6-wide parallel arrays now, moved to absolute memory (the data block
16+; down by the sprite tables) so up to six fit; index 0..5 picks the enemy.
17+enemy_count = $07 ; how many enemies are active this wave (3, rising to 6)
1818 state = $10 ; 0 = title, 1 = playing, 2 = game over
1919 lives = $11 ; lives remaining (starts at 3)
2020 death_timer = $12 ; frames of post-hit flash (and, in step 2, invulnerability)
...
2929 ui_lock = $31 ; frames before fire counts on the title / game-over screens
3030 ; --- step 3: a real spawn RNG, and enemies across the whole field ---
3131 rng_seed = $32 ; 8-bit LFSR state — the enemy-column pseudo-random source
32-enemy_xhi_tbl = $33 ; 3 bytes ($33-$35): each enemy's 9th X bit (0 or 1)
3332 col_tmp = $36 ; scratch: the spawn column's low byte mid-calculation
3433 dx_lo = $37 ; scratch: 9-bit collision delta, low byte
3534 dx_hi = $38 ; scratch: 9-bit collision delta, high byte
...
340339 clc
341340 adc fall_speed ; the wave's tuning, read fresh every frame
342341 sta enemy_y_tbl,x
342+ lda wave
343+ cmp #$07
344+ bcc drift_slow ; before wave 7: home every other frame (~0.5 px)
345+ ; wave 7+: three frames in four (~0.75 px) — a firmer pull, not a lunge
346+ lda frame_count
347+ and #$03
348+ bne drift_now ; drift on 3 of every 4 frames
349+ beq drift_skip ; rest on the 4th
350+drift_slow:
343351 lda frame_count
344352 lsr ; bit 0 -> carry
345- bcc + ; drift only on odd frames (~0.5 px/frame sideways)
353+ bcc drift_skip ; earlier waves -> home every other frame
354+drift_now:
346355 jsr drift_enemy_x
347-+ lda enemy_y_tbl,x
356+drift_skip:
357+ lda enemy_y_tbl,x
348358 cmp #$f8 ; off the bottom? (Y >= 248)
349359 bcc update_sprite ; still on screen
350360 lda #$32 ; respawn this enemy at the top, new column
...
368378 jsr set_enemy_hibit ; reflect this enemy's 9th X bit into $d010
369379 next_enemy:
370380 inx
371- cpx #$03 ; the full wave of three
381+ cpx enemy_count ; every active enemy this wave (3..6)
372382 bne enemy_loop
373383
374384 ; --- Bullet vs the wave: test each enemy until one is hit ---
...
419429
420430 next_collision:
421431 inx
422- cpx #$03
432+ cpx enemy_count
423433 bne collision_loop
424434 jmp no_hit
425435
...
460470 clc
461471 adc #$01
462472 sta score
473+ lda score_hi
474+ adc #$00 ; the carry out of the low two digits ripples up
475+ sta score_hi
463476 cld ; decimal mode off (every later ADC/SBC needs it off)
464-
465- ; Refresh the two digits: high nybble -> tens, low nybble -> ones
466- lda score
467- lsr
468- lsr
469- lsr
470- lsr ; high nybble down to 0-9
471- clc
472- adc #$30 ; to screen code
473- sta $0400 ; tens digit
474- lda score
475- and #$0f ; low nybble, 0-9
476- clc
477- adc #$30
478- sta $0401 ; ones digit
477+ jsr draw_score ; refresh all four digits
479478
480479 ; Ten hits clear the wave: the lanes quicken
481480 inc kills
...
533532
534533 next_ship_check:
535534 inx
536- cpx #$03
535+ cpx enemy_count
537536 bne ship_collision_loop
538537 jmp no_ship_hit
539538
...
677676 sta $d010
678677 rts
679678 enemy_d010_bit:
680- !byte $04, $08, $10 ; sprite 2, 3, 4 high-X bits
679+ !byte $04, $08, $10, $20, $40, $80 ; sprites 2-7 high-X bits
681680
682681 ; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X.
683682 ; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and
...
716715 .drift_done:
717716 rts
718717
719-; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
718+; Per-enemy VIC-II register offsets (sprites 2-7)
720719 sprite_pos_off:
721- !byte $04, $06, $08 ; X offsets: $d004, $d006, $d008
720+ !byte $04, $06, $08, $0a, $0c, $0e ; X offsets: $d004..$d00e
722721 sprite_colour_off:
723- !byte $29, $2a, $2b ; colour offsets: $d029, $d02a, $d02b
722+ !byte $29, $2a, $2b, $2c, $2d, $2e ; colour offsets: $d029..$d02e
723+
724+; The enemy parallel arrays, six wide, in absolute RAM (moved out of zero page so
725+; six fit). Spawn fills them; the loops walk 0..enemy_count-1.
726+enemy_x_tbl: !fill 6, 0
727+enemy_y_tbl: !fill 6, 0
728+flash_tbl: !fill 6, 0
729+enemy_xhi_tbl: !fill 6, 0
730+
731+; staggered spawn heights for up to six enemies
732+spawn_y_tbl: !byte $32, $82, $d2, $5a, $aa, $28
724733
725734 ; ------------------------------------------------
726735 ; Subroutine: print "GAME OVER" at row 12, column 16
...
821830 sta $07fa ; enemy 0
822831 sta $07fb ; enemy 1
823832 sta $07fc ; enemy 2
833+ sta $07fd ; enemy 3
834+ sta $07fe ; enemy 4
835+ sta $07ff ; enemy 5
824836 ; Ship at its start, white (it may have gone red on game over)
825837 lda #172
826838 sta $d000
...
830842 sta $d027
831843 lda #$00
832844 sta $d010
845+ ; Start with three enemies; the wave grows this toward six.
846+ lda #$03
847+ sta enemy_count
833848 ; Enable ship + three enemies (the bullet stays off)
834849 lda #%00011101
835850 sta $d015
...
839854 lda $d012
840855 ora #$01
841856 sta rng_seed
842- ; Spawn the wave at staggered heights
843- lda #$32
857+ ; Spawn the starting wave at staggered heights
844858 ldx #$00
845- jsr spawn_enemy
846- lda #$82
847- ldx #$01
848- jsr spawn_enemy
849- lda #$d2
850- ldx #$02
859+.spawn_wave:
860+ lda spawn_y_tbl,x
851861 jsr spawn_enemy
862+ inx
863+ cpx enemy_count
864+ bne .spawn_wave
852865 ; Reset per-game state
853866 lda #$00
854867 sta bullet_active
855868 sta death_timer
856869 sta $d020 ; border black
857870 sta score
858- ; Score "00", white
871+ sta score_hi
872+ ; Score "0000", white
859873 lda #$30
860874 sta $0400
861875 sta $0401
876+ sta $0402
877+ sta $0403
862878 lda #$01
863879 sta $d800
864880 sta $d801
881+ sta $d802
882+ sta $d803
865883 ; Lives "3", white
866884 lda #$03
867885 sta lives
...
893911 lda #$00
894912 sta kills
895913 inc wave
914+ ; Grow the swarm: every other wave, add one enemy until six are on screen.
915+ ; This is the escalation axis the speed table ran out of — the field keeps
916+ ; filling after the lanes stop quickening.
917+ lda wave
918+ and #$01
919+ bne no_grow ; odd wave -> no new enemy this time
920+ lda enemy_count
921+ cmp #$06
922+ bcs no_grow ; already six
923+ tax ; X = the new enemy's index (= the current count)
924+ inc enemy_count
925+ lda enemy_d010_bit,x ; sprite (2+X)'s enable bit is the same byte
926+ ora $d015
927+ sta $d015 ; switch the new sprite on
928+ lda #$32
929+ jsr spawn_enemy ; drop it in at the top, LFSR column
930+no_grow:
896931 ; table index = wave - 1, clamped to the last entry
897932 ldy wave
898933 dey
...
916951 sta $d412 ; triangle, gate OFF (reset the envelope)
917952 lda #$11
918953 sta $d412 ; triangle, gate ON
954+ rts
955+
956+; ------------------------------------------------
957+; Subroutine: draw_score — four BCD digits at the top-left ($0400-$0403)
958+; thousands:hundreds live in score_hi, tens:ones in score
959+; ------------------------------------------------
960+draw_score:
961+ lda score_hi
962+ lsr
963+ lsr
964+ lsr
965+ lsr
966+ clc
967+ adc #$30
968+ sta $0400 ; thousands
969+ lda score_hi
970+ and #$0f
971+ clc
972+ adc #$30
973+ sta $0401 ; hundreds
974+ lda score
975+ lsr
976+ lsr
977+ lsr
978+ lsr
979+ clc
980+ adc #$30
981+ sta $0402 ; tens
982+ lda score
983+ and #$0f
984+ clc
985+ adc #$30
986+ sta $0403 ; ones
919987 rts
920988
921989 ; ------------------------------------------------
The complete program
; 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.

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.

The next game on this machine starts from all of it. Platform Panic trades the open sky for floors and ladders — and brings the first problem sprites can’t solve alone.