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.
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.
| 17 | 17 | game_over = $10 ; 0 = playing, 1 = the ship has been hit | |
| 18 | 18 | lives = $11 ; lives remaining (starts at 3) | |
| 19 | 19 | 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 | |
| 20 | 24 | | |
| 21 | 25 | ; ------------------------------------------------ | |
| 22 | 26 | ; BASIC stub | |
| ... | |||
| 123 | 127 | lda #$00 | |
| 124 | 128 | sta game_over | |
| 125 | 129 | 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 | |
| 126 | 143 | | |
| 127 | 144 | ; ------------------------------------------------ | |
| 128 | 145 | ; Game loop — runs once per frame | |
| ... | |||
| 142 | 159 | bne game_loop ; not pressed — hold the GAME OVER screen | |
| 143 | 160 | jmp start ; fire pressed — restart the whole game | |
| 144 | 161 | 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 | |
| 145 | 179 | | |
| 146 | 180 | ; --- Read joystick and move ship --- | |
| 147 | 181 | | |
| ... | |||
| 605 | 639 | inx | |
| 606 | 640 | cpx #$09 | |
| 607 | 641 | 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 | |
| 608 | 679 | 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 | |
| 609 | 703 | | |
| 610 | 704 | ; ------------------------------------------------ | |
| 611 | 705 | ; 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:
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.
| 160 | 160 | jmp start ; fire pressed — restart the whole game | |
| 161 | 161 | game_active: | |
| 162 | 162 | | |
| 163 | - | ; --- Scrolling starfield: erase, move down, redraw each star --- | |
| 163 | + | ; --- Parallax starfield: three depths move at three speeds --- | |
| 164 | 164 | inc frame_count | |
| 165 | 165 | ldx #$00 | |
| 166 | 166 | star_loop: | |
| 167 | 167 | 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 | |
| 169 | 185 | lda star_row,x | |
| 170 | 186 | cmp #25 | |
| 171 | 187 | 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 | |
| 173 | 189 | sta star_row,x | |
| 174 | 190 | star_move_done: | |
| 175 | 191 | jsr draw_star | |
| ... | |||
| 696 | 712 | !byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22 | |
| 697 | 713 | star_init_col: | |
| 698 | 714 | !byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25 | |
| 715 | + | ; Appearance reinforces the depth: near = bright white '*', far = dim grey '.' | |
| 699 | 716 | 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 | |
| 701 | 718 | 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 | |
| 703 | 720 | | |
| 704 | 721 | ; ------------------------------------------------ | |
| 705 | 722 | ; 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:
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/$fcmust be loaded fromrow_addr_lo/row_addr_hifor the star's row, with Y holding the column, before thesta ($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
$d4is added to$fc, the samesta ($fb),ymust 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, or39. 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 %00000011is zero, middle whenand %00000001is 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 addressing —
sta ($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
$d4to 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.