Skip to content
Game 1 Unit 15 of 16 1 hr learning time

Starfield

Fill the empty space — draw a scrolling starfield straight into screen RAM with indirect indexed addressing, then split it into three depths moving at three speeds.

94% of Starfield

The game plays — fly, shoot, score, survive — but it all happens in flat black nothing. The ship isn't going anywhere; it's just sitting in the dark. This unit gives the game its name and its sense of place: a starfield, scrolling down behind everything, with stars at different depths moving at different speeds so the screen reads as deep. And it's where a technique the Primer planted finally earns its keep.

Where we start

Unit 14 is the complete game on an empty background. Everything below paints stars behind it.

Milestone 1 — stars in screen RAM

A star is a character — an asterisk — written into a screen-RAM cell. The catch is which cell. A star at row R, column C lives at $0400 + R × 40 + C, and that multiply-by-40 is the snag: doing it for every star, every frame, by hand is slow and fiddly.

This is exactly the shape the Primer's "finger on the boxes" was for — indirect indexed addressing, sta ($fb),y. You keep a pointer in two zero-page bytes ($fb/$fc) — the finger — and point it at the start of a row. Then the Y register is the column, and sta ($fb),y writes to "the address the finger holds, plus Y." Point at the row, count along to the column, drop the character. No multiply in sight — because the row start comes from a small lookup table, row_addr_lo/row_addr_hi, that has the address of all 25 rows precomputed.

One more trick rides along. Colour RAM at $d800 mirrors screen RAM's layout exactly, and $d8 is $04 + $d4 — so once the pointer is at a screen cell, adding $d4 to its high byte moves it to the matching colour cell. The same ,y offset then colours the same square.

We wrap that in draw_star and erase_star, and the loop each frame is: erase every star, move it down a row (wrapping at the bottom), draw it again.

Step 1: draw and scroll stars with sta ($fb),y
+94
1717 game_over = $10 ; 0 = playing, 1 = the ship has been hit
1818 lives = $11 ; lives remaining (starts at 3)
1919 death_timer = $12 ; frames of post-hit flash (and, in step 2, invulnerability)
20+frame_count = $13 ; free-running frame counter (parallax timing)
21+star_row = $14 ; 12 stars: row of each ($14-$1f)
22+star_col = $20 ; 12 stars: column of each ($20-$2b)
23+; $fb/$fc: scratch pointer used by the star routines
2024
2125 ; ------------------------------------------------
2226 ; BASIC stub
...
123127 lda #$00
124128 sta game_over
125129 sta death_timer ; not flashing
130+ sta frame_count
131+
132+ ; Place the 12 stars at their start positions and paint them
133+ ldx #$00
134+init_star_loop:
135+ lda star_init_row,x
136+ sta star_row,x
137+ lda star_init_col,x
138+ sta star_col,x
139+ jsr draw_star
140+ inx
141+ cpx #12
142+ bne init_star_loop
126143
127144 ; ------------------------------------------------
128145 ; Game loop — runs once per frame
...
142159 bne game_loop ; not pressed — hold the GAME OVER screen
143160 jmp start ; fire pressed — restart the whole game
144161 game_active:
162+
163+ ; --- Scrolling starfield: erase, move down, redraw each star ---
164+ inc frame_count
165+ ldx #$00
166+star_loop:
167+ jsr erase_star
168+ inc star_row,x ; one row down per frame
169+ lda star_row,x
170+ cmp #25
171+ bcc star_move_done
172+ lda #$00 ; past the bottom row -> wrap to the top
173+ sta star_row,x
174+star_move_done:
175+ jsr draw_star
176+ inx
177+ cpx #12
178+ bne star_loop
145179
146180 ; --- Read joystick and move ship ---
147181
...
605639 inx
606640 cpx #$09
607641 bne -
642+ rts
643+
644+; ------------------------------------------------
645+; Subroutine: erase_star (X = star index)
646+; blanks the star's current cell back to a space
647+; ------------------------------------------------
648+erase_star:
649+ ldy star_row,x
650+ lda row_addr_lo,y ; point $fb/$fc at the start of this star's row
651+ sta $fb
652+ lda row_addr_hi,y
653+ sta $fc
654+ ldy star_col,x ; Y = the column offset along that row
655+ lda #$20 ; a space
656+ sta ($fb),y ; "finger on the boxes" — pointer + Y offset
657+ rts
658+
659+; ------------------------------------------------
660+; Subroutine: draw_star (X = star index)
661+; writes the star's character + colour at its (row, col)
662+; ------------------------------------------------
663+draw_star:
664+ ldy star_row,x
665+ lda row_addr_lo,y
666+ sta $fb ; row start, low byte
667+ lda row_addr_hi,y
668+ sta $fc ; row start, high byte
669+ ldy star_col,x ; Y = column
670+ lda star_char_tbl,x
671+ sta ($fb),y ; STA ($fb),Y -> the screen-RAM cell
672+ ; screen RAM $04xx-$07xx maps to colour RAM $d8xx-$dbxx: high byte + $d4
673+ lda $fc
674+ clc
675+ adc #$d4
676+ sta $fc
677+ lda star_colour_tbl,x
678+ sta ($fb),y ; same column offset, now into colour RAM
608679 rts
680+
681+; ------------------------------------------------
682+; Star data tables
683+; ------------------------------------------------
684+; Screen-RAM start address of each row (row x 40 + $0400), rows 0-24
685+row_addr_lo:
686+ !byte $00,$28,$50,$78,$a0,$c8,$f0,$18
687+ !byte $40,$68,$90,$b8,$e0,$08,$30,$58
688+ !byte $80,$a8,$d0,$f8,$20,$48,$70,$98,$c0
689+row_addr_hi:
690+ !byte $04,$04,$04,$04,$04,$04,$04,$05
691+ !byte $05,$05,$05,$05,$05,$06,$06,$06
692+ !byte $06,$06,$06,$06,$07,$07,$07,$07,$07
693+
694+; 12 stars. Columns avoid 0, 1 and 39 — the score and lives cells.
695+star_init_row:
696+ !byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
697+star_init_col:
698+ !byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
699+star_char_tbl:
700+ !byte $2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a
701+star_colour_tbl:
702+ !byte $01,$01,$01,$01,$01,$01,$01,$01,$01,$01,$01,$01
609703
610704 ; ------------------------------------------------
611705 ; Sprite data at $2000 (block 128) — ship
The complete step 1 program
; Starfield - Unit 15: Starfield
; Cumulative steps: step-00 (black space) -> step-01 (+ a scrolling starfield) -> step-02 (+ parallax: three depths at three speeds)
; 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
game_over     = $10     ; 0 = playing, 1 = the ship has been hit
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)
; $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:
        ; Black screen
        lda #$00
        sta $d020           ; Border colour
        sta $d021           ; Background colour

        ; Clear the screen
        ldx #$00
-       lda #$20
        sta $0400,x
        sta $0500,x
        sta $0600,x
        sta $0700,x
        inx
        bne -

        ; Sprite 0 setup (ship)
        lda #128
        sta $07f8           ; Data pointer (block 128 = $2000)
        lda #172
        sta $d000           ; X position
        lda #220
        sta $d001           ; Y position
        lda #$01
        sta $d027           ; Colour (white)
        lda #%00011101
        sta $d015           ; Enable sprites 0 (ship), 2-4 (three enemies)
        lda #$00
        sta $d010           ; sprite high-X bits clear (ship starts under X=256)

        ; Sprite 1 setup (bullet)
        lda #129
        sta $07f9           ; Data pointer (block 129 = $2040)
        lda #$07
        sta $d028           ; Colour (yellow)

        ; Enemy sprites 2, 3 and 4 share the one enemy shape in block 130.
        ; Colour and position are set per-enemy by spawn_enemy, below.
        lda #130
        sta $07fa           ; sprite 2 data pointer
        sta $07fb           ; sprite 3 data pointer
        sta $07fc           ; sprite 4 data pointer

        ; Bullet starts inactive
        lda #$00
        sta bullet_active

        ; SID setup — voice 1 laser sound
        lda #$0f
        sta $d418           ; Volume to maximum

        lda #$00
        sta $d400           ; Frequency low byte
        lda #$10
        sta $d401           ; Frequency high byte ($1000 = mid-high pitch)

        lda #$06
        sta $d405           ; Attack=0, Decay=6 (a short, snappy fall)
        lda #$00
        sta $d406           ; Sustain=0, Release=0

        ; Score readout: "00" in the top-left, white. (We've written to screen
        ; RAM since Unit 1's clear loop — now we write characters, not spaces.)
        lda #$00
        sta score
        lda #$30            ; screen code for '0'
        sta $0400           ; tens digit (row 0, col 0)
        sta $0401           ; ones digit (row 0, col 1)
        lda #$01
        sta $d800           ; colour both digits white
        sta $d801

        ; Three lives, shown in the top-right corner
        lda #$03
        sta lives
        lda #$33            ; screen code for '3'
        sta $0427           ; row 0, col 39
        lda #$01
        sta $d827           ; colour white

        ; Spawn the wave at staggered heights (A = start Y, X = enemy index)
        lda #$32
        ldx #$00
        jsr spawn_enemy
        lda #$82
        ldx #$01
        jsr spawn_enemy
        lda #$d2
        ldx #$02
        jsr spawn_enemy

        ; The game starts alive
        lda #$00
        sta game_over
        sta death_timer         ; not flashing
        sta frame_count

        ; Place the 12 stars at their start positions and paint them
        ldx #$00
init_star_loop:
        lda star_init_row,x
        sta star_row,x
        lda star_init_col,x
        sta star_col,x
        jsr draw_star
        inx
        cpx #12
        bne init_star_loop

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

        ; Game over? Wait for fire to restart; otherwise play on.
        lda game_over
        beq game_active
        lda $dc00
        and #%00010000          ; fire button (bit 4)
        bne game_loop           ; not pressed — hold the GAME OVER screen
        jmp start               ; fire pressed — restart the whole game
game_active:

        ; --- Scrolling starfield: erase, move down, redraw each star ---
        inc frame_count
        ldx #$00
star_loop:
        jsr erase_star
        inc star_row,x          ; one row down per frame
        lda star_row,x
        cmp #25
        bcc star_move_done
        lda #$00                ; past the bottom row -> wrap to the top
        sta star_row,x
star_move_done:
        jsr draw_star
        inx
        cpx #12
        bne star_loop

        ; --- Read joystick and move ship ---

        ; UP (bit 0) — clamp to Y >= 50
        lda $dc00           ; Read joystick port 2
        and #%00000001      ; Isolate bit 0
        bne not_up          ; Bit is 1 = NOT pressed (active low)
        lda $d001
        cmp #52             ; 50 + room for a 2-pixel move
        bcc not_up          ; already at the top — don't move
        dec $d001           ; Move ship up (decrease Y)
        dec $d001           ; 2 pixels per frame
not_up:

        ; DOWN (bit 1) — clamp to Y <= 234
        lda $dc00
        and #%00000010
        bne not_down
        lda $d001
        cmp #233            ; 234 - room for a 2-pixel move
        bcs not_down        ; already at the bottom — don't move
        inc $d001           ; Move ship down (increase Y)
        inc $d001
not_down:

        ; LEFT (bit 2) — 9-bit X, clamp to X >= 24
        lda $dc00
        and #%00000100
        bne not_left
        lda $d010
        and #$01
        bne left_ok         ; high bit set: X >= 256, always safe to go left
        lda $d000
        cmp #26             ; 24 + room for a 2-pixel move
        bcc not_left        ; already at the left edge — don't move
left_ok:
        ; before each step, flip the 9th bit when X is about to wrap $00 -> $ff
        lda $d000
        bne +
        lda $d010
        eor #$01            ; the eor bit-flip from the Primer, on sprite 0's high X bit
        sta $d010
+       dec $d000
        lda $d000
        bne +
        lda $d010
        eor #$01
        sta $d010
+       dec $d000
not_left:

        ; RIGHT (bit 3) — 9-bit X, clamp to X <= 320
        lda $dc00
        and #%00001000
        bne not_right
        lda $d010
        and #$01
        beq right_ok        ; high bit clear: X < 256, always safe to go right
        lda $d000
        cmp #63             ; (320 - 256) - room for a 2-pixel move
        bcs not_right       ; already at the right edge — don't move
right_ok:
        ; after each step, flip the 9th bit when X wraps $ff -> $00
        inc $d000
        bne +
        lda $d010
        eor #$01
        sta $d010
+       inc $d000
        bne +
        lda $d010
        eor #$01
        sta $d010
+
not_right:

        ; --- Fire button (bit 4) ---
        lda $dc00
        and #%00010000
        bne no_fire         ; Bit is 1 = NOT pressed

        ; Only spawn if no bullet is already flying
        lda bullet_active
        bne no_fire

        ; Spawn the bullet at the ship's position
        lda $d000           ; Ship X (low byte) -> bullet X
        sta $d002
        lda $d001           ; Ship Y -> bullet Y
        sta bullet_y

        ; Copy the ship's 9th X bit (bit 0) to the bullet's (bit 1),
        ; so a shot fired from the right half spawns under the ship
        lda $d010
        and #%11111101      ; clear the bullet's 9th bit first
        sta $d010
        lda $d010
        and #$01            ; the ship's 9th bit
        asl                 ; shift it into the bullet's position (bit 1)
        ora $d010
        sta $d010

        ; Enable sprite 1 (keep sprite 0 enabled)
        lda $d015
        ora #%00000010
        sta $d015

        lda #$01
        sta bullet_active

        ; Trigger laser sound: start the pitch high, gate off then on
        lda #$40
        sta laser_freq      ; start high
        sta $d401           ; SID frequency high byte
        lda #$20
        sta $d404           ; Sawtooth, gate OFF (reset envelope)
        lda #$21
        sta $d404           ; Sawtooth, gate ON (start sound)
        lda #$0a
        sta laser_timer     ; sweep down over 10 frames

no_fire:

        ; --- Laser pitch sweep: the 'pew' ---
        ; Drop the pitch a little each frame while the sweep is running.
        ; We keep our own copy because SID frequency registers are write-only.
        lda laser_timer
        beq no_sweep
        lda laser_freq
        sec
        sbc #$06
        sta laser_freq
        sta $d401           ; write the new pitch to the SID
        dec laser_timer
no_sweep:

        ; --- Update the bullet ---
        lda bullet_active
        beq no_bullet

        ; Move it up 4 pixels a frame
        lda bullet_y
        sec
        sbc #$04
        sta bullet_y
        sta $d003           ; sprite 1 Y

        ; Gone off the top? (Y < 30) -> remove it
        cmp #$1e
        bcs no_bullet

        lda #$00
        sta bullet_active
        lda $d015
        and #%11111101      ; disable sprite 1, keep sprite 0
        sta $d015
        lda $d010
        and #%11111101      ; clear the bullet's 9th bit
        sta $d010

no_bullet:

        ; --- Update every enemy: one indexed loop does all of them ---
        ldx #$00
enemy_loop:
        lda flash_tbl,x
        bne do_flash            ; this enemy is mid-flash

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

do_flash:
        dec flash_tbl,x
        bne update_sprite       ; still flashing -> stay frozen, white
        lda #$32                ; flash done -> respawn (spawn_enemy restores green)
        jsr spawn_enemy
        jmp next_enemy

update_sprite:
        ; copy this enemy's position into its VIC-II sprite registers
        ldy sprite_pos_off,x
        lda enemy_x_tbl,x
        sta $d000,y             ; sprite X  ($d004, $d006, ...)
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y  ($d005, $d007, ...)

next_enemy:
        inx
        cpx #$03                ; the full wave of three
        bne enemy_loop

        ; --- Bullet vs the wave: test each enemy until one is hit ---
        lda bullet_active
        bne check_collision
        jmp no_hit
check_collision:
        ldx #$00
collision_loop:
        lda flash_tbl,x
        bne next_collision      ; skip an enemy that's already exploding

        ; Y distance (8-bit subtract wraps, so two ranges count as close)
        lda bullet_y
        sec
        sbc enemy_y_tbl,x
        cmp #$10
        bcc check_x             ; 0..15 apart: close
        cmp #$f0
        bcc next_collision      ; 16..239 apart: too far
check_x:
        ; A bullet in the right portion (9th bit set) is past X=255, far from
        ; any enemy, so rule it out before comparing low bytes.
        lda $d010
        and #%00000010          ; bullet's 9th X bit (sprite 1)
        bne next_collision
        lda $d002
        sec
        sbc enemy_x_tbl,x
        cmp #$10
        bcc hit_enemy           ; 0..15 apart: close
        cmp #$f0
        bcc next_collision      ; 16..239 apart: too far
        jmp hit_enemy           ; 240..255: close from the other side

next_collision:
        inx
        cpx #$03
        bne collision_loop
        jmp no_hit

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

        ; Flash THIS enemy white and start its 8-frame timer. The enemy loop
        ; freezes it white until the timer runs out, then respawns it.
        lda #$08
        sta flash_tbl,x
        ldy sprite_colour_off,x
        lda #$01
        sta $d000,y             ; this enemy's colour register = white

        ; Explosion sound — SID voice 2, noise waveform (voice 1 keeps the laser)
        lda #$00
        sta $d407               ; voice 2 frequency low
        lda #$08
        sta $d408               ; voice 2 frequency high (a low rumble)
        lda #$09
        sta $d40c               ; attack 0, decay 9
        lda #$00
        sta $d40d               ; sustain 0, release 0
        lda #$80
        sta $d40b               ; noise, gate OFF (reset the envelope)
        lda #$81
        sta $d40b               ; noise, gate ON (trigger the burst)

        ; Score one hit. In decimal mode ADC carries at 10, so the byte stays
        ; readable as two decimal digits (BCD) — no conversion needed.
        sed                     ; decimal mode on
        lda score
        clc
        adc #$01
        sta score
        cld                     ; decimal mode off (every later ADC/SBC needs it off)

        ; Refresh the two digits: high nybble -> tens, low nybble -> ones
        lda score
        lsr
        lsr
        lsr
        lsr                     ; high nybble down to 0-9
        clc
        adc #$30                ; to screen code
        sta $0400               ; tens digit
        lda score
        and #$0f                ; low nybble, 0-9
        clc
        adc #$30
        sta $0401               ; ones digit

no_hit:

        ; --- 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 -> end the game (the freeze + restart from Unit 12)
        lda #$01
        sta game_over
        lda #$02
        sta $d027               ; ship turns red
        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: 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
star_char_tbl:
        !byte $2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a
star_colour_tbl:
        !byte $01,$01,$01,$01,$01,$01,$01,$01,$01,$01,$01,$01

; ------------------------------------------------
; 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 columns are chosen to dodge the score and lives cells, so the readouts stay put. Now the black is full of drifting stars, behind the ship and the wave:

A field of white stars scrolls down behind the ship and the wave — each one written straight into screen RAM through the indirect pointer. They all move at one speed for now.

Milestone 2 — depth, through speed

A flat field of identical stars all moving together reads as a moving wall, not space. Depth comes from parallax: things that are near move faster than things that are far. We sort the twelve stars into three layers — near, middle, far — and move each layer at a different rate by checking the frame counter. The near four move every frame; the middle four every second frame; the far four every fourth. Two and masks on frame_count decide it — no division needed, because the rates are powers of two.

We back the speed up with brightness: the near stars stay bright white asterisks, the middle ones light grey, the far ones a dim grey dot. Fast-and-bright versus slow-and-faint is the cue the eye reads as distance.

Step 2: three layers at three speeds
+22-5
160160 jmp start ; fire pressed — restart the whole game
161161 game_active:
162162
163- ; --- Scrolling starfield: erase, move down, redraw each star ---
163+ ; --- Parallax starfield: three depths move at three speeds ---
164164 inc frame_count
165165 ldx #$00
166166 star_loop:
167167 jsr erase_star
168- inc star_row,x ; one row down per frame
168+ ; Does THIS star move this frame? Near (0-3) every frame, mid (4-7)
169+ ; every 2nd frame, far (8-11) every 4th frame.
170+ cpx #$04
171+ bcc star_do_move ; near layer: always
172+ cpx #$08
173+ bcc star_mid ; mid layer
174+ ; far layer: only when the low two frame bits are clear (1 in 4)
175+ lda frame_count
176+ and #%00000011
177+ bne star_move_done
178+ beq star_do_move
179+star_mid:
180+ lda frame_count
181+ and #%00000001 ; every other frame
182+ bne star_move_done
183+star_do_move:
184+ inc star_row,x ; one row down
169185 lda star_row,x
170186 cmp #25
171187 bcc star_move_done
172- lda #$00 ; past the bottom row -> wrap to the top
188+ lda #$00 ; past the bottom -> wrap to the top
173189 sta star_row,x
174190 star_move_done:
175191 jsr draw_star
...
696712 !byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
697713 star_init_col:
698714 !byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
715+; Appearance reinforces the depth: near = bright white '*', far = dim grey '.'
699716 star_char_tbl:
700- !byte $2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a,$2a
717+ !byte $2a,$2a,$2a,$2a, $2a,$2a,$2a,$2a, $2e,$2e,$2e,$2e
701718 star_colour_tbl:
702- !byte $01,$01,$01,$01,$01,$01,$01,$01,$01,$01,$01,$01
719+ !byte $01,$01,$01,$01, $0f,$0f,$0f,$0f, $0b,$0b,$0b,$0b
703720
704721 ; ------------------------------------------------
705722 ; Sprite data at $2000 (block 128) — ship
The complete program
; Starfield - Unit 15: Starfield
; Cumulative steps: step-00 (black space) -> step-01 (+ a scrolling starfield) -> step-02 (+ parallax: three depths at three speeds)
; 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
game_over     = $10     ; 0 = playing, 1 = the ship has been hit
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)
; $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:
        ; Black screen
        lda #$00
        sta $d020           ; Border colour
        sta $d021           ; Background colour

        ; Clear the screen
        ldx #$00
-       lda #$20
        sta $0400,x
        sta $0500,x
        sta $0600,x
        sta $0700,x
        inx
        bne -

        ; Sprite 0 setup (ship)
        lda #128
        sta $07f8           ; Data pointer (block 128 = $2000)
        lda #172
        sta $d000           ; X position
        lda #220
        sta $d001           ; Y position
        lda #$01
        sta $d027           ; Colour (white)
        lda #%00011101
        sta $d015           ; Enable sprites 0 (ship), 2-4 (three enemies)
        lda #$00
        sta $d010           ; sprite high-X bits clear (ship starts under X=256)

        ; Sprite 1 setup (bullet)
        lda #129
        sta $07f9           ; Data pointer (block 129 = $2040)
        lda #$07
        sta $d028           ; Colour (yellow)

        ; Enemy sprites 2, 3 and 4 share the one enemy shape in block 130.
        ; Colour and position are set per-enemy by spawn_enemy, below.
        lda #130
        sta $07fa           ; sprite 2 data pointer
        sta $07fb           ; sprite 3 data pointer
        sta $07fc           ; sprite 4 data pointer

        ; Bullet starts inactive
        lda #$00
        sta bullet_active

        ; SID setup — voice 1 laser sound
        lda #$0f
        sta $d418           ; Volume to maximum

        lda #$00
        sta $d400           ; Frequency low byte
        lda #$10
        sta $d401           ; Frequency high byte ($1000 = mid-high pitch)

        lda #$06
        sta $d405           ; Attack=0, Decay=6 (a short, snappy fall)
        lda #$00
        sta $d406           ; Sustain=0, Release=0

        ; Score readout: "00" in the top-left, white. (We've written to screen
        ; RAM since Unit 1's clear loop — now we write characters, not spaces.)
        lda #$00
        sta score
        lda #$30            ; screen code for '0'
        sta $0400           ; tens digit (row 0, col 0)
        sta $0401           ; ones digit (row 0, col 1)
        lda #$01
        sta $d800           ; colour both digits white
        sta $d801

        ; Three lives, shown in the top-right corner
        lda #$03
        sta lives
        lda #$33            ; screen code for '3'
        sta $0427           ; row 0, col 39
        lda #$01
        sta $d827           ; colour white

        ; Spawn the wave at staggered heights (A = start Y, X = enemy index)
        lda #$32
        ldx #$00
        jsr spawn_enemy
        lda #$82
        ldx #$01
        jsr spawn_enemy
        lda #$d2
        ldx #$02
        jsr spawn_enemy

        ; The game starts alive
        lda #$00
        sta game_over
        sta death_timer         ; not flashing
        sta frame_count

        ; Place the 12 stars at their start positions and paint them
        ldx #$00
init_star_loop:
        lda star_init_row,x
        sta star_row,x
        lda star_init_col,x
        sta star_col,x
        jsr draw_star
        inx
        cpx #12
        bne init_star_loop

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

        ; Game over? Wait for fire to restart; otherwise play on.
        lda game_over
        beq game_active
        lda $dc00
        and #%00010000          ; fire button (bit 4)
        bne game_loop           ; not pressed — hold the GAME OVER screen
        jmp start               ; fire pressed — restart the whole game
game_active:

        ; --- Parallax starfield: three depths move at three speeds ---
        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

        ; --- Read joystick and move ship ---

        ; UP (bit 0) — clamp to Y >= 50
        lda $dc00           ; Read joystick port 2
        and #%00000001      ; Isolate bit 0
        bne not_up          ; Bit is 1 = NOT pressed (active low)
        lda $d001
        cmp #52             ; 50 + room for a 2-pixel move
        bcc not_up          ; already at the top — don't move
        dec $d001           ; Move ship up (decrease Y)
        dec $d001           ; 2 pixels per frame
not_up:

        ; DOWN (bit 1) — clamp to Y <= 234
        lda $dc00
        and #%00000010
        bne not_down
        lda $d001
        cmp #233            ; 234 - room for a 2-pixel move
        bcs not_down        ; already at the bottom — don't move
        inc $d001           ; Move ship down (increase Y)
        inc $d001
not_down:

        ; LEFT (bit 2) — 9-bit X, clamp to X >= 24
        lda $dc00
        and #%00000100
        bne not_left
        lda $d010
        and #$01
        bne left_ok         ; high bit set: X >= 256, always safe to go left
        lda $d000
        cmp #26             ; 24 + room for a 2-pixel move
        bcc not_left        ; already at the left edge — don't move
left_ok:
        ; before each step, flip the 9th bit when X is about to wrap $00 -> $ff
        lda $d000
        bne +
        lda $d010
        eor #$01            ; the eor bit-flip from the Primer, on sprite 0's high X bit
        sta $d010
+       dec $d000
        lda $d000
        bne +
        lda $d010
        eor #$01
        sta $d010
+       dec $d000
not_left:

        ; RIGHT (bit 3) — 9-bit X, clamp to X <= 320
        lda $dc00
        and #%00001000
        bne not_right
        lda $d010
        and #$01
        beq right_ok        ; high bit clear: X < 256, always safe to go right
        lda $d000
        cmp #63             ; (320 - 256) - room for a 2-pixel move
        bcs not_right       ; already at the right edge — don't move
right_ok:
        ; after each step, flip the 9th bit when X wraps $ff -> $00
        inc $d000
        bne +
        lda $d010
        eor #$01
        sta $d010
+       inc $d000
        bne +
        lda $d010
        eor #$01
        sta $d010
+
not_right:

        ; --- Fire button (bit 4) ---
        lda $dc00
        and #%00010000
        bne no_fire         ; Bit is 1 = NOT pressed

        ; Only spawn if no bullet is already flying
        lda bullet_active
        bne no_fire

        ; Spawn the bullet at the ship's position
        lda $d000           ; Ship X (low byte) -> bullet X
        sta $d002
        lda $d001           ; Ship Y -> bullet Y
        sta bullet_y

        ; Copy the ship's 9th X bit (bit 0) to the bullet's (bit 1),
        ; so a shot fired from the right half spawns under the ship
        lda $d010
        and #%11111101      ; clear the bullet's 9th bit first
        sta $d010
        lda $d010
        and #$01            ; the ship's 9th bit
        asl                 ; shift it into the bullet's position (bit 1)
        ora $d010
        sta $d010

        ; Enable sprite 1 (keep sprite 0 enabled)
        lda $d015
        ora #%00000010
        sta $d015

        lda #$01
        sta bullet_active

        ; Trigger laser sound: start the pitch high, gate off then on
        lda #$40
        sta laser_freq      ; start high
        sta $d401           ; SID frequency high byte
        lda #$20
        sta $d404           ; Sawtooth, gate OFF (reset envelope)
        lda #$21
        sta $d404           ; Sawtooth, gate ON (start sound)
        lda #$0a
        sta laser_timer     ; sweep down over 10 frames

no_fire:

        ; --- Laser pitch sweep: the 'pew' ---
        ; Drop the pitch a little each frame while the sweep is running.
        ; We keep our own copy because SID frequency registers are write-only.
        lda laser_timer
        beq no_sweep
        lda laser_freq
        sec
        sbc #$06
        sta laser_freq
        sta $d401           ; write the new pitch to the SID
        dec laser_timer
no_sweep:

        ; --- Update the bullet ---
        lda bullet_active
        beq no_bullet

        ; Move it up 4 pixels a frame
        lda bullet_y
        sec
        sbc #$04
        sta bullet_y
        sta $d003           ; sprite 1 Y

        ; Gone off the top? (Y < 30) -> remove it
        cmp #$1e
        bcs no_bullet

        lda #$00
        sta bullet_active
        lda $d015
        and #%11111101      ; disable sprite 1, keep sprite 0
        sta $d015
        lda $d010
        and #%11111101      ; clear the bullet's 9th bit
        sta $d010

no_bullet:

        ; --- Update every enemy: one indexed loop does all of them ---
        ldx #$00
enemy_loop:
        lda flash_tbl,x
        bne do_flash            ; this enemy is mid-flash

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

do_flash:
        dec flash_tbl,x
        bne update_sprite       ; still flashing -> stay frozen, white
        lda #$32                ; flash done -> respawn (spawn_enemy restores green)
        jsr spawn_enemy
        jmp next_enemy

update_sprite:
        ; copy this enemy's position into its VIC-II sprite registers
        ldy sprite_pos_off,x
        lda enemy_x_tbl,x
        sta $d000,y             ; sprite X  ($d004, $d006, ...)
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y  ($d005, $d007, ...)

next_enemy:
        inx
        cpx #$03                ; the full wave of three
        bne enemy_loop

        ; --- Bullet vs the wave: test each enemy until one is hit ---
        lda bullet_active
        bne check_collision
        jmp no_hit
check_collision:
        ldx #$00
collision_loop:
        lda flash_tbl,x
        bne next_collision      ; skip an enemy that's already exploding

        ; Y distance (8-bit subtract wraps, so two ranges count as close)
        lda bullet_y
        sec
        sbc enemy_y_tbl,x
        cmp #$10
        bcc check_x             ; 0..15 apart: close
        cmp #$f0
        bcc next_collision      ; 16..239 apart: too far
check_x:
        ; A bullet in the right portion (9th bit set) is past X=255, far from
        ; any enemy, so rule it out before comparing low bytes.
        lda $d010
        and #%00000010          ; bullet's 9th X bit (sprite 1)
        bne next_collision
        lda $d002
        sec
        sbc enemy_x_tbl,x
        cmp #$10
        bcc hit_enemy           ; 0..15 apart: close
        cmp #$f0
        bcc next_collision      ; 16..239 apart: too far
        jmp hit_enemy           ; 240..255: close from the other side

next_collision:
        inx
        cpx #$03
        bne collision_loop
        jmp no_hit

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

        ; Flash THIS enemy white and start its 8-frame timer. The enemy loop
        ; freezes it white until the timer runs out, then respawns it.
        lda #$08
        sta flash_tbl,x
        ldy sprite_colour_off,x
        lda #$01
        sta $d000,y             ; this enemy's colour register = white

        ; Explosion sound — SID voice 2, noise waveform (voice 1 keeps the laser)
        lda #$00
        sta $d407               ; voice 2 frequency low
        lda #$08
        sta $d408               ; voice 2 frequency high (a low rumble)
        lda #$09
        sta $d40c               ; attack 0, decay 9
        lda #$00
        sta $d40d               ; sustain 0, release 0
        lda #$80
        sta $d40b               ; noise, gate OFF (reset the envelope)
        lda #$81
        sta $d40b               ; noise, gate ON (trigger the burst)

        ; Score one hit. In decimal mode ADC carries at 10, so the byte stays
        ; readable as two decimal digits (BCD) — no conversion needed.
        sed                     ; decimal mode on
        lda score
        clc
        adc #$01
        sta score
        cld                     ; decimal mode off (every later ADC/SBC needs it off)

        ; Refresh the two digits: high nybble -> tens, low nybble -> ones
        lda score
        lsr
        lsr
        lsr
        lsr                     ; high nybble down to 0-9
        clc
        adc #$30                ; to screen code
        sta $0400               ; tens digit
        lda score
        and #$0f                ; low nybble, 0-9
        clc
        adc #$30
        sta $0401               ; ones digit

no_hit:

        ; --- 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 -> end the game (the freeze + restart from Unit 12)
        lda #$01
        sta game_over
        lda #$02
        sta $d027               ; ship turns red
        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: 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   ;

Now the field has depth — the front rushes past while the back barely moves, and the ship finally feels like it's travelling through somewhere:

Three depths at three speeds: bright white stars race down the front, light-grey ones drift slower, and dim grey ones crawl at the back — flat space becomes deep space.

When it's wrong, see why

The pointer and the loop order are where star bugs live:

  • No stars, or they land in the wrong place. The pointer setup. $fb/$fc must be loaded from row_addr_lo/row_addr_hi for the star's row, with Y holding the column, before the sta ($fb),y. A pointer left from a previous star scatters characters across the screen.
  • Stars are invisible until something else colours them. The colour write. After $d4 is added to $fc, the same sta ($fb),y must run again with the colour value — skip it and the character is drawn in whatever colour the cell already had (often black on black).
  • Stars leave a trail of asterisks. Erase order. Each star must be erased at its old position before it moves, then drawn at the new one. Move first and the old cell is never cleared.
  • The score or lives digit flickers or vanishes. A star column hit 0, 1, or 39. Those cells hold the readouts; keep every star's column away from them.
  • All the layers move together. The frame-count masks. Far should advance only when frame_count and %00000011 is zero, middle when and %00000001 is zero; a wrong mask collapses the depth back to one speed.

Before and after

We started with the game adrift in black and finished with it moving through a three-deep starfield — the same sta ($fb),y the Primer taught, now carrying the whole background. The depth is free in cycles: it's the same draw loop, with two and masks deciding which stars step this frame. Flat became deep for the cost of a brightness table and a comparison.

Try this

  • A denser sky. Add more stars — extend the tables and the loop count. Watch the cost: every star is an erase and a draw each frame, so there's a budget; find where it starts to bite.
  • A fourth, faster layer. Add a near-near layer that moves two rows a frame for streaking, close-up stars. Speed alone changes how fast the game feels like it's flying.
  • Twinkle. Flip a star's colour between two values based on a bit of frame_count, and the field shimmers — plenty of life for a couple of instructions.

What you've learnt

  • Indirect indexed addressingsta ($fb),y: a pointer you aim at a row, plus Y for the column, to write anywhere on screen without arithmetic in the loop. The Primer's "finger on the boxes," put to work.
  • A row-address lookup table — the start of every row precomputed, so the per-frame loop never multiplies by 40.
  • The colour-RAM mirror — add $d4 to a screen pointer's high byte to reach the matching colour cell.
  • Parallax from speed — layers moving at different rates (set by power-of-two frame masks) read as depth, reinforced by brightness.

What's next

The game is whole — it plays, it looks like space, it ends and restarts. The one thing it drops you into with no warning is the game itself. Next, the final piece: a title screen, and the front-to-back states that turn a program into something that feels finished.