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

Title Screen

Turn the program into a finished game — a title screen and a three-state machine (title, playing, game over) that opens, plays, ends, and loops back to the start.

100% of Starfield

The game is complete — it flies, shoots, scores, ends, and looks like space. But switch the machine on and it drops you straight into the action, mid-wave, no warning. Real games have a front door: a title screen that names the game and waits for you to be ready. This final unit adds it, and in doing so turns the loose "playing / game over" handling into a proper state machine — the structure that makes a program feel like a finished thing.

Where we start

Unit 15 is the whole game, but it boots into play and, on game over, restarts straight back into play. We give it a title, and a clean cycle between its states.

Milestone 1 — a title screen and three states

So far a single flag, game_over, has carried the game's status: playing, or over. A title adds a third status, so the flag becomes a state: 0 title, 1 playing, 2 game over. The loop reads it once a frame and branches — the dispatch at the heart of every game.

Each state needs its own setup, so the old one-shot init splits in three. The one-time hardware (colours, SID, star positions) stays at the top and runs once. Everything game-specific moves into enter_game — spawn the wave, reset the score and lives, enable the sprites. And a new enter_title does the title: clear to the stars, switch the sprites off (the title has no ship or wave), and print STARFIELD and PRESS FIRE. Boot now opens the title; fire starts a game.

Step 1: a state machine with a title state
+201-102
1414 enemy_x_tbl = $07 ; 3 bytes ($07,$08,$09): each enemy's X
1515 enemy_y_tbl = $0a ; 3 bytes ($0a,$0b,$0c): each enemy's Y
1616 flash_tbl = $0d ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer
17-game_over = $10 ; 0 = playing, 1 = the ship has been hit
17+state = $10 ; 0 = title, 1 = playing, 2 = game over
1818 lives = $11 ; lives remaining (starts at 3)
1919 death_timer = $12 ; frames of post-hit flash (and, in step 2, invulnerability)
2020 frame_count = $13 ; free-running frame counter (parallax timing)
...
3333 ; ------------------------------------------------
3434 *= $080d
3535 start:
36- ; Black screen
36+ ; --- One-time hardware setup (runs once, not per game) ---
3737 lda #$00
38- sta $d020 ; Border colour
39- sta $d021 ; Background colour
40-
41- ; Clear the screen
42- ldx #$00
43-- lda #$20
44- sta $0400,x
45- sta $0500,x
46- sta $0600,x
47- sta $0700,x
48- inx
49- bne -
38+ sta $d020 ; border black
39+ sta $d021 ; background black
40+ sta $d010 ; ship 9th X bit clear
5041
51- ; Sprite 0 setup (ship)
52- lda #128
53- sta $07f8 ; Data pointer (block 128 = $2000)
54- lda #172
55- sta $d000 ; X position
56- lda #220
57- sta $d001 ; Y position
42+ ; Fixed sprite colours
5843 lda #$01
59- sta $d027 ; Colour (white)
60- lda #%00011101
61- sta $d015 ; Enable sprites 0 (ship), 2-4 (three enemies)
62- lda #$00
63- sta $d010 ; sprite high-X bits clear (ship starts under X=256)
64-
65- ; Sprite 1 setup (bullet)
66- lda #129
67- sta $07f9 ; Data pointer (block 129 = $2040)
44+ sta $d027 ; ship white
6845 lda #$07
69- sta $d028 ; Colour (yellow)
70-
71- ; Enemy sprites 2, 3 and 4 share the one enemy shape in block 130.
72- ; Colour and position are set per-enemy by spawn_enemy, below.
73- lda #130
74- sta $07fa ; sprite 2 data pointer
75- sta $07fb ; sprite 3 data pointer
76- sta $07fc ; sprite 4 data pointer
77-
78- ; Bullet starts inactive
79- lda #$00
80- sta bullet_active
46+ sta $d028 ; bullet yellow
8147
82- ; SID setup — voice 1 laser sound
48+ ; SID voice 1 — the laser
8349 lda #$0f
84- sta $d418 ; Volume to maximum
85-
50+ sta $d418 ; volume to maximum
8651 lda #$00
87- sta $d400 ; Frequency low byte
52+ sta $d400
8853 lda #$10
89- sta $d401 ; Frequency high byte ($1000 = mid-high pitch)
90-
54+ sta $d401
9155 lda #$06
92- sta $d405 ; Attack=0, Decay=6 (a short, snappy fall)
93- lda #$00
94- sta $d406 ; Sustain=0, Release=0
95-
96- ; Score readout: "00" in the top-left, white. (We've written to screen
97- ; RAM since Unit 1's clear loop — now we write characters, not spaces.)
98- lda #$00
99- sta score
100- lda #$30 ; screen code for '0'
101- sta $0400 ; tens digit (row 0, col 0)
102- sta $0401 ; ones digit (row 0, col 1)
103- lda #$01
104- sta $d800 ; colour both digits white
105- sta $d801
106-
107- ; Three lives, shown in the top-right corner
108- lda #$03
109- sta lives
110- lda #$33 ; screen code for '3'
111- sta $0427 ; row 0, col 39
112- lda #$01
113- sta $d827 ; colour white
114-
115- ; Spawn the wave at staggered heights (A = start Y, X = enemy index)
116- lda #$32
117- ldx #$00
118- jsr spawn_enemy
119- lda #$82
120- ldx #$01
121- jsr spawn_enemy
122- lda #$d2
123- ldx #$02
124- jsr spawn_enemy
125-
126- ; The game starts alive
56+ sta $d405
12757 lda #$00
128- sta game_over
129- sta death_timer ; not flashing
130- sta frame_count
58+ sta $d406
13159
132- ; Place the 12 stars at their start positions and paint them
60+ ; Star positions (drawn by enter_title / enter_game)
61+ sta frame_count ; A is still 0
13362 ldx #$00
13463 init_star_loop:
13564 lda star_init_row,x
13665 sta star_row,x
13766 lda star_init_col,x
13867 sta star_col,x
139- jsr draw_star
14068 inx
14169 cpx #12
14270 bne init_star_loop
71+
72+ ; Open on the title screen
73+ jsr enter_title
14374
14475 ; ------------------------------------------------
14576 ; Game loop — runs once per frame
...
15081 - lda $d012
15182 cmp #$ff
15283 bne -
153-
154- ; Game over? Wait for fire to restart; otherwise play on.
155- lda game_over
156- beq game_active
157- lda $dc00
158- and #%00010000 ; fire button (bit 4)
159- bne game_loop ; not pressed — hold the GAME OVER screen
160- jmp start ; fire pressed — restart the whole game
161-game_active:
16284
163- ; --- Parallax starfield: three depths move at three speeds ---
85+ ; --- Parallax starfield: scrolls in every state (title, play, over) ---
16486 inc frame_count
16587 ldx #$00
16688 star_loop:
...
192114 inx
193115 cpx #12
194116 bne star_loop
117+
118+ ; --- State machine: title (0) / playing (1) / game over (2) ---
119+ lda state
120+ beq title_state
121+ cmp #$02
122+ beq over_state
123+ jmp game_active ; 1 = playing
124+
125+title_state:
126+ jsr show_title ; repaint, in case a star scrolled across it
127+ lda $dc00
128+ and #%00010000 ; fire button (bit 4)
129+ bne loop_again ; not pressed — wait on the title
130+ jsr enter_game ; fire -> start a game
131+loop_again:
132+ jmp game_loop
133+
134+over_state:
135+ jsr show_game_over ; repaint over any star damage
136+ lda $dc00
137+ and #%00010000
138+ bne loop_again
139+ jsr enter_game ; fire -> play again
140+ jmp game_loop
141+
142+game_active:
195143
196144 ; --- Read joystick and move ship ---
197145
...
535483 lda lives
536484 bne life_lost ; lives remain -> respawn and play on
537485
538- ; Out of lives -> end the game (the freeze + restart from Unit 12)
539- lda #$01
540- sta game_over
486+ ; Out of lives -> game over state (the dispatch handles the freeze)
541487 lda #$02
542- sta $d027 ; ship turns red
488+ sta state
489+ sta $d027 ; ship turns red ($02 = red, reused here)
543490 jsr show_game_over
544491 jmp death_sound
545492
...
652599 lda #$01
653600 ldx #$00
654601 - sta $d9f0,x
602+ inx
603+ cpx #$09
604+ bne -
605+ rts
606+
607+; ------------------------------------------------
608+; Subroutine: clear_and_stars — wipe the screen, then repaint every star
609+; ------------------------------------------------
610+clear_and_stars:
611+ ldx #$00
612+cas_clear:
613+ lda #$20
614+ sta $0400,x
615+ sta $0500,x
616+ sta $0600,x
617+ sta $0700,x
618+ inx
619+ bne cas_clear
620+ ldx #$00
621+cas_draw:
622+ jsr draw_star
623+ inx
624+ cpx #12
625+ bne cas_draw
626+ rts
627+
628+; ------------------------------------------------
629+; Subroutine: enter_title — show the title, hide the game, state = 0
630+; ------------------------------------------------
631+enter_title:
632+ jsr clear_and_stars
633+ lda #$00
634+ sta $d015 ; all sprites off: the title has no ship or wave
635+ sta $d020 ; border black
636+ jsr show_title
637+ lda #$00
638+ sta state ; 0 = title
639+ rts
640+
641+; ------------------------------------------------
642+; Subroutine: enter_game — set up a fresh game, state = 1
643+; ------------------------------------------------
644+enter_game:
645+ jsr clear_and_stars
646+ ; The sprite data pointers live in screen RAM ($07f8+), so the clear just
647+ ; wiped them — set them here, after the clear, or the sprites show garbage.
648+ lda #128
649+ sta $07f8 ; ship
650+ lda #129
651+ sta $07f9 ; bullet
652+ lda #130
653+ sta $07fa ; enemy 0
654+ sta $07fb ; enemy 1
655+ sta $07fc ; enemy 2
656+ ; Ship at its start, white (it may have gone red on game over)
657+ lda #172
658+ sta $d000
659+ lda #220
660+ sta $d001
661+ lda #$01
662+ sta $d027
663+ lda #$00
664+ sta $d010
665+ ; Enable ship + three enemies (the bullet stays off)
666+ lda #%00011101
667+ sta $d015
668+ ; Spawn the wave at staggered heights
669+ lda #$32
670+ ldx #$00
671+ jsr spawn_enemy
672+ lda #$82
673+ ldx #$01
674+ jsr spawn_enemy
675+ lda #$d2
676+ ldx #$02
677+ jsr spawn_enemy
678+ ; Reset per-game state
679+ lda #$00
680+ sta bullet_active
681+ sta death_timer
682+ sta $d020 ; border black
683+ sta score
684+ ; Score "00", white
685+ lda #$30
686+ sta $0400
687+ sta $0401
688+ lda #$01
689+ sta $d800
690+ sta $d801
691+ ; Lives "3", white
692+ lda #$03
693+ sta lives
694+ lda #$33
695+ sta $0427
696+ lda #$01
697+ sta $d827
698+ ; state = playing
699+ lda #$01
700+ sta state
701+ rts
702+
703+; ------------------------------------------------
704+; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
705+; ------------------------------------------------
706+show_title:
707+ lda #$13 ; S
708+ sta $05a0
709+ lda #$14 ; T
710+ sta $05a1
711+ lda #$01 ; A
712+ sta $05a2
713+ lda #$12 ; R
714+ sta $05a3
715+ lda #$06 ; F
716+ sta $05a4
717+ lda #$09 ; I
718+ sta $05a5
719+ lda #$05 ; E
720+ sta $05a6
721+ lda #$0c ; L
722+ sta $05a7
723+ lda #$04 ; D
724+ sta $05a8
725+ lda #$01 ; white
726+ ldx #$00
727+- sta $d9a0,x
655728 inx
656729 cpx #$09
730+ bne -
731+ lda #$10 ; P
732+ sta $063f
733+ lda #$12 ; R
734+ sta $0640
735+ lda #$05 ; E
736+ sta $0641
737+ lda #$13 ; S
738+ sta $0642
739+ lda #$13 ; S
740+ sta $0643
741+ lda #$20 ; (space)
742+ sta $0644
743+ lda #$06 ; F
744+ sta $0645
745+ lda #$09 ; I
746+ sta $0646
747+ lda #$12 ; R
748+ sta $0647
749+ lda #$05 ; E
750+ sta $0648
751+ lda #$0f ; light grey
752+ ldx #$00
753+- sta $da3f,x
754+ inx
755+ cpx #$0a
657756 bne -
658757 rts
659758
The complete step 1 program
; Starfield - Unit 16: Title Screen
; Cumulative steps: step-00 (boots into play) -> step-01 (+ a title screen: press fire to start) -> step-02 (+ game over returns to the title)
; Assemble: acme -f cbm -o <step>.prg <step>.asm

; ------------------------------------------------
; Zero-page variables
; ------------------------------------------------
bullet_active = $02     ; 0 = no bullet, 1 = active
bullet_y      = $03     ; Bullet Y position
laser_timer   = $04     ; Frames of laser pitch-sweep remaining (0 = idle)
laser_freq    = $05     ; Our copy of the sweep pitch (SID freq regs are write-only)
score         = $06     ; Two-digit score, BCD (one decimal digit per nybble)
; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
enemy_x_tbl   = $07     ; 3 bytes ($07,$08,$09): each enemy's X
enemy_y_tbl   = $0a     ; 3 bytes ($0a,$0b,$0c): each enemy's Y
flash_tbl     = $0d     ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer
state         = $10     ; 0 = title, 1 = playing, 2 = game over
lives         = $11     ; lives remaining (starts at 3)
death_timer   = $12     ; frames of post-hit flash (and, in step 2, invulnerability)
frame_count   = $13     ; free-running frame counter (parallax timing)
star_row      = $14     ; 12 stars: row of each   ($14-$1f)
star_col      = $20     ; 12 stars: column of each ($20-$2b)
; $fb/$fc: scratch pointer used by the star routines

; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00

; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
start:
        ; --- One-time hardware setup (runs once, not per game) ---
        lda #$00
        sta $d020           ; border black
        sta $d021           ; background black
        sta $d010           ; ship 9th X bit clear

        ; Fixed sprite colours
        lda #$01
        sta $d027           ; ship white
        lda #$07
        sta $d028           ; bullet yellow

        ; SID voice 1 — the laser
        lda #$0f
        sta $d418           ; volume to maximum
        lda #$00
        sta $d400
        lda #$10
        sta $d401
        lda #$06
        sta $d405
        lda #$00
        sta $d406

        ; Star positions (drawn by enter_title / enter_game)
        sta frame_count     ; A is still 0
        ldx #$00
init_star_loop:
        lda star_init_row,x
        sta star_row,x
        lda star_init_col,x
        sta star_col,x
        inx
        cpx #12
        bne init_star_loop

        ; Open on the title screen
        jsr enter_title

; ------------------------------------------------
; Game loop — runs once per frame
; ------------------------------------------------
game_loop:
        ; Wait for the raster beam to reach line 255
        ; This syncs our code to the display (~50Hz PAL)
-       lda $d012
        cmp #$ff
        bne -

        ; --- Parallax starfield: scrolls in every state (title, play, over) ---
        inc frame_count
        ldx #$00
star_loop:
        jsr erase_star
        ; Does THIS star move this frame? Near (0-3) every frame, mid (4-7)
        ; every 2nd frame, far (8-11) every 4th frame.
        cpx #$04
        bcc star_do_move        ; near layer: always
        cpx #$08
        bcc star_mid            ; mid layer
        ; far layer: only when the low two frame bits are clear (1 in 4)
        lda frame_count
        and #%00000011
        bne star_move_done
        beq star_do_move
star_mid:
        lda frame_count
        and #%00000001          ; every other frame
        bne star_move_done
star_do_move:
        inc star_row,x          ; one row down
        lda star_row,x
        cmp #25
        bcc star_move_done
        lda #$00                ; past the bottom -> wrap to the top
        sta star_row,x
star_move_done:
        jsr draw_star
        inx
        cpx #12
        bne star_loop

        ; --- State machine: title (0) / playing (1) / game over (2) ---
        lda state
        beq title_state
        cmp #$02
        beq over_state
        jmp game_active             ; 1 = playing

title_state:
        jsr show_title              ; repaint, in case a star scrolled across it
        lda $dc00
        and #%00010000              ; fire button (bit 4)
        bne loop_again              ; not pressed — wait on the title
        jsr enter_game              ; fire -> start a game
loop_again:
        jmp game_loop

over_state:
        jsr show_game_over          ; repaint over any star damage
        lda $dc00
        and #%00010000
        bne loop_again
        jsr enter_game              ; fire -> play again
        jmp game_loop

game_active:

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

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

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

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

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

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

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

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

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

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

        lda #$01
        sta bullet_active

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

no_fire:

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

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

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

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

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

no_bullet:

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

        ; not flashing: drift down 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 -> game over state (the dispatch handles the freeze)
        lda #$02
        sta state
        sta $d027               ; ship turns red ($02 = red, reused here)
        jsr show_game_over
        jmp death_sound

life_lost:
        ; Respawn the ship at its start position
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda $d010
        and #%11111110          ; clear the ship's 9th bit (back under X=256)
        sta $d010

        ; Start the life-lost flash (step 2 makes it an invulnerability window too)
        lda #90
        sta death_timer

death_sound:
        ; Death sound — SID voice 3 (plays on every death)
        lda #$00
        sta $d40e               ; voice 3 frequency low
        lda #$10
        sta $d40f               ; voice 3 frequency high
        lda #$0a
        sta $d413               ; attack 0, decay 10 (a long, slow fade)
        lda #$00
        sta $d414               ; sustain 0, release 0
        lda #$20
        sta $d412               ; sawtooth, gate OFF (reset the envelope)
        lda #$21
        sta $d412               ; sawtooth, gate ON (trigger)

no_ship_hit:

        ; --- Life-lost flash: while the timer runs, blink the border ---
        lda death_timer
        beq flash_done
        dec death_timer
        lda death_timer
        and #%00001000          ; bit 3 toggles every 8 frames
        bne flash_bright
        lda #$00                ; dark phase
        sta $d020
        jmp flash_tick
flash_bright:
        lda #$02                ; bright phase (red border)
        sta $d020
flash_tick:
        lda death_timer
        bne flash_done
        lda #$00                ; just expired -> border back to black
        sta $d020
flash_done:

        jmp game_loop

; ------------------------------------------------
; Subroutine: spawn one enemy
;   A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
        sta enemy_y_tbl,x
        lda $d012               ; raster line -> pseudo-random column
        and #$7f
        clc
        adc #$30                ; 48-175, inside the visible width
        sta enemy_x_tbl,x
        lda #$00
        sta flash_tbl,x         ; not flashing
        ldy sprite_colour_off,x
        lda #$05
        sta $d000,y             ; this enemy's colour = green
        ldy sprite_pos_off,x
        lda enemy_x_tbl,x
        sta $d000,y             ; sprite X
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y
        rts

; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
sprite_pos_off:
        !byte $04, $06, $08     ; X offsets: $d004, $d006, $d008
sprite_colour_off:
        !byte $29, $2a, $2b     ; colour offsets: $d029, $d02a, $d02b

; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
;   Row 12 x 40 + 16 = 496 = $1f0, so screen RAM $05f0, colour RAM $d9f0
; ------------------------------------------------
show_game_over:
        lda #$07            ; G
        sta $05f0
        lda #$01            ; A
        sta $05f1
        lda #$0d            ; M
        sta $05f2
        lda #$05            ; E
        sta $05f3
        lda #$20            ; (space)
        sta $05f4
        lda #$0f            ; O
        sta $05f5
        lda #$16            ; V
        sta $05f6
        lda #$05            ; E
        sta $05f7
        lda #$12            ; R
        sta $05f8
        ; colour the nine cells white ($d9f0..$d9f8)
        lda #$01
        ldx #$00
-       sta $d9f0,x
        inx
        cpx #$09
        bne -
        rts

; ------------------------------------------------
; Subroutine: clear_and_stars — wipe the screen, then repaint every star
; ------------------------------------------------
clear_and_stars:
        ldx #$00
cas_clear:
        lda #$20
        sta $0400,x
        sta $0500,x
        sta $0600,x
        sta $0700,x
        inx
        bne cas_clear
        ldx #$00
cas_draw:
        jsr draw_star
        inx
        cpx #12
        bne cas_draw
        rts

; ------------------------------------------------
; Subroutine: enter_title — show the title, hide the game, state = 0
; ------------------------------------------------
enter_title:
        jsr clear_and_stars
        lda #$00
        sta $d015               ; all sprites off: the title has no ship or wave
        sta $d020               ; border black
        jsr show_title
        lda #$00
        sta state               ; 0 = title
        rts

; ------------------------------------------------
; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
        jsr clear_and_stars
        ; The sprite data pointers live in screen RAM ($07f8+), so the clear just
        ; wiped them — set them here, after the clear, or the sprites show garbage.
        lda #128
        sta $07f8           ; ship
        lda #129
        sta $07f9           ; bullet
        lda #130
        sta $07fa           ; enemy 0
        sta $07fb           ; enemy 1
        sta $07fc           ; enemy 2
        ; Ship at its start, white (it may have gone red on game over)
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda #$01
        sta $d027
        lda #$00
        sta $d010
        ; Enable ship + three enemies (the bullet stays off)
        lda #%00011101
        sta $d015
        ; Spawn the wave at staggered heights
        lda #$32
        ldx #$00
        jsr spawn_enemy
        lda #$82
        ldx #$01
        jsr spawn_enemy
        lda #$d2
        ldx #$02
        jsr spawn_enemy
        ; Reset per-game state
        lda #$00
        sta bullet_active
        sta death_timer
        sta $d020               ; border black
        sta score
        ; Score "00", white
        lda #$30
        sta $0400
        sta $0401
        lda #$01
        sta $d800
        sta $d801
        ; Lives "3", white
        lda #$03
        sta lives
        lda #$33
        sta $0427
        lda #$01
        sta $d827
        ; state = playing
        lda #$01
        sta state
        rts

; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
; ------------------------------------------------
show_title:
        lda #$13            ; S
        sta $05a0
        lda #$14            ; T
        sta $05a1
        lda #$01            ; A
        sta $05a2
        lda #$12            ; R
        sta $05a3
        lda #$06            ; F
        sta $05a4
        lda #$09            ; I
        sta $05a5
        lda #$05            ; E
        sta $05a6
        lda #$0c            ; L
        sta $05a7
        lda #$04            ; D
        sta $05a8
        lda #$01            ; white
        ldx #$00
-       sta $d9a0,x
        inx
        cpx #$09
        bne -
        lda #$10            ; P
        sta $063f
        lda #$12            ; R
        sta $0640
        lda #$05            ; E
        sta $0641
        lda #$13            ; S
        sta $0642
        lda #$13            ; S
        sta $0643
        lda #$20            ; (space)
        sta $0644
        lda #$06            ; F
        sta $0645
        lda #$09            ; I
        sta $0646
        lda #$12            ; R
        sta $0647
        lda #$05            ; E
        sta $0648
        lda #$0f            ; light grey
        ldx #$00
-       sta $da3f,x
        inx
        cpx #$0a
        bne -
        rts

; ------------------------------------------------
; Subroutine: erase_star  (X = star index)
;   blanks the star's current cell back to a space
; ------------------------------------------------
erase_star:
        ldy star_row,x
        lda row_addr_lo,y       ; point $fb/$fc at the start of this star's row
        sta $fb
        lda row_addr_hi,y
        sta $fc
        ldy star_col,x          ; Y = the column offset along that row
        lda #$20                ; a space
        sta ($fb),y             ; "finger on the boxes" — pointer + Y offset
        rts

; ------------------------------------------------
; Subroutine: draw_star  (X = star index)
;   writes the star's character + colour at its (row, col)
; ------------------------------------------------
draw_star:
        ldy star_row,x
        lda row_addr_lo,y
        sta $fb                 ; row start, low byte
        lda row_addr_hi,y
        sta $fc                 ; row start, high byte
        ldy star_col,x          ; Y = column
        lda star_char_tbl,x
        sta ($fb),y             ; STA ($fb),Y -> the screen-RAM cell
        ; screen RAM $04xx-$07xx maps to colour RAM $d8xx-$dbxx: high byte + $d4
        lda $fc
        clc
        adc #$d4
        sta $fc
        lda star_colour_tbl,x
        sta ($fb),y             ; same column offset, now into colour RAM
        rts

; ------------------------------------------------
; Star data tables
; ------------------------------------------------
; Screen-RAM start address of each row (row x 40 + $0400), rows 0-24
row_addr_lo:
        !byte $00,$28,$50,$78,$a0,$c8,$f0,$18
        !byte $40,$68,$90,$b8,$e0,$08,$30,$58
        !byte $80,$a8,$d0,$f8,$20,$48,$70,$98,$c0
row_addr_hi:
        !byte $04,$04,$04,$04,$04,$04,$04,$05
        !byte $05,$05,$05,$05,$05,$06,$06,$06
        !byte $06,$06,$06,$06,$07,$07,$07,$07,$07

; 12 stars. Columns avoid 0, 1 and 39 — the score and lives cells.
star_init_row:
        !byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
star_init_col:
        !byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
; Appearance reinforces the depth: near = bright white '*', far = dim grey '.'
star_char_tbl:
        !byte $2a,$2a,$2a,$2a, $2a,$2a,$2a,$2a, $2e,$2e,$2e,$2e
star_colour_tbl:
        !byte $01,$01,$01,$01, $0f,$0f,$0f,$0f, $0b,$0b,$0b,$0b

; ------------------------------------------------
; Sprite data at $2000 (block 128) — ship
; ------------------------------------------------
*= $2000
        !byte $00,$18,$00   ;        ##
        !byte $00,$3c,$00   ;       ####
        !byte $00,$3c,$00   ;       ####
        !byte $00,$7e,$00   ;      ######
        !byte $00,$7e,$00   ;      ######
        !byte $00,$ff,$00   ;     ########
        !byte $00,$ff,$00   ;     ########
        !byte $01,$ff,$80   ;    ##########
        !byte $03,$ff,$c0   ;   ############
        !byte $07,$ff,$e0   ;  ##############
        !byte $07,$ff,$e0   ;  ##############
        !byte $07,$e7,$e0   ;  ###..####..###
        !byte $03,$c3,$c0   ;   ##....##....##
        !byte $01,$ff,$80   ;    ##########
        !byte $00,$ff,$00   ;     ########
        !byte $00,$ff,$00   ;     ########
        !byte $00,$db,$00   ;     ##.##.##
        !byte $00,$db,$00   ;     ##.##.##
        !byte $00,$66,$00   ;      ##..##
        !byte $00,$24,$00   ;       #..#
        !byte $00,$00,$00   ;

; ------------------------------------------------
; Sprite data at $2040 (block 129) — bullet
; ------------------------------------------------
*= $2040
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$18,$00   ;        ##
        !byte $00,$18,$00   ;        ##
        !byte $00,$18,$00   ;        ##
        !byte $00,$18,$00   ;        ##
        !byte $00,$18,$00   ;        ##
        !byte $00,$18,$00   ;        ##
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
; ------------------------------------------------
; Sprite data at $2080 (block 130) — enemy
; ------------------------------------------------
*= $2080
        !byte $00,$66,$00   ;      ##..##
        !byte $00,$3c,$00   ;       ####
        !byte $00,$7e,$00   ;      ######
        !byte $00,$db,$00   ;     ##.##.##
        !byte $00,$ff,$00   ;     ########
        !byte $01,$ff,$80   ;    ##########
        !byte $01,$7e,$80   ;    #.######.#
        !byte $01,$3c,$80   ;    #..####..#
        !byte $00,$a5,$00   ;     #.#..#.#
        !byte $01,$81,$80   ;    ##......##
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;

One subtlety bit hard while building this, and it's worth knowing: the sprite data pointers live in screen RAM ($07f8$07fc, the last bytes of the screen). Clearing the screen wipes them — so they must be set after every clear, inside enter_game, not once at boot. Set them before the clear and the sprites render garbage. Here's the title, and the game it starts:

The game opens on a title — STARFIELD and PRESS FIRE over the drifting stars, no ship or wave. Press fire and the game proper begins: ship, enemies, score and lives.

Milestone 2 — closing the loop

The cycle still has a loose end: game over restarts straight into a new game, skipping the title we just built. With the state machine in place, fixing that is a single line — game over's fire press calls enter_title instead of enter_game.

Step 2: game over returns to the title
+1-1
136136 lda $dc00
137137 and #%00010000
138138 bne loop_again
139- jsr enter_game ; fire -> play again
139+ jsr enter_title ; fire -> back to the title screen
140140 jmp game_loop
141141
142142 game_active:
The complete program
; Starfield - Unit 16: Title Screen
; Cumulative steps: step-00 (boots into play) -> step-01 (+ a title screen: press fire to start) -> step-02 (+ game over returns to the title)
; Assemble: acme -f cbm -o <step>.prg <step>.asm

; ------------------------------------------------
; Zero-page variables
; ------------------------------------------------
bullet_active = $02     ; 0 = no bullet, 1 = active
bullet_y      = $03     ; Bullet Y position
laser_timer   = $04     ; Frames of laser pitch-sweep remaining (0 = idle)
laser_freq    = $05     ; Our copy of the sweep pitch (SID freq regs are write-only)
score         = $06     ; Two-digit score, BCD (one decimal digit per nybble)
; Parallel arrays — index 0,1,2 picks enemy 0,1,2 (sprites 2,3,4)
enemy_x_tbl   = $07     ; 3 bytes ($07,$08,$09): each enemy's X
enemy_y_tbl   = $0a     ; 3 bytes ($0a,$0b,$0c): each enemy's Y
flash_tbl     = $0d     ; 3 bytes ($0d,$0e,$0f): each enemy's flash timer
state         = $10     ; 0 = title, 1 = playing, 2 = game over
lives         = $11     ; lives remaining (starts at 3)
death_timer   = $12     ; frames of post-hit flash (and, in step 2, invulnerability)
frame_count   = $13     ; free-running frame counter (parallax timing)
star_row      = $14     ; 12 stars: row of each   ($14-$1f)
star_col      = $20     ; 12 stars: column of each ($20-$2b)
; $fb/$fc: scratch pointer used by the star routines

; ------------------------------------------------
; BASIC stub
; ------------------------------------------------
*= $0801
!byte $0c,$08,$0a,$00,$9e,$32,$30,$36,$31,$00,$00,$00

; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
start:
        ; --- One-time hardware setup (runs once, not per game) ---
        lda #$00
        sta $d020           ; border black
        sta $d021           ; background black
        sta $d010           ; ship 9th X bit clear

        ; Fixed sprite colours
        lda #$01
        sta $d027           ; ship white
        lda #$07
        sta $d028           ; bullet yellow

        ; SID voice 1 — the laser
        lda #$0f
        sta $d418           ; volume to maximum
        lda #$00
        sta $d400
        lda #$10
        sta $d401
        lda #$06
        sta $d405
        lda #$00
        sta $d406

        ; Star positions (drawn by enter_title / enter_game)
        sta frame_count     ; A is still 0
        ldx #$00
init_star_loop:
        lda star_init_row,x
        sta star_row,x
        lda star_init_col,x
        sta star_col,x
        inx
        cpx #12
        bne init_star_loop

        ; Open on the title screen
        jsr enter_title

; ------------------------------------------------
; Game loop — runs once per frame
; ------------------------------------------------
game_loop:
        ; Wait for the raster beam to reach line 255
        ; This syncs our code to the display (~50Hz PAL)
-       lda $d012
        cmp #$ff
        bne -

        ; --- Parallax starfield: scrolls in every state (title, play, over) ---
        inc frame_count
        ldx #$00
star_loop:
        jsr erase_star
        ; Does THIS star move this frame? Near (0-3) every frame, mid (4-7)
        ; every 2nd frame, far (8-11) every 4th frame.
        cpx #$04
        bcc star_do_move        ; near layer: always
        cpx #$08
        bcc star_mid            ; mid layer
        ; far layer: only when the low two frame bits are clear (1 in 4)
        lda frame_count
        and #%00000011
        bne star_move_done
        beq star_do_move
star_mid:
        lda frame_count
        and #%00000001          ; every other frame
        bne star_move_done
star_do_move:
        inc star_row,x          ; one row down
        lda star_row,x
        cmp #25
        bcc star_move_done
        lda #$00                ; past the bottom -> wrap to the top
        sta star_row,x
star_move_done:
        jsr draw_star
        inx
        cpx #12
        bne star_loop

        ; --- State machine: title (0) / playing (1) / game over (2) ---
        lda state
        beq title_state
        cmp #$02
        beq over_state
        jmp game_active             ; 1 = playing

title_state:
        jsr show_title              ; repaint, in case a star scrolled across it
        lda $dc00
        and #%00010000              ; fire button (bit 4)
        bne loop_again              ; not pressed — wait on the title
        jsr enter_game              ; fire -> start a game
loop_again:
        jmp game_loop

over_state:
        jsr show_game_over          ; repaint over any star damage
        lda $dc00
        and #%00010000
        bne loop_again
        jsr enter_title             ; fire -> back to the title screen
        jmp game_loop

game_active:

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

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

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

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

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

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

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

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

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

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

        lda #$01
        sta bullet_active

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

no_fire:

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

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

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

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

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

no_bullet:

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

        ; not flashing: drift down 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 -> game over state (the dispatch handles the freeze)
        lda #$02
        sta state
        sta $d027               ; ship turns red ($02 = red, reused here)
        jsr show_game_over
        jmp death_sound

life_lost:
        ; Respawn the ship at its start position
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda $d010
        and #%11111110          ; clear the ship's 9th bit (back under X=256)
        sta $d010

        ; Start the life-lost flash (step 2 makes it an invulnerability window too)
        lda #90
        sta death_timer

death_sound:
        ; Death sound — SID voice 3 (plays on every death)
        lda #$00
        sta $d40e               ; voice 3 frequency low
        lda #$10
        sta $d40f               ; voice 3 frequency high
        lda #$0a
        sta $d413               ; attack 0, decay 10 (a long, slow fade)
        lda #$00
        sta $d414               ; sustain 0, release 0
        lda #$20
        sta $d412               ; sawtooth, gate OFF (reset the envelope)
        lda #$21
        sta $d412               ; sawtooth, gate ON (trigger)

no_ship_hit:

        ; --- Life-lost flash: while the timer runs, blink the border ---
        lda death_timer
        beq flash_done
        dec death_timer
        lda death_timer
        and #%00001000          ; bit 3 toggles every 8 frames
        bne flash_bright
        lda #$00                ; dark phase
        sta $d020
        jmp flash_tick
flash_bright:
        lda #$02                ; bright phase (red border)
        sta $d020
flash_tick:
        lda death_timer
        bne flash_done
        lda #$00                ; just expired -> border back to black
        sta $d020
flash_done:

        jmp game_loop

; ------------------------------------------------
; Subroutine: spawn one enemy
;   A = starting Y, X = enemy index (X is preserved)
; ------------------------------------------------
spawn_enemy:
        sta enemy_y_tbl,x
        lda $d012               ; raster line -> pseudo-random column
        and #$7f
        clc
        adc #$30                ; 48-175, inside the visible width
        sta enemy_x_tbl,x
        lda #$00
        sta flash_tbl,x         ; not flashing
        ldy sprite_colour_off,x
        lda #$05
        sta $d000,y             ; this enemy's colour = green
        ldy sprite_pos_off,x
        lda enemy_x_tbl,x
        sta $d000,y             ; sprite X
        lda enemy_y_tbl,x
        sta $d001,y             ; sprite Y
        rts

; Per-enemy VIC-II register offsets (sprites 2, 3, 4)
sprite_pos_off:
        !byte $04, $06, $08     ; X offsets: $d004, $d006, $d008
sprite_colour_off:
        !byte $29, $2a, $2b     ; colour offsets: $d029, $d02a, $d02b

; ------------------------------------------------
; Subroutine: print "GAME OVER" at row 12, column 16
;   Row 12 x 40 + 16 = 496 = $1f0, so screen RAM $05f0, colour RAM $d9f0
; ------------------------------------------------
show_game_over:
        lda #$07            ; G
        sta $05f0
        lda #$01            ; A
        sta $05f1
        lda #$0d            ; M
        sta $05f2
        lda #$05            ; E
        sta $05f3
        lda #$20            ; (space)
        sta $05f4
        lda #$0f            ; O
        sta $05f5
        lda #$16            ; V
        sta $05f6
        lda #$05            ; E
        sta $05f7
        lda #$12            ; R
        sta $05f8
        ; colour the nine cells white ($d9f0..$d9f8)
        lda #$01
        ldx #$00
-       sta $d9f0,x
        inx
        cpx #$09
        bne -
        rts

; ------------------------------------------------
; Subroutine: clear_and_stars — wipe the screen, then repaint every star
; ------------------------------------------------
clear_and_stars:
        ldx #$00
cas_clear:
        lda #$20
        sta $0400,x
        sta $0500,x
        sta $0600,x
        sta $0700,x
        inx
        bne cas_clear
        ldx #$00
cas_draw:
        jsr draw_star
        inx
        cpx #12
        bne cas_draw
        rts

; ------------------------------------------------
; Subroutine: enter_title — show the title, hide the game, state = 0
; ------------------------------------------------
enter_title:
        jsr clear_and_stars
        lda #$00
        sta $d015               ; all sprites off: the title has no ship or wave
        sta $d020               ; border black
        jsr show_title
        lda #$00
        sta state               ; 0 = title
        rts

; ------------------------------------------------
; Subroutine: enter_game — set up a fresh game, state = 1
; ------------------------------------------------
enter_game:
        jsr clear_and_stars
        ; The sprite data pointers live in screen RAM ($07f8+), so the clear just
        ; wiped them — set them here, after the clear, or the sprites show garbage.
        lda #128
        sta $07f8           ; ship
        lda #129
        sta $07f9           ; bullet
        lda #130
        sta $07fa           ; enemy 0
        sta $07fb           ; enemy 1
        sta $07fc           ; enemy 2
        ; Ship at its start, white (it may have gone red on game over)
        lda #172
        sta $d000
        lda #220
        sta $d001
        lda #$01
        sta $d027
        lda #$00
        sta $d010
        ; Enable ship + three enemies (the bullet stays off)
        lda #%00011101
        sta $d015
        ; Spawn the wave at staggered heights
        lda #$32
        ldx #$00
        jsr spawn_enemy
        lda #$82
        ldx #$01
        jsr spawn_enemy
        lda #$d2
        ldx #$02
        jsr spawn_enemy
        ; Reset per-game state
        lda #$00
        sta bullet_active
        sta death_timer
        sta $d020               ; border black
        sta score
        ; Score "00", white
        lda #$30
        sta $0400
        sta $0401
        lda #$01
        sta $d800
        sta $d801
        ; Lives "3", white
        lda #$03
        sta lives
        lda #$33
        sta $0427
        lda #$01
        sta $d827
        ; state = playing
        lda #$01
        sta state
        rts

; ------------------------------------------------
; Subroutine: show_title — "STARFIELD" (row 10) and "PRESS FIRE" (row 14)
; ------------------------------------------------
show_title:
        lda #$13            ; S
        sta $05a0
        lda #$14            ; T
        sta $05a1
        lda #$01            ; A
        sta $05a2
        lda #$12            ; R
        sta $05a3
        lda #$06            ; F
        sta $05a4
        lda #$09            ; I
        sta $05a5
        lda #$05            ; E
        sta $05a6
        lda #$0c            ; L
        sta $05a7
        lda #$04            ; D
        sta $05a8
        lda #$01            ; white
        ldx #$00
-       sta $d9a0,x
        inx
        cpx #$09
        bne -
        lda #$10            ; P
        sta $063f
        lda #$12            ; R
        sta $0640
        lda #$05            ; E
        sta $0641
        lda #$13            ; S
        sta $0642
        lda #$13            ; S
        sta $0643
        lda #$20            ; (space)
        sta $0644
        lda #$06            ; F
        sta $0645
        lda #$09            ; I
        sta $0646
        lda #$12            ; R
        sta $0647
        lda #$05            ; E
        sta $0648
        lda #$0f            ; light grey
        ldx #$00
-       sta $da3f,x
        inx
        cpx #$0a
        bne -
        rts

; ------------------------------------------------
; Subroutine: erase_star  (X = star index)
;   blanks the star's current cell back to a space
; ------------------------------------------------
erase_star:
        ldy star_row,x
        lda row_addr_lo,y       ; point $fb/$fc at the start of this star's row
        sta $fb
        lda row_addr_hi,y
        sta $fc
        ldy star_col,x          ; Y = the column offset along that row
        lda #$20                ; a space
        sta ($fb),y             ; "finger on the boxes" — pointer + Y offset
        rts

; ------------------------------------------------
; Subroutine: draw_star  (X = star index)
;   writes the star's character + colour at its (row, col)
; ------------------------------------------------
draw_star:
        ldy star_row,x
        lda row_addr_lo,y
        sta $fb                 ; row start, low byte
        lda row_addr_hi,y
        sta $fc                 ; row start, high byte
        ldy star_col,x          ; Y = column
        lda star_char_tbl,x
        sta ($fb),y             ; STA ($fb),Y -> the screen-RAM cell
        ; screen RAM $04xx-$07xx maps to colour RAM $d8xx-$dbxx: high byte + $d4
        lda $fc
        clc
        adc #$d4
        sta $fc
        lda star_colour_tbl,x
        sta ($fb),y             ; same column offset, now into colour RAM
        rts

; ------------------------------------------------
; Star data tables
; ------------------------------------------------
; Screen-RAM start address of each row (row x 40 + $0400), rows 0-24
row_addr_lo:
        !byte $00,$28,$50,$78,$a0,$c8,$f0,$18
        !byte $40,$68,$90,$b8,$e0,$08,$30,$58
        !byte $80,$a8,$d0,$f8,$20,$48,$70,$98,$c0
row_addr_hi:
        !byte $04,$04,$04,$04,$04,$04,$04,$05
        !byte $05,$05,$05,$05,$05,$06,$06,$06
        !byte $06,$06,$06,$06,$07,$07,$07,$07,$07

; 12 stars. Columns avoid 0, 1 and 39 — the score and lives cells.
star_init_row:
        !byte 2, 8, 14, 20, 5, 11, 17, 23, 3, 9, 16, 22
star_init_col:
        !byte 5, 28, 15, 35, 18, 7, 32, 22, 12, 30, 9, 25
; Appearance reinforces the depth: near = bright white '*', far = dim grey '.'
star_char_tbl:
        !byte $2a,$2a,$2a,$2a, $2a,$2a,$2a,$2a, $2e,$2e,$2e,$2e
star_colour_tbl:
        !byte $01,$01,$01,$01, $0f,$0f,$0f,$0f, $0b,$0b,$0b,$0b

; ------------------------------------------------
; Sprite data at $2000 (block 128) — ship
; ------------------------------------------------
*= $2000
        !byte $00,$18,$00   ;        ##
        !byte $00,$3c,$00   ;       ####
        !byte $00,$3c,$00   ;       ####
        !byte $00,$7e,$00   ;      ######
        !byte $00,$7e,$00   ;      ######
        !byte $00,$ff,$00   ;     ########
        !byte $00,$ff,$00   ;     ########
        !byte $01,$ff,$80   ;    ##########
        !byte $03,$ff,$c0   ;   ############
        !byte $07,$ff,$e0   ;  ##############
        !byte $07,$ff,$e0   ;  ##############
        !byte $07,$e7,$e0   ;  ###..####..###
        !byte $03,$c3,$c0   ;   ##....##....##
        !byte $01,$ff,$80   ;    ##########
        !byte $00,$ff,$00   ;     ########
        !byte $00,$ff,$00   ;     ########
        !byte $00,$db,$00   ;     ##.##.##
        !byte $00,$db,$00   ;     ##.##.##
        !byte $00,$66,$00   ;      ##..##
        !byte $00,$24,$00   ;       #..#
        !byte $00,$00,$00   ;

; ------------------------------------------------
; Sprite data at $2040 (block 129) — bullet
; ------------------------------------------------
*= $2040
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$18,$00   ;        ##
        !byte $00,$18,$00   ;        ##
        !byte $00,$18,$00   ;        ##
        !byte $00,$18,$00   ;        ##
        !byte $00,$18,$00   ;        ##
        !byte $00,$18,$00   ;        ##
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
        !byte $00,$00,$00
; ------------------------------------------------
; Sprite data at $2080 (block 130) — enemy
; ------------------------------------------------
*= $2080
        !byte $00,$66,$00   ;      ##..##
        !byte $00,$3c,$00   ;       ####
        !byte $00,$7e,$00   ;      ######
        !byte $00,$db,$00   ;     ##.##.##
        !byte $00,$ff,$00   ;     ########
        !byte $01,$ff,$80   ;    ##########
        !byte $01,$7e,$80   ;    #.######.#
        !byte $01,$3c,$80   ;    #..####..#
        !byte $00,$a5,$00   ;     #.#..#.#
        !byte $01,$81,$80   ;    ##......##
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;
        !byte $00,$00,$00   ;

Now the loop is closed: title, play, game over, back to the title — a complete arcade cycle from one well-named byte.

The full cycle: from the title, a game is played out until all three lives are lost; GAME OVER now offers a way back, and a press of fire returns to the title, ready to go again.

When it's wrong, see why

A state machine fails by getting stuck in the wrong state or doing the wrong setup:

  • The sprites are garbage. The pointer trap. $07f8$07fc sit in screen RAM, so the screen clear blanks them to spaces. They must be written in enter_game, after the clear — set them at boot and the first clear destroys them.
  • The title shows the ship and enemies. enter_title didn't disable the sprites. It must write $00 to $d015; the playing state turns them back on.
  • The game starts on its own / the title flashes past. Held fire chaining two transitions in one press: game over sets the state to title, and the same still-held press is then read by the title and starts a game. A real fix is to wait for the button to be released before accepting it again.
  • A state never changes. The dispatch or the state writes. state must be 0/1/2, set by enter_title/enter_game/the game-over path, and the loop's compares must route each value to its block.

Before and after

We started with a game that dropped you straight into the wave and finished with one that opens on a title, plays, ends, and returns — the difference between a program that runs a game loop and a game you sit down to. It cost one extra state value, two setup routines, and a dispatch; the whole game now turns on that single byte.

Try this

  • Debounce the button. Track last frame's fire state and only act on a fresh press, so holding fire through game over doesn't skip the title. It's the polish that separates "works" from "feels right."
  • Show the high score. Keep the best score in a spare byte and draw it on the title — survival now has a number to beat across games.
  • An attract mode. After a while on the title with no input, let a few demo frames play themselves. The endless attract loop is the sound of an arcade.

What you've learnt

  • A state machine — one byte naming the program's mode, read once a frame and dispatched; the backbone of a game's structure.
  • Per-state setup routinesenter_title and enter_game each put the machine into a known state, so a transition is a single call.
  • The screen-RAM pointer trap — sprite pointers live in the screen, so they survive only if set after the last clear.
  • Closing the loop — wiring every ending back to a beginning is what turns a running program into a finished game.

You've built a game

That's the whole of it. From a single white sprite on a black screen, you've assembled — by hand, in 6510 — a complete game: a ship you fly and clamp to the edges, a shot with a voice, a wave of enemies driven from one indexed loop, collisions that score and explode, lives and death and a fair respawn, a starfield with real depth, and a title that opens and closes the whole cycle. Every piece runs on the bare metal, with no operating system underneath doing the work for you.

The techniques here aren't specific to this game. Sprites and collision, sound and state, lookup tables and indirect addressing, the game loop and its machine — they're the vocabulary of every game on this hardware. The next game is the same vocabulary, arranged differently. Go and build it.