The Curve
Starfield works; nothing escalates. A wave counter, a speed table and a live fall speed put a difficulty curve in the game; a SID jingle gives the title a voice; and a dwell on both ends of the loop lets the endings land. Then the winnability gate does its work: the turret park — park under the lane, hold fire, never lose — is closed with a real LFSR spawn, full-field 9-bit enemies, and a hunt that leaves no square safe. And finally the game gets a curve worth playing: the swarm grows from three enemies to six over the waves, the hunt tightens late, and a four-digit score can hold a long game.
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.
| 1 | 1 | ; Starfield - Unit 17: The Curve | |
| 2 | 2 | ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop) | |
| 3 | + | ; This step: the wave system. | |
| 3 | 4 | ; Assemble: acme -f cbm -o <step>.prg <step>.asm | |
| 4 | 5 | | |
| 5 | 6 | ; ------------------------------------------------ | |
| ... | |||
| 20 | 21 | frame_count = $13 ; free-running frame counter (parallax timing) | |
| 21 | 22 | star_row = $14 ; 12 stars: row of each ($14-$1f) | |
| 22 | 23 | star_col = $20 ; 12 stars: column of each ($20-$2b) | |
| 24 | + | wave = $2c ; wave counter (1, 2, 3, ... — never stops climbing) | |
| 25 | + | kills = $2d ; hits this wave; 10 advances the wave | |
| 26 | + | fall_speed = $2e ; pixels per frame the enemies fall — poked by advance_wave | |
| 23 | 27 | ; $fb/$fc: scratch pointer used by the star routines | |
| 24 | 28 | | |
| 25 | 29 | ; ------------------------------------------------ | |
| ... | |||
| 308 | 312 | lda flash_tbl,x | |
| 309 | 313 | bne do_flash ; this enemy is mid-flash | |
| 310 | 314 | | |
| 311 | - | ; not flashing: drift down 1 pixel per frame | |
| 315 | + | ; not flashing: drift down at the wave's pace | |
| 312 | 316 | lda enemy_y_tbl,x | |
| 313 | 317 | clc | |
| 314 | - | adc #$01 ; clc before adc, the addition from the Primer | |
| 318 | + | adc fall_speed ; the wave's tuning, read fresh every frame | |
| 315 | 319 | sta enemy_y_tbl,x | |
| 316 | 320 | cmp #$f8 ; off the bottom? (Y >= 248) | |
| 317 | 321 | bcc update_sprite ; still on screen | |
| ... | |||
| 431 | 435 | clc | |
| 432 | 436 | adc #$30 | |
| 433 | 437 | sta $0401 ; ones digit | |
| 438 | + | | |
| 439 | + | ; Ten hits clear the wave: the lanes quicken | |
| 440 | + | inc kills | |
| 441 | + | lda kills | |
| 442 | + | cmp #10 | |
| 443 | + | bcc no_hit | |
| 444 | + | jsr advance_wave | |
| 434 | 445 | | |
| 435 | 446 | no_hit: | |
| 436 | 447 | | |
| ... | |||
| 695 | 706 | sta $0427 | |
| 696 | 707 | lda #$01 | |
| 697 | 708 | sta $d827 | |
| 709 | + | ; Wave 1: counter, kills, the live speed, and the readout | |
| 710 | + | lda #$01 | |
| 711 | + | sta wave | |
| 712 | + | lda #$00 | |
| 713 | + | sta kills | |
| 714 | + | lda wave_speed_tbl ; first table entry | |
| 715 | + | sta fall_speed | |
| 716 | + | jsr draw_wave | |
| 698 | 717 | ; state = playing | |
| 699 | 718 | lda #$01 | |
| 700 | 719 | sta state | |
| 720 | + | rts | |
| 721 | + | | |
| 722 | + | ; ------------------------------------------------ | |
| 723 | + | ; Subroutine: advance_wave — ten kills: quicken the lanes, say so | |
| 724 | + | ; Three different caps live here, and they are not the same thing: | |
| 725 | + | ; the COUNTER never stops, the table INDEX clamps at the last row, | |
| 726 | + | ; and the DIGIT shows 9 forever after. The player's number tells the | |
| 727 | + | ; truth; the physics admits it ran out of new ideas. | |
| 728 | + | ; ------------------------------------------------ | |
| 729 | + | advance_wave: | |
| 730 | + | lda #$00 | |
| 731 | + | sta kills | |
| 732 | + | inc wave | |
| 733 | + | ; table index = wave - 1, clamped to the last entry | |
| 734 | + | ldy wave | |
| 735 | + | dey | |
| 736 | + | cpy #WAVE_TOP | |
| 737 | + | bcc wave_speed_ok | |
| 738 | + | ldy #WAVE_TOP | |
| 739 | + | wave_speed_ok: | |
| 740 | + | lda wave_speed_tbl,y | |
| 741 | + | sta fall_speed | |
| 742 | + | jsr draw_wave | |
| 743 | + | ; The chirp — voice 3, a bright triangle ding over the explosion | |
| 744 | + | lda #$00 | |
| 745 | + | sta $d40e ; voice 3 frequency low | |
| 746 | + | lda #$40 | |
| 747 | + | sta $d40f ; voice 3 frequency high (a high ding) | |
| 748 | + | lda #$09 | |
| 749 | + | sta $d413 ; attack 0, decay 9 | |
| 750 | + | lda #$00 | |
| 751 | + | sta $d414 ; sustain 0, release 0 | |
| 752 | + | lda #$10 | |
| 753 | + | sta $d412 ; triangle, gate OFF (reset the envelope) | |
| 754 | + | lda #$11 | |
| 755 | + | sta $d412 ; triangle, gate ON | |
| 756 | + | rts | |
| 757 | + | | |
| 758 | + | ; ------------------------------------------------ | |
| 759 | + | ; Subroutine: draw_wave — "W" and the wave digit, top centre | |
| 760 | + | ; The digit caps at 9; the wave itself keeps counting. | |
| 761 | + | ; ------------------------------------------------ | |
| 762 | + | draw_wave: | |
| 763 | + | lda #$17 ; W | |
| 764 | + | sta $0413 | |
| 765 | + | lda wave | |
| 766 | + | cmp #$0a | |
| 767 | + | bcc wave_digit_ok | |
| 768 | + | lda #$09 ; show 9 from here on | |
| 769 | + | wave_digit_ok: | |
| 770 | + | clc | |
| 771 | + | adc #$30 ; to screen code | |
| 772 | + | sta $0414 | |
| 773 | + | lda #$01 ; white | |
| 774 | + | sta $d813 | |
| 775 | + | sta $d814 | |
| 701 | 776 | rts | |
| 777 | + | | |
| 778 | + | ; Pixels per frame, one entry per wave; the last entry is the wall | |
| 779 | + | WAVE_TOP = 4 ; last index of the table below | |
| 780 | + | wave_speed_tbl: | |
| 781 | + | !byte 1, 2, 2, 3, 3 | |
| 702 | 782 | | |
| 703 | 783 | ; ------------------------------------------------ | |
| 704 | 784 | ; 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, in Gloaming, a beeper tune owns the processor — every half-cycle of every note is a CPU loop, and the game stops while music plays. Here the SID holds each note by itself: jingle_tick touches the chip twice per note, at the start and at the end, and the starfield scrolls on regardless. That is what a sound chip buys you over a sound bit — and the tune is Twinkle Twinkle, Little Star, because what else would a starfield play?
One catch worth meeting now: voice 1 already belongs to the laser. The title lends it to the band — enter_title installs a softer envelope, enter_game gates the voice off and hands the laser its envelope back. One voice, two owners, and the transitions are exactly where the handover lives. Miss half of it and the laser comes back sounding like a flute.
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.
| 1 | 1 | ; Starfield - Unit 17: The Curve | |
| 2 | 2 | ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop) | |
| 3 | - | ; This step: the wave system. | |
| 3 | + | ; This step: a SID title jingle, and a dwell on both ends of the loop. | |
| 4 | 4 | ; Assemble: acme -f cbm -o <step>.prg <step>.asm | |
| 5 | 5 | | |
| 6 | 6 | ; ------------------------------------------------ | |
| ... | |||
| 24 | 24 | wave = $2c ; wave counter (1, 2, 3, ... — never stops climbing) | |
| 25 | 25 | kills = $2d ; hits this wave; 10 advances the wave | |
| 26 | 26 | fall_speed = $2e ; pixels per frame the enemies fall — poked by advance_wave | |
| 27 | + | jingle_idx = $2f ; which jingle table entry is sounding | |
| 28 | + | jingle_timer = $30 ; frames left on the current note | |
| 29 | + | ui_lock = $31 ; frames before fire counts on the title / game-over screens | |
| 27 | 30 | ; $fb/$fc: scratch pointer used by the star routines | |
| 28 | 31 | | |
| 29 | 32 | ; ------------------------------------------------ | |
| ... | |||
| 128 | 131 | | |
| 129 | 132 | title_state: | |
| 130 | 133 | jsr show_title ; repaint, in case a star scrolled across it | |
| 134 | + | jsr jingle_tick ; the band plays; the SID holds each note itself | |
| 135 | + | lda ui_lock ; a beat before fire counts — | |
| 136 | + | beq title_ready ; the press that ended the last screen | |
| 137 | + | dec ui_lock ; must not start this one | |
| 138 | + | jmp game_loop | |
| 139 | + | title_ready: | |
| 131 | 140 | lda $dc00 | |
| 132 | 141 | and #%00010000 ; fire button (bit 4) | |
| 133 | 142 | bne loop_again ; not pressed — wait on the title | |
| ... | |||
| 137 | 146 | | |
| 138 | 147 | over_state: | |
| 139 | 148 | jsr show_game_over ; repaint over any star damage | |
| 149 | + | lda ui_lock ; the dwell: let the ending land | |
| 150 | + | beq over_ready | |
| 151 | + | dec ui_lock | |
| 152 | + | jmp game_loop | |
| 153 | + | over_ready: | |
| 140 | 154 | lda $dc00 | |
| 141 | 155 | and #%00010000 | |
| 142 | 156 | bne loop_again | |
| ... | |||
| 498 | 512 | lda #$02 | |
| 499 | 513 | sta state | |
| 500 | 514 | sta $d027 ; ship turns red ($02 = red, reused here) | |
| 515 | + | lda #60 | |
| 516 | + | sta ui_lock ; the dwell — stillness before fire counts again | |
| 501 | 517 | jsr show_game_over | |
| 502 | 518 | jmp death_sound | |
| 503 | 519 | | |
| ... | |||
| 645 | 661 | sta $d015 ; all sprites off: the title has no ship or wave | |
| 646 | 662 | sta $d020 ; border black | |
| 647 | 663 | jsr show_title | |
| 664 | + | lda #25 | |
| 665 | + | sta ui_lock ; half a second before fire is believed | |
| 666 | + | ; Voice 1 becomes the band: a softer envelope than the laser's | |
| 667 | + | lda #$18 | |
| 668 | + | sta $d405 ; attack 1, decay 8 | |
| 669 | + | lda #$a0 | |
| 670 | + | sta $d406 ; sustain 10, release 0 | |
| 671 | + | lda #$00 | |
| 672 | + | sta jingle_idx ; start the tune from the top, | |
| 673 | + | lda #$01 | |
| 674 | + | sta jingle_timer ; first note due on the next tick | |
| 648 | 675 | lda #$00 | |
| 649 | 676 | sta state ; 0 = title | |
| 650 | 677 | rts | |
| ... | |||
| 653 | 680 | ; Subroutine: enter_game — set up a fresh game, state = 1 | |
| 654 | 681 | ; ------------------------------------------------ | |
| 655 | 682 | enter_game: | |
| 683 | + | ; The band stops: gate voice 1 off and hand it back to the laser | |
| 684 | + | lda #$10 | |
| 685 | + | sta $d404 ; triangle, gate OFF | |
| 686 | + | lda #$06 | |
| 687 | + | sta $d405 ; the laser's envelope again | |
| 688 | + | lda #$00 | |
| 689 | + | sta $d406 | |
| 656 | 690 | jsr clear_and_stars | |
| 657 | 691 | ; The sprite data pointers live in screen RAM ($07f8+), so the clear just | |
| 658 | 692 | ; wiped them — set them here, after the clear, or the sprites show garbage. | |
| ... | |||
| 779 | 813 | WAVE_TOP = 4 ; last index of the table below | |
| 780 | 814 | wave_speed_tbl: | |
| 781 | 815 | !byte 1, 2, 2, 3, 3 | |
| 816 | + | | |
| 817 | + | ; ------------------------------------------------ | |
| 818 | + | ; Subroutine: jingle_tick — one frame of title music | |
| 819 | + | ; A melody is a table: a pitch (two bytes) and a duration in frames. | |
| 820 | + | ; The SID holds each note by itself — this routine only does anything | |
| 821 | + | ; when the current note's time is up. Pitch high byte 0 is a rest | |
| 822 | + | ; (silence is a note too); $ff loops back to the top. | |
| 823 | + | ; ------------------------------------------------ | |
| 824 | + | jingle_tick: | |
| 825 | + | dec jingle_timer | |
| 826 | + | beq jingle_next | |
| 827 | + | rts | |
| 828 | + | jingle_next: | |
| 829 | + | ldx jingle_idx | |
| 830 | + | lda jingle_hi,x | |
| 831 | + | cmp #$ff ; the loop marker | |
| 832 | + | bne jingle_play | |
| 833 | + | lda #$00 ; back to the top | |
| 834 | + | sta jingle_idx | |
| 835 | + | ldx #$00 | |
| 836 | + | lda jingle_hi,x | |
| 837 | + | jingle_play: | |
| 838 | + | bne jingle_note | |
| 839 | + | ; a rest: gate off, keep counting | |
| 840 | + | lda #$10 | |
| 841 | + | sta $d404 | |
| 842 | + | jmp jingle_clock | |
| 843 | + | jingle_note: | |
| 844 | + | sta $d401 ; pitch, high byte | |
| 845 | + | lda jingle_lo,x | |
| 846 | + | sta $d400 ; pitch, low byte | |
| 847 | + | lda #$10 | |
| 848 | + | sta $d404 ; gate off first — retrigger the envelope | |
| 849 | + | lda #$11 | |
| 850 | + | sta $d404 ; triangle, gate on | |
| 851 | + | jingle_clock: | |
| 852 | + | lda jingle_dur,x | |
| 853 | + | sta jingle_timer | |
| 854 | + | inc jingle_idx | |
| 855 | + | rts | |
| 856 | + | | |
| 857 | + | ; Twinkle Twinkle, Little Star — fourteen notes, a breath, and round | |
| 858 | + | ; again. A starfield needs no other tune. (PAL SID pitch values.) | |
| 859 | + | jingle_lo: | |
| 860 | + | !byte $67,$67,$13,$13,$45,$45,$13 | |
| 861 | + | !byte $3b,$3b,$ed,$ed,$88,$88,$67 | |
| 862 | + | !byte $00,$00 | |
| 863 | + | jingle_hi: | |
| 864 | + | !byte $11,$11,$1a,$1a,$1d,$1d,$1a | |
| 865 | + | !byte $17,$17,$15,$15,$13,$13,$11 | |
| 866 | + | !byte $00,$ff | |
| 867 | + | jingle_dur: | |
| 868 | + | !byte 20,20,20,20,20,20,40 | |
| 869 | + | !byte 20,20,20,20,20,20,40 | |
| 870 | + | !byte 35,1 | |
| 782 | 871 | | |
| 783 | 872 | ; ------------------------------------------------ | |
| 784 | 873 | ; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14) |
The complete program
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop)
; This step: a SID title jingle, and a dwell on both ends of the loop.
; Assemble: acme -f cbm -o <step>.prg <step>.asm
; ------------------------------------------------
; Zero-page variables
; ------------------------------------------------
bullet_active = $02 ; 0 = no bullet, 1 = active
bullet_y = $03 ; Bullet Y position
laser_timer = $04 ; Frames of laser pitch-sweep remaining (0 = idle)
laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only)
score = $06 ; Two-digit score, BCD (one decimal digit per nybble)
; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
enemy_x_tbl = $07 ; 3 bytes ($07,$08,$09): each enemy's X
enemy_y_tbl = $0a ; 3 bytes ($0a,$0b,$0c): each enemy's Y
flash_tbl = $0d ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer
state = $10 ; 0 = title, 1 = playing, 2 = game over
lives = $11 ; lives remaining (starts at 3)
death_timer = $12 ; frames of post-hit flash (and, in step 2, invulnerability)
frame_count = $13 ; free-running frame counter (parallax timing)
star_row = $14 ; 12 stars: row of each ($14-$1f)
star_col = $20 ; 12 stars: column of each ($20-$2b)
wave = $2c ; wave counter (1, 2, 3, ... — never stops climbing)
kills = $2d ; hits this wave; 10 advances the wave
fall_speed = $2e ; pixels per frame the enemies fall — poked by advance_wave
jingle_idx = $2f ; which jingle table entry is sounding
jingle_timer = $30 ; frames left on the current note
ui_lock = $31 ; frames before fire counts on the title / game-over screens
; $fb/$fc: scratch pointer used by the star routines
; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00
; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
start:
; --- One-time hardware setup (runs once, not per game) ---
lda #$00
sta $d020 ; border black
sta $d021 ; background black
sta $d010 ; ship 9th X bit clear
; Fixed sprite colours
lda #$01
sta $d027 ; ship white
lda #$07
sta $d028 ; bullet yellow
; SID voice 1 — the laser
lda #$0f
sta $d418 ; volume to maximum
lda #$00
sta $d400
lda #$10
sta $d401
lda #$06
sta $d405
lda #$00
sta $d406
; Star positions (drawn by enter_title / enter_game)
sta frame_count ; A is still 0
ldx #$00
init_star_loop:
lda star_init_row,x
sta star_row,x
lda star_init_col,x
sta star_col,x
inx
cpx #12
bne init_star_loop
; Open on the title screen
jsr enter_title
; ------------------------------------------------
; Game loop — runs once per frame
; ------------------------------------------------
game_loop:
; Wait for the raster beam to reach line 255
; This syncs our code to the display (~50Hz PAL)
- lda $d012
cmp #$ff
bne -
; --- Parallax starfield: scrolls in every state (title, play, over) ---
inc frame_count
ldx #$00
star_loop:
jsr erase_star
; Does THIS star move this frame? Near (0-3) every frame, mid (4-7)
; every 2nd frame, far (8-11) every 4th frame.
cpx #$04
bcc star_do_move ; near layer: always
cpx #$08
bcc star_mid ; mid layer
; far layer: only when the low two frame bits are clear (1 in 4)
lda frame_count
and #%00000011
bne star_move_done
beq star_do_move
star_mid:
lda frame_count
and #%00000001 ; every other frame
bne star_move_done
star_do_move:
inc star_row,x ; one row down
lda star_row,x
cmp #25
bcc star_move_done
lda #$00 ; past the bottom -> wrap to the top
sta star_row,x
star_move_done:
jsr draw_star
inx
cpx #12
bne star_loop
; --- State machine: title (0) / playing (1) / game over (2) ---
lda state
beq title_state
cmp #$02
beq over_state
jmp game_active ; 1 = playing
title_state:
jsr show_title ; repaint, in case a star scrolled across it
jsr jingle_tick ; the band plays; the SID holds each note itself
lda ui_lock ; a beat before fire counts —
beq title_ready ; the press that ended the last screen
dec ui_lock ; must not start this one
jmp game_loop
title_ready:
lda $dc00
and #%00010000 ; fire button (bit 4)
bne loop_again ; not pressed — wait on the title
jsr enter_game ; fire -> start a game
loop_again:
jmp game_loop
over_state:
jsr show_game_over ; repaint over any star damage
lda ui_lock ; the dwell: let the ending land
beq over_ready
dec ui_lock
jmp game_loop
over_ready:
lda $dc00
and #%00010000
bne loop_again
jsr enter_title ; fire -> back to the title screen
jmp game_loop
game_active:
; --- Read joystick and move ship ---
; UP (bit 0) — clamp to Y >= 50
lda $dc00 ; Read joystick port 2
and #%00000001 ; Isolate bit 0
bne not_up ; Bit is 1 = NOT pressed (active low)
lda $d001
cmp #52 ; 50 + room for a 2-pixel move
bcc not_up ; already at the top — don't move
dec $d001 ; Move ship up (decrease Y)
dec $d001 ; 2 pixels per frame
not_up:
; DOWN (bit 1) — clamp to Y <= 234
lda $dc00
and #%00000010
bne not_down
lda $d001
cmp #233 ; 234 - room for a 2-pixel move
bcs not_down ; already at the bottom — don't move
inc $d001 ; Move ship down (increase Y)
inc $d001
not_down:
; LEFT (bit 2) — 9-bit X, clamp to X >= 24
lda $dc00
and #%00000100
bne not_left
lda $d010
and #$01
bne left_ok ; high bit set: X >= 256, always safe to go left
lda $d000
cmp #26 ; 24 + room for a 2-pixel move
bcc not_left ; already at the left edge — don't move
left_ok:
; before each step, flip the 9th bit when X is about to wrap $00 -> $ff
lda $d000
bne +
lda $d010
eor #$01 ; the eor bit-flip from the Primer, on sprite 0's high X bit
sta $d010
+ dec $d000
lda $d000
bne +
lda $d010
eor #$01
sta $d010
+ dec $d000
not_left:
; RIGHT (bit 3) — 9-bit X, clamp to X <= 320
lda $dc00
and #%00001000
bne not_right
lda $d010
and #$01
beq right_ok ; high bit clear: X < 256, always safe to go right
lda $d000
cmp #63 ; (320 - 256) - room for a 2-pixel move
bcs not_right ; already at the right edge — don't move
right_ok:
; after each step, flip the 9th bit when X wraps $ff -> $00
inc $d000
bne +
lda $d010
eor #$01
sta $d010
+ inc $d000
bne +
lda $d010
eor #$01
sta $d010
+
not_right:
; --- Fire button (bit 4) ---
lda $dc00
and #%00010000
bne no_fire ; Bit is 1 = NOT pressed
; Only spawn if no bullet is already flying
lda bullet_active
bne no_fire
; Spawn the bullet at the ship's position
lda $d000 ; Ship X (low byte) -> bullet X
sta $d002
lda $d001 ; Ship Y -> bullet Y
sta bullet_y
; Copy the ship's 9th X bit (bit 0) to the bullet's (bit 1),
; so a shot fired from the right half spawns under the ship
lda $d010
and #%11111101 ; clear the bullet's 9th bit first
sta $d010
lda $d010
and #$01 ; the ship's 9th bit
asl ; shift it into the bullet's position (bit 1)
ora $d010
sta $d010
; Enable sprite 1 (keep sprite 0 enabled)
lda $d015
ora #%00000010
sta $d015
lda #$01
sta bullet_active
; Trigger laser sound: start the pitch high, gate off then on
lda #$40
sta laser_freq ; start high
sta $d401 ; SID frequency high byte
lda #$20
sta $d404 ; Sawtooth, gate OFF (reset envelope)
lda #$21
sta $d404 ; Sawtooth, gate ON (start sound)
lda #$0a
sta laser_timer ; sweep down over 10 frames
no_fire:
; --- Laser pitch sweep: the 'pew' ---
; Drop the pitch a little each frame while the sweep is running.
; We keep our own copy because SID frequency registers are write-only.
lda laser_timer
beq no_sweep
lda laser_freq
sec
sbc #$06
sta laser_freq
sta $d401 ; write the new pitch to the SID
dec laser_timer
no_sweep:
; --- Update the bullet ---
lda bullet_active
beq no_bullet
; Move it up 4 pixels a frame
lda bullet_y
sec
sbc #$04
sta bullet_y
sta $d003 ; sprite 1 Y
; Gone off the top? (Y < 30) -> remove it
cmp #$1e
bcs no_bullet
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; disable sprite 1, keep sprite 0
sta $d015
lda $d010
and #%11111101 ; clear the bullet's 9th bit
sta $d010
no_bullet:
; --- Update every enemy: one indexed loop does all of them ---
ldx #$00
enemy_loop:
lda flash_tbl,x
bne do_flash ; this enemy is mid-flash
; not flashing: drift down at the wave's pace
lda enemy_y_tbl,x
clc
adc fall_speed ; the wave's tuning, read fresh every frame
sta enemy_y_tbl,x
cmp #$f8 ; off the bottom? (Y >= 248)
bcc update_sprite ; still on screen
lda #$32 ; respawn this enemy at the top, new column
jsr spawn_enemy
jmp next_enemy
do_flash:
dec flash_tbl,x
bne update_sprite ; still flashing -> stay frozen, white
lda #$32 ; flash done -> respawn (spawn_enemy restores green)
jsr spawn_enemy
jmp next_enemy
update_sprite:
; copy this enemy's position into its VIC-II sprite registers
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X ($d004, $d006, ...)
lda enemy_y_tbl,x
sta $d001,y ; sprite Y ($d005, $d007, ...)
next_enemy:
inx
cpx #$03 ; the full wave of three
bne enemy_loop
; --- Bullet vs the wave: test each enemy until one is hit ---
lda bullet_active
bne check_collision
jmp no_hit
check_collision:
ldx #$00
collision_loop:
lda flash_tbl,x
bne next_collision ; skip an enemy that's already exploding
; Y distance (8-bit subtract wraps, so two ranges count as close)
lda bullet_y
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_x ; 0..15 apart: close
cmp #$f0
bcc next_collision ; 16..239 apart: too far
check_x:
; A bullet in the right portion (9th bit set) is past X=255, far from
; any enemy, so rule it out before comparing low bytes.
lda $d010
and #%00000010 ; bullet's 9th X bit (sprite 1)
bne next_collision
lda $d002
sec
sbc enemy_x_tbl,x
cmp #$10
bcc hit_enemy ; 0..15 apart: close
cmp #$f0
bcc next_collision ; 16..239 apart: too far
jmp hit_enemy ; 240..255: close from the other side
next_collision:
inx
cpx #$03
bne collision_loop
jmp no_hit
hit_enemy:
; X = the enemy that was hit. Remove the bullet.
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; sprite 1 (bullet) off
sta $d015
; Flash THIS enemy white and start its 8-frame timer. The enemy loop
; freezes it white until the timer runs out, then respawns it.
lda #$08
sta flash_tbl,x
ldy sprite_colour_off,x
lda #$01
sta $d000,y ; this enemy's colour register = white
; Explosion sound — SID voice 2, noise waveform (voice 1 keeps the laser)
lda #$00
sta $d407 ; voice 2 frequency low
lda #$08
sta $d408 ; voice 2 frequency high (a low rumble)
lda #$09
sta $d40c ; attack 0, decay 9
lda #$00
sta $d40d ; sustain 0, release 0
lda #$80
sta $d40b ; noise, gate OFF (reset the envelope)
lda #$81
sta $d40b ; noise, gate ON (trigger the burst)
; Score one hit. In decimal mode ADC carries at 10, so the byte stays
; readable as two decimal digits (BCD) — no conversion needed.
sed ; decimal mode on
lda score
clc
adc #$01
sta score
cld ; decimal mode off (every later ADC/SBC needs it off)
; Refresh the two digits: high nybble -> tens, low nybble -> ones
lda score
lsr
lsr
lsr
lsr ; high nybble down to 0-9
clc
adc #$30 ; to screen code
sta $0400 ; tens digit
lda score
and #$0f ; low nybble, 0-9
clc
adc #$30
sta $0401 ; ones digit
; Ten hits clear the wave: the lanes quicken
inc kills
lda kills
cmp #10
bcc no_hit
jsr advance_wave
no_hit:
; --- Ship vs the wave: has any enemy reached the ship? ---
; ...but not while the life-lost flash runs — the ship is invulnerable
lda death_timer
beq do_ship_collision ; not flashing -> run the check
jmp no_ship_hit ; flashing -> skip it (jmp, the target is far)
do_ship_collision:
ldx #$00
ship_collision_loop:
lda flash_tbl,x
bne next_ship_check ; ignore an exploding enemy
; Y distance: ship Y ($d001) vs this enemy's Y
lda $d001
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_ship_x
cmp #$f0
bcc next_ship_check
check_ship_x:
; ship past X=255 (9th bit set) is far from any enemy — rule it out
lda $d010
and #%00000001 ; ship's 9th X bit (sprite 0)
bne next_ship_check
lda $d000
sec
sbc enemy_x_tbl,x
cmp #$10
bcc ship_hit
cmp #$f0
bcc next_ship_check
jmp ship_hit ; 240..255: close from the other side
next_ship_check:
inx
cpx #$03
bne ship_collision_loop
jmp no_ship_hit
ship_hit:
; Lose a life and update the readout
dec lives
lda lives
clc
adc #$30
sta $0427 ; lives digit, top-right
lda lives
bne life_lost ; lives remain -> respawn and play on
; Out of lives -> game over state (the dispatch handles the freeze)
lda #$02
sta state
sta $d027 ; ship turns red ($02 = red, reused here)
lda #60
sta ui_lock ; the dwell — stillness before fire counts again
jsr show_game_over
jmp death_sound
life_lost:
; Respawn the ship at its start position
lda #172
sta $d000
lda #220
sta $d001
lda $d010
and #%11111110 ; clear the ship's 9th bit (back under X=256)
sta $d010
; Start the life-lost flash (step 2 makes it an invulnerability window too)
lda #90
sta death_timer
death_sound:
; Death sound — SID voice 3 (plays on every death)
lda #$00
sta $d40e ; voice 3 frequency low
lda #$10
sta $d40f ; voice 3 frequency high
lda #$0a
sta $d413 ; attack 0, decay 10 (a long, slow fade)
lda #$00
sta $d414 ; sustain 0, release 0
lda #$20
sta $d412 ; sawtooth, gate OFF (reset the envelope)
lda #$21
sta $d412 ; sawtooth, gate ON (trigger)
no_ship_hit:
; --- Life-lost flash: while the timer runs, blink the border ---
lda death_timer
beq flash_done
dec death_timer
lda death_timer
and #%00001000 ; bit 3 toggles every 8 frames
bne flash_bright
lda #$00 ; dark phase
sta $d020
jmp flash_tick
flash_bright:
lda #$02 ; bright phase (red border)
sta $d020
flash_tick:
lda death_timer
bne flash_done
lda #$00 ; just expired -> border back to black
sta $d020
flash_done:
jmp game_loop
; ------------------------------------------------
; Subroutine: spawn one enemy
; A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
sta enemy_y_tbl,x
lda $d012 ; raster line -> pseudo-random column
and #$7f
clc
adc #$30 ; 48-175, inside the visible width
sta enemy_x_tbl,x
lda #$00
sta flash_tbl,x ; not flashing
ldy sprite_colour_off,x
lda #$05
sta $d000,y ; this enemy's colour = green
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X
lda enemy_y_tbl,x
sta $d001,y ; sprite Y
rts
; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
sprite_pos_off:
!byte $04, $06, $08 ; X offsets: $d004, $d006, $d008
sprite_colour_off:
!byte $29, $2a, $2b ; colour offsets: $d029, $d02a, $d02b
; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
; Row 12 x 40 + 16 = 496 = $1f0, so screen RAM $05f0, colour RAM $d9f0
; ------------------------------------------------
show_game_over:
lda #$07 ; G
sta $05f0
lda #$01 ; A
sta $05f1
lda #$0d ; M
sta $05f2
lda #$05 ; E
sta $05f3
lda #$20 ; (space)
sta $05f4
lda #$0f ; O
sta $05f5
lda #$16 ; V
sta $05f6
lda #$05 ; E
sta $05f7
lda #$12 ; R
sta $05f8
; colour the nine cells white ($d9f0..$d9f8)
lda #$01
ldx #$00
- sta $d9f0,x
inx
cpx #$09
bne -
rts
; ------------------------------------------------
; Subroutine: clear_and_stars — wipe the screen, then repaint every star
; ------------------------------------------------
clear_and_stars:
ldx #$00
cas_clear:
lda #$20
sta $0400,x
sta $0500,x
sta $0600,x
sta $0700,x
inx
bne cas_clear
ldx #$00
cas_draw:
jsr draw_star
inx
cpx #12
bne cas_draw
rts
; ------------------------------------------------
; Subroutine: enter_title — show the title, hide the game, state = 0
; ------------------------------------------------
enter_title:
jsr clear_and_stars
lda #$00
sta $d015 ; all sprites off: the title has no ship or wave
sta $d020 ; border black
jsr show_title
lda #25
sta ui_lock ; half a second before fire is believed
; Voice 1 becomes the band: a softer envelope than the laser's
lda #$18
sta $d405 ; attack 1, decay 8
lda #$a0
sta $d406 ; sustain 10, release 0
lda #$00
sta jingle_idx ; start the tune from the top,
lda #$01
sta jingle_timer ; first note due on the next tick
lda #$00
sta state ; 0 = title
rts
; ------------------------------------------------
; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
; The band stops: gate voice 1 off and hand it back to the laser
lda #$10
sta $d404 ; triangle, gate OFF
lda #$06
sta $d405 ; the laser's envelope again
lda #$00
sta $d406
jsr clear_and_stars
; The sprite data pointers live in screen RAM ($07f8+), so the clear just
; wiped them — set them here, after the clear, or the sprites show garbage.
lda #128
sta $07f8 ; ship
lda #129
sta $07f9 ; bullet
lda #130
sta $07fa ; enemy 0
sta $07fb ; enemy 1
sta $07fc ; enemy 2
; Ship at its start, white (it may have gone red on game over)
lda #172
sta $d000
lda #220
sta $d001
lda #$01
sta $d027
lda #$00
sta $d010
; Enable ship + three enemies (the bullet stays off)
lda #%00011101
sta $d015
; Spawn the wave at staggered heights
lda #$32
ldx #$00
jsr spawn_enemy
lda #$82
ldx #$01
jsr spawn_enemy
lda #$d2
ldx #$02
jsr spawn_enemy
; Reset per-game state
lda #$00
sta bullet_active
sta death_timer
sta $d020 ; border black
sta score
; Score "00", white
lda #$30
sta $0400
sta $0401
lda #$01
sta $d800
sta $d801
; Lives "3", white
lda #$03
sta lives
lda #$33
sta $0427
lda #$01
sta $d827
; Wave 1: counter, kills, the live speed, and the readout
lda #$01
sta wave
lda #$00
sta kills
lda wave_speed_tbl ; first table entry
sta fall_speed
jsr draw_wave
; state = playing
lda #$01
sta state
rts
; ------------------------------------------------
; Subroutine: advance_wave — ten kills: quicken the lanes, say so
; Three different caps live here, and they are not the same thing:
; the COUNTER never stops, the table INDEX clamps at the last row,
; and the DIGIT shows 9 forever after. The player's number tells the
; truth; the physics admits it ran out of new ideas.
; ------------------------------------------------
advance_wave:
lda #$00
sta kills
inc wave
; table index = wave - 1, clamped to the last entry
ldy wave
dey
cpy #WAVE_TOP
bcc wave_speed_ok
ldy #WAVE_TOP
wave_speed_ok:
lda wave_speed_tbl,y
sta fall_speed
jsr draw_wave
; The chirp — voice 3, a bright triangle ding over the explosion
lda #$00
sta $d40e ; voice 3 frequency low
lda #$40
sta $d40f ; voice 3 frequency high (a high ding)
lda #$09
sta $d413 ; attack 0, decay 9
lda #$00
sta $d414 ; sustain 0, release 0
lda #$10
sta $d412 ; triangle, gate OFF (reset the envelope)
lda #$11
sta $d412 ; triangle, gate ON
rts
; ------------------------------------------------
; Subroutine: draw_wave — "W" and the wave digit, top centre
; The digit caps at 9; the wave itself keeps counting.
; ------------------------------------------------
draw_wave:
lda #$17 ; W
sta $0413
lda wave
cmp #$0a
bcc wave_digit_ok
lda #$09 ; show 9 from here on
wave_digit_ok:
clc
adc #$30 ; to screen code
sta $0414
lda #$01 ; white
sta $d813
sta $d814
rts
; Pixels per frame, one entry per wave; the last entry is the wall
WAVE_TOP = 4 ; last index of the table below
wave_speed_tbl:
!byte 1, 2, 2, 3, 3
; ------------------------------------------------
; Subroutine: jingle_tick — one frame of title music
; A melody is a table: a pitch (two bytes) and a duration in frames.
; The SID holds each note by itself — this routine only does anything
; when the current note's time is up. Pitch high byte 0 is a rest
; (silence is a note too); $ff loops back to the top.
; ------------------------------------------------
jingle_tick:
dec jingle_timer
beq jingle_next
rts
jingle_next:
ldx jingle_idx
lda jingle_hi,x
cmp #$ff ; the loop marker
bne jingle_play
lda #$00 ; back to the top
sta jingle_idx
ldx #$00
lda jingle_hi,x
jingle_play:
bne jingle_note
; a rest: gate off, keep counting
lda #$10
sta $d404
jmp jingle_clock
jingle_note:
sta $d401 ; pitch, high byte
lda jingle_lo,x
sta $d400 ; pitch, low byte
lda #$10
sta $d404 ; gate off first — retrigger the envelope
lda #$11
sta $d404 ; triangle, gate on
jingle_clock:
lda jingle_dur,x
sta jingle_timer
inc jingle_idx
rts
; Twinkle Twinkle, Little Star — fourteen notes, a breath, and round
; again. A starfield needs no other tune. (PAL SID pitch values.)
jingle_lo:
!byte $67,$67,$13,$13,$45,$45,$13
!byte $3b,$3b,$ed,$ed,$88,$88,$67
!byte $00,$00
jingle_hi:
!byte $11,$11,$1a,$1a,$1d,$1d,$1a
!byte $17,$17,$15,$15,$13,$13,$11
!byte $00,$ff
jingle_dur:
!byte 20,20,20,20,20,20,40
!byte 20,20,20,20,20,20,40
!byte 35,1
; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
; ------------------------------------------------
show_title:
lda #$13 ; S
sta $05a0
lda #$14 ; T
sta $05a1
lda #$01 ; A
sta $05a2
lda #$12 ; R
sta $05a3
lda #$06 ; F
sta $05a4
lda #$09 ; I
sta $05a5
lda #$05 ; E
sta $05a6
lda #$0c ; L
sta $05a7
lda #$04 ; D
sta $05a8
lda #$01 ; white
ldx #$00
- sta $d9a0,x
inx
cpx #$09
bne -
lda #$10 ; P
sta $063f
lda #$12 ; R
sta $0640
lda #$05 ; E
sta $0641
lda #$13 ; S
sta $0642
lda #$13 ; S
sta $0643
lda #$20 ; (space)
sta $0644
lda #$06 ; F
sta $0645
lda #$09 ; I
sta $0646
lda #$12 ; R
sta $0647
lda #$05 ; E
sta $0648
lda #$0f ; light grey
ldx #$00
- sta $da3f,x
inx
cpx #$0a
bne -
rts
; ------------------------------------------------
; Subroutine: erase_star (X = star index)
; blanks the star's current cell back to a space
; ------------------------------------------------
erase_star:
ldy star_row,x
lda row_addr_lo,y ; point $fb/$fc at the start of this star's row
sta $fb
lda row_addr_hi,y
sta $fc
ldy star_col,x ; Y = the column offset along that row
lda #$20 ; a space
sta ($fb),y ; "finger on the boxes" — pointer + Y offset
rts
; ------------------------------------------------
; Subroutine: draw_star (X = star index)
; writes the star's character + colour at its (row, col)
; ------------------------------------------------
draw_star:
ldy star_row,x
lda row_addr_lo,y
sta $fb ; row start, low byte
lda row_addr_hi,y
sta $fc ; row start, high byte
ldy star_col,x ; Y = column
lda star_char_tbl,x
sta ($fb),y ; STA ($fb),Y -> the screen-RAM cell
; screen RAM $04xx-$07xx maps to colour RAM $d8xx-$dbxx: high byte + $d4
lda $fc
clc
adc #$d4
sta $fc
lda star_colour_tbl,x
sta ($fb),y ; same column offset, now into colour RAM
rts
; ------------------------------------------------
; Star data tables
; ------------------------------------------------
; Screen-RAM start address of each row (row x 40 + $0400), rows 0-24
row_addr_lo:
!byte $00,$28,$50,$78,$a0,$c8,$f0,$18
!byte $40,$68,$90,$b8,$e0,$08,$30,$58
!byte $80,$a8,$d0,$f8,$20,$48,$70,$98,$c0
row_addr_hi:
!byte $04,$04,$04,$04,$04,$04,$04,$05
!byte $05,$05,$05,$05,$05,$06,$06,$06
!byte $06,$06,$06,$06,$07,$07,$07,$07,$07
; 12 stars. Columns avoid 0, 1 and 39 — the score and lives cells.
star_init_row:
!byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
star_init_col:
!byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
; Appearance reinforces the depth: near = bright white '*', far = dim grey '.'
star_char_tbl:
!byte $2a,$2a,$2a,$2a, $2a,$2a,$2a,$2a, $2e,$2e,$2e,$2e
star_colour_tbl:
!byte $01,$01,$01,$01, $0f,$0f,$0f,$0f, $0b,$0b,$0b,$0b
; ------------------------------------------------
; Sprite data at $2000 (block 128) — ship
; ------------------------------------------------
*= $2000
!byte $00,$18,$00 ; ##
!byte $00,$3c,$00 ; ####
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$7e,$00 ; ######
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $03,$ff,$c0 ; ############
!byte $07,$ff,$e0 ; ##############
!byte $07,$ff,$e0 ; ##############
!byte $07,$e7,$e0 ; ###..####..###
!byte $03,$c3,$c0 ; ##....##....##
!byte $01,$ff,$80 ; ##########
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $00,$db,$00 ; ##.##.##
!byte $00,$db,$00 ; ##.##.##
!byte $00,$66,$00 ; ##..##
!byte $00,$24,$00 ; #..#
!byte $00,$00,$00 ;
; ------------------------------------------------
; Sprite data at $2040 (block 129) — bullet
; ------------------------------------------------
*= $2040
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
; ------------------------------------------------
; Sprite data at $2080 (block 130) — enemy
; ------------------------------------------------
*= $2080
!byte $00,$66,$00 ; ##..##
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$db,$00 ; ##.##.##
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $01,$7e,$80 ; #.######.#
!byte $01,$3c,$80 ; #..####..#
!byte $00,$a5,$00 ; #.#..#.#
!byte $01,$81,$80 ; ##......##
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
Milestone 3 — the turret park, closed
Starfield looked finished at step 2. But finished has a second test on this course — the one the winnability gate asks of every game: can it be lost by a player who’s actually trying to win? Drive Starfield the way a bored, clever player does, and it can’t.
Park the ship under the enemies’ lane, hold fire, never move. Every enemy funnels into your bullet stream and dies; you score freely and you are immortal. You needn’t even aim — fly to the right third of the screen and stop, and enemies never reach there at all. Two ways to switch the danger off, and both are one bug wearing two coats.
The bug is where the enemies come from. The shipped spawn picks a column from the raster line:
lda $d012 ; the raster line, as "randomness"
and #$7f
adc #$30 ; X = 48..175
It reads at the same instant every time — respawns fire at a fixed point in the frame, so the beam has barely moved — so every enemy appears in a band about a dozen pixels wide, narrower than the 16-pixel hit box. One parked, firing ship covers the whole band. And that band (48–175) is far narrower than the ship’s own travel (out past 300): everything to the right is a place no enemy can go.
The design-stress pass named it and its fix in one breath: give the spawn a real random source, spread it across the whole field, and make no square safe.
A real random column — the LFSR
Raster-as-random was a fair beginner shortcut; the grown-up version is a linear-feedback shift register — one byte walking a maximal 255-long pseudo-random cycle, one step per spawn:
advance_rng:
lda rng_seed
asl ; shift left; the bit that falls out -> carry
bcc +
eor #$1d ; a 1 fell out: fold the tap back in
+ sta rng_seed
rts
Eight bytes, no state but its own, and no pattern you can stand under. Seeded from the raster once at the start of each game, it gives every game a different run while keeping the columns scattered within a run.
Enemies across the whole field — the 9th bit
To threaten the sky the ship can reach, an enemy has to cross X=255 — and a sprite’s X is 9 bits: the low eight in its position register, the ninth over in $d010. You met that bit in the Primer, on the ship. Now the enemies use it too: an enemy_xhi_tbl byte each, written into $d010 beside the low X, and both collision tests taught to compare the full nine bits instead of skipping the right half. The safe corner is gone — an enemy can be anywhere the ship can.

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.


| 1 | 1 | ; Starfield - Unit 17: The Curve | |
| 2 | - | ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop) | |
| 3 | - | ; This step: a SID title jingle, and a dwell on both ends of the loop. | |
| 2 | + | ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop) -> step-03 (+ a real spawn RNG, full-field enemies, and homing: close the turret-park dominant strategy and the right-side no-death zone the design-stress pass found) | |
| 3 | + | ; This step: replace the raster-seeded enemy column with an 8-bit LFSR, spread enemies across the whole 9-bit field the ship can reach, make both collision tests 9-bit-aware, and have enemies home toward the ship's column so no parked position is safe — the fail state is reachable again. | |
| 4 | 4 | ; Assemble: acme -f cbm -o <step>.prg <step>.asm | |
| 5 | 5 | | |
| 6 | 6 | ; ------------------------------------------------ | |
| ... | |||
| 27 | 27 | jingle_idx = $2f ; which jingle table entry is sounding | |
| 28 | 28 | jingle_timer = $30 ; frames left on the current note | |
| 29 | 29 | ui_lock = $31 ; frames before fire counts on the title / game-over screens | |
| 30 | + | ; --- step 3: a real spawn RNG, and enemies across the whole field --- | |
| 31 | + | rng_seed = $32 ; 8-bit LFSR state — the enemy-column pseudo-random source | |
| 32 | + | enemy_xhi_tbl = $33 ; 3 bytes ($33-$35): each enemy's 9th X bit (0 or 1) | |
| 33 | + | col_tmp = $36 ; scratch: the spawn column's low byte mid-calculation | |
| 34 | + | dx_lo = $37 ; scratch: 9-bit collision delta, low byte | |
| 35 | + | dx_hi = $38 ; scratch: 9-bit collision delta, high byte | |
| 30 | 36 | ; $fb/$fc: scratch pointer used by the star routines | |
| 31 | 37 | | |
| 32 | 38 | ; ------------------------------------------------ | |
| ... | |||
| 326 | 332 | lda flash_tbl,x | |
| 327 | 333 | bne do_flash ; this enemy is mid-flash | |
| 328 | 334 | | |
| 329 | - | ; not flashing: drift down at the wave's pace | |
| 335 | + | ; not flashing: fall at the wave's pace, then home toward the ship's | |
| 336 | + | ; column every other frame — a moving ship still shakes them, a still one | |
| 337 | + | ; gets run down. This is what makes the fail state reachable: no parked | |
| 338 | + | ; position is safe once the enemies come to you. | |
| 330 | 339 | lda enemy_y_tbl,x | |
| 331 | 340 | clc | |
| 332 | 341 | adc fall_speed ; the wave's tuning, read fresh every frame | |
| 333 | 342 | sta enemy_y_tbl,x | |
| 343 | + | lda frame_count | |
| 344 | + | lsr ; bit 0 -> carry | |
| 345 | + | bcc + ; drift only on odd frames (~0.5 px/frame sideways) | |
| 346 | + | jsr drift_enemy_x | |
| 347 | + | + lda enemy_y_tbl,x | |
| 334 | 348 | cmp #$f8 ; off the bottom? (Y >= 248) | |
| 335 | 349 | bcc update_sprite ; still on screen | |
| 336 | 350 | lda #$32 ; respawn this enemy at the top, new column | |
| ... | |||
| 351 | 365 | sta $d000,y ; sprite X ($d004, $d006, ...) | |
| 352 | 366 | lda enemy_y_tbl,x | |
| 353 | 367 | sta $d001,y ; sprite Y ($d005, $d007, ...) | |
| 354 | - | | |
| 368 | + | jsr set_enemy_hibit ; reflect this enemy's 9th X bit into $d010 | |
| 355 | 369 | next_enemy: | |
| 356 | 370 | inx | |
| 357 | 371 | cpx #$03 ; the full wave of three | |
| ... | |||
| 376 | 390 | cmp #$f0 | |
| 377 | 391 | bcc next_collision ; 16..239 apart: too far | |
| 378 | 392 | check_x: | |
| 379 | - | ; A bullet in the right portion (9th bit set) is past X=255, far from | |
| 380 | - | ; any enemy, so rule it out before comparing low bytes. | |
| 381 | - | lda $d010 | |
| 382 | - | and #%00000010 ; bullet's 9th X bit (sprite 1) | |
| 383 | - | bne next_collision | |
| 393 | + | | |
| 394 | + | ; right-side bullet can no longer be ruled out by its 9th bit — compare the | |
| 395 | + | ; full 9-bit X of both (low byte, then the high bit, keeping the borrow). | |
| 384 | 396 | lda $d002 | |
| 385 | 397 | sec | |
| 386 | - | sbc enemy_x_tbl,x | |
| 387 | - | cmp #$10 | |
| 388 | - | bcc hit_enemy ; 0..15 apart: close | |
| 398 | + | sbc enemy_x_tbl,x ; low byte; carry holds the borrow | |
| 399 | + | sta dx_lo | |
| 400 | + | lda $d010 | |
| 401 | + | and #%00000010 ; bullet's 9th X bit (sprite 1) | |
| 402 | + | beq bcol_hi0 | |
| 403 | + | lda #$01 | |
| 404 | + | bcol_hi0: | |
| 405 | + | sbc enemy_xhi_tbl,x ; high byte, minus the borrow from the low | |
| 406 | + | sta dx_hi | |
| 407 | + | beq bcol_pos ; delta 0..255: near only if the low byte is < 16 | |
| 408 | + | cmp #$ff | |
| 409 | + | bne next_collision ; delta < -255 or > 255: far | |
| 410 | + | lda dx_lo ; delta -256..-1: near from above (-16..-1) | |
| 389 | 411 | cmp #$f0 | |
| 390 | - | bcc next_collision ; 16..239 apart: too far | |
| 391 | - | jmp hit_enemy ; 240..255: close from the other side | |
| 412 | + | bcs hit_enemy | |
| 413 | + | jmp next_collision | |
| 414 | + | bcol_pos: | |
| 415 | + | lda dx_lo | |
| 416 | + | cmp #$10 | |
| 417 | + | bcc hit_enemy ; 0..15 apart: a hit | |
| 418 | + | jmp next_collision | |
| 392 | 419 | | |
| 393 | 420 | next_collision: | |
| 394 | 421 | inx | |
| ... | |||
| 478 | 505 | cmp #$f0 | |
| 479 | 506 | bcc next_ship_check | |
| 480 | 507 | check_ship_x: | |
| 481 | - | ; ship past X=255 (9th bit set) is far from any enemy — rule it out | |
| 482 | - | lda $d010 | |
| 483 | - | and #%00000001 ; ship's 9th X bit (sprite 0) | |
| 484 | - | bne next_ship_check | |
| 508 | + | ; 9-bit |shipX - enemyX| < 16. The ship's whole travel is reachable now, | |
| 509 | + | ; so we compare full 9-bit X instead of skipping the right half. | |
| 485 | 510 | lda $d000 | |
| 486 | 511 | sec | |
| 487 | 512 | sbc enemy_x_tbl,x | |
| 513 | + | sta dx_lo | |
| 514 | + | lda $d010 | |
| 515 | + | and #%00000001 ; ship's 9th X bit (sprite 0) | |
| 516 | + | beq scol_hi0 | |
| 517 | + | lda #$01 | |
| 518 | + | scol_hi0: | |
| 519 | + | sbc enemy_xhi_tbl,x | |
| 520 | + | sta dx_hi | |
| 521 | + | beq scol_pos | |
| 522 | + | cmp #$ff | |
| 523 | + | bne next_ship_check | |
| 524 | + | lda dx_lo | |
| 525 | + | cmp #$f0 | |
| 526 | + | bcs ship_hit | |
| 527 | + | jmp next_ship_check | |
| 528 | + | scol_pos: | |
| 529 | + | lda dx_lo | |
| 488 | 530 | cmp #$10 | |
| 489 | 531 | bcc ship_hit | |
| 490 | - | cmp #$f0 | |
| 491 | - | bcc next_ship_check | |
| 492 | - | jmp ship_hit ; 240..255: close from the other side | |
| 532 | + | jmp next_ship_check | |
| 493 | 533 | | |
| 494 | 534 | next_ship_check: | |
| 495 | 535 | inx | |
| ... | |||
| 576 | 616 | ; ------------------------------------------------ | |
| 577 | 617 | spawn_enemy: | |
| 578 | 618 | sta enemy_y_tbl,x | |
| 579 | - | lda $d012 ; raster line -> pseudo-random column | |
| 580 | - | and #$7f | |
| 619 | + | ; A real per-spawn column. Advance the LFSR and spread its byte across | |
| 620 | + | ; the whole field the ship can reach: b + b/8 lifts 0..255 to 0..286, and | |
| 621 | + | ; +24 lands it at X = 24..310 as a 9-bit value (low byte + a 9th bit), so | |
| 622 | + | ; the three lanes scatter and none sits outside the ship's travel. | |
| 623 | + | jsr advance_rng | |
| 624 | + | lda rng_seed | |
| 625 | + | sta col_tmp | |
| 626 | + | lsr | |
| 627 | + | lsr | |
| 628 | + | lsr ; b >> 3 (0..31) | |
| 581 | 629 | clc | |
| 582 | - | adc #$30 ; 48-175, inside the visible width | |
| 630 | + | adc col_tmp ; b + b/8 (0..286); carry = the 9th bit | |
| 631 | + | sta enemy_x_tbl,x | |
| 632 | + | lda #$00 | |
| 633 | + | adc #$00 ; capture the carry as the high bit | |
| 634 | + | sta enemy_xhi_tbl,x | |
| 635 | + | lda enemy_x_tbl,x | |
| 636 | + | clc | |
| 637 | + | adc #24 ; shift the range up off the left edge | |
| 583 | 638 | sta enemy_x_tbl,x | |
| 639 | + | lda enemy_xhi_tbl,x | |
| 640 | + | adc #$00 ; carry ripples into the 9th bit | |
| 641 | + | sta enemy_xhi_tbl,x | |
| 584 | 642 | lda #$00 | |
| 585 | 643 | sta flash_tbl,x ; not flashing | |
| 586 | 644 | ldy sprite_colour_off,x | |
| ... | |||
| 588 | 646 | sta $d000,y ; this enemy's colour = green | |
| 589 | 647 | ldy sprite_pos_off,x | |
| 590 | 648 | lda enemy_x_tbl,x | |
| 591 | - | sta $d000,y ; sprite X | |
| 649 | + | sta $d000,y ; sprite X (low 8 bits) | |
| 592 | 650 | lda enemy_y_tbl,x | |
| 593 | 651 | sta $d001,y ; sprite Y | |
| 652 | + | jsr set_enemy_hibit ; and its 9th X bit | |
| 653 | + | rts | |
| 654 | + | | |
| 655 | + | ; advance_rng — one step of an 8-bit Galois LFSR (tap $1d, maximal length 255). | |
| 656 | + | ; Shift left; if a 1 fell out, fold the tap back in. From any non-zero seed it | |
| 657 | + | ; never reaches 0, so the spawn-column source never gets stuck. | |
| 658 | + | advance_rng: | |
| 659 | + | lda rng_seed | |
| 660 | + | asl | |
| 661 | + | bcc + | |
| 662 | + | eor #$1d | |
| 663 | + | + sta rng_seed | |
| 664 | + | rts | |
| 665 | + | | |
| 666 | + | ; set_enemy_hibit — reflect enemy_xhi_tbl,x into this enemy's 9th X bit in $d010 | |
| 667 | + | ; (enemies are sprites 2,3,4 -> $d010 bits 2,3,4). X = enemy index, preserved. | |
| 668 | + | set_enemy_hibit: | |
| 669 | + | lda enemy_d010_bit,x | |
| 670 | + | ldy enemy_xhi_tbl,x | |
| 671 | + | bne + | |
| 672 | + | eor #$ff ; clear this sprite's bit | |
| 673 | + | and $d010 | |
| 674 | + | sta $d010 | |
| 675 | + | rts | |
| 676 | + | + ora $d010 ; set this sprite's bit | |
| 677 | + | sta $d010 | |
| 678 | + | rts | |
| 679 | + | enemy_d010_bit: | |
| 680 | + | !byte $04, $08, $10 ; sprite 2, 3, 4 high-X bits | |
| 681 | + | | |
| 682 | + | ; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X. | |
| 683 | + | ; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and | |
| 684 | + | ; steps the enemy one pixel the short way; equal columns hold. | |
| 685 | + | drift_enemy_x: | |
| 686 | + | lda enemy_x_tbl,x | |
| 687 | + | sec | |
| 688 | + | sbc $d000 ; (enemy - ship) low; carry holds the borrow | |
| 689 | + | sta dx_lo | |
| 690 | + | lda $d010 | |
| 691 | + | and #%00000001 ; ship's 9th X bit | |
| 692 | + | sta dx_hi ; ship high (temp) | |
| 693 | + | lda enemy_xhi_tbl,x | |
| 694 | + | sbc dx_hi ; A = (enemy - ship) high; N = sign | |
| 695 | + | bmi .drift_right ; enemy left of ship -> move right | |
| 696 | + | bne .drift_left ; enemy far right of ship -> move left | |
| 697 | + | lda dx_lo | |
| 698 | + | beq .drift_done ; same column -> hold | |
| 699 | + | .drift_left: | |
| 700 | + | lda enemy_x_tbl,x | |
| 701 | + | sec | |
| 702 | + | sbc #$01 | |
| 703 | + | sta enemy_x_tbl,x | |
| 704 | + | lda enemy_xhi_tbl,x | |
| 705 | + | sbc #$00 | |
| 706 | + | sta enemy_xhi_tbl,x | |
| 707 | + | rts | |
| 708 | + | .drift_right: | |
| 709 | + | lda enemy_x_tbl,x | |
| 710 | + | clc | |
| 711 | + | adc #$01 | |
| 712 | + | sta enemy_x_tbl,x | |
| 713 | + | lda enemy_xhi_tbl,x | |
| 714 | + | adc #$00 | |
| 715 | + | sta enemy_xhi_tbl,x | |
| 716 | + | .drift_done: | |
| 594 | 717 | rts | |
| 595 | 718 | | |
| 596 | 719 | ; Per-enemy VIC-II register offsets (sprites 2, 3, 4) | |
| ... | |||
| 710 | 833 | ; Enable ship + three enemies (the bullet stays off) | |
| 711 | 834 | lda #%00011101 | |
| 712 | 835 | sta $d015 | |
| 836 | + | ; Seed the column LFSR from the raster the instant play begins, forced | |
| 837 | + | ; non-zero (the LFSR locks at 0) — so each game's spawn run differs while | |
| 838 | + | ; the LFSR keeps the columns spread within a run. | |
| 839 | + | lda $d012 | |
| 840 | + | ora #$01 | |
| 841 | + | sta rng_seed | |
| 713 | 842 | ; Spawn the wave at staggered heights | |
| 714 | 843 | lda #$32 | |
| 715 | 844 | ldx #$00 |
The complete corrected program
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop) -> step-03 (+ a real spawn RNG, full-field enemies, and homing: close the turret-park dominant strategy and the right-side no-death zone the design-stress pass found)
; This step: replace the raster-seeded enemy column with an 8-bit LFSR, spread enemies across the whole 9-bit field the ship can reach, make both collision tests 9-bit-aware, and have enemies home toward the ship's column so no parked position is safe — the fail state is reachable again.
; Assemble: acme -f cbm -o <step>.prg <step>.asm
; ------------------------------------------------
; Zero-page variables
; ------------------------------------------------
bullet_active = $02 ; 0 = no bullet, 1 = active
bullet_y = $03 ; Bullet Y position
laser_timer = $04 ; Frames of laser pitch-sweep remaining (0 = idle)
laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only)
score = $06 ; Two-digit score, BCD (one decimal digit per nybble)
; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
enemy_x_tbl = $07 ; 3 bytes ($07,$08,$09): each enemy's X
enemy_y_tbl = $0a ; 3 bytes ($0a,$0b,$0c): each enemy's Y
flash_tbl = $0d ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer
state = $10 ; 0 = title, 1 = playing, 2 = game over
lives = $11 ; lives remaining (starts at 3)
death_timer = $12 ; frames of post-hit flash (and, in step 2, invulnerability)
frame_count = $13 ; free-running frame counter (parallax timing)
star_row = $14 ; 12 stars: row of each ($14-$1f)
star_col = $20 ; 12 stars: column of each ($20-$2b)
wave = $2c ; wave counter (1, 2, 3, ... — never stops climbing)
kills = $2d ; hits this wave; 10 advances the wave
fall_speed = $2e ; pixels per frame the enemies fall — poked by advance_wave
jingle_idx = $2f ; which jingle table entry is sounding
jingle_timer = $30 ; frames left on the current note
ui_lock = $31 ; frames before fire counts on the title / game-over screens
; --- step 3: a real spawn RNG, and enemies across the whole field ---
rng_seed = $32 ; 8-bit LFSR state — the enemy-column pseudo-random source
enemy_xhi_tbl = $33 ; 3 bytes ($33-$35): each enemy's 9th X bit (0 or 1)
col_tmp = $36 ; scratch: the spawn column's low byte mid-calculation
dx_lo = $37 ; scratch: 9-bit collision delta, low byte
dx_hi = $38 ; scratch: 9-bit collision delta, high byte
; $fb/$fc: scratch pointer used by the star routines
; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00
; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
start:
; --- One-time hardware setup (runs once, not per game) ---
lda #$00
sta $d020 ; border black
sta $d021 ; background black
sta $d010 ; ship 9th X bit clear
; Fixed sprite colours
lda #$01
sta $d027 ; ship white
lda #$07
sta $d028 ; bullet yellow
; SID voice 1 — the laser
lda #$0f
sta $d418 ; volume to maximum
lda #$00
sta $d400
lda #$10
sta $d401
lda #$06
sta $d405
lda #$00
sta $d406
; Star positions (drawn by enter_title / enter_game)
sta frame_count ; A is still 0
ldx #$00
init_star_loop:
lda star_init_row,x
sta star_row,x
lda star_init_col,x
sta star_col,x
inx
cpx #12
bne init_star_loop
; Open on the title screen
jsr enter_title
; ------------------------------------------------
; Game loop — runs once per frame
; ------------------------------------------------
game_loop:
; Wait for the raster beam to reach line 255
; This syncs our code to the display (~50Hz PAL)
- lda $d012
cmp #$ff
bne -
; --- Parallax starfield: scrolls in every state (title, play, over) ---
inc frame_count
ldx #$00
star_loop:
jsr erase_star
; Does THIS star move this frame? Near (0-3) every frame, mid (4-7)
; every 2nd frame, far (8-11) every 4th frame.
cpx #$04
bcc star_do_move ; near layer: always
cpx #$08
bcc star_mid ; mid layer
; far layer: only when the low two frame bits are clear (1 in 4)
lda frame_count
and #%00000011
bne star_move_done
beq star_do_move
star_mid:
lda frame_count
and #%00000001 ; every other frame
bne star_move_done
star_do_move:
inc star_row,x ; one row down
lda star_row,x
cmp #25
bcc star_move_done
lda #$00 ; past the bottom -> wrap to the top
sta star_row,x
star_move_done:
jsr draw_star
inx
cpx #12
bne star_loop
; --- State machine: title (0) / playing (1) / game over (2) ---
lda state
beq title_state
cmp #$02
beq over_state
jmp game_active ; 1 = playing
title_state:
jsr show_title ; repaint, in case a star scrolled across it
jsr jingle_tick ; the band plays; the SID holds each note itself
lda ui_lock ; a beat before fire counts —
beq title_ready ; the press that ended the last screen
dec ui_lock ; must not start this one
jmp game_loop
title_ready:
lda $dc00
and #%00010000 ; fire button (bit 4)
bne loop_again ; not pressed — wait on the title
jsr enter_game ; fire -> start a game
loop_again:
jmp game_loop
over_state:
jsr show_game_over ; repaint over any star damage
lda ui_lock ; the dwell: let the ending land
beq over_ready
dec ui_lock
jmp game_loop
over_ready:
lda $dc00
and #%00010000
bne loop_again
jsr enter_title ; fire -> back to the title screen
jmp game_loop
game_active:
; --- Read joystick and move ship ---
; UP (bit 0) — clamp to Y >= 50
lda $dc00 ; Read joystick port 2
and #%00000001 ; Isolate bit 0
bne not_up ; Bit is 1 = NOT pressed (active low)
lda $d001
cmp #52 ; 50 + room for a 2-pixel move
bcc not_up ; already at the top — don't move
dec $d001 ; Move ship up (decrease Y)
dec $d001 ; 2 pixels per frame
not_up:
; DOWN (bit 1) — clamp to Y <= 234
lda $dc00
and #%00000010
bne not_down
lda $d001
cmp #233 ; 234 - room for a 2-pixel move
bcs not_down ; already at the bottom — don't move
inc $d001 ; Move ship down (increase Y)
inc $d001
not_down:
; LEFT (bit 2) — 9-bit X, clamp to X >= 24
lda $dc00
and #%00000100
bne not_left
lda $d010
and #$01
bne left_ok ; high bit set: X >= 256, always safe to go left
lda $d000
cmp #26 ; 24 + room for a 2-pixel move
bcc not_left ; already at the left edge — don't move
left_ok:
; before each step, flip the 9th bit when X is about to wrap $00 -> $ff
lda $d000
bne +
lda $d010
eor #$01 ; the eor bit-flip from the Primer, on sprite 0's high X bit
sta $d010
+ dec $d000
lda $d000
bne +
lda $d010
eor #$01
sta $d010
+ dec $d000
not_left:
; RIGHT (bit 3) — 9-bit X, clamp to X <= 320
lda $dc00
and #%00001000
bne not_right
lda $d010
and #$01
beq right_ok ; high bit clear: X < 256, always safe to go right
lda $d000
cmp #63 ; (320 - 256) - room for a 2-pixel move
bcs not_right ; already at the right edge — don't move
right_ok:
; after each step, flip the 9th bit when X wraps $ff -> $00
inc $d000
bne +
lda $d010
eor #$01
sta $d010
+ inc $d000
bne +
lda $d010
eor #$01
sta $d010
+
not_right:
; --- Fire button (bit 4) ---
lda $dc00
and #%00010000
bne no_fire ; Bit is 1 = NOT pressed
; Only spawn if no bullet is already flying
lda bullet_active
bne no_fire
; Spawn the bullet at the ship's position
lda $d000 ; Ship X (low byte) -> bullet X
sta $d002
lda $d001 ; Ship Y -> bullet Y
sta bullet_y
; Copy the ship's 9th X bit (bit 0) to the bullet's (bit 1),
; so a shot fired from the right half spawns under the ship
lda $d010
and #%11111101 ; clear the bullet's 9th bit first
sta $d010
lda $d010
and #$01 ; the ship's 9th bit
asl ; shift it into the bullet's position (bit 1)
ora $d010
sta $d010
; Enable sprite 1 (keep sprite 0 enabled)
lda $d015
ora #%00000010
sta $d015
lda #$01
sta bullet_active
; Trigger laser sound: start the pitch high, gate off then on
lda #$40
sta laser_freq ; start high
sta $d401 ; SID frequency high byte
lda #$20
sta $d404 ; Sawtooth, gate OFF (reset envelope)
lda #$21
sta $d404 ; Sawtooth, gate ON (start sound)
lda #$0a
sta laser_timer ; sweep down over 10 frames
no_fire:
; --- Laser pitch sweep: the 'pew' ---
; Drop the pitch a little each frame while the sweep is running.
; We keep our own copy because SID frequency registers are write-only.
lda laser_timer
beq no_sweep
lda laser_freq
sec
sbc #$06
sta laser_freq
sta $d401 ; write the new pitch to the SID
dec laser_timer
no_sweep:
; --- Update the bullet ---
lda bullet_active
beq no_bullet
; Move it up 4 pixels a frame
lda bullet_y
sec
sbc #$04
sta bullet_y
sta $d003 ; sprite 1 Y
; Gone off the top? (Y < 30) -> remove it
cmp #$1e
bcs no_bullet
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; disable sprite 1, keep sprite 0
sta $d015
lda $d010
and #%11111101 ; clear the bullet's 9th bit
sta $d010
no_bullet:
; --- Update every enemy: one indexed loop does all of them ---
ldx #$00
enemy_loop:
lda flash_tbl,x
bne do_flash ; this enemy is mid-flash
; not flashing: fall at the wave's pace, then home toward the ship's
; column every other frame — a moving ship still shakes them, a still one
; gets run down. This is what makes the fail state reachable: no parked
; position is safe once the enemies come to you.
lda enemy_y_tbl,x
clc
adc fall_speed ; the wave's tuning, read fresh every frame
sta enemy_y_tbl,x
lda frame_count
lsr ; bit 0 -> carry
bcc + ; drift only on odd frames (~0.5 px/frame sideways)
jsr drift_enemy_x
+ lda enemy_y_tbl,x
cmp #$f8 ; off the bottom? (Y >= 248)
bcc update_sprite ; still on screen
lda #$32 ; respawn this enemy at the top, new column
jsr spawn_enemy
jmp next_enemy
do_flash:
dec flash_tbl,x
bne update_sprite ; still flashing -> stay frozen, white
lda #$32 ; flash done -> respawn (spawn_enemy restores green)
jsr spawn_enemy
jmp next_enemy
update_sprite:
; copy this enemy's position into its VIC-II sprite registers
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X ($d004, $d006, ...)
lda enemy_y_tbl,x
sta $d001,y ; sprite Y ($d005, $d007, ...)
jsr set_enemy_hibit ; reflect this enemy's 9th X bit into $d010
next_enemy:
inx
cpx #$03 ; the full wave of three
bne enemy_loop
; --- Bullet vs the wave: test each enemy until one is hit ---
lda bullet_active
bne check_collision
jmp no_hit
check_collision:
ldx #$00
collision_loop:
lda flash_tbl,x
bne next_collision ; skip an enemy that's already exploding
; Y distance (8-bit subtract wraps, so two ranges count as close)
lda bullet_y
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_x ; 0..15 apart: close
cmp #$f0
bcc next_collision ; 16..239 apart: too far
check_x:
; 9-bit |bulletX - enemyX| < 16. Enemies now range the whole field, so a
; right-side bullet can no longer be ruled out by its 9th bit — compare the
; full 9-bit X of both (low byte, then the high bit, keeping the borrow).
lda $d002
sec
sbc enemy_x_tbl,x ; low byte; carry holds the borrow
sta dx_lo
lda $d010
and #%00000010 ; bullet's 9th X bit (sprite 1)
beq bcol_hi0
lda #$01
bcol_hi0:
sbc enemy_xhi_tbl,x ; high byte, minus the borrow from the low
sta dx_hi
beq bcol_pos ; delta 0..255: near only if the low byte is < 16
cmp #$ff
bne next_collision ; delta < -255 or > 255: far
lda dx_lo ; delta -256..-1: near from above (-16..-1)
cmp #$f0
bcs hit_enemy
jmp next_collision
bcol_pos:
lda dx_lo
cmp #$10
bcc hit_enemy ; 0..15 apart: a hit
jmp next_collision
next_collision:
inx
cpx #$03
bne collision_loop
jmp no_hit
hit_enemy:
; X = the enemy that was hit. Remove the bullet.
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; sprite 1 (bullet) off
sta $d015
; Flash THIS enemy white and start its 8-frame timer. The enemy loop
; freezes it white until the timer runs out, then respawns it.
lda #$08
sta flash_tbl,x
ldy sprite_colour_off,x
lda #$01
sta $d000,y ; this enemy's colour register = white
; Explosion sound — SID voice 2, noise waveform (voice 1 keeps the laser)
lda #$00
sta $d407 ; voice 2 frequency low
lda #$08
sta $d408 ; voice 2 frequency high (a low rumble)
lda #$09
sta $d40c ; attack 0, decay 9
lda #$00
sta $d40d ; sustain 0, release 0
lda #$80
sta $d40b ; noise, gate OFF (reset the envelope)
lda #$81
sta $d40b ; noise, gate ON (trigger the burst)
; Score one hit. In decimal mode ADC carries at 10, so the byte stays
; readable as two decimal digits (BCD) — no conversion needed.
sed ; decimal mode on
lda score
clc
adc #$01
sta score
cld ; decimal mode off (every later ADC/SBC needs it off)
; Refresh the two digits: high nybble -> tens, low nybble -> ones
lda score
lsr
lsr
lsr
lsr ; high nybble down to 0-9
clc
adc #$30 ; to screen code
sta $0400 ; tens digit
lda score
and #$0f ; low nybble, 0-9
clc
adc #$30
sta $0401 ; ones digit
; Ten hits clear the wave: the lanes quicken
inc kills
lda kills
cmp #10
bcc no_hit
jsr advance_wave
no_hit:
; --- Ship vs the wave: has any enemy reached the ship? ---
; ...but not while the life-lost flash runs — the ship is invulnerable
lda death_timer
beq do_ship_collision ; not flashing -> run the check
jmp no_ship_hit ; flashing -> skip it (jmp, the target is far)
do_ship_collision:
ldx #$00
ship_collision_loop:
lda flash_tbl,x
bne next_ship_check ; ignore an exploding enemy
; Y distance: ship Y ($d001) vs this enemy's Y
lda $d001
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_ship_x
cmp #$f0
bcc next_ship_check
check_ship_x:
; 9-bit |shipX - enemyX| < 16. The ship's whole travel is reachable now,
; so we compare full 9-bit X instead of skipping the right half.
lda $d000
sec
sbc enemy_x_tbl,x
sta dx_lo
lda $d010
and #%00000001 ; ship's 9th X bit (sprite 0)
beq scol_hi0
lda #$01
scol_hi0:
sbc enemy_xhi_tbl,x
sta dx_hi
beq scol_pos
cmp #$ff
bne next_ship_check
lda dx_lo
cmp #$f0
bcs ship_hit
jmp next_ship_check
scol_pos:
lda dx_lo
cmp #$10
bcc ship_hit
jmp next_ship_check
next_ship_check:
inx
cpx #$03
bne ship_collision_loop
jmp no_ship_hit
ship_hit:
; Lose a life and update the readout
dec lives
lda lives
clc
adc #$30
sta $0427 ; lives digit, top-right
lda lives
bne life_lost ; lives remain -> respawn and play on
; Out of lives -> game over state (the dispatch handles the freeze)
lda #$02
sta state
sta $d027 ; ship turns red ($02 = red, reused here)
lda #60
sta ui_lock ; the dwell — stillness before fire counts again
jsr show_game_over
jmp death_sound
life_lost:
; Respawn the ship at its start position
lda #172
sta $d000
lda #220
sta $d001
lda $d010
and #%11111110 ; clear the ship's 9th bit (back under X=256)
sta $d010
; Start the life-lost flash (step 2 makes it an invulnerability window too)
lda #90
sta death_timer
death_sound:
; Death sound — SID voice 3 (plays on every death)
lda #$00
sta $d40e ; voice 3 frequency low
lda #$10
sta $d40f ; voice 3 frequency high
lda #$0a
sta $d413 ; attack 0, decay 10 (a long, slow fade)
lda #$00
sta $d414 ; sustain 0, release 0
lda #$20
sta $d412 ; sawtooth, gate OFF (reset the envelope)
lda #$21
sta $d412 ; sawtooth, gate ON (trigger)
no_ship_hit:
; --- Life-lost flash: while the timer runs, blink the border ---
lda death_timer
beq flash_done
dec death_timer
lda death_timer
and #%00001000 ; bit 3 toggles every 8 frames
bne flash_bright
lda #$00 ; dark phase
sta $d020
jmp flash_tick
flash_bright:
lda #$02 ; bright phase (red border)
sta $d020
flash_tick:
lda death_timer
bne flash_done
lda #$00 ; just expired -> border back to black
sta $d020
flash_done:
jmp game_loop
; ------------------------------------------------
; Subroutine: spawn one enemy
; A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
sta enemy_y_tbl,x
; A real per-spawn column. Advance the LFSR and spread its byte across
; the whole field the ship can reach: b + b/8 lifts 0..255 to 0..286, and
; +24 lands it at X = 24..310 as a 9-bit value (low byte + a 9th bit), so
; the three lanes scatter and none sits outside the ship's travel.
jsr advance_rng
lda rng_seed
sta col_tmp
lsr
lsr
lsr ; b >> 3 (0..31)
clc
adc col_tmp ; b + b/8 (0..286); carry = the 9th bit
sta enemy_x_tbl,x
lda #$00
adc #$00 ; capture the carry as the high bit
sta enemy_xhi_tbl,x
lda enemy_x_tbl,x
clc
adc #24 ; shift the range up off the left edge
sta enemy_x_tbl,x
lda enemy_xhi_tbl,x
adc #$00 ; carry ripples into the 9th bit
sta enemy_xhi_tbl,x
lda #$00
sta flash_tbl,x ; not flashing
ldy sprite_colour_off,x
lda #$05
sta $d000,y ; this enemy's colour = green
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X (low 8 bits)
lda enemy_y_tbl,x
sta $d001,y ; sprite Y
jsr set_enemy_hibit ; and its 9th X bit
rts
; advance_rng — one step of an 8-bit Galois LFSR (tap $1d, maximal length 255).
; Shift left; if a 1 fell out, fold the tap back in. From any non-zero seed it
; never reaches 0, so the spawn-column source never gets stuck.
advance_rng:
lda rng_seed
asl
bcc +
eor #$1d
+ sta rng_seed
rts
; set_enemy_hibit — reflect enemy_xhi_tbl,x into this enemy's 9th X bit in $d010
; (enemies are sprites 2,3,4 -> $d010 bits 2,3,4). X = enemy index, preserved.
set_enemy_hibit:
lda enemy_d010_bit,x
ldy enemy_xhi_tbl,x
bne +
eor #$ff ; clear this sprite's bit
and $d010
sta $d010
rts
+ ora $d010 ; set this sprite's bit
sta $d010
rts
enemy_d010_bit:
!byte $04, $08, $10 ; sprite 2, 3, 4 high-X bits
; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X.
; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and
; steps the enemy one pixel the short way; equal columns hold.
drift_enemy_x:
lda enemy_x_tbl,x
sec
sbc $d000 ; (enemy - ship) low; carry holds the borrow
sta dx_lo
lda $d010
and #%00000001 ; ship's 9th X bit
sta dx_hi ; ship high (temp)
lda enemy_xhi_tbl,x
sbc dx_hi ; A = (enemy - ship) high; N = sign
bmi .drift_right ; enemy left of ship -> move right
bne .drift_left ; enemy far right of ship -> move left
lda dx_lo
beq .drift_done ; same column -> hold
.drift_left:
lda enemy_x_tbl,x
sec
sbc #$01
sta enemy_x_tbl,x
lda enemy_xhi_tbl,x
sbc #$00
sta enemy_xhi_tbl,x
rts
.drift_right:
lda enemy_x_tbl,x
clc
adc #$01
sta enemy_x_tbl,x
lda enemy_xhi_tbl,x
adc #$00
sta enemy_xhi_tbl,x
.drift_done:
rts
; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
sprite_pos_off:
!byte $04, $06, $08 ; X offsets: $d004, $d006, $d008
sprite_colour_off:
!byte $29, $2a, $2b ; colour offsets: $d029, $d02a, $d02b
; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
; Row 12 x 40 + 16 = 496 = $1f0, so screen RAM $05f0, colour RAM $d9f0
; ------------------------------------------------
show_game_over:
lda #$07 ; G
sta $05f0
lda #$01 ; A
sta $05f1
lda #$0d ; M
sta $05f2
lda #$05 ; E
sta $05f3
lda #$20 ; (space)
sta $05f4
lda #$0f ; O
sta $05f5
lda #$16 ; V
sta $05f6
lda #$05 ; E
sta $05f7
lda #$12 ; R
sta $05f8
; colour the nine cells white ($d9f0..$d9f8)
lda #$01
ldx #$00
- sta $d9f0,x
inx
cpx #$09
bne -
rts
; ------------------------------------------------
; Subroutine: clear_and_stars — wipe the screen, then repaint every star
; ------------------------------------------------
clear_and_stars:
ldx #$00
cas_clear:
lda #$20
sta $0400,x
sta $0500,x
sta $0600,x
sta $0700,x
inx
bne cas_clear
ldx #$00
cas_draw:
jsr draw_star
inx
cpx #12
bne cas_draw
rts
; ------------------------------------------------
; Subroutine: enter_title — show the title, hide the game, state = 0
; ------------------------------------------------
enter_title:
jsr clear_and_stars
lda #$00
sta $d015 ; all sprites off: the title has no ship or wave
sta $d020 ; border black
jsr show_title
lda #25
sta ui_lock ; half a second before fire is believed
; Voice 1 becomes the band: a softer envelope than the laser's
lda #$18
sta $d405 ; attack 1, decay 8
lda #$a0
sta $d406 ; sustain 10, release 0
lda #$00
sta jingle_idx ; start the tune from the top,
lda #$01
sta jingle_timer ; first note due on the next tick
lda #$00
sta state ; 0 = title
rts
; ------------------------------------------------
; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
; The band stops: gate voice 1 off and hand it back to the laser
lda #$10
sta $d404 ; triangle, gate OFF
lda #$06
sta $d405 ; the laser's envelope again
lda #$00
sta $d406
jsr clear_and_stars
; The sprite data pointers live in screen RAM ($07f8+), so the clear just
; wiped them — set them here, after the clear, or the sprites show garbage.
lda #128
sta $07f8 ; ship
lda #129
sta $07f9 ; bullet
lda #130
sta $07fa ; enemy 0
sta $07fb ; enemy 1
sta $07fc ; enemy 2
; Ship at its start, white (it may have gone red on game over)
lda #172
sta $d000
lda #220
sta $d001
lda #$01
sta $d027
lda #$00
sta $d010
; Enable ship + three enemies (the bullet stays off)
lda #%00011101
sta $d015
; Seed the column LFSR from the raster the instant play begins, forced
; non-zero (the LFSR locks at 0) — so each game's spawn run differs while
; the LFSR keeps the columns spread within a run.
lda $d012
ora #$01
sta rng_seed
; Spawn the wave at staggered heights
lda #$32
ldx #$00
jsr spawn_enemy
lda #$82
ldx #$01
jsr spawn_enemy
lda #$d2
ldx #$02
jsr spawn_enemy
; Reset per-game state
lda #$00
sta bullet_active
sta death_timer
sta $d020 ; border black
sta score
; Score "00", white
lda #$30
sta $0400
sta $0401
lda #$01
sta $d800
sta $d801
; Lives "3", white
lda #$03
sta lives
lda #$33
sta $0427
lda #$01
sta $d827
; Wave 1: counter, kills, the live speed, and the readout
lda #$01
sta wave
lda #$00
sta kills
lda wave_speed_tbl ; first table entry
sta fall_speed
jsr draw_wave
; state = playing
lda #$01
sta state
rts
; ------------------------------------------------
; Subroutine: advance_wave — ten kills: quicken the lanes, say so
; Three different caps live here, and they are not the same thing:
; the COUNTER never stops, the table INDEX clamps at the last row,
; and the DIGIT shows 9 forever after. The player's number tells the
; truth; the physics admits it ran out of new ideas.
; ------------------------------------------------
advance_wave:
lda #$00
sta kills
inc wave
; table index = wave - 1, clamped to the last entry
ldy wave
dey
cpy #WAVE_TOP
bcc wave_speed_ok
ldy #WAVE_TOP
wave_speed_ok:
lda wave_speed_tbl,y
sta fall_speed
jsr draw_wave
; The chirp — voice 3, a bright triangle ding over the explosion
lda #$00
sta $d40e ; voice 3 frequency low
lda #$40
sta $d40f ; voice 3 frequency high (a high ding)
lda #$09
sta $d413 ; attack 0, decay 9
lda #$00
sta $d414 ; sustain 0, release 0
lda #$10
sta $d412 ; triangle, gate OFF (reset the envelope)
lda #$11
sta $d412 ; triangle, gate ON
rts
; ------------------------------------------------
; Subroutine: draw_wave — "W" and the wave digit, top centre
; The digit caps at 9; the wave itself keeps counting.
; ------------------------------------------------
draw_wave:
lda #$17 ; W
sta $0413
lda wave
cmp #$0a
bcc wave_digit_ok
lda #$09 ; show 9 from here on
wave_digit_ok:
clc
adc #$30 ; to screen code
sta $0414
lda #$01 ; white
sta $d813
sta $d814
rts
; Pixels per frame, one entry per wave; the last entry is the wall
WAVE_TOP = 4 ; last index of the table below
wave_speed_tbl:
!byte 1, 2, 2, 3, 3
; ------------------------------------------------
; Subroutine: jingle_tick — one frame of title music
; A melody is a table: a pitch (two bytes) and a duration in frames.
; The SID holds each note by itself — this routine only does anything
; when the current note's time is up. Pitch high byte 0 is a rest
; (silence is a note too); $ff loops back to the top.
; ------------------------------------------------
jingle_tick:
dec jingle_timer
beq jingle_next
rts
jingle_next:
ldx jingle_idx
lda jingle_hi,x
cmp #$ff ; the loop marker
bne jingle_play
lda #$00 ; back to the top
sta jingle_idx
ldx #$00
lda jingle_hi,x
jingle_play:
bne jingle_note
; a rest: gate off, keep counting
lda #$10
sta $d404
jmp jingle_clock
jingle_note:
sta $d401 ; pitch, high byte
lda jingle_lo,x
sta $d400 ; pitch, low byte
lda #$10
sta $d404 ; gate off first — retrigger the envelope
lda #$11
sta $d404 ; triangle, gate on
jingle_clock:
lda jingle_dur,x
sta jingle_timer
inc jingle_idx
rts
; Twinkle Twinkle, Little Star — fourteen notes, a breath, and round
; again. A starfield needs no other tune. (PAL SID pitch values.)
jingle_lo:
!byte $67,$67,$13,$13,$45,$45,$13
!byte $3b,$3b,$ed,$ed,$88,$88,$67
!byte $00,$00
jingle_hi:
!byte $11,$11,$1a,$1a,$1d,$1d,$1a
!byte $17,$17,$15,$15,$13,$13,$11
!byte $00,$ff
jingle_dur:
!byte 20,20,20,20,20,20,40
!byte 20,20,20,20,20,20,40
!byte 35,1
; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
; ------------------------------------------------
show_title:
lda #$13 ; S
sta $05a0
lda #$14 ; T
sta $05a1
lda #$01 ; A
sta $05a2
lda #$12 ; R
sta $05a3
lda #$06 ; F
sta $05a4
lda #$09 ; I
sta $05a5
lda #$05 ; E
sta $05a6
lda #$0c ; L
sta $05a7
lda #$04 ; D
sta $05a8
lda #$01 ; white
ldx #$00
- sta $d9a0,x
inx
cpx #$09
bne -
lda #$10 ; P
sta $063f
lda #$12 ; R
sta $0640
lda #$05 ; E
sta $0641
lda #$13 ; S
sta $0642
lda #$13 ; S
sta $0643
lda #$20 ; (space)
sta $0644
lda #$06 ; F
sta $0645
lda #$09 ; I
sta $0646
lda #$12 ; R
sta $0647
lda #$05 ; E
sta $0648
lda #$0f ; light grey
ldx #$00
- sta $da3f,x
inx
cpx #$0a
bne -
rts
; ------------------------------------------------
; Subroutine: erase_star (X = star index)
; blanks the star's current cell back to a space
; ------------------------------------------------
erase_star:
ldy star_row,x
lda row_addr_lo,y ; point $fb/$fc at the start of this star's row
sta $fb
lda row_addr_hi,y
sta $fc
ldy star_col,x ; Y = the column offset along that row
lda #$20 ; a space
sta ($fb),y ; "finger on the boxes" — pointer + Y offset
rts
; ------------------------------------------------
; Subroutine: draw_star (X = star index)
; writes the star's character + colour at its (row, col)
; ------------------------------------------------
draw_star:
ldy star_row,x
lda row_addr_lo,y
sta $fb ; row start, low byte
lda row_addr_hi,y
sta $fc ; row start, high byte
ldy star_col,x ; Y = column
lda star_char_tbl,x
sta ($fb),y ; STA ($fb),Y -> the screen-RAM cell
; screen RAM $04xx-$07xx maps to colour RAM $d8xx-$dbxx: high byte + $d4
lda $fc
clc
adc #$d4
sta $fc
lda star_colour_tbl,x
sta ($fb),y ; same column offset, now into colour RAM
rts
; ------------------------------------------------
; Star data tables
; ------------------------------------------------
; Screen-RAM start address of each row (row x 40 + $0400), rows 0-24
row_addr_lo:
!byte $00,$28,$50,$78,$a0,$c8,$f0,$18
!byte $40,$68,$90,$b8,$e0,$08,$30,$58
!byte $80,$a8,$d0,$f8,$20,$48,$70,$98,$c0
row_addr_hi:
!byte $04,$04,$04,$04,$04,$04,$04,$05
!byte $05,$05,$05,$05,$05,$06,$06,$06
!byte $06,$06,$06,$06,$07,$07,$07,$07,$07
; 12 stars. Columns avoid 0, 1 and 39 — the score and lives cells.
star_init_row:
!byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
star_init_col:
!byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
; Appearance reinforces the depth: near = bright white '*', far = dim grey '.'
star_char_tbl:
!byte $2a,$2a,$2a,$2a, $2a,$2a,$2a,$2a, $2e,$2e,$2e,$2e
star_colour_tbl:
!byte $01,$01,$01,$01, $0f,$0f,$0f,$0f, $0b,$0b,$0b,$0b
; ------------------------------------------------
; Sprite data at $2000 (block 128) — ship
; ------------------------------------------------
*= $2000
!byte $00,$18,$00 ; ##
!byte $00,$3c,$00 ; ####
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$7e,$00 ; ######
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $03,$ff,$c0 ; ############
!byte $07,$ff,$e0 ; ##############
!byte $07,$ff,$e0 ; ##############
!byte $07,$e7,$e0 ; ###..####..###
!byte $03,$c3,$c0 ; ##....##....##
!byte $01,$ff,$80 ; ##########
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $00,$db,$00 ; ##.##.##
!byte $00,$db,$00 ; ##.##.##
!byte $00,$66,$00 ; ##..##
!byte $00,$24,$00 ; #..#
!byte $00,$00,$00 ;
; ------------------------------------------------
; Sprite data at $2040 (block 129) — bullet
; ------------------------------------------------
*= $2040
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
; ------------------------------------------------
; Sprite data at $2080 (block 130) — enemy
; ------------------------------------------------
*= $2080
!byte $00,$66,$00 ; ##..##
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$db,$00 ; ##.##.##
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $01,$7e,$80 ; #.######.#
!byte $01,$3c,$80 ; #..####..#
!byte $00,$a5,$00 ; #.#..#.#
!byte $01,$81,$80 ; ##......##
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
None of this was invented for a lesson — the turret park shipped, and a scripted “park and hold fire” run found it exactly the way the winnability gate is meant to: by playing to break the game and watching it break. step-03 is Starfield with the hole closed — the LFSR earns its place, and the game finally can’t be won by refusing to play it.
Milestone 4 — a curve that keeps bending
Closing the turret park made Starfield a game you can lose. It didn’t make it a game worth a long sitting. Three enemies, a field of black, a difficulty that stops climbing at wave 5 — the design-stress pass named that too: minute ten is identical to minute two. The lanes quicken, then run out of ideas and stop. So this last step gives the game a curve that keeps bending.
The swarm grows
The speed table ran out of room; the sprite hardware did not. The ship is sprite 0, the bullet sprite 1, the enemies 2, 3 and 4 — which leaves sprites 5, 6 and 7 idle. The escalation the game was missing sat in the VIC-II the whole time: more enemies.
Turning three enemies into a variable count is the parallel-array pattern you’ve used since Unit 1, one size larger. The four enemy arrays — X, Y, flash, the 9th bit — widen from three bytes to six, and every loop that walked #$03 now walks enemy_count:
inx
cpx enemy_count ; every active enemy this wave (3..6)
bne enemy_loop
advance_wave grows that count — one more enemy every other wave, up to six — switching on the next sprite and dropping it in from the top:
lda enemy_count
cmp #$06
bcs no_grow ; already six
tax ; X = the new enemy's index
inc enemy_count
lda enemy_d010_bit,x ; its sprite's enable bit
ora $d015 ; switch the sprite on
sta $d015
lda #$32
jsr spawn_enemy ; drop it in, LFSR column


The hunt tightens
The count caps at six around wave six, and the speed table topped out at five. Past that the game would go flat again — so the homing becomes the late escalator. Early on, enemies drift toward your column every other frame (a gentle half-pixel); from wave seven, three frames in four — firmer, a pull you feel, without the lunge that collapses the swarm onto you at once:
lda wave
cmp #$07
bcc drift_slow ; before wave 7: every other frame (~0.5 px)
lda frame_count
and #$03
bne drift_now ; wave 7+: three frames in four (~0.75 px)
A score that can hold a long game
A game you can play for nine waves needs a scoreboard that counts that high. The old score was one BCD byte — two digits, wrapping silently at a hundred. Now it is two bytes, four digits, the carry rippling from the low pair to the high:
sed
lda score
clc
adc #$01
sta score
lda score_hi
adc #$00 ; the carry ripples up into the top two digits
sta score_hi
cld
Four digits is room for 9,999 kills — more than any sitting will spend, which is the point: the number never lies to you again.
| 1 | 1 | ; Starfield - Unit 17: The Curve | |
| 2 | - | ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves: a speed table, a live fall speed, a HUD wave digit, a wave-up chirp) -> step-02 (+ a SID title jingle, and a dwell on both ends of the loop) -> step-03 (+ a real spawn RNG, full-field enemies, and homing: close the turret-park dominant strategy and the right-side no-death zone the design-stress pass found) | |
| 3 | - | ; This step: replace the raster-seeded enemy column with an 8-bit LFSR, spread enemies across the whole 9-bit field the ship can reach, make both collision tests 9-bit-aware, and have enemies home toward the ship's column so no parked position is safe — the fail state is reachable again. | |
| 2 | + | ; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves) -> step-02 (+ a SID title jingle and a dwell) -> step-03 (+ LFSR spawn, full-field 9-bit enemies, and homing: close the turret park + no-death zone) -> step-04 (+ a real curve: the swarm grows 3->6 over waves, the hunt tightens late-game, and the score widens to four digits so a long game is trackable) | |
| 3 | + | ; This step: make the enemy count a difficulty axis (parallel arrays widened from 3 to 6, spawned from the free sprites 5-7 as waves climb), tighten the hunt from wave 7 (homing three frames in four instead of one in two), and carry a 2-byte BCD score — the curve keeps bending past the point the speed table gave up. | |
| 4 | 4 | ; Assemble: acme -f cbm -o <step>.prg <step>.asm | |
| 5 | 5 | | |
| 6 | 6 | ; ------------------------------------------------ | |
| ... | |||
| 10 | 10 | bullet_y = $03 ; Bullet Y position | |
| 11 | 11 | laser_timer = $04 ; Frames of laser pitch-sweep remaining (0 = idle) | |
| 12 | 12 | laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only) | |
| 13 | - | score = $06 ; Two-digit score, BCD (one decimal digit per nybble) | |
| 14 | - | ; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4) | |
| 15 | - | enemy_x_tbl = $07 ; 3 bytes ($07,$08,$09): each enemy's X | |
| 16 | - | enemy_y_tbl = $0a ; 3 bytes ($0a,$0b,$0c): each enemy's Y | |
| 17 | - | flash_tbl = $0d ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer | |
| 13 | + | score = $06 ; score low byte, BCD (tens:ones) | |
| 14 | + | score_hi = $08 ; score high byte, BCD (thousands:hundreds) — freed with the arrays | |
| 15 | + | ; Enemies are 6-wide parallel arrays now, moved to absolute memory (the data block | |
| 16 | + | ; down by the sprite tables) so up to six fit; index 0..5 picks the enemy. | |
| 17 | + | enemy_count = $07 ; how many enemies are active this wave (3, rising to 6) | |
| 18 | 18 | state = $10 ; 0 = title, 1 = playing, 2 = game over | |
| 19 | 19 | lives = $11 ; lives remaining (starts at 3) | |
| 20 | 20 | death_timer = $12 ; frames of post-hit flash (and, in step 2, invulnerability) | |
| ... | |||
| 29 | 29 | ui_lock = $31 ; frames before fire counts on the title / game-over screens | |
| 30 | 30 | ; --- step 3: a real spawn RNG, and enemies across the whole field --- | |
| 31 | 31 | rng_seed = $32 ; 8-bit LFSR state — the enemy-column pseudo-random source | |
| 32 | - | enemy_xhi_tbl = $33 ; 3 bytes ($33-$35): each enemy's 9th X bit (0 or 1) | |
| 33 | 32 | col_tmp = $36 ; scratch: the spawn column's low byte mid-calculation | |
| 34 | 33 | dx_lo = $37 ; scratch: 9-bit collision delta, low byte | |
| 35 | 34 | dx_hi = $38 ; scratch: 9-bit collision delta, high byte | |
| ... | |||
| 340 | 339 | clc | |
| 341 | 340 | adc fall_speed ; the wave's tuning, read fresh every frame | |
| 342 | 341 | sta enemy_y_tbl,x | |
| 342 | + | lda wave | |
| 343 | + | cmp #$07 | |
| 344 | + | bcc drift_slow ; before wave 7: home every other frame (~0.5 px) | |
| 345 | + | ; wave 7+: three frames in four (~0.75 px) — a firmer pull, not a lunge | |
| 346 | + | lda frame_count | |
| 347 | + | and #$03 | |
| 348 | + | bne drift_now ; drift on 3 of every 4 frames | |
| 349 | + | beq drift_skip ; rest on the 4th | |
| 350 | + | drift_slow: | |
| 343 | 351 | lda frame_count | |
| 344 | 352 | lsr ; bit 0 -> carry | |
| 345 | - | bcc + ; drift only on odd frames (~0.5 px/frame sideways) | |
| 353 | + | bcc drift_skip ; earlier waves -> home every other frame | |
| 354 | + | drift_now: | |
| 346 | 355 | jsr drift_enemy_x | |
| 347 | - | + lda enemy_y_tbl,x | |
| 356 | + | drift_skip: | |
| 357 | + | lda enemy_y_tbl,x | |
| 348 | 358 | cmp #$f8 ; off the bottom? (Y >= 248) | |
| 349 | 359 | bcc update_sprite ; still on screen | |
| 350 | 360 | lda #$32 ; respawn this enemy at the top, new column | |
| ... | |||
| 368 | 378 | jsr set_enemy_hibit ; reflect this enemy's 9th X bit into $d010 | |
| 369 | 379 | next_enemy: | |
| 370 | 380 | inx | |
| 371 | - | cpx #$03 ; the full wave of three | |
| 381 | + | cpx enemy_count ; every active enemy this wave (3..6) | |
| 372 | 382 | bne enemy_loop | |
| 373 | 383 | | |
| 374 | 384 | ; --- Bullet vs the wave: test each enemy until one is hit --- | |
| ... | |||
| 419 | 429 | | |
| 420 | 430 | next_collision: | |
| 421 | 431 | inx | |
| 422 | - | cpx #$03 | |
| 432 | + | cpx enemy_count | |
| 423 | 433 | bne collision_loop | |
| 424 | 434 | jmp no_hit | |
| 425 | 435 | | |
| ... | |||
| 460 | 470 | clc | |
| 461 | 471 | adc #$01 | |
| 462 | 472 | sta score | |
| 473 | + | lda score_hi | |
| 474 | + | adc #$00 ; the carry out of the low two digits ripples up | |
| 475 | + | sta score_hi | |
| 463 | 476 | cld ; decimal mode off (every later ADC/SBC needs it off) | |
| 464 | - | | |
| 465 | - | ; Refresh the two digits: high nybble -> tens, low nybble -> ones | |
| 466 | - | lda score | |
| 467 | - | lsr | |
| 468 | - | lsr | |
| 469 | - | lsr | |
| 470 | - | lsr ; high nybble down to 0-9 | |
| 471 | - | clc | |
| 472 | - | adc #$30 ; to screen code | |
| 473 | - | sta $0400 ; tens digit | |
| 474 | - | lda score | |
| 475 | - | and #$0f ; low nybble, 0-9 | |
| 476 | - | clc | |
| 477 | - | adc #$30 | |
| 478 | - | sta $0401 ; ones digit | |
| 477 | + | jsr draw_score ; refresh all four digits | |
| 479 | 478 | | |
| 480 | 479 | ; Ten hits clear the wave: the lanes quicken | |
| 481 | 480 | inc kills | |
| ... | |||
| 533 | 532 | | |
| 534 | 533 | next_ship_check: | |
| 535 | 534 | inx | |
| 536 | - | cpx #$03 | |
| 535 | + | cpx enemy_count | |
| 537 | 536 | bne ship_collision_loop | |
| 538 | 537 | jmp no_ship_hit | |
| 539 | 538 | | |
| ... | |||
| 677 | 676 | sta $d010 | |
| 678 | 677 | rts | |
| 679 | 678 | enemy_d010_bit: | |
| 680 | - | !byte $04, $08, $10 ; sprite 2, 3, 4 high-X bits | |
| 679 | + | !byte $04, $08, $10, $20, $40, $80 ; sprites 2-7 high-X bits | |
| 681 | 680 | | |
| 682 | 681 | ; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X. | |
| 683 | 682 | ; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and | |
| ... | |||
| 716 | 715 | .drift_done: | |
| 717 | 716 | rts | |
| 718 | 717 | | |
| 719 | - | ; Per-enemy VIC-II register offsets (sprites 2, 3, 4) | |
| 718 | + | ; Per-enemy VIC-II register offsets (sprites 2-7) | |
| 720 | 719 | sprite_pos_off: | |
| 721 | - | !byte $04, $06, $08 ; X offsets: $d004, $d006, $d008 | |
| 720 | + | !byte $04, $06, $08, $0a, $0c, $0e ; X offsets: $d004..$d00e | |
| 722 | 721 | sprite_colour_off: | |
| 723 | - | !byte $29, $2a, $2b ; colour offsets: $d029, $d02a, $d02b | |
| 722 | + | !byte $29, $2a, $2b, $2c, $2d, $2e ; colour offsets: $d029..$d02e | |
| 723 | + | | |
| 724 | + | ; The enemy parallel arrays, six wide, in absolute RAM (moved out of zero page so | |
| 725 | + | ; six fit). Spawn fills them; the loops walk 0..enemy_count-1. | |
| 726 | + | enemy_x_tbl: !fill 6, 0 | |
| 727 | + | enemy_y_tbl: !fill 6, 0 | |
| 728 | + | flash_tbl: !fill 6, 0 | |
| 729 | + | enemy_xhi_tbl: !fill 6, 0 | |
| 730 | + | | |
| 731 | + | ; staggered spawn heights for up to six enemies | |
| 732 | + | spawn_y_tbl: !byte $32, $82, $d2, $5a, $aa, $28 | |
| 724 | 733 | | |
| 725 | 734 | ; ------------------------------------------------ | |
| 726 | 735 | ; Subroutine: print "GAME OVER" at row 12, column 16 | |
| ... | |||
| 821 | 830 | sta $07fa ; enemy 0 | |
| 822 | 831 | sta $07fb ; enemy 1 | |
| 823 | 832 | sta $07fc ; enemy 2 | |
| 833 | + | sta $07fd ; enemy 3 | |
| 834 | + | sta $07fe ; enemy 4 | |
| 835 | + | sta $07ff ; enemy 5 | |
| 824 | 836 | ; Ship at its start, white (it may have gone red on game over) | |
| 825 | 837 | lda #172 | |
| 826 | 838 | sta $d000 | |
| ... | |||
| 830 | 842 | sta $d027 | |
| 831 | 843 | lda #$00 | |
| 832 | 844 | sta $d010 | |
| 845 | + | ; Start with three enemies; the wave grows this toward six. | |
| 846 | + | lda #$03 | |
| 847 | + | sta enemy_count | |
| 833 | 848 | ; Enable ship + three enemies (the bullet stays off) | |
| 834 | 849 | lda #%00011101 | |
| 835 | 850 | sta $d015 | |
| ... | |||
| 839 | 854 | lda $d012 | |
| 840 | 855 | ora #$01 | |
| 841 | 856 | sta rng_seed | |
| 842 | - | ; Spawn the wave at staggered heights | |
| 843 | - | lda #$32 | |
| 857 | + | ; Spawn the starting wave at staggered heights | |
| 844 | 858 | ldx #$00 | |
| 845 | - | jsr spawn_enemy | |
| 846 | - | lda #$82 | |
| 847 | - | ldx #$01 | |
| 848 | - | jsr spawn_enemy | |
| 849 | - | lda #$d2 | |
| 850 | - | ldx #$02 | |
| 859 | + | .spawn_wave: | |
| 860 | + | lda spawn_y_tbl,x | |
| 851 | 861 | jsr spawn_enemy | |
| 862 | + | inx | |
| 863 | + | cpx enemy_count | |
| 864 | + | bne .spawn_wave | |
| 852 | 865 | ; Reset per-game state | |
| 853 | 866 | lda #$00 | |
| 854 | 867 | sta bullet_active | |
| 855 | 868 | sta death_timer | |
| 856 | 869 | sta $d020 ; border black | |
| 857 | 870 | sta score | |
| 858 | - | ; Score "00", white | |
| 871 | + | sta score_hi | |
| 872 | + | ; Score "0000", white | |
| 859 | 873 | lda #$30 | |
| 860 | 874 | sta $0400 | |
| 861 | 875 | sta $0401 | |
| 876 | + | sta $0402 | |
| 877 | + | sta $0403 | |
| 862 | 878 | lda #$01 | |
| 863 | 879 | sta $d800 | |
| 864 | 880 | sta $d801 | |
| 881 | + | sta $d802 | |
| 882 | + | sta $d803 | |
| 865 | 883 | ; Lives "3", white | |
| 866 | 884 | lda #$03 | |
| 867 | 885 | sta lives | |
| ... | |||
| 893 | 911 | lda #$00 | |
| 894 | 912 | sta kills | |
| 895 | 913 | inc wave | |
| 914 | + | ; Grow the swarm: every other wave, add one enemy until six are on screen. | |
| 915 | + | ; This is the escalation axis the speed table ran out of — the field keeps | |
| 916 | + | ; filling after the lanes stop quickening. | |
| 917 | + | lda wave | |
| 918 | + | and #$01 | |
| 919 | + | bne no_grow ; odd wave -> no new enemy this time | |
| 920 | + | lda enemy_count | |
| 921 | + | cmp #$06 | |
| 922 | + | bcs no_grow ; already six | |
| 923 | + | tax ; X = the new enemy's index (= the current count) | |
| 924 | + | inc enemy_count | |
| 925 | + | lda enemy_d010_bit,x ; sprite (2+X)'s enable bit is the same byte | |
| 926 | + | ora $d015 | |
| 927 | + | sta $d015 ; switch the new sprite on | |
| 928 | + | lda #$32 | |
| 929 | + | jsr spawn_enemy ; drop it in at the top, LFSR column | |
| 930 | + | no_grow: | |
| 896 | 931 | ; table index = wave - 1, clamped to the last entry | |
| 897 | 932 | ldy wave | |
| 898 | 933 | dey | |
| ... | |||
| 916 | 951 | sta $d412 ; triangle, gate OFF (reset the envelope) | |
| 917 | 952 | lda #$11 | |
| 918 | 953 | sta $d412 ; triangle, gate ON | |
| 954 | + | rts | |
| 955 | + | | |
| 956 | + | ; ------------------------------------------------ | |
| 957 | + | ; Subroutine: draw_score — four BCD digits at the top-left ($0400-$0403) | |
| 958 | + | ; thousands:hundreds live in score_hi, tens:ones in score | |
| 959 | + | ; ------------------------------------------------ | |
| 960 | + | draw_score: | |
| 961 | + | lda score_hi | |
| 962 | + | lsr | |
| 963 | + | lsr | |
| 964 | + | lsr | |
| 965 | + | lsr | |
| 966 | + | clc | |
| 967 | + | adc #$30 | |
| 968 | + | sta $0400 ; thousands | |
| 969 | + | lda score_hi | |
| 970 | + | and #$0f | |
| 971 | + | clc | |
| 972 | + | adc #$30 | |
| 973 | + | sta $0401 ; hundreds | |
| 974 | + | lda score | |
| 975 | + | lsr | |
| 976 | + | lsr | |
| 977 | + | lsr | |
| 978 | + | lsr | |
| 979 | + | clc | |
| 980 | + | adc #$30 | |
| 981 | + | sta $0402 ; tens | |
| 982 | + | lda score | |
| 983 | + | and #$0f | |
| 984 | + | clc | |
| 985 | + | adc #$30 | |
| 986 | + | sta $0403 ; ones | |
| 919 | 987 | rts | |
| 920 | 988 | | |
| 921 | 989 | ; ------------------------------------------------ |
The complete program
; Starfield - Unit 17: The Curve
; Cumulative steps: step-00 (the finished Unit 16 game) -> step-01 (+ waves) -> step-02 (+ a SID title jingle and a dwell) -> step-03 (+ LFSR spawn, full-field 9-bit enemies, and homing: close the turret park + no-death zone) -> step-04 (+ a real curve: the swarm grows 3->6 over waves, the hunt tightens late-game, and the score widens to four digits so a long game is trackable)
; This step: make the enemy count a difficulty axis (parallel arrays widened from 3 to 6, spawned from the free sprites 5-7 as waves climb), tighten the hunt from wave 7 (homing three frames in four instead of one in two), and carry a 2-byte BCD score — the curve keeps bending past the point the speed table gave up.
; Assemble: acme -f cbm -o <step>.prg <step>.asm
; ------------------------------------------------
; Zero-page variables
; ------------------------------------------------
bullet_active = $02 ; 0 = no bullet, 1 = active
bullet_y = $03 ; Bullet Y position
laser_timer = $04 ; Frames of laser pitch-sweep remaining (0 = idle)
laser_freq = $05 ; Our copy of the sweep pitch (SID freq regs are write-only)
score = $06 ; score low byte, BCD (tens:ones)
score_hi = $08 ; score high byte, BCD (thousands:hundreds) — freed with the arrays
; Enemies are 6-wide parallel arrays now, moved to absolute memory (the data block
; down by the sprite tables) so up to six fit; index 0..5 picks the enemy.
enemy_count = $07 ; how many enemies are active this wave (3, rising to 6)
state = $10 ; 0 = title, 1 = playing, 2 = game over
lives = $11 ; lives remaining (starts at 3)
death_timer = $12 ; frames of post-hit flash (and, in step 2, invulnerability)
frame_count = $13 ; free-running frame counter (parallax timing)
star_row = $14 ; 12 stars: row of each ($14-$1f)
star_col = $20 ; 12 stars: column of each ($20-$2b)
wave = $2c ; wave counter (1, 2, 3, ... — never stops climbing)
kills = $2d ; hits this wave; 10 advances the wave
fall_speed = $2e ; pixels per frame the enemies fall — poked by advance_wave
jingle_idx = $2f ; which jingle table entry is sounding
jingle_timer = $30 ; frames left on the current note
ui_lock = $31 ; frames before fire counts on the title / game-over screens
; --- step 3: a real spawn RNG, and enemies across the whole field ---
rng_seed = $32 ; 8-bit LFSR state — the enemy-column pseudo-random source
col_tmp = $36 ; scratch: the spawn column's low byte mid-calculation
dx_lo = $37 ; scratch: 9-bit collision delta, low byte
dx_hi = $38 ; scratch: 9-bit collision delta, high byte
; $fb/$fc: scratch pointer used by the star routines
; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00
; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
start:
; --- One-time hardware setup (runs once, not per game) ---
lda #$00
sta $d020 ; border black
sta $d021 ; background black
sta $d010 ; ship 9th X bit clear
; Fixed sprite colours
lda #$01
sta $d027 ; ship white
lda #$07
sta $d028 ; bullet yellow
; SID voice 1 — the laser
lda #$0f
sta $d418 ; volume to maximum
lda #$00
sta $d400
lda #$10
sta $d401
lda #$06
sta $d405
lda #$00
sta $d406
; Star positions (drawn by enter_title / enter_game)
sta frame_count ; A is still 0
ldx #$00
init_star_loop:
lda star_init_row,x
sta star_row,x
lda star_init_col,x
sta star_col,x
inx
cpx #12
bne init_star_loop
; Open on the title screen
jsr enter_title
; ------------------------------------------------
; Game loop — runs once per frame
; ------------------------------------------------
game_loop:
; Wait for the raster beam to reach line 255
; This syncs our code to the display (~50Hz PAL)
- lda $d012
cmp #$ff
bne -
; --- Parallax starfield: scrolls in every state (title, play, over) ---
inc frame_count
ldx #$00
star_loop:
jsr erase_star
; Does THIS star move this frame? Near (0-3) every frame, mid (4-7)
; every 2nd frame, far (8-11) every 4th frame.
cpx #$04
bcc star_do_move ; near layer: always
cpx #$08
bcc star_mid ; mid layer
; far layer: only when the low two frame bits are clear (1 in 4)
lda frame_count
and #%00000011
bne star_move_done
beq star_do_move
star_mid:
lda frame_count
and #%00000001 ; every other frame
bne star_move_done
star_do_move:
inc star_row,x ; one row down
lda star_row,x
cmp #25
bcc star_move_done
lda #$00 ; past the bottom -> wrap to the top
sta star_row,x
star_move_done:
jsr draw_star
inx
cpx #12
bne star_loop
; --- State machine: title (0) / playing (1) / game over (2) ---
lda state
beq title_state
cmp #$02
beq over_state
jmp game_active ; 1 = playing
title_state:
jsr show_title ; repaint, in case a star scrolled across it
jsr jingle_tick ; the band plays; the SID holds each note itself
lda ui_lock ; a beat before fire counts —
beq title_ready ; the press that ended the last screen
dec ui_lock ; must not start this one
jmp game_loop
title_ready:
lda $dc00
and #%00010000 ; fire button (bit 4)
bne loop_again ; not pressed — wait on the title
jsr enter_game ; fire -> start a game
loop_again:
jmp game_loop
over_state:
jsr show_game_over ; repaint over any star damage
lda ui_lock ; the dwell: let the ending land
beq over_ready
dec ui_lock
jmp game_loop
over_ready:
lda $dc00
and #%00010000
bne loop_again
jsr enter_title ; fire -> back to the title screen
jmp game_loop
game_active:
; --- Read joystick and move ship ---
; UP (bit 0) — clamp to Y >= 50
lda $dc00 ; Read joystick port 2
and #%00000001 ; Isolate bit 0
bne not_up ; Bit is 1 = NOT pressed (active low)
lda $d001
cmp #52 ; 50 + room for a 2-pixel move
bcc not_up ; already at the top — don't move
dec $d001 ; Move ship up (decrease Y)
dec $d001 ; 2 pixels per frame
not_up:
; DOWN (bit 1) — clamp to Y <= 234
lda $dc00
and #%00000010
bne not_down
lda $d001
cmp #233 ; 234 - room for a 2-pixel move
bcs not_down ; already at the bottom — don't move
inc $d001 ; Move ship down (increase Y)
inc $d001
not_down:
; LEFT (bit 2) — 9-bit X, clamp to X >= 24
lda $dc00
and #%00000100
bne not_left
lda $d010
and #$01
bne left_ok ; high bit set: X >= 256, always safe to go left
lda $d000
cmp #26 ; 24 + room for a 2-pixel move
bcc not_left ; already at the left edge — don't move
left_ok:
; before each step, flip the 9th bit when X is about to wrap $00 -> $ff
lda $d000
bne +
lda $d010
eor #$01 ; the eor bit-flip from the Primer, on sprite 0's high X bit
sta $d010
+ dec $d000
lda $d000
bne +
lda $d010
eor #$01
sta $d010
+ dec $d000
not_left:
; RIGHT (bit 3) — 9-bit X, clamp to X <= 320
lda $dc00
and #%00001000
bne not_right
lda $d010
and #$01
beq right_ok ; high bit clear: X < 256, always safe to go right
lda $d000
cmp #63 ; (320 - 256) - room for a 2-pixel move
bcs not_right ; already at the right edge — don't move
right_ok:
; after each step, flip the 9th bit when X wraps $ff -> $00
inc $d000
bne +
lda $d010
eor #$01
sta $d010
+ inc $d000
bne +
lda $d010
eor #$01
sta $d010
+
not_right:
; --- Fire button (bit 4) ---
lda $dc00
and #%00010000
bne no_fire ; Bit is 1 = NOT pressed
; Only spawn if no bullet is already flying
lda bullet_active
bne no_fire
; Spawn the bullet at the ship's position
lda $d000 ; Ship X (low byte) -> bullet X
sta $d002
lda $d001 ; Ship Y -> bullet Y
sta bullet_y
; Copy the ship's 9th X bit (bit 0) to the bullet's (bit 1),
; so a shot fired from the right half spawns under the ship
lda $d010
and #%11111101 ; clear the bullet's 9th bit first
sta $d010
lda $d010
and #$01 ; the ship's 9th bit
asl ; shift it into the bullet's position (bit 1)
ora $d010
sta $d010
; Enable sprite 1 (keep sprite 0 enabled)
lda $d015
ora #%00000010
sta $d015
lda #$01
sta bullet_active
; Trigger laser sound: start the pitch high, gate off then on
lda #$40
sta laser_freq ; start high
sta $d401 ; SID frequency high byte
lda #$20
sta $d404 ; Sawtooth, gate OFF (reset envelope)
lda #$21
sta $d404 ; Sawtooth, gate ON (start sound)
lda #$0a
sta laser_timer ; sweep down over 10 frames
no_fire:
; --- Laser pitch sweep: the 'pew' ---
; Drop the pitch a little each frame while the sweep is running.
; We keep our own copy because SID frequency registers are write-only.
lda laser_timer
beq no_sweep
lda laser_freq
sec
sbc #$06
sta laser_freq
sta $d401 ; write the new pitch to the SID
dec laser_timer
no_sweep:
; --- Update the bullet ---
lda bullet_active
beq no_bullet
; Move it up 4 pixels a frame
lda bullet_y
sec
sbc #$04
sta bullet_y
sta $d003 ; sprite 1 Y
; Gone off the top? (Y < 30) -> remove it
cmp #$1e
bcs no_bullet
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; disable sprite 1, keep sprite 0
sta $d015
lda $d010
and #%11111101 ; clear the bullet's 9th bit
sta $d010
no_bullet:
; --- Update every enemy: one indexed loop does all of them ---
ldx #$00
enemy_loop:
lda flash_tbl,x
bne do_flash ; this enemy is mid-flash
; not flashing: fall at the wave's pace, then home toward the ship's
; column every other frame — a moving ship still shakes them, a still one
; gets run down. This is what makes the fail state reachable: no parked
; position is safe once the enemies come to you.
lda enemy_y_tbl,x
clc
adc fall_speed ; the wave's tuning, read fresh every frame
sta enemy_y_tbl,x
lda wave
cmp #$07
bcc drift_slow ; before wave 7: home every other frame (~0.5 px)
; wave 7+: three frames in four (~0.75 px) — a firmer pull, not a lunge
lda frame_count
and #$03
bne drift_now ; drift on 3 of every 4 frames
beq drift_skip ; rest on the 4th
drift_slow:
lda frame_count
lsr ; bit 0 -> carry
bcc drift_skip ; earlier waves -> home every other frame
drift_now:
jsr drift_enemy_x
drift_skip:
lda enemy_y_tbl,x
cmp #$f8 ; off the bottom? (Y >= 248)
bcc update_sprite ; still on screen
lda #$32 ; respawn this enemy at the top, new column
jsr spawn_enemy
jmp next_enemy
do_flash:
dec flash_tbl,x
bne update_sprite ; still flashing -> stay frozen, white
lda #$32 ; flash done -> respawn (spawn_enemy restores green)
jsr spawn_enemy
jmp next_enemy
update_sprite:
; copy this enemy's position into its VIC-II sprite registers
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X ($d004, $d006, ...)
lda enemy_y_tbl,x
sta $d001,y ; sprite Y ($d005, $d007, ...)
jsr set_enemy_hibit ; reflect this enemy's 9th X bit into $d010
next_enemy:
inx
cpx enemy_count ; every active enemy this wave (3..6)
bne enemy_loop
; --- Bullet vs the wave: test each enemy until one is hit ---
lda bullet_active
bne check_collision
jmp no_hit
check_collision:
ldx #$00
collision_loop:
lda flash_tbl,x
bne next_collision ; skip an enemy that's already exploding
; Y distance (8-bit subtract wraps, so two ranges count as close)
lda bullet_y
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_x ; 0..15 apart: close
cmp #$f0
bcc next_collision ; 16..239 apart: too far
check_x:
; 9-bit |bulletX - enemyX| < 16. Enemies now range the whole field, so a
; right-side bullet can no longer be ruled out by its 9th bit — compare the
; full 9-bit X of both (low byte, then the high bit, keeping the borrow).
lda $d002
sec
sbc enemy_x_tbl,x ; low byte; carry holds the borrow
sta dx_lo
lda $d010
and #%00000010 ; bullet's 9th X bit (sprite 1)
beq bcol_hi0
lda #$01
bcol_hi0:
sbc enemy_xhi_tbl,x ; high byte, minus the borrow from the low
sta dx_hi
beq bcol_pos ; delta 0..255: near only if the low byte is < 16
cmp #$ff
bne next_collision ; delta < -255 or > 255: far
lda dx_lo ; delta -256..-1: near from above (-16..-1)
cmp #$f0
bcs hit_enemy
jmp next_collision
bcol_pos:
lda dx_lo
cmp #$10
bcc hit_enemy ; 0..15 apart: a hit
jmp next_collision
next_collision:
inx
cpx enemy_count
bne collision_loop
jmp no_hit
hit_enemy:
; X = the enemy that was hit. Remove the bullet.
lda #$00
sta bullet_active
lda $d015
and #%11111101 ; sprite 1 (bullet) off
sta $d015
; Flash THIS enemy white and start its 8-frame timer. The enemy loop
; freezes it white until the timer runs out, then respawns it.
lda #$08
sta flash_tbl,x
ldy sprite_colour_off,x
lda #$01
sta $d000,y ; this enemy's colour register = white
; Explosion sound — SID voice 2, noise waveform (voice 1 keeps the laser)
lda #$00
sta $d407 ; voice 2 frequency low
lda #$08
sta $d408 ; voice 2 frequency high (a low rumble)
lda #$09
sta $d40c ; attack 0, decay 9
lda #$00
sta $d40d ; sustain 0, release 0
lda #$80
sta $d40b ; noise, gate OFF (reset the envelope)
lda #$81
sta $d40b ; noise, gate ON (trigger the burst)
; Score one hit. In decimal mode ADC carries at 10, so the byte stays
; readable as two decimal digits (BCD) — no conversion needed.
sed ; decimal mode on
lda score
clc
adc #$01
sta score
lda score_hi
adc #$00 ; the carry out of the low two digits ripples up
sta score_hi
cld ; decimal mode off (every later ADC/SBC needs it off)
jsr draw_score ; refresh all four digits
; Ten hits clear the wave: the lanes quicken
inc kills
lda kills
cmp #10
bcc no_hit
jsr advance_wave
no_hit:
; --- Ship vs the wave: has any enemy reached the ship? ---
; ...but not while the life-lost flash runs — the ship is invulnerable
lda death_timer
beq do_ship_collision ; not flashing -> run the check
jmp no_ship_hit ; flashing -> skip it (jmp, the target is far)
do_ship_collision:
ldx #$00
ship_collision_loop:
lda flash_tbl,x
bne next_ship_check ; ignore an exploding enemy
; Y distance: ship Y ($d001) vs this enemy's Y
lda $d001
sec
sbc enemy_y_tbl,x
cmp #$10
bcc check_ship_x
cmp #$f0
bcc next_ship_check
check_ship_x:
; 9-bit |shipX - enemyX| < 16. The ship's whole travel is reachable now,
; so we compare full 9-bit X instead of skipping the right half.
lda $d000
sec
sbc enemy_x_tbl,x
sta dx_lo
lda $d010
and #%00000001 ; ship's 9th X bit (sprite 0)
beq scol_hi0
lda #$01
scol_hi0:
sbc enemy_xhi_tbl,x
sta dx_hi
beq scol_pos
cmp #$ff
bne next_ship_check
lda dx_lo
cmp #$f0
bcs ship_hit
jmp next_ship_check
scol_pos:
lda dx_lo
cmp #$10
bcc ship_hit
jmp next_ship_check
next_ship_check:
inx
cpx enemy_count
bne ship_collision_loop
jmp no_ship_hit
ship_hit:
; Lose a life and update the readout
dec lives
lda lives
clc
adc #$30
sta $0427 ; lives digit, top-right
lda lives
bne life_lost ; lives remain -> respawn and play on
; Out of lives -> game over state (the dispatch handles the freeze)
lda #$02
sta state
sta $d027 ; ship turns red ($02 = red, reused here)
lda #60
sta ui_lock ; the dwell — stillness before fire counts again
jsr show_game_over
jmp death_sound
life_lost:
; Respawn the ship at its start position
lda #172
sta $d000
lda #220
sta $d001
lda $d010
and #%11111110 ; clear the ship's 9th bit (back under X=256)
sta $d010
; Start the life-lost flash (step 2 makes it an invulnerability window too)
lda #90
sta death_timer
death_sound:
; Death sound — SID voice 3 (plays on every death)
lda #$00
sta $d40e ; voice 3 frequency low
lda #$10
sta $d40f ; voice 3 frequency high
lda #$0a
sta $d413 ; attack 0, decay 10 (a long, slow fade)
lda #$00
sta $d414 ; sustain 0, release 0
lda #$20
sta $d412 ; sawtooth, gate OFF (reset the envelope)
lda #$21
sta $d412 ; sawtooth, gate ON (trigger)
no_ship_hit:
; --- Life-lost flash: while the timer runs, blink the border ---
lda death_timer
beq flash_done
dec death_timer
lda death_timer
and #%00001000 ; bit 3 toggles every 8 frames
bne flash_bright
lda #$00 ; dark phase
sta $d020
jmp flash_tick
flash_bright:
lda #$02 ; bright phase (red border)
sta $d020
flash_tick:
lda death_timer
bne flash_done
lda #$00 ; just expired -> border back to black
sta $d020
flash_done:
jmp game_loop
; ------------------------------------------------
; Subroutine: spawn one enemy
; A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
sta enemy_y_tbl,x
; A real per-spawn column. Advance the LFSR and spread its byte across
; the whole field the ship can reach: b + b/8 lifts 0..255 to 0..286, and
; +24 lands it at X = 24..310 as a 9-bit value (low byte + a 9th bit), so
; the three lanes scatter and none sits outside the ship's travel.
jsr advance_rng
lda rng_seed
sta col_tmp
lsr
lsr
lsr ; b >> 3 (0..31)
clc
adc col_tmp ; b + b/8 (0..286); carry = the 9th bit
sta enemy_x_tbl,x
lda #$00
adc #$00 ; capture the carry as the high bit
sta enemy_xhi_tbl,x
lda enemy_x_tbl,x
clc
adc #24 ; shift the range up off the left edge
sta enemy_x_tbl,x
lda enemy_xhi_tbl,x
adc #$00 ; carry ripples into the 9th bit
sta enemy_xhi_tbl,x
lda #$00
sta flash_tbl,x ; not flashing
ldy sprite_colour_off,x
lda #$05
sta $d000,y ; this enemy's colour = green
ldy sprite_pos_off,x
lda enemy_x_tbl,x
sta $d000,y ; sprite X (low 8 bits)
lda enemy_y_tbl,x
sta $d001,y ; sprite Y
jsr set_enemy_hibit ; and its 9th X bit
rts
; advance_rng — one step of an 8-bit Galois LFSR (tap $1d, maximal length 255).
; Shift left; if a 1 fell out, fold the tap back in. From any non-zero seed it
; never reaches 0, so the spawn-column source never gets stuck.
advance_rng:
lda rng_seed
asl
bcc +
eor #$1d
+ sta rng_seed
rts
; set_enemy_hibit — reflect enemy_xhi_tbl,x into this enemy's 9th X bit in $d010
; (enemies are sprites 2,3,4 -> $d010 bits 2,3,4). X = enemy index, preserved.
set_enemy_hibit:
lda enemy_d010_bit,x
ldy enemy_xhi_tbl,x
bne +
eor #$ff ; clear this sprite's bit
and $d010
sta $d010
rts
+ ora $d010 ; set this sprite's bit
sta $d010
rts
enemy_d010_bit:
!byte $04, $08, $10, $20, $40, $80 ; sprites 2-7 high-X bits
; drift_enemy_x — nudge this enemy's 9-bit X one pixel toward the ship's 9-bit X.
; X = enemy index (preserved). Compares (enemy - ship) as a signed 9-bit value and
; steps the enemy one pixel the short way; equal columns hold.
drift_enemy_x:
lda enemy_x_tbl,x
sec
sbc $d000 ; (enemy - ship) low; carry holds the borrow
sta dx_lo
lda $d010
and #%00000001 ; ship's 9th X bit
sta dx_hi ; ship high (temp)
lda enemy_xhi_tbl,x
sbc dx_hi ; A = (enemy - ship) high; N = sign
bmi .drift_right ; enemy left of ship -> move right
bne .drift_left ; enemy far right of ship -> move left
lda dx_lo
beq .drift_done ; same column -> hold
.drift_left:
lda enemy_x_tbl,x
sec
sbc #$01
sta enemy_x_tbl,x
lda enemy_xhi_tbl,x
sbc #$00
sta enemy_xhi_tbl,x
rts
.drift_right:
lda enemy_x_tbl,x
clc
adc #$01
sta enemy_x_tbl,x
lda enemy_xhi_tbl,x
adc #$00
sta enemy_xhi_tbl,x
.drift_done:
rts
; Per-enemy VIC-II register offsets (sprites 2-7)
sprite_pos_off:
!byte $04, $06, $08, $0a, $0c, $0e ; X offsets: $d004..$d00e
sprite_colour_off:
!byte $29, $2a, $2b, $2c, $2d, $2e ; colour offsets: $d029..$d02e
; The enemy parallel arrays, six wide, in absolute RAM (moved out of zero page so
; six fit). Spawn fills them; the loops walk 0..enemy_count-1.
enemy_x_tbl: !fill 6, 0
enemy_y_tbl: !fill 6, 0
flash_tbl: !fill 6, 0
enemy_xhi_tbl: !fill 6, 0
; staggered spawn heights for up to six enemies
spawn_y_tbl: !byte $32, $82, $d2, $5a, $aa, $28
; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
; Row 12 x 40 + 16 = 496 = $1f0, so screen RAM $05f0, colour RAM $d9f0
; ------------------------------------------------
show_game_over:
lda #$07 ; G
sta $05f0
lda #$01 ; A
sta $05f1
lda #$0d ; M
sta $05f2
lda #$05 ; E
sta $05f3
lda #$20 ; (space)
sta $05f4
lda #$0f ; O
sta $05f5
lda #$16 ; V
sta $05f6
lda #$05 ; E
sta $05f7
lda #$12 ; R
sta $05f8
; colour the nine cells white ($d9f0..$d9f8)
lda #$01
ldx #$00
- sta $d9f0,x
inx
cpx #$09
bne -
rts
; ------------------------------------------------
; Subroutine: clear_and_stars — wipe the screen, then repaint every star
; ------------------------------------------------
clear_and_stars:
ldx #$00
cas_clear:
lda #$20
sta $0400,x
sta $0500,x
sta $0600,x
sta $0700,x
inx
bne cas_clear
ldx #$00
cas_draw:
jsr draw_star
inx
cpx #12
bne cas_draw
rts
; ------------------------------------------------
; Subroutine: enter_title — show the title, hide the game, state = 0
; ------------------------------------------------
enter_title:
jsr clear_and_stars
lda #$00
sta $d015 ; all sprites off: the title has no ship or wave
sta $d020 ; border black
jsr show_title
lda #25
sta ui_lock ; half a second before fire is believed
; Voice 1 becomes the band: a softer envelope than the laser's
lda #$18
sta $d405 ; attack 1, decay 8
lda #$a0
sta $d406 ; sustain 10, release 0
lda #$00
sta jingle_idx ; start the tune from the top,
lda #$01
sta jingle_timer ; first note due on the next tick
lda #$00
sta state ; 0 = title
rts
; ------------------------------------------------
; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
; The band stops: gate voice 1 off and hand it back to the laser
lda #$10
sta $d404 ; triangle, gate OFF
lda #$06
sta $d405 ; the laser's envelope again
lda #$00
sta $d406
jsr clear_and_stars
; The sprite data pointers live in screen RAM ($07f8+), so the clear just
; wiped them — set them here, after the clear, or the sprites show garbage.
lda #128
sta $07f8 ; ship
lda #129
sta $07f9 ; bullet
lda #130
sta $07fa ; enemy 0
sta $07fb ; enemy 1
sta $07fc ; enemy 2
sta $07fd ; enemy 3
sta $07fe ; enemy 4
sta $07ff ; enemy 5
; Ship at its start, white (it may have gone red on game over)
lda #172
sta $d000
lda #220
sta $d001
lda #$01
sta $d027
lda #$00
sta $d010
; Start with three enemies; the wave grows this toward six.
lda #$03
sta enemy_count
; Enable ship + three enemies (the bullet stays off)
lda #%00011101
sta $d015
; Seed the column LFSR from the raster the instant play begins, forced
; non-zero (the LFSR locks at 0) — so each game's spawn run differs while
; the LFSR keeps the columns spread within a run.
lda $d012
ora #$01
sta rng_seed
; Spawn the starting wave at staggered heights
ldx #$00
.spawn_wave:
lda spawn_y_tbl,x
jsr spawn_enemy
inx
cpx enemy_count
bne .spawn_wave
; Reset per-game state
lda #$00
sta bullet_active
sta death_timer
sta $d020 ; border black
sta score
sta score_hi
; Score "0000", white
lda #$30
sta $0400
sta $0401
sta $0402
sta $0403
lda #$01
sta $d800
sta $d801
sta $d802
sta $d803
; Lives "3", white
lda #$03
sta lives
lda #$33
sta $0427
lda #$01
sta $d827
; Wave 1: counter, kills, the live speed, and the readout
lda #$01
sta wave
lda #$00
sta kills
lda wave_speed_tbl ; first table entry
sta fall_speed
jsr draw_wave
; state = playing
lda #$01
sta state
rts
; ------------------------------------------------
; Subroutine: advance_wave — ten kills: quicken the lanes, say so
; Three different caps live here, and they are not the same thing:
; the COUNTER never stops, the table INDEX clamps at the last row,
; and the DIGIT shows 9 forever after. The player's number tells the
; truth; the physics admits it ran out of new ideas.
; ------------------------------------------------
advance_wave:
lda #$00
sta kills
inc wave
; Grow the swarm: every other wave, add one enemy until six are on screen.
; This is the escalation axis the speed table ran out of — the field keeps
; filling after the lanes stop quickening.
lda wave
and #$01
bne no_grow ; odd wave -> no new enemy this time
lda enemy_count
cmp #$06
bcs no_grow ; already six
tax ; X = the new enemy's index (= the current count)
inc enemy_count
lda enemy_d010_bit,x ; sprite (2+X)'s enable bit is the same byte
ora $d015
sta $d015 ; switch the new sprite on
lda #$32
jsr spawn_enemy ; drop it in at the top, LFSR column
no_grow:
; table index = wave - 1, clamped to the last entry
ldy wave
dey
cpy #WAVE_TOP
bcc wave_speed_ok
ldy #WAVE_TOP
wave_speed_ok:
lda wave_speed_tbl,y
sta fall_speed
jsr draw_wave
; The chirp — voice 3, a bright triangle ding over the explosion
lda #$00
sta $d40e ; voice 3 frequency low
lda #$40
sta $d40f ; voice 3 frequency high (a high ding)
lda #$09
sta $d413 ; attack 0, decay 9
lda #$00
sta $d414 ; sustain 0, release 0
lda #$10
sta $d412 ; triangle, gate OFF (reset the envelope)
lda #$11
sta $d412 ; triangle, gate ON
rts
; ------------------------------------------------
; Subroutine: draw_score — four BCD digits at the top-left ($0400-$0403)
; thousands:hundreds live in score_hi, tens:ones in score
; ------------------------------------------------
draw_score:
lda score_hi
lsr
lsr
lsr
lsr
clc
adc #$30
sta $0400 ; thousands
lda score_hi
and #$0f
clc
adc #$30
sta $0401 ; hundreds
lda score
lsr
lsr
lsr
lsr
clc
adc #$30
sta $0402 ; tens
lda score
and #$0f
clc
adc #$30
sta $0403 ; ones
rts
; ------------------------------------------------
; Subroutine: draw_wave — "W" and the wave digit, top centre
; The digit caps at 9; the wave itself keeps counting.
; ------------------------------------------------
draw_wave:
lda #$17 ; W
sta $0413
lda wave
cmp #$0a
bcc wave_digit_ok
lda #$09 ; show 9 from here on
wave_digit_ok:
clc
adc #$30 ; to screen code
sta $0414
lda #$01 ; white
sta $d813
sta $d814
rts
; Pixels per frame, one entry per wave; the last entry is the wall
WAVE_TOP = 4 ; last index of the table below
wave_speed_tbl:
!byte 1, 2, 2, 3, 3
; ------------------------------------------------
; Subroutine: jingle_tick — one frame of title music
; A melody is a table: a pitch (two bytes) and a duration in frames.
; The SID holds each note by itself — this routine only does anything
; when the current note's time is up. Pitch high byte 0 is a rest
; (silence is a note too); $ff loops back to the top.
; ------------------------------------------------
jingle_tick:
dec jingle_timer
beq jingle_next
rts
jingle_next:
ldx jingle_idx
lda jingle_hi,x
cmp #$ff ; the loop marker
bne jingle_play
lda #$00 ; back to the top
sta jingle_idx
ldx #$00
lda jingle_hi,x
jingle_play:
bne jingle_note
; a rest: gate off, keep counting
lda #$10
sta $d404
jmp jingle_clock
jingle_note:
sta $d401 ; pitch, high byte
lda jingle_lo,x
sta $d400 ; pitch, low byte
lda #$10
sta $d404 ; gate off first — retrigger the envelope
lda #$11
sta $d404 ; triangle, gate on
jingle_clock:
lda jingle_dur,x
sta jingle_timer
inc jingle_idx
rts
; Twinkle Twinkle, Little Star — fourteen notes, a breath, and round
; again. A starfield needs no other tune. (PAL SID pitch values.)
jingle_lo:
!byte $67,$67,$13,$13,$45,$45,$13
!byte $3b,$3b,$ed,$ed,$88,$88,$67
!byte $00,$00
jingle_hi:
!byte $11,$11,$1a,$1a,$1d,$1d,$1a
!byte $17,$17,$15,$15,$13,$13,$11
!byte $00,$ff
jingle_dur:
!byte 20,20,20,20,20,20,40
!byte 20,20,20,20,20,20,40
!byte 35,1
; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
; ------------------------------------------------
show_title:
lda #$13 ; S
sta $05a0
lda #$14 ; T
sta $05a1
lda #$01 ; A
sta $05a2
lda #$12 ; R
sta $05a3
lda #$06 ; F
sta $05a4
lda #$09 ; I
sta $05a5
lda #$05 ; E
sta $05a6
lda #$0c ; L
sta $05a7
lda #$04 ; D
sta $05a8
lda #$01 ; white
ldx #$00
- sta $d9a0,x
inx
cpx #$09
bne -
lda #$10 ; P
sta $063f
lda #$12 ; R
sta $0640
lda #$05 ; E
sta $0641
lda #$13 ; S
sta $0642
lda #$13 ; S
sta $0643
lda #$20 ; (space)
sta $0644
lda #$06 ; F
sta $0645
lda #$09 ; I
sta $0646
lda #$12 ; R
sta $0647
lda #$05 ; E
sta $0648
lda #$0f ; light grey
ldx #$00
- sta $da3f,x
inx
cpx #$0a
bne -
rts
; ------------------------------------------------
; Subroutine: erase_star (X = star index)
; blanks the star's current cell back to a space
; ------------------------------------------------
erase_star:
ldy star_row,x
lda row_addr_lo,y ; point $fb/$fc at the start of this star's row
sta $fb
lda row_addr_hi,y
sta $fc
ldy star_col,x ; Y = the column offset along that row
lda #$20 ; a space
sta ($fb),y ; "finger on the boxes" — pointer + Y offset
rts
; ------------------------------------------------
; Subroutine: draw_star (X = star index)
; writes the star's character + colour at its (row, col)
; ------------------------------------------------
draw_star:
ldy star_row,x
lda row_addr_lo,y
sta $fb ; row start, low byte
lda row_addr_hi,y
sta $fc ; row start, high byte
ldy star_col,x ; Y = column
lda star_char_tbl,x
sta ($fb),y ; STA ($fb),Y -> the screen-RAM cell
; screen RAM $04xx-$07xx maps to colour RAM $d8xx-$dbxx: high byte + $d4
lda $fc
clc
adc #$d4
sta $fc
lda star_colour_tbl,x
sta ($fb),y ; same column offset, now into colour RAM
rts
; ------------------------------------------------
; Star data tables
; ------------------------------------------------
; Screen-RAM start address of each row (row x 40 + $0400), rows 0-24
row_addr_lo:
!byte $00,$28,$50,$78,$a0,$c8,$f0,$18
!byte $40,$68,$90,$b8,$e0,$08,$30,$58
!byte $80,$a8,$d0,$f8,$20,$48,$70,$98,$c0
row_addr_hi:
!byte $04,$04,$04,$04,$04,$04,$04,$05
!byte $05,$05,$05,$05,$05,$06,$06,$06
!byte $06,$06,$06,$06,$07,$07,$07,$07,$07
; 12 stars. Columns avoid 0, 1 and 39 — the score and lives cells.
star_init_row:
!byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
star_init_col:
!byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
; Appearance reinforces the depth: near = bright white '*', far = dim grey '.'
star_char_tbl:
!byte $2a,$2a,$2a,$2a, $2a,$2a,$2a,$2a, $2e,$2e,$2e,$2e
star_colour_tbl:
!byte $01,$01,$01,$01, $0f,$0f,$0f,$0f, $0b,$0b,$0b,$0b
; ------------------------------------------------
; Sprite data at $2000 (block 128) — ship
; ------------------------------------------------
*= $2000
!byte $00,$18,$00 ; ##
!byte $00,$3c,$00 ; ####
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$7e,$00 ; ######
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $03,$ff,$c0 ; ############
!byte $07,$ff,$e0 ; ##############
!byte $07,$ff,$e0 ; ##############
!byte $07,$e7,$e0 ; ###..####..###
!byte $03,$c3,$c0 ; ##....##....##
!byte $01,$ff,$80 ; ##########
!byte $00,$ff,$00 ; ########
!byte $00,$ff,$00 ; ########
!byte $00,$db,$00 ; ##.##.##
!byte $00,$db,$00 ; ##.##.##
!byte $00,$66,$00 ; ##..##
!byte $00,$24,$00 ; #..#
!byte $00,$00,$00 ;
; ------------------------------------------------
; Sprite data at $2040 (block 129) — bullet
; ------------------------------------------------
*= $2040
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$18,$00 ; ##
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
!byte $00,$00,$00
; ------------------------------------------------
; Sprite data at $2080 (block 130) — enemy
; ------------------------------------------------
*= $2080
!byte $00,$66,$00 ; ##..##
!byte $00,$3c,$00 ; ####
!byte $00,$7e,$00 ; ######
!byte $00,$db,$00 ; ##.##.##
!byte $00,$ff,$00 ; ########
!byte $01,$ff,$80 ; ##########
!byte $01,$7e,$80 ; #.######.#
!byte $01,$3c,$80 ; #..####..#
!byte $00,$a5,$00 ; #.#..#.#
!byte $01,$81,$80 ; ##......##
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
!byte $00,$00,$00 ;
The design-stress pass asked for a second escalation axis once the speed table gave out. Enemy count is that axis, the tightening hunt is a third, and the score finally counts high enough to prove you climbed. Starfield isn’t just un-broken now — it’s a game with somewhere to go.
When it’s wrong, see why
- The jingle plays its first note and holds it forever.
jingle_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.
The next game on this machine starts from all of it. Platform Panic trades the open sky for floors and ladders — and brings the first problem sprites can’t solve alone.