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:
wavecounts where you are — it starts at 1 and never stops climbing.wave_speed_tblsays what each wave means — one byte per wave, pixels of fall per frame.fall_speedis 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.
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop)
Added: ; This step: the wave system.
; Assemble: acme -f cbm -o <step>.prg <step>.asm
; ------------------------------------------------
…frame_count = $13 ; free-running frame counter (parallax timing)
star_row = $14 ; 12 stars: row of each ($14-$1f)
star_col = $20 ; 12 stars: column of each ($20-$2b)
Added: wave = $2c ; wave counter (1, 2, 3, ... — never stops climbing)
Added: kills = $2d ; hits this wave; 10 advances the wave
Added: fall_speed = $2e ; pixels per frame the enemies fall — poked by advance_wave
; $fb/$fc: scratch pointer used by the star routines
; ------------------------------------------------
… lda flash_tbl,x
bne do_flash ; this enemy is mid-flash
Removed: ; not flashing: drift down 1 pixel per frame
Added: ; not flashing: drift down at the wave's pace
lda enemy_y_tbl,x
clc
Removed: adc #$01 ; clc before adc, the addition from the Primer
Added: adc fall_speed ; the wave's tuning, read fresh every frame
sta enemy_y_tbl,x
cmp #$f8 ; off the bottom? (Y >= 248)
bcc update_sprite ; still on screen
… clc
adc #$30
sta $0401 ; ones digit
Added:
Added: ; Ten hits clear the wave: the lanes quicken
Added: inc kills
Added: lda kills
Added: cmp #10
Added: bcc no_hit
Added: jsr advance_wave
no_hit:
… sta $0427
lda #$01
sta $d827
Added: ; Wave 1: counter, kills, the live speed, and the readout
Added: lda #$01
Added: sta wave
Added: lda #$00
Added: sta kills
Added: lda wave_speed_tbl ; first table entry
Added: sta fall_speed
Added: jsr draw_wave
; state = playing
lda #$01
sta state
Added: rts
Added:
Added: ; ------------------------------------------------
Added: ; Subroutine: advance_wave — ten kills: quicken the lanes, say so
Added: ; Three different caps live here, and they are not the same thing:
Added: ; the COUNTER never stops, the table INDEX clamps at the last row,
Added: ; and the DIGIT shows 9 forever after. The player's number tells the
Added: ; truth; the physics admits it ran out of new ideas.
Added: ; ------------------------------------------------
Added: advance_wave:
Added: lda #$00
Added: sta kills
Added: inc wave
Added: ; table index = wave - 1, clamped to the last entry
Added: ldy wave
Added: dey
Added: cpy #WAVE_TOP
Added: bcc wave_speed_ok
Added: ldy #WAVE_TOP
Added: wave_speed_ok:
Added: lda wave_speed_tbl,y
Added: sta fall_speed
Added: jsr draw_wave
Added: ; The chirp — voice 3, a bright triangle ding over the explosion
Added: lda #$00
Added: sta $d40e ; voice 3 frequency low
Added: lda #$40
Added: sta $d40f ; voice 3 frequency high (a high ding)
Added: lda #$09
Added: sta $d413 ; attack 0, decay 9
Added: lda #$00
Added: sta $d414 ; sustain 0, release 0
Added: lda #$10
Added: sta $d412 ; triangle, gate OFF (reset the envelope)
Added: lda #$11
Added: sta $d412 ; triangle, gate ON
Added: rts
Added:
Added: ; ------------------------------------------------
Added: ; Subroutine: draw_wave — "W" and the wave digit, top centre
Added: ; The digit caps at 9; the wave itself keeps counting.
Added: ; ------------------------------------------------
Added: draw_wave:
Added: lda #$17 ; W
Added: sta $0413
Added: lda wave
Added: cmp #$0a
Added: bcc wave_digit_ok
Added: lda #$09 ; show 9 from here on
Added: wave_digit_ok:
Added: clc
Added: adc #$30 ; to screen code
Added: sta $0414
Added: lda #$01 ; white
Added: sta $d813
Added: sta $d814
rts
Added:
Added: ; Pixels per frame, one entry per wave; the last entry is the wall
Added: WAVE_TOP = 4 ; last index of the table below
Added: wave_speed_tbl:
Added: !byte 1, 2, 2, 3, 3
; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
The complete program
; 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 ;
Here is the moment the curve bends — the tenth kill’s explosion with the wave-up chirp riding on top of it:
Milestone 2 — the band, and a beat of stillness
The title music is the same table idea pointed at the SID. A melody is rows of pitch and duration; jingle_tick runs once per title frame, counts the current note down, and serves the next row when it expires. A pitch of zero is a rest — gate the voice off and keep counting, because silence is a note too — and $ff loops back to the top.
Notice how little the 6510 does. On the Spectrum, a beeper tune owns the processor — every half-cycle of every note is a CPU loop, and the game stops while music plays. Here the SID generates the waveform between player calls. A sounding row makes four register writes: frequency high, frequency low, gate off and gate on. A rest writes gate off; other calls still count down the timer. The starfield can continue while the SID sounds, provided the game meets its update budget. Compare the sequencer contracts before adapting the table or timer. That is what a sound chip buys you over a sound bit — and the tune is Twinkle Twinkle, Little Star, because what else would a starfield play?
One catch worth meeting now: voice 1 already belongs to the laser. The title lends it to the band — enter_title installs a softer envelope, enter_game gates the voice off and hands the laser its envelope back. One voice, two owners, and the transitions are exactly where the handover lives. Miss half of it and the laser comes back sounding like a flute.
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.
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop)
Removed: ; This step: the wave system.
Added: ; This step: a SID title jingle, and a dwell on both ends of the loop.
; Assemble: acme -f cbm -o <step>.prg <step>.asm
; ------------------------------------------------
…wave = $2c ; wave counter (1, 2, 3, ... — never stops climbing)
kills = $2d ; hits this wave; 10 advances the wave
fall_speed = $2e ; pixels per frame the enemies fall — poked by advance_wave
Added: jingle_idx = $2f ; which jingle table entry is sounding
Added: jingle_timer = $30 ; frames left on the current note
Added: ui_lock = $31 ; frames before fire counts on the title / game-over screens
; $fb/$fc: scratch pointer used by the star routines
; ------------------------------------------------
…
title_state:
jsr show_title ; repaint, in case a star scrolled across it
Added: jsr jingle_tick ; the band plays; the SID holds each note itself
Added: lda ui_lock ; a beat before fire counts —
Added: beq title_ready ; the press that ended the last screen
Added: dec ui_lock ; must not start this one
Added: jmp game_loop
Added: title_ready:
lda $dc00
and #%00010000 ; fire button (bit 4)
bne loop_again ; not pressed — wait on the title
…
over_state:
jsr show_game_over ; repaint over any star damage
Added: lda ui_lock ; the dwell: let the ending land
Added: beq over_ready
Added: dec ui_lock
Added: jmp game_loop
Added: over_ready:
lda $dc00
and #%00010000
bne loop_again
… lda #$02
sta state
sta $d027 ; ship turns red ($02 = red, reused here)
Added: lda #60
Added: sta ui_lock ; the dwell — stillness before fire counts again
jsr show_game_over
jmp death_sound
… sta $d015 ; all sprites off: the title has no ship or wave
sta $d020 ; border black
jsr show_title
Added: lda #25
Added: sta ui_lock ; half a second before fire is believed
Added: ; Voice 1 becomes the band: a softer envelope than the laser's
Added: lda #$18
Added: sta $d405 ; attack 1, decay 8
Added: lda #$a0
Added: sta $d406 ; sustain 10, release 0
Added: lda #$00
Added: sta jingle_idx ; start the tune from the top,
Added: lda #$01
Added: sta jingle_timer ; first note due on the next tick
lda #$00
sta state ; 0 = title
rts
…; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
Added: ; The band stops: gate voice 1 off and hand it back to the laser
Added: lda #$10
Added: sta $d404 ; triangle, gate OFF
Added: lda #$06
Added: sta $d405 ; the laser's envelope again
Added: lda #$00
Added: sta $d406
jsr clear_and_stars
; The sprite data pointers live in screen RAM ($07f8+), so the clear just
; wiped them — set them here, after the clear, or the sprites show garbage.
…WAVE_TOP = 4 ; last index of the table below
wave_speed_tbl:
!byte 1, 2, 2, 3, 3
Added:
Added: ; ------------------------------------------------
Added: ; Subroutine: jingle_tick — one frame of title music
Added: ; A melody is a table: a pitch (two bytes) and a duration in frames.
Added: ; The SID holds each note by itself — this routine only does anything
Added: ; when the current note's time is up. Pitch high byte 0 is a rest
Added: ; (silence is a note too); $ff loops back to the top.
Added: ; ------------------------------------------------
Added: jingle_tick:
Added: dec jingle_timer
Added: beq jingle_next
Added: rts
Added: jingle_next:
Added: ldx jingle_idx
Added: lda jingle_hi,x
Added: cmp #$ff ; the loop marker
Added: bne jingle_play
Added: lda #$00 ; back to the top
Added: sta jingle_idx
Added: ldx #$00
Added: lda jingle_hi,x
Added: jingle_play:
Added: bne jingle_note
Added: ; a rest: gate off, keep counting
Added: lda #$10
Added: sta $d404
Added: jmp jingle_clock
Added: jingle_note:
Added: sta $d401 ; pitch, high byte
Added: lda jingle_lo,x
Added: sta $d400 ; pitch, low byte
Added: lda #$10
Added: sta $d404 ; gate off first — retrigger the envelope
Added: lda #$11
Added: sta $d404 ; triangle, gate on
Added: jingle_clock:
Added: lda jingle_dur,x
Added: sta jingle_timer
Added: inc jingle_idx
Added: rts
Added:
Added: ; Twinkle Twinkle, Little Star — fourteen notes, a breath, and round
Added: ; again. A starfield needs no other tune. (PAL SID pitch values.)
Added: jingle_lo:
Added: !byte $67,$67,$13,$13,$45,$45,$13
Added: !byte $3b,$3b,$ed,$ed,$88,$88,$67
Added: !byte $00,$00
Added: jingle_hi:
Added: !byte $11,$11,$1a,$1a,$1d,$1d,$1a
Added: !byte $17,$17,$15,$15,$13,$13,$11
Added: !byte $00,$ff
Added: jingle_dur:
Added: !byte 20,20,20,20,20,20,40
Added: !byte 20,20,20,20,20,20,40
Added: !byte 35,1
; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
The complete program
; 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..175It reads at the same instant every time — respawns fire at a fixed point in the frame, so the beam has barely moved — so every enemy appears in a band about a dozen pixels wide, narrower than the 16-pixel hit box. One parked, firing ship covers the whole band. And that band (48–175) is far narrower than the ship’s own travel (out past 300): everything to the right is a place no enemy can go.
The design-stress pass named it and its fix in one breath: give the spawn a real random source, spread it across the whole field, and make no square safe.
A real random column — the LFSR
We replace the repeated raster sample with a linear-feedback shift register — one byte walking a maximal 255-long pseudo-random cycle, one step per spawn:
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
rtsThe routine advances one byte of state per spawn. A non-zero seed walks all 255 non-zero byte values before repeating. Seeding from the raster once at game entry can vary the starting point, but two starts can choose the same seed. The same seed and call order reproduce the sequence; this generator is predictable, not a security tool.
Enemies across the whole field — the 9th bit
To threaten the sky the ship can reach, an enemy has to cross X=255 — and a sprite’s X is 9 bits: the low eight in its position register, the ninth over in $d010. You met that bit in the Primer, on the ship. Now the enemies use it too: an enemy_xhi_tbl byte each, written into $d010 beside the low X, and both collision tests taught to compare the full nine bits instead of skipping the right half. The enemies can now spawn on both sides of X=255. The mapping is 24 + b + floor(b/8). For an arbitrary byte 0–255 that would span 24–310, as the source comment describes. Our LFSR never supplies zero, so its actual spawn set contains 255 distinct columns from 25 through 310, with gaps. For example, b=7 gives X=31 and b=8 gives X=33: X=32 is skipped. It is not a uniform choice among all screen columns.
Predict whether X=24 can appear as a spawn, and whether that makes it a permanently safe place to park. The first answer comes from the mapping; the second also depends on enemy width, collisions and the hunt movement below. LFSR range mapping explains why reaching a useful range does not establish fairness.

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.


; Starfield - Unit 17: The Curve
Removed: ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop)
Removed: ; This step: a SID title jingle, and a dwell on both ends of the loop.
Added: ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop) -> step-03 (+ a real spawn RNG, full-field enemies, and homing: close the turret-park dominant strategy and the right-side no-death zone the design-stress pass found)
Added: ; This step: replace the raster-seeded enemy column with an 8-bit LFSR, spread enemies across the whole 9-bit field the ship can reach, make both collision tests 9-bit-aware, and have enemies home toward the ship's column so no parked position is safe — the fail state is reachable again.
; Assemble: acme -f cbm -o <step>.prg <step>.asm
; ------------------------------------------------
…jingle_idx = $2f ; which jingle table entry is sounding
jingle_timer = $30 ; frames left on the current note
ui_lock = $31 ; frames before fire counts on the title / game-over screens
Added: ; --- step 3: a real spawn RNG, and enemies across the whole field ---
Added: rng_seed = $32 ; 8-bit LFSR state — the enemy-column pseudo-random source
Added: enemy_xhi_tbl = $33 ; 3 bytes ($33-$35): each enemy's 9th X bit (0 or 1)
Added: col_tmp = $36 ; scratch: the spawn column's low byte mid-calculation
Added: dx_lo = $37 ; scratch: 9-bit collision delta, low byte
Added: dx_hi = $38 ; scratch: 9-bit collision delta, high byte
; $fb/$fc: scratch pointer used by the star routines
; ------------------------------------------------
… lda flash_tbl,x
bne do_flash ; this enemy is mid-flash
Removed: ; not flashing: drift down at the wave's pace
Added: ; not flashing: fall at the wave's pace, then home toward the ship's
Added: ; column every other frame — a moving ship still shakes them, a still one
Added: ; gets run down. This is what makes the fail state reachable: no parked
Added: ; position is safe once the enemies come to you.
lda enemy_y_tbl,x
clc
adc fall_speed ; the wave's tuning, read fresh every frame
sta enemy_y_tbl,x
Added: lda frame_count
Added: lsr ; bit 0 -> carry
Added: bcc + ; drift only on odd frames (~0.5 px/frame sideways)
Added: jsr drift_enemy_x
Added: + lda enemy_y_tbl,x
cmp #$f8 ; off the bottom? (Y >= 248)
bcc update_sprite ; still on screen
lda #$32 ; respawn this enemy at the top, new column
… sta $d000,y ; sprite X ($d004, $d006, ...)
lda enemy_y_tbl,x
sta $d001,y ; sprite Y ($d005, $d007, ...)
Removed:
Added: jsr set_enemy_hibit ; reflect this enemy's 9th X bit into $d010
next_enemy:
inx
cpx #$03 ; the full wave of three
… cmp #$f0
bcc next_collision ; 16..239 apart: too far
check_x:
Removed: ; A bullet in the right portion (9th bit set) is past X=255, far from
Removed: ; any enemy, so rule it out before comparing low bytes.
Removed: lda $d010
Removed: and #%00000010 ; bullet's 9th X bit (sprite 1)
Removed: bne next_collision
Added: ; 9-bit |bulletX - enemyX| < 16. Enemies now range the whole field, so a
Added: ; right-side bullet can no longer be ruled out by its 9th bit — compare the
Added: ; full 9-bit X of both (low byte, then the high bit, keeping the borrow).
lda $d002
sec
Removed: sbc enemy_x_tbl,x
Removed: cmp #$10
Removed: bcc hit_enemy ; 0..15 apart: close
Added: sbc enemy_x_tbl,x ; low byte; carry holds the borrow
Added: sta dx_lo
Added: lda $d010
Added: and #%00000010 ; bullet's 9th X bit (sprite 1)
Added: beq bcol_hi0
Added: lda #$01
Added: bcol_hi0:
Added: sbc enemy_xhi_tbl,x ; high byte, minus the borrow from the low
Added: sta dx_hi
Added: beq bcol_pos ; delta 0..255: near only if the low byte is < 16
Added: cmp #$ff
Added: bne next_collision ; delta < -255 or > 255: far
Added: lda dx_lo ; delta -256..-1: near from above (-16..-1)
cmp #$f0
Removed: bcc next_collision ; 16..239 apart: too far
Removed: jmp hit_enemy ; 240..255: close from the other side
Added: bcs hit_enemy
Added: jmp next_collision
Added: bcol_pos:
Added: lda dx_lo
Added: cmp #$10
Added: bcc hit_enemy ; 0..15 apart: a hit
Added: jmp next_collision
next_collision:
inx
… cmp #$f0
bcc next_ship_check
check_ship_x:
Removed: ; ship past X=255 (9th bit set) is far from any enemy — rule it out
Removed: lda $d010
Removed: and #%00000001 ; ship's 9th X bit (sprite 0)
Removed: bne next_ship_check
Added: ; 9-bit |shipX - enemyX| < 16. The ship's whole travel is reachable now,
Added: ; so we compare full 9-bit X instead of skipping the right half.
lda $d000
sec
sbc enemy_x_tbl,x
Added: sta dx_lo
Added: lda $d010
Added: and #%00000001 ; ship's 9th X bit (sprite 0)
Added: beq scol_hi0
Added: lda #$01
Added: scol_hi0:
Added: sbc enemy_xhi_tbl,x
Added: sta dx_hi
Added: beq scol_pos
Added: cmp #$ff
Added: bne next_ship_check
Added: lda dx_lo
Added: cmp #$f0
Added: bcs ship_hit
Added: jmp next_ship_check
Added: scol_pos:
Added: lda dx_lo
cmp #$10
bcc ship_hit
Removed: cmp #$f0
Removed: bcc next_ship_check
Removed: jmp ship_hit ; 240..255: close from the other side
Added: jmp next_ship_check
next_ship_check:
inx
…; ------------------------------------------------
spawn_enemy:
sta enemy_y_tbl,x
Removed: lda $d012 ; raster line -> pseudo-random column
Removed: and #$7f
Added: ; A real per-spawn column. Advance the LFSR and spread its byte across
Added: ; the whole field the ship can reach: b + b/8 lifts 0..255 to 0..286, and
Added: ; +24 lands it at X = 24..310 as a 9-bit value (low byte + a 9th bit), so
Added: ; the three lanes scatter and none sits outside the ship's travel.
Added: jsr advance_rng
Added: lda rng_seed
Added: sta col_tmp
Added: lsr
Added: lsr
Added: lsr ; b >> 3 (0..31)
clc
Removed: adc #$30 ; 48-175, inside the visible width
Added: adc col_tmp ; b + b/8 (0..286); carry = the 9th bit
Added: sta enemy_x_tbl,x
Added: lda #$00
Added: adc #$00 ; capture the carry as the high bit
Added: sta enemy_xhi_tbl,x
Added: lda enemy_x_tbl,x
Added: clc
Added: adc #24 ; shift the range up off the left edge
sta enemy_x_tbl,x
Added: lda enemy_xhi_tbl,x
Added: adc #$00 ; carry ripples into the 9th bit
Added: sta enemy_xhi_tbl,x
lda #$00
sta flash_tbl,x ; not flashing
ldy sprite_colour_off,x
… sta $d000,y ; this enemy's colour = green
ldy sprite_pos_off,x
lda enemy_x_tbl,x
Removed: sta $d000,y ; sprite X
Added: sta $d000,y ; sprite X (low 8 bits)
lda enemy_y_tbl,x
sta $d001,y ; sprite Y
Added: jsr set_enemy_hibit ; and its 9th X bit
Added: rts
Added:
Added: ; advance_rng — one step of an 8-bit Galois LFSR (tap $1d, maximal length 255).
Added: ; Shift left; if a 1 fell out, fold the tap back in. From any non-zero seed it
Added: ; never reaches 0, so the spawn-column source never gets stuck.
Added: advance_rng:
Added: lda rng_seed
Added: asl
Added: bcc +
Added: eor #$1d
Added: + sta rng_seed
Added: rts
Added:
Added: ; set_enemy_hibit — reflect enemy_xhi_tbl,x into this enemy's 9th X bit in $d010
Added: ; (enemies are sprites 2,3,4 -> $d010 bits 2,3,4). X = enemy index, preserved.
Added: set_enemy_hibit:
Added: lda enemy_d010_bit,x
Added: ldy enemy_xhi_tbl,x
Added: bne +
Added: eor #$ff ; clear this sprite's bit
Added: and $d010
Added: sta $d010
Added: rts
Added: + ora $d010 ; set this sprite's bit
Added: sta $d010
Added: rts
Added: enemy_d010_bit:
Added: !byte $04, $08, $10 ; sprite 2, 3, 4 high-X bits
Added:
Added: ; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X.
Added: ; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and
Added: ; steps the enemy one pixel the short way; equal columns hold.
Added: drift_enemy_x:
Added: lda enemy_x_tbl,x
Added: sec
Added: sbc $d000 ; (enemy - ship) low; carry holds the borrow
Added: sta dx_lo
Added: lda $d010
Added: and #%00000001 ; ship's 9th X bit
Added: sta dx_hi ; ship high (temp)
Added: lda enemy_xhi_tbl,x
Added: sbc dx_hi ; A = (enemy - ship) high; N = sign
Added: bmi .drift_right ; enemy left of ship -> move right
Added: bne .drift_left ; enemy far right of ship -> move left
Added: lda dx_lo
Added: beq .drift_done ; same column -> hold
Added: .drift_left:
Added: lda enemy_x_tbl,x
Added: sec
Added: sbc #$01
Added: sta enemy_x_tbl,x
Added: lda enemy_xhi_tbl,x
Added: sbc #$00
Added: sta enemy_xhi_tbl,x
Added: rts
Added: .drift_right:
Added: lda enemy_x_tbl,x
Added: clc
Added: adc #$01
Added: sta enemy_x_tbl,x
Added: lda enemy_xhi_tbl,x
Added: adc #$00
Added: sta enemy_xhi_tbl,x
Added: .drift_done:
rts
; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
… ; Enable ship + three enemies (the bullet stays off)
lda #%00011101
sta $d015
Added: ; Seed the column LFSR from the raster the instant play begins, forced
Added: ; non-zero (the LFSR locks at 0) — so each game's spawn run differs while
Added: ; the LFSR keeps the columns spread within a run.
Added: lda $d012
Added: ora #$01
Added: sta rng_seed
; Spawn the wave at staggered heights
lda #$32
ldx #$00
The complete corrected program
; 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_loopadvance_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

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
cldFour digits is room for 9,999 kills — more than any sitting will spend, which is the point: the number never lies to you again.
; Starfield - Unit 17: The Curve
Removed: ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop) -> step-03 (+ a real spawn RNG, full-field enemies, and homing: close the turret-park dominant strategy and the right-side no-death zone the design-stress pass found)
Removed: ; This step: replace the raster-seeded enemy column with an 8-bit LFSR, spread enemies across the whole 9-bit field the ship can reach, make both collision tests 9-bit-aware, and have enemies home toward the ship's column so no parked position is safe — the fail state is reachable again.
Added: ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves) -> step-02 (+ a SID title jingle and a dwell) -> step-03 (+ LFSR spawn, full-field 9-bit enemies, and homing: close the turret park + no-death zone) -> step-04 (+ a real curve: the swarm grows 3->6 over waves, the hunt tightens late-game, and the score widens to four digits so a long game is trackable)
Added: ; This step: make the enemy count a difficulty axis (parallel arrays widened from 3 to 6, spawned from the free sprites 5-7 as waves climb), tighten the hunt from wave 7 (homing three frames in four instead of one in two), and carry a 2-byte BCD score — the curve keeps bending past the point the speed table gave up.
; Assemble: acme -f cbm -o <step>.prg <step>.asm
; ------------------------------------------------
…bullet_y = $03 ; Bullet Y position
laser_timer = $04 ; Frames of laser pitch-sweep remaining (0 = idle)
laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only)
Removed: score = $06 ; Two-digit score, BCD (one decimal digit per nybble)
Removed: ; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
Removed: enemy_x_tbl = $07 ; 3 bytes ($07,$08,$09): each enemy's X
Removed: enemy_y_tbl = $0a ; 3 bytes ($0a,$0b,$0c): each enemy's Y
Removed: flash_tbl = $0d ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer
Added: score = $06 ; score low byte, BCD (tens:ones)
Added: score_hi = $08 ; score high byte, BCD (thousands:hundreds) — freed with the arrays
Added: ; Enemies are 6-wide parallel arrays now, moved to absolute memory (the data block
Added: ; down by the sprite tables) so up to six fit; index 0..5 picks the enemy.
Added: enemy_count = $07 ; how many enemies are active this wave (3, rising to 6)
state = $10 ; 0 = title, 1 = playing, 2 = game over
lives = $11 ; lives remaining (starts at 3)
death_timer = $12 ; frames of post-hit flash (and, in step 2, invulnerability)
…ui_lock = $31 ; frames before fire counts on the title / game-over screens
; --- step 3: a real spawn RNG, and enemies across the whole field ---
rng_seed = $32 ; 8-bit LFSR state — the enemy-column pseudo-random source
Removed: enemy_xhi_tbl = $33 ; 3 bytes ($33-$35): each enemy's 9th X bit (0 or 1)
col_tmp = $36 ; scratch: the spawn column's low byte mid-calculation
dx_lo = $37 ; scratch: 9-bit collision delta, low byte
dx_hi = $38 ; scratch: 9-bit collision delta, high byte
… clc
adc fall_speed ; the wave's tuning, read fresh every frame
sta enemy_y_tbl,x
Added: lda wave
Added: cmp #$07
Added: bcc drift_slow ; before wave 7: home every other frame (~0.5 px)
Added: ; wave 7+: three frames in four (~0.75 px) — a firmer pull, not a lunge
Added: lda frame_count
Added: and #$03
Added: bne drift_now ; drift on 3 of every 4 frames
Added: beq drift_skip ; rest on the 4th
Added: drift_slow:
lda frame_count
lsr ; bit 0 -> carry
Removed: bcc + ; drift only on odd frames (~0.5 px/frame sideways)
Added: bcc drift_skip ; earlier waves -> home every other frame
Added: drift_now:
jsr drift_enemy_x
Removed: + lda enemy_y_tbl,x
Added: drift_skip:
Added: lda enemy_y_tbl,x
cmp #$f8 ; off the bottom? (Y >= 248)
bcc update_sprite ; still on screen
lda #$32 ; respawn this enemy at the top, new column
… jsr set_enemy_hibit ; reflect this enemy's 9th X bit into $d010
next_enemy:
inx
Removed: cpx #$03 ; the full wave of three
Added: cpx enemy_count ; every active enemy this wave (3..6)
bne enemy_loop
; --- Bullet vs the wave: test each enemy until one is hit ---
…
next_collision:
inx
Removed: cpx #$03
Added: cpx enemy_count
bne collision_loop
jmp no_hit
… clc
adc #$01
sta score
Added: lda score_hi
Added: adc #$00 ; the carry out of the low two digits ripples up
Added: sta score_hi
cld ; decimal mode off (every later ADC/SBC needs it off)
Removed:
Removed: ; Refresh the two digits: high nybble -> tens, low nybble -> ones
Removed: lda score
Removed: lsr
Removed: lsr
Removed: lsr
Removed: lsr ; high nybble down to 0-9
Removed: clc
Removed: adc #$30 ; to screen code
Removed: sta $0400 ; tens digit
Removed: lda score
Removed: and #$0f ; low nybble, 0-9
Removed: clc
Removed: adc #$30
Removed: sta $0401 ; ones digit
Added: jsr draw_score ; refresh all four digits
; Ten hits clear the wave: the lanes quicken
inc kills
…
next_ship_check:
inx
Removed: cpx #$03
Added: cpx enemy_count
bne ship_collision_loop
jmp no_ship_hit
… sta $d010
rts
enemy_d010_bit:
Removed: !byte $04, $08, $10 ; sprite 2, 3, 4 high-X bits
Added: !byte $04, $08, $10, $20, $40, $80 ; sprites 2-7 high-X bits
; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X.
; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and
….drift_done:
rts
Removed: ; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
Added: ; Per-enemy VIC-II register offsets (sprites 2-7)
sprite_pos_off:
Removed: !byte $04, $06, $08 ; X offsets: $d004, $d006, $d008
Added: !byte $04, $06, $08, $0a, $0c, $0e ; X offsets: $d004..$d00e
sprite_colour_off:
Removed: !byte $29, $2a, $2b ; colour offsets: $d029, $d02a, $d02b
Added: !byte $29, $2a, $2b, $2c, $2d, $2e ; colour offsets: $d029..$d02e
Added:
Added: ; The enemy parallel arrays, six wide, in absolute RAM (moved out of zero page so
Added: ; six fit). Spawn fills them; the loops walk 0..enemy_count-1.
Added: enemy_x_tbl: !fill 6, 0
Added: enemy_y_tbl: !fill 6, 0
Added: flash_tbl: !fill 6, 0
Added: enemy_xhi_tbl: !fill 6, 0
Added:
Added: ; staggered spawn heights for up to six enemies
Added: spawn_y_tbl: !byte $32, $82, $d2, $5a, $aa, $28
; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
… sta $07fa ; enemy 0
sta $07fb ; enemy 1
sta $07fc ; enemy 2
Added: sta $07fd ; enemy 3
Added: sta $07fe ; enemy 4
Added: sta $07ff ; enemy 5
; Ship at its start, white (it may have gone red on game over)
lda #172
sta $d000
… sta $d027
lda #$00
sta $d010
Added: ; Start with three enemies; the wave grows this toward six.
Added: lda #$03
Added: sta enemy_count
; Enable ship + three enemies (the bullet stays off)
lda #%00011101
sta $d015
… lda $d012
ora #$01
sta rng_seed
Removed: ; Spawn the wave at staggered heights
Removed: lda #$32
Added: ; Spawn the starting wave at staggered heights
ldx #$00
Removed: jsr spawn_enemy
Removed: lda #$82
Removed: ldx #$01
Removed: jsr spawn_enemy
Removed: lda #$d2
Removed: ldx #$02
Added: .spawn_wave:
Added: lda spawn_y_tbl,x
jsr spawn_enemy
Added: inx
Added: cpx enemy_count
Added: bne .spawn_wave
; Reset per-game state
lda #$00
sta bullet_active
sta death_timer
sta $d020 ; border black
sta score
Removed: ; Score "00", white
Added: sta score_hi
Added: ; Score "0000", white
lda #$30
sta $0400
sta $0401
Added: sta $0402
Added: sta $0403
lda #$01
sta $d800
sta $d801
Added: sta $d802
Added: sta $d803
; Lives "3", white
lda #$03
sta lives
… lda #$00
sta kills
inc wave
Added: ; Grow the swarm: every other wave, add one enemy until six are on screen.
Added: ; This is the escalation axis the speed table ran out of — the field keeps
Added: ; filling after the lanes stop quickening.
Added: lda wave
Added: and #$01
Added: bne no_grow ; odd wave -> no new enemy this time
Added: lda enemy_count
Added: cmp #$06
Added: bcs no_grow ; already six
Added: tax ; X = the new enemy's index (= the current count)
Added: inc enemy_count
Added: lda enemy_d010_bit,x ; sprite (2+X)'s enable bit is the same byte
Added: ora $d015
Added: sta $d015 ; switch the new sprite on
Added: lda #$32
Added: jsr spawn_enemy ; drop it in at the top, LFSR column
Added: no_grow:
; table index = wave - 1, clamped to the last entry
ldy wave
dey
… sta $d412 ; triangle, gate OFF (reset the envelope)
lda #$11
sta $d412 ; triangle, gate ON
Added: rts
Added:
Added: ; ------------------------------------------------
Added: ; Subroutine: draw_score — four BCD digits at the top-left ($0400-$0403)
Added: ; thousands:hundreds live in score_hi, tens:ones in score
Added: ; ------------------------------------------------
Added: draw_score:
Added: lda score_hi
Added: lsr
Added: lsr
Added: lsr
Added: lsr
Added: clc
Added: adc #$30
Added: sta $0400 ; thousands
Added: lda score_hi
Added: and #$0f
Added: clc
Added: adc #$30
Added: sta $0401 ; hundreds
Added: lda score
Added: lsr
Added: lsr
Added: lsr
Added: lsr
Added: clc
Added: adc #$30
Added: sta $0402 ; tens
Added: lda score
Added: and #$0f
Added: clc
Added: adc #$30
Added: sta $0403 ; ones
rts
; ------------------------------------------------
The complete program
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves) -> step-02 (+ a SID title jingle and a dwell) -> step-03 (+ LFSR spawn, full-field 9-bit enemies, and homing: close the turret park + no-death zone) -> step-04 (+ a real curve: the swarm grows 3->6 over waves, the hunt tightens late-game, and the score widens to four digits so a long game is trackable)
; This step: make the enemy count a difficulty axis (parallel arrays widened from 3 to 6, spawned from the free sprites 5-7 as waves climb), tighten the hunt from wave 7 (homing three frames in four instead of one in two), and carry a 2-byte BCD score — the curve keeps bending past the point the speed table gave up.
; Assemble: acme -f cbm -o <step>.prg <step>.asm
; ------------------------------------------------
; Zero-page variables
; ------------------------------------------------
bullet_active = $02 ; 0 = no bullet, 1 = active
bullet_y = $03 ; Bullet Y position
laser_timer = $04 ; Frames of laser pitch-sweep remaining (0 = idle)
laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only)
score = $06 ; score low byte, BCD (tens:ones)
score_hi = $08 ; score high byte, BCD (thousands:hundreds) — freed with the arrays
; Enemies are 6-wide parallel arrays now, moved to absolute memory (the data block
; down by the sprite tables) so up to six fit; index 0..5 picks the enemy.
enemy_count = $07 ; how many enemies are active this wave (3, rising to 6)
state = $10 ; 0 = title, 1 = playing, 2 = game over
lives = $11 ; lives remaining (starts at 3)
death_timer = $12 ; frames of post-hit flash (and, in step 2, invulnerability)
frame_count = $13 ; free-running frame counter (parallax timing)
star_row = $14 ; 12 stars: row of each ($14-$1f)
star_col = $20 ; 12 stars: column of each ($20-$2b)
wave = $2c ; wave counter (1, 2, 3, ... — never stops climbing)
kills = $2d ; hits this wave; 10 advances the wave
fall_speed = $2e ; pixels per frame the enemies fall — poked by advance_wave
jingle_idx = $2f ; which jingle table entry is sounding
jingle_timer = $30 ; frames left on the current note
ui_lock = $31 ; frames before fire counts on the title / game-over screens
; --- step 3: a real spawn RNG, and enemies across the whole field ---
rng_seed = $32 ; 8-bit LFSR state — the enemy-column pseudo-random source
col_tmp = $36 ; scratch: the spawn column's low byte mid-calculation
dx_lo = $37 ; scratch: 9-bit collision delta, low byte
dx_hi = $38 ; scratch: 9-bit collision delta, high byte
; $fb/$fc: scratch pointer used by the star routines
; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00
; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
start:
; --- One-time hardware setup (runs once, not per game) ---
lda #$00
sta $d020 ; border black
sta $d021 ; background black
sta $d010 ; ship 9th X bit clear
; Fixed sprite colours
lda #$01
sta $d027 ; ship white
lda #$07
sta $d028 ; bullet yellow
; SID voice 1 — the laser
lda #$0f
sta $d418 ; volume to maximum
lda #$00
sta $d400
lda #$10
sta $d401
lda #$06
sta $d405
lda #$00
sta $d406
; Star positions (drawn by enter_title / enter_game)
sta frame_count ; A is still 0
ldx #$00
init_star_loop:
lda star_init_row,x
sta star_row,x
lda star_init_col,x
sta star_col,x
inx
cpx #12
bne init_star_loop
; Open on the title screen
jsr enter_title
; ------------------------------------------------
; Game loop — runs once per frame
; ------------------------------------------------
game_loop:
; Wait for the raster beam to reach line 255
; This syncs our code to the display (~50Hz PAL)
- lda $d012
cmp #$ff
bne -
; --- Parallax starfield: scrolls in every state (title, play, over) ---
inc frame_count
ldx #$00
star_loop:
jsr erase_star
; Does THIS star move this frame? Near (0-3) every frame, mid (4-7)
; every 2nd frame, far (8-11) every 4th frame.
cpx #$04
bcc star_do_move ; near layer: always
cpx #$08
bcc star_mid ; mid layer
; far layer: only when the low two frame bits are clear (1 in 4)
lda frame_count
and #%00000011
bne star_move_done
beq star_do_move
star_mid:
lda frame_count
and #%00000001 ; every other frame
bne star_move_done
star_do_move:
inc star_row,x ; one row down
lda star_row,x
cmp #25
bcc star_move_done
lda #$00 ; past the bottom -> wrap to the top
sta star_row,x
star_move_done:
jsr draw_star
inx
cpx #12
bne star_loop
; --- State machine: title (0) / playing (1) / game over (2) ---
lda state
beq title_state
cmp #$02
beq over_state
jmp game_active ; 1 = playing
title_state:
jsr show_title ; repaint, in case a star scrolled across it
jsr jingle_tick ; the band plays; the SID holds each note itself
lda ui_lock ; a beat before fire counts —
beq title_ready ; the press that ended the last screen
dec ui_lock ; must not start this one
jmp game_loop
title_ready:
lda $dc00
and #%00010000 ; fire button (bit 4)
bne loop_again ; not pressed — wait on the title
jsr enter_game ; fire -> start a game
loop_again:
jmp game_loop
over_state:
jsr show_game_over ; repaint over any star damage
lda ui_lock ; the dwell: let the ending land
beq over_ready
dec ui_lock
jmp game_loop
over_ready:
lda $dc00
and #%00010000
bne loop_again
jsr enter_title ; fire -> back to the title screen
jmp game_loop
game_active:
; --- Read joystick and move ship ---
; UP (bit 0) — clamp to Y >= 50
lda $dc00 ; Read joystick port 2
and #%00000001 ; Isolate bit 0
bne not_up ; Bit is 1 = NOT pressed (active low)
lda $d001
cmp #52 ; 50 + room for a 2-pixel move
bcc not_up ; already at the top — don't move
dec $d001 ; Move ship up (decrease Y)
dec $d001 ; 2 pixels per frame
not_up:
; DOWN (bit 1) — clamp to Y <= 234
lda $dc00
and #%00000010
bne not_down
lda $d001
cmp #233 ; 234 - room for a 2-pixel move
bcs not_down ; already at the bottom — don't move
inc $d001 ; Move ship down (increase Y)
inc $d001
not_down:
; LEFT (bit 2) — 9-bit X, clamp to X >= 24
lda $dc00
and #%00000100
bne not_left
lda $d010
and #$01
bne left_ok ; high bit set: X >= 256, always safe to go left
lda $d000
cmp #26 ; 24 + room for a 2-pixel move
bcc not_left ; already at the left edge — don't move
left_ok:
; before each step, flip the 9th bit when X is about to wrap $00 -> $ff
lda $d000
bne +
lda $d010
eor #$01 ; the eor bit-flip from the Primer, on sprite 0's high X bit
sta $d010
+ dec $d000
lda $d000
bne +
lda $d010
eor #$01
sta $d010
+ dec $d000
not_left:
; RIGHT (bit 3) — 9-bit X, clamp to X <= 320
lda $dc00
and #%00001000
bne not_right
lda $d010
and #$01
beq right_ok ; high bit clear: X < 256, always safe to go right
lda $d000
cmp #63 ; (320 - 256) - room for a 2-pixel move
bcs not_right ; already at the right edge — don't move
right_ok:
; after each step, flip the 9th bit when X wraps $ff -> $00
inc $d000
bne +
lda $d010
eor #$01
sta $d010
+ inc $d000
bne +
lda $d010
eor #$01
sta $d010
+
not_right:
; --- Fire button (bit 4) ---
lda $dc00
and #%00010000
bne no_fire ; Bit is 1 = NOT pressed
; Only spawn if no bullet is already flying
lda bullet_active
bne no_fire
; Spawn the bullet at the ship's position
lda $d000 ; Ship X (low byte) -> bullet X
sta $d002
lda $d001 ; Ship Y -> bullet Y
sta bullet_y
; Copy the ship's 9th X bit (bit 0) to the bullet's (bit 1),
; so a shot fired from the right half spawns under the ship
lda $d010
and #%11111101 ; clear the bullet's 9th bit first
sta $d010
lda $d010
and #$01 ; the ship's 9th bit
asl ; shift it into the bullet's position (bit 1)
ora $d010
sta $d010
; Enable sprite 1 (keep sprite 0 enabled)
lda $d015
ora #%00000010
sta $d015
lda #$01
sta bullet_active
; Trigger laser sound: start the pitch high, gate off then on
lda #$40
sta laser_freq ; start high
sta $d401 ; SID frequency high byte
lda #$20
sta $d404 ; Sawtooth, gate OFF (reset envelope)
lda #$21
sta $d404 ; Sawtooth, gate ON (start sound)
lda #$0a
sta laser_timer ; sweep down over 10 frames
no_fire:
; --- Laser pitch sweep: the 'pew' ---
; Drop the pitch a little each frame while the sweep is running.
; We keep our own copy because SID frequency registers are write-only.
lda laser_timer
beq no_sweep
lda laser_freq
sec
sbc #$06
sta laser_freq
sta $d401 ; write the new pitch to the SID
dec laser_timer
no_sweep:
; --- Update the bullet ---
lda bullet_active
beq no_bullet
; Move it up 4 pixels a frame
lda bullet_y
sec
sbc #$04
sta bullet_y
sta $d003 ; sprite 1 Y
; Gone off the top? (Y < 30) -> remove it
cmp #$1e
bcs no_bullet
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; disable sprite 1, keep sprite 0
sta $d015
lda $d010
and #%11111101 ; clear the bullet's 9th bit
sta $d010
no_bullet:
; --- Update every enemy: one indexed loop does all of them ---
ldx #$00
enemy_loop:
lda flash_tbl,x
bne do_flash ; this enemy is mid-flash
; not flashing: fall at the wave's pace, then home toward the ship's
; column every other frame — a moving ship still shakes them, a still one
; gets run down. This is what makes the fail state reachable: no parked
; position is safe once the enemies come to you.
lda enemy_y_tbl,x
clc
adc fall_speed ; the wave's tuning, read fresh every frame
sta enemy_y_tbl,x
lda wave
cmp #$07
bcc drift_slow ; before wave 7: home every other frame (~0.5 px)
; wave 7+: three frames in four (~0.75 px) — a firmer pull, not a lunge
lda frame_count
and #$03
bne drift_now ; drift on 3 of every 4 frames
beq drift_skip ; rest on the 4th
drift_slow:
lda frame_count
lsr ; bit 0 -> carry
bcc drift_skip ; earlier waves -> home every other frame
drift_now:
jsr drift_enemy_x
drift_skip:
lda enemy_y_tbl,x
cmp #$f8 ; off the bottom? (Y >= 248)
bcc update_sprite ; still on screen
lda #$32 ; respawn this enemy at the top, new column
jsr spawn_enemy
jmp next_enemy
do_flash:
dec flash_tbl,x
bne update_sprite ; still flashing -> stay frozen, white
lda #$32 ; flash done -> respawn (spawn_enemy restores green)
jsr spawn_enemy
jmp next_enemy
update_sprite:
; copy this enemy's position into its VIC-II sprite registers
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X ($d004, $d006, ...)
lda enemy_y_tbl,x
sta $d001,y ; sprite Y ($d005, $d007, ...)
jsr set_enemy_hibit ; reflect this enemy's 9th X bit into $d010
next_enemy:
inx
cpx enemy_count ; every active enemy this wave (3..6)
bne enemy_loop
; --- Bullet vs the wave: test each enemy until one is hit ---
lda bullet_active
bne check_collision
jmp no_hit
check_collision:
ldx #$00
collision_loop:
lda flash_tbl,x
bne next_collision ; skip an enemy that's already exploding
; Y distance (8-bit subtract wraps, so two ranges count as close)
lda bullet_y
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_x ; 0..15 apart: close
cmp #$f0
bcc next_collision ; 16..239 apart: too far
check_x:
; 9-bit |bulletX - enemyX| < 16. Enemies now range the whole field, so a
; right-side bullet can no longer be ruled out by its 9th bit — compare the
; full 9-bit X of both (low byte, then the high bit, keeping the borrow).
lda $d002
sec
sbc enemy_x_tbl,x ; low byte; carry holds the borrow
sta dx_lo
lda $d010
and #%00000010 ; bullet's 9th X bit (sprite 1)
beq bcol_hi0
lda #$01
bcol_hi0:
sbc enemy_xhi_tbl,x ; high byte, minus the borrow from the low
sta dx_hi
beq bcol_pos ; delta 0..255: near only if the low byte is < 16
cmp #$ff
bne next_collision ; delta < -255 or > 255: far
lda dx_lo ; delta -256..-1: near from above (-16..-1)
cmp #$f0
bcs hit_enemy
jmp next_collision
bcol_pos:
lda dx_lo
cmp #$10
bcc hit_enemy ; 0..15 apart: a hit
jmp next_collision
next_collision:
inx
cpx enemy_count
bne collision_loop
jmp no_hit
hit_enemy:
; X = the enemy that was hit. Remove the bullet.
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; sprite 1 (bullet) off
sta $d015
; Flash THIS enemy white and start its 8-frame timer. The enemy loop
; freezes it white until the timer runs out, then respawns it.
lda #$08
sta flash_tbl,x
ldy sprite_colour_off,x
lda #$01
sta $d000,y ; this enemy's colour register = white
; Explosion sound — SID voice 2, noise waveform (voice 1 keeps the laser)
lda #$00
sta $d407 ; voice 2 frequency low
lda #$08
sta $d408 ; voice 2 frequency high (a low rumble)
lda #$09
sta $d40c ; attack 0, decay 9
lda #$00
sta $d40d ; sustain 0, release 0
lda #$80
sta $d40b ; noise, gate OFF (reset the envelope)
lda #$81
sta $d40b ; noise, gate ON (trigger the burst)
; Score one hit. In decimal mode ADC carries at 10, so the byte stays
; readable as two decimal digits (BCD) — no conversion needed.
sed ; decimal mode on
lda score
clc
adc #$01
sta score
lda score_hi
adc #$00 ; the carry out of the low two digits ripples up
sta score_hi
cld ; decimal mode off (every later ADC/SBC needs it off)
jsr draw_score ; refresh all four digits
; Ten hits clear the wave: the lanes quicken
inc kills
lda kills
cmp #10
bcc no_hit
jsr advance_wave
no_hit:
; --- Ship vs the wave: has any enemy reached the ship? ---
; ...but not while the life-lost flash runs — the ship is invulnerable
lda death_timer
beq do_ship_collision ; not flashing -> run the check
jmp no_ship_hit ; flashing -> skip it (jmp, the target is far)
do_ship_collision:
ldx #$00
ship_collision_loop:
lda flash_tbl,x
bne next_ship_check ; ignore an exploding enemy
; Y distance: ship Y ($d001) vs this enemy's Y
lda $d001
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_ship_x
cmp #$f0
bcc next_ship_check
check_ship_x:
; 9-bit |shipX - enemyX| < 16. The ship's whole travel is reachable now,
; so we compare full 9-bit X instead of skipping the right half.
lda $d000
sec
sbc enemy_x_tbl,x
sta dx_lo
lda $d010
and #%00000001 ; ship's 9th X bit (sprite 0)
beq scol_hi0
lda #$01
scol_hi0:
sbc enemy_xhi_tbl,x
sta dx_hi
beq scol_pos
cmp #$ff
bne next_ship_check
lda dx_lo
cmp #$f0
bcs ship_hit
jmp next_ship_check
scol_pos:
lda dx_lo
cmp #$10
bcc ship_hit
jmp next_ship_check
next_ship_check:
inx
cpx enemy_count
bne ship_collision_loop
jmp no_ship_hit
ship_hit:
; Lose a life and update the readout
dec lives
lda lives
clc
adc #$30
sta $0427 ; lives digit, top-right
lda lives
bne life_lost ; lives remain -> respawn and play on
; Out of lives -> game over state (the dispatch handles the freeze)
lda #$02
sta state
sta $d027 ; ship turns red ($02 = red, reused here)
lda #60
sta ui_lock ; the dwell — stillness before fire counts again
jsr show_game_over
jmp death_sound
life_lost:
; Respawn the ship at its start position
lda #172
sta $d000
lda #220
sta $d001
lda $d010
and #%11111110 ; clear the ship's 9th bit (back under X=256)
sta $d010
; Start the life-lost flash (step 2 makes it an invulnerability window too)
lda #90
sta death_timer
death_sound:
; Death sound — SID voice 3 (plays on every death)
lda #$00
sta $d40e ; voice 3 frequency low
lda #$10
sta $d40f ; voice 3 frequency high
lda #$0a
sta $d413 ; attack 0, decay 10 (a long, slow fade)
lda #$00
sta $d414 ; sustain 0, release 0
lda #$20
sta $d412 ; sawtooth, gate OFF (reset the envelope)
lda #$21
sta $d412 ; sawtooth, gate ON (trigger)
no_ship_hit:
; --- Life-lost flash: while the timer runs, blink the border ---
lda death_timer
beq flash_done
dec death_timer
lda death_timer
and #%00001000 ; bit 3 toggles every 8 frames
bne flash_bright
lda #$00 ; dark phase
sta $d020
jmp flash_tick
flash_bright:
lda #$02 ; bright phase (red border)
sta $d020
flash_tick:
lda death_timer
bne flash_done
lda #$00 ; just expired -> border back to black
sta $d020
flash_done:
jmp game_loop
; ------------------------------------------------
; Subroutine: spawn one enemy
; A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
sta enemy_y_tbl,x
; A real per-spawn column. Advance the LFSR and spread its byte across
; the whole field the ship can reach: b + b/8 lifts 0..255 to 0..286, and
; +24 lands it at X = 24..310 as a 9-bit value (low byte + a 9th bit), so
; the three lanes scatter and none sits outside the ship's travel.
jsr advance_rng
lda rng_seed
sta col_tmp
lsr
lsr
lsr ; b >> 3 (0..31)
clc
adc col_tmp ; b + b/8 (0..286); carry = the 9th bit
sta enemy_x_tbl,x
lda #$00
adc #$00 ; capture the carry as the high bit
sta enemy_xhi_tbl,x
lda enemy_x_tbl,x
clc
adc #24 ; shift the range up off the left edge
sta enemy_x_tbl,x
lda enemy_xhi_tbl,x
adc #$00 ; carry ripples into the 9th bit
sta enemy_xhi_tbl,x
lda #$00
sta flash_tbl,x ; not flashing
ldy sprite_colour_off,x
lda #$05
sta $d000,y ; this enemy's colour = green
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X (low 8 bits)
lda enemy_y_tbl,x
sta $d001,y ; sprite Y
jsr set_enemy_hibit ; and its 9th X bit
rts
; advance_rng — one step of an 8-bit Galois LFSR (tap $1d, maximal length 255).
; Shift left; if a 1 fell out, fold the tap back in. From any non-zero seed it
; never reaches 0, so the spawn-column source never gets stuck.
advance_rng:
lda rng_seed
asl
bcc +
eor #$1d
+ sta rng_seed
rts
; set_enemy_hibit — reflect enemy_xhi_tbl,x into this enemy's 9th X bit in $d010
; (enemies are sprites 2,3,4 -> $d010 bits 2,3,4). X = enemy index, preserved.
set_enemy_hibit:
lda enemy_d010_bit,x
ldy enemy_xhi_tbl,x
bne +
eor #$ff ; clear this sprite's bit
and $d010
sta $d010
rts
+ ora $d010 ; set this sprite's bit
sta $d010
rts
enemy_d010_bit:
!byte $04, $08, $10, $20, $40, $80 ; sprites 2-7 high-X bits
; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X.
; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and
; steps the enemy one pixel the short way; equal columns hold.
drift_enemy_x:
lda enemy_x_tbl,x
sec
sbc $d000 ; (enemy - ship) low; carry holds the borrow
sta dx_lo
lda $d010
and #%00000001 ; ship's 9th X bit
sta dx_hi ; ship high (temp)
lda enemy_xhi_tbl,x
sbc dx_hi ; A = (enemy - ship) high; N = sign
bmi .drift_right ; enemy left of ship -> move right
bne .drift_left ; enemy far right of ship -> move left
lda dx_lo
beq .drift_done ; same column -> hold
.drift_left:
lda enemy_x_tbl,x
sec
sbc #$01
sta enemy_x_tbl,x
lda enemy_xhi_tbl,x
sbc #$00
sta enemy_xhi_tbl,x
rts
.drift_right:
lda enemy_x_tbl,x
clc
adc #$01
sta enemy_x_tbl,x
lda enemy_xhi_tbl,x
adc #$00
sta enemy_xhi_tbl,x
.drift_done:
rts
; Per-enemy VIC-II register offsets (sprites 2-7)
sprite_pos_off:
!byte $04, $06, $08, $0a, $0c, $0e ; X offsets: $d004..$d00e
sprite_colour_off:
!byte $29, $2a, $2b, $2c, $2d, $2e ; colour offsets: $d029..$d02e
; The enemy parallel arrays, six wide, in absolute RAM (moved out of zero page so
; six fit). Spawn fills them; the loops walk 0..enemy_count-1.
enemy_x_tbl: !fill 6, 0
enemy_y_tbl: !fill 6, 0
flash_tbl: !fill 6, 0
enemy_xhi_tbl: !fill 6, 0
; staggered spawn heights for up to six enemies
spawn_y_tbl: !byte $32, $82, $d2, $5a, $aa, $28
; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
; Row 12 x 40 + 16 = 496 = $1f0, so screen RAM $05f0, colour RAM $d9f0
; ------------------------------------------------
show_game_over:
lda #$07 ; G
sta $05f0
lda #$01 ; A
sta $05f1
lda #$0d ; M
sta $05f2
lda #$05 ; E
sta $05f3
lda #$20 ; (space)
sta $05f4
lda #$0f ; O
sta $05f5
lda #$16 ; V
sta $05f6
lda #$05 ; E
sta $05f7
lda #$12 ; R
sta $05f8
; colour the nine cells white ($d9f0..$d9f8)
lda #$01
ldx #$00
- sta $d9f0,x
inx
cpx #$09
bne -
rts
; ------------------------------------------------
; Subroutine: clear_and_stars — wipe the screen, then repaint every star
; ------------------------------------------------
clear_and_stars:
ldx #$00
cas_clear:
lda #$20
sta $0400,x
sta $0500,x
sta $0600,x
sta $0700,x
inx
bne cas_clear
ldx #$00
cas_draw:
jsr draw_star
inx
cpx #12
bne cas_draw
rts
; ------------------------------------------------
; Subroutine: enter_title — show the title, hide the game, state = 0
; ------------------------------------------------
enter_title:
jsr clear_and_stars
lda #$00
sta $d015 ; all sprites off: the title has no ship or wave
sta $d020 ; border black
jsr show_title
lda #25
sta ui_lock ; half a second before fire is believed
; Voice 1 becomes the band: a softer envelope than the laser's
lda #$18
sta $d405 ; attack 1, decay 8
lda #$a0
sta $d406 ; sustain 10, release 0
lda #$00
sta jingle_idx ; start the tune from the top,
lda #$01
sta jingle_timer ; first note due on the next tick
lda #$00
sta state ; 0 = title
rts
; ------------------------------------------------
; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
; The band stops: gate voice 1 off and hand it back to the laser
lda #$10
sta $d404 ; triangle, gate OFF
lda #$06
sta $d405 ; the laser's envelope again
lda #$00
sta $d406
jsr clear_and_stars
; The sprite data pointers live in screen RAM ($07f8+), so the clear just
; wiped them — set them here, after the clear, or the sprites show garbage.
lda #128
sta $07f8 ; ship
lda #129
sta $07f9 ; bullet
lda #130
sta $07fa ; enemy 0
sta $07fb ; enemy 1
sta $07fc ; enemy 2
sta $07fd ; enemy 3
sta $07fe ; enemy 4
sta $07ff ; enemy 5
; Ship at its start, white (it may have gone red on game over)
lda #172
sta $d000
lda #220
sta $d001
lda #$01
sta $d027
lda #$00
sta $d010
; Start with three enemies; the wave grows this toward six.
lda #$03
sta enemy_count
; Enable ship + three enemies (the bullet stays off)
lda #%00011101
sta $d015
; Seed the column LFSR from the raster the instant play begins, forced
; non-zero (the LFSR locks at 0) — so each game's spawn run differs while
; the LFSR keeps the columns spread within a run.
lda $d012
ora #$01
sta rng_seed
; Spawn the starting wave at staggered heights
ldx #$00
.spawn_wave:
lda spawn_y_tbl,x
jsr spawn_enemy
inx
cpx enemy_count
bne .spawn_wave
; Reset per-game state
lda #$00
sta bullet_active
sta death_timer
sta $d020 ; border black
sta score
sta score_hi
; Score "0000", white
lda #$30
sta $0400
sta $0401
sta $0402
sta $0403
lda #$01
sta $d800
sta $d801
sta $d802
sta $d803
; Lives "3", white
lda #$03
sta lives
lda #$33
sta $0427
lda #$01
sta $d827
; Wave 1: counter, kills, the live speed, and the readout
lda #$01
sta wave
lda #$00
sta kills
lda wave_speed_tbl ; first table entry
sta fall_speed
jsr draw_wave
; state = playing
lda #$01
sta state
rts
; ------------------------------------------------
; Subroutine: advance_wave — ten kills: quicken the lanes, say so
; Three different caps live here, and they are not the same thing:
; the COUNTER never stops, the table INDEX clamps at the last row,
; and the DIGIT shows 9 forever after. The player's number tells the
; truth; the physics admits it ran out of new ideas.
; ------------------------------------------------
advance_wave:
lda #$00
sta kills
inc wave
; Grow the swarm: every other wave, add one enemy until six are on screen.
; This is the escalation axis the speed table ran out of — the field keeps
; filling after the lanes stop quickening.
lda wave
and #$01
bne no_grow ; odd wave -> no new enemy this time
lda enemy_count
cmp #$06
bcs no_grow ; already six
tax ; X = the new enemy's index (= the current count)
inc enemy_count
lda enemy_d010_bit,x ; sprite (2+X)'s enable bit is the same byte
ora $d015
sta $d015 ; switch the new sprite on
lda #$32
jsr spawn_enemy ; drop it in at the top, LFSR column
no_grow:
; table index = wave - 1, clamped to the last entry
ldy wave
dey
cpy #WAVE_TOP
bcc wave_speed_ok
ldy #WAVE_TOP
wave_speed_ok:
lda wave_speed_tbl,y
sta fall_speed
jsr draw_wave
; The chirp — voice 3, a bright triangle ding over the explosion
lda #$00
sta $d40e ; voice 3 frequency low
lda #$40
sta $d40f ; voice 3 frequency high (a high ding)
lda #$09
sta $d413 ; attack 0, decay 9
lda #$00
sta $d414 ; sustain 0, release 0
lda #$10
sta $d412 ; triangle, gate OFF (reset the envelope)
lda #$11
sta $d412 ; triangle, gate ON
rts
; ------------------------------------------------
; Subroutine: draw_score — four BCD digits at the top-left ($0400-$0403)
; thousands:hundreds live in score_hi, tens:ones in score
; ------------------------------------------------
draw_score:
lda score_hi
lsr
lsr
lsr
lsr
clc
adc #$30
sta $0400 ; thousands
lda score_hi
and #$0f
clc
adc #$30
sta $0401 ; hundreds
lda score
lsr
lsr
lsr
lsr
clc
adc #$30
sta $0402 ; tens
lda score
and #$0f
clc
adc #$30
sta $0403 ; ones
rts
; ------------------------------------------------
; Subroutine: draw_wave — "W" and the wave digit, top centre
; The digit caps at 9; the wave itself keeps counting.
; ------------------------------------------------
draw_wave:
lda #$17 ; W
sta $0413
lda wave
cmp #$0a
bcc wave_digit_ok
lda #$09 ; show 9 from here on
wave_digit_ok:
clc
adc #$30 ; to screen code
sta $0414
lda #$01 ; white
sta $d813
sta $d814
rts
; Pixels per frame, one entry per wave; the last entry is the wall
WAVE_TOP = 4 ; last index of the table below
wave_speed_tbl:
!byte 1, 2, 2, 3, 3
; ------------------------------------------------
; Subroutine: jingle_tick — one frame of title music
; A melody is a table: a pitch (two bytes) and a duration in frames.
; The SID holds each note by itself — this routine only does anything
; when the current note's time is up. Pitch high byte 0 is a rest
; (silence is a note too); $ff loops back to the top.
; ------------------------------------------------
jingle_tick:
dec jingle_timer
beq jingle_next
rts
jingle_next:
ldx jingle_idx
lda jingle_hi,x
cmp #$ff ; the loop marker
bne jingle_play
lda #$00 ; back to the top
sta jingle_idx
ldx #$00
lda jingle_hi,x
jingle_play:
bne jingle_note
; a rest: gate off, keep counting
lda #$10
sta $d404
jmp jingle_clock
jingle_note:
sta $d401 ; pitch, high byte
lda jingle_lo,x
sta $d400 ; pitch, low byte
lda #$10
sta $d404 ; gate off first — retrigger the envelope
lda #$11
sta $d404 ; triangle, gate on
jingle_clock:
lda jingle_dur,x
sta jingle_timer
inc jingle_idx
rts
; Twinkle Twinkle, Little Star — fourteen notes, a breath, and round
; again. A starfield needs no other tune. (PAL SID pitch values.)
jingle_lo:
!byte $67,$67,$13,$13,$45,$45,$13
!byte $3b,$3b,$ed,$ed,$88,$88,$67
!byte $00,$00
jingle_hi:
!byte $11,$11,$1a,$1a,$1d,$1d,$1a
!byte $17,$17,$15,$15,$13,$13,$11
!byte $00,$ff
jingle_dur:
!byte 20,20,20,20,20,20,40
!byte 20,20,20,20,20,20,40
!byte 35,1
; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
; ------------------------------------------------
show_title:
lda #$13 ; S
sta $05a0
lda #$14 ; T
sta $05a1
lda #$01 ; A
sta $05a2
lda #$12 ; R
sta $05a3
lda #$06 ; F
sta $05a4
lda #$09 ; I
sta $05a5
lda #$05 ; E
sta $05a6
lda #$0c ; L
sta $05a7
lda #$04 ; D
sta $05a8
lda #$01 ; white
ldx #$00
- sta $d9a0,x
inx
cpx #$09
bne -
lda #$10 ; P
sta $063f
lda #$12 ; R
sta $0640
lda #$05 ; E
sta $0641
lda #$13 ; S
sta $0642
lda #$13 ; S
sta $0643
lda #$20 ; (space)
sta $0644
lda #$06 ; F
sta $0645
lda #$09 ; I
sta $0646
lda #$12 ; R
sta $0647
lda #$05 ; E
sta $0648
lda #$0f ; light grey
ldx #$00
- sta $da3f,x
inx
cpx #$0a
bne -
rts
; ------------------------------------------------
; Subroutine: erase_star (X = star index)
; blanks the star's current cell back to a space
; ------------------------------------------------
erase_star:
ldy star_row,x
lda row_addr_lo,y ; point $fb/$fc at the start of this star's row
sta $fb
lda row_addr_hi,y
sta $fc
ldy star_col,x ; Y = the column offset along that row
lda #$20 ; a space
sta ($fb),y ; "finger on the boxes" — pointer + Y offset
rts
; ------------------------------------------------
; Subroutine: draw_star (X = star index)
; writes the star's character + colour at its (row, col)
; ------------------------------------------------
draw_star:
ldy star_row,x
lda row_addr_lo,y
sta $fb ; row start, low byte
lda row_addr_hi,y
sta $fc ; row start, high byte
ldy star_col,x ; Y = column
lda star_char_tbl,x
sta ($fb),y ; STA ($fb),Y -> the screen-RAM cell
; screen RAM $04xx-$07xx maps to colour RAM $d8xx-$dbxx: high byte + $d4
lda $fc
clc
adc #$d4
sta $fc
lda star_colour_tbl,x
sta ($fb),y ; same column offset, now into colour RAM
rts
; ------------------------------------------------
; Star data tables
; ------------------------------------------------
; Screen-RAM start address of each row (row x 40 + $0400), rows 0-24
row_addr_lo:
!byte $00,$28,$50,$78,$a0,$c8,$f0,$18
!byte $40,$68,$90,$b8,$e0,$08,$30,$58
!byte $80,$a8,$d0,$f8,$20,$48,$70,$98,$c0
row_addr_hi:
!byte $04,$04,$04,$04,$04,$04,$04,$05
!byte $05,$05,$05,$05,$05,$06,$06,$06
!byte $06,$06,$06,$06,$07,$07,$07,$07,$07
; 12 stars. Columns avoid 0, 1 and 39 — the score and lives cells.
star_init_row:
!byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
star_init_col:
!byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
; Appearance reinforces the depth: near = bright white '*', far = dim grey '.'
star_char_tbl:
!byte $2a,$2a,$2a,$2a, $2a,$2a,$2a,$2a, $2e,$2e,$2e,$2e
star_colour_tbl:
!byte $01,$01,$01,$01, $0f,$0f,$0f,$0f, $0b,$0b,$0b,$0b
; ------------------------------------------------
; Sprite data at $2000 (block 128) — ship
; ------------------------------------------------
*= $2000
!byte $00,$18,$00 ; ##
!byte $00,$3c,$00 ; ####
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$7e,$00 ; ######
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $03,$ff,$c0 ; ############
!byte $07,$ff,$e0 ; ##############
!byte $07,$ff,$e0 ; ##############
!byte $07,$e7,$e0 ; ###..####..###
!byte $03,$c3,$c0 ; ##....##....##
!byte $01,$ff,$80 ; ##########
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $00,$db,$00 ; ##.##.##
!byte $00,$db,$00 ; ##.##.##
!byte $00,$66,$00 ; ##..##
!byte $00,$24,$00 ; #..#
!byte $00,$00,$00 ;
; ------------------------------------------------
; Sprite data at $2040 (block 129) — bullet
; ------------------------------------------------
*= $2040
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
; ------------------------------------------------
; Sprite data at $2080 (block 130) — enemy
; ------------------------------------------------
*= $2080
!byte $00,$66,$00 ; ##..##
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$db,$00 ; ##.##.##
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $01,$7e,$80 ; #.######.#
!byte $01,$3c,$80 ; #..####..#
!byte $00,$a5,$00 ; #.#..#.#
!byte $01,$81,$80 ; ##......##
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;The design-stress pass asked for a second escalation axis once the speed table gave out. Enemy count is that axis, the tightening hunt is a third, and the score finally counts high enough to prove you climbed. Starfield isn’t just un-broken now — it’s a game with somewhere to go.
Restart investigation: does game three begin like game one?
A title screen returning is useful evidence, but it does not prove that the next game starts cleanly. Use the optional restart companion with step 4. It builds a baseline, one deliberately faulty copy and a repair. Keep the original game as our working checkpoint.
We call the real entry and wave-growth routines in a controlled test. After each
of the first two entries, five calls to advance_wave grow the swarm to six.
The fixture also supplies a non-zero score, one remaining life, an active bullet
and seven kills. It then returns to the title and enters the game again. These
are prepared test conditions, not three played-through games.
Before running, predict the state immediately after each enter_game:
| State | First, second and third entry should each have |
|---|---|
| Enemy count | 3 |
| Score, including its high byte | 0000 |
| Lives | 3 |
| Wave, kills, fall speed | 1, 0, 1 |
| Bullet active | 0 |
| Sprite enable bits | Ship and first three enemies on; bullet off |
The faulty copy omits just sta enemy_count from enter_game. The fixture
explicitly sets the count to three once before the first entry. That makes
the first faulty game look correct without relying on initial RAM contents.
Deleting the store in an ordinary launch could fail on the first game instead.
Run the baseline, then the faulty copy. Record all three displayed enemy counts. Does a correct first entry establish that restart works? Choose a hypothesis: a wrong starting count, a wrong wave-growth rule, or a missing per-game reset. What evidence would distinguish them?
Hint 1: follow the lifetime of the count
Find the store in enter_game before the spawn loop. Then find the increment
in advance_wave. Does entering the title restore that value? Which value does
the next spawn loop use if the store is missing?
Hint 2: compare a rule with its presentation
enter_game still resets wave to 1 and enables the first three enemy sprites.
Neither operation changes enemy_count. Inspect the recorded count as well as
the readout and sprite-enable bits.
Explain the evidence and check the repair
The baseline reports 3, 3, 3. The faulty copy reports 3, 6, 6: the first entry borrows the fixture’s initial value, then the larger count survives each restart. Wave 1 and three enabled sprites do not make the logical count three. The later loops can still process the extra entries.
Restore sta enemy_count after lda #$03, before spawning the starting wave.
Rebuild and repeat the three entries. Expect 3, 3, 3, and check the other
fields in the table too. The companion records them in memory and includes a
native verification script; repairing the count must not undo score, lives,
wave or bullet initialisation.
Changing the wave table, hiding more sprites or setting the count only at launch would not repair this lifetime error. The count belongs to each play session, so each new session must establish it.
Explain the fault without naming the missing instruction: which old state crossed a boundary where it no longer belonged? Then identify another field in the entry table with the same lifetime. Keep random-call order and sound handover out of this investigation; one reliable fault is enough. Return to the original step-04 game after the experiment.
Position investigation: repeat the choices, find the first difference
After the restart investigation, use the optional fixed-seed companion. It makes twelve calls to the existing spawn routine with seed 1, always using enemy 0, which is sprite 2. There are no joystick, movement or collision updates between calls. The same launch input schedule runs each copy. We have fixed the starting state and call order as well as the seed; this is a small routine experiment, not a recorded playthrough.
One deliberately faulty copy omits the call to set_enemy_hibit at the end of
spawn_enemy. The random generator and coordinate calculation are unchanged.
Predict whether the first error will appear in the random byte, the logical
coordinate or the sprite registers. Run the baseline and fault, then compare
their records in order instead of hunting for an arbitrary difference on screen.
Each record contains the random byte, logical X low/high bytes, $d004 and
$d010. The screen shows the last three records in hexadecimal. The companion
records all twelve for inspection. Initially $d010=$ab: sprite 2’s high bit is
clear, while several other bits are set so we can check that the helper preserves
them. Its mask for this sprite is $04.
| Call | Random byte (decimal) | Calculate 24 + b + floor(b/8) |
Predict low byte / high byte |
|---|---|---|---|
| 10 | 116 | Write our result | Write before running |
| 11 | 232 | Write our result | Write before running |
| 12 | 205 | Write our result | Write before running |
Hint 1: find where the representations separate
For X above 255, subtract 256 to find its low byte. Compare the logical high byte
with bit 2 of $d010. A correct random byte does not prove that its position
reached the display correctly.
Hint 2: follow the next spawn too
The same sprite is reused. After setting its high bit, what must happen when its
next coordinate is below 256? What would happen if we left the bit set, or wrote
the logical high byte over all of $d010?
Compare the first divergence and repair
The coordinates are 154, 285, 254. Calls 1–10 agree. On call 11, both programs
calculate X=285: low byte 29 ($1d), high byte 1. Both write 29 to $d004.
The working helper changes $d010 from $ab to $af; the faulty copy leaves
it at $ab. The register-selected position is therefore 29, not 285. This is a
handoff fault, not a different random choice.
Restore the original jsr set_enemy_hibit. On call 12, the helper must also clear
sprite 2’s high bit, returning $d010 to $ab for X=254. Leaving it set would
select X=510. The other sprite bits must survive both operations.
The companion’s checks compare all twelve random bytes and coordinates, verify the register values on both sides of 255, and repeat the baseline from a cold start. The repair reproduces the baseline PRG byte for byte. A single plausible sprite position would be weaker evidence than this ordered trace.
Explain the first wrong value and which routine owns the transfer. Then explain why changing the seed would hide or move this failure without repairing it. Keep the random calls in their original order for this investigation. We will change that variable separately; a seed alone cannot guarantee reproducibility. Return to the original step-04 game afterwards.
Call-order investigation: who gets the next byte?
Now keep the position handling correct. The optional call-order companion reuses the fixed-seed fixture and adds one cosmetic draw for a border colour. This decoration belongs to the experiment, not the normal game’s starfield. Both versions consume thirteen bytes: twelve for enemies and one for decoration. We move the cosmetic draw from just before the first spawn to just after it.
Keep seed 1, the initial state and the launch inputs identical. Before running,
assign the first four bytes—2, 4, 8, 16—to their consumers. Use
24 + b + floor(b/8) to predict the first two enemy positions. Will every later
spawn differ, or can the two traces agree again?
Run both copies. The display shows their first three spawn records in hexadecimal; the companion also logs each draw as a consumer ID and its byte. We can inspect those values without distinguishing the border colours. Compare the raw random sequence and the consumers: matching seeds alone do not answer both questions.
Hint 1: label the draws before calculating positions
In one version the order begins decoration, enemy, enemy. In the other it begins enemy, decoration, enemy. Each request advances the same generator once.
Hint 2: count what has happened before spawn two
How many bytes has each version consumed by then? There are no collision or movement updates between these calls that could introduce another difference.
Compare the consumers and explain the result
| Draw | Decoration before spawn 1 | Decoration after spawn 1 |
|---|---|---|
| Byte 2 | Decoration | Spawn 1: X=26 |
| Byte 4 | Spawn 1: X=28 | Decoration |
| Byte 8 | Spawn 2: X=33 | Spawn 2: X=33 |
| Byte 16 | Spawn 3: X=42 | Spawn 3: X=42 |
The generator produces exactly the same sequence. The first enemy receives a different byte because decoration takes a different turn. Unlike the previous fault, the logical position and sprite registers agree in both versions.
Spawns 2–12 agree: both versions have consumed two bytes before spawn 2. That does not mean a real game would automatically recover the same history. A changed first position could affect a collision, score or later random calls. Our isolated fixture excludes those consequences so we can see this one cause.
Explain what we should do if a harmless-looking decoration edit changes an important test scenario. Restoring the old call order restores these choices; accepting a deliberate behaviour change means recording a new baseline. A separate random state for decoration is another possible design when that independence is needed, but it brings another seed and reset responsibility. We do not need a new generator or replay engine to investigate this example.
Finish by describing the evidence a bug report needs beyond its seed: which build, initial state, inputs and random consumers produced the result? Return to the original step-04 game afterwards. These three investigations isolate a reset fault, a representation handoff and a change in who consumes the data.
When it’s wrong, see why
- The jingle plays its first note and holds it forever.
jingle_timerisn’t being reloaded, orjingle_idxnever increments — the tick serves one row and then keeps agreeing with itself. - The jingle keeps playing into the game.
enter_gamemust 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_titleinstalled the band’s attack/decay andenter_gamenever restored the laser’s. Write both registers back. - The wave never advances.
killsisn’t reset inadvance_wave(it sails past 10 and thecmp #10/bccnever 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_tblthe lookup reads neighbouring bytes as speeds.WAVE_TOPis the wall. - Fire still skips the game-over screen.
ui_lockhas to be loaded where the state changes — in the out-of-lives branch — not inshow_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
#$03instead ofenemy_count, oradvance_wavenever 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
$d015wasn’t switched on, or its shape pointer at$07fd-$07ffwasn’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 #$00intoscore_hiis 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_lockslows 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, 3with 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*2arithmetic 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_titleandenter_game. - Endings need dwell — a lock the ending screens count down before believing the button; the pause is part of the design.
- A game is proven by trying to break it — a scripted “park and hold fire” run found a dominant strategy the finished game shipped with. Randomness you can stand under isn’t random; a field the danger can’t reach isn’t a field. A real LFSR, full-field 9-bit enemies, and a hunt made losing possible again.
- Difficulty is more than one axis — when the speed table topped out, enemy count and homing pressure carried the curve on. Escalation lives in the hardware you haven’t used yet (three idle sprites) and the behaviour you can tighten.
- The parallel-array pattern scales — three enemies to six is the same arrays one size wider and one variable in place of a literal; the loops never learn how many there are.
What’s next
Now Starfield is finished — and finished means losable and worth replaying: opening music, a curve that keeps bending past where the speed table gave out, a swarm that grows into the idle sprites, endings that land, a four-digit number for the run you died on, and no square left to switch the danger off. The vocabulary list from Unit 16 gains its last entries: escalation as data, music as a table, patience as a lock, a random source you can’t out-park, and difficulty spread across more axes than one.
Whatever you build next on this machine starts from all of it.
Run it here
This unit's program, built for you.
- Program
- step-04.prg
- Machine
- Commodore 64 (PAL Breadbin)