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.
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.
| 14 | 14 | enemy_x_tbl = $07 ; 3 bytes ($07,$08,$09): each enemy's X | |
| 15 | 15 | enemy_y_tbl = $0a ; 3 bytes ($0a,$0b,$0c): each enemy's Y | |
| 16 | 16 | 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 | |
| 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 | 20 | frame_count = $13 ; free-running frame counter (parallax timing) | |
| ... | |||
| 33 | 33 | ; ------------------------------------------------ | |
| 34 | 34 | *= $080d | |
| 35 | 35 | start: | |
| 36 | - | ; Black screen | |
| 36 | + | ; --- One-time hardware setup (runs once, not per game) --- | |
| 37 | 37 | 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 | |
| 50 | 41 | | |
| 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 | |
| 58 | 43 | 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 | |
| 68 | 45 | 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 | |
| 81 | 47 | | |
| 82 | - | ; SID setup — voice 1 laser sound | |
| 48 | + | ; SID voice 1 — the laser | |
| 83 | 49 | lda #$0f | |
| 84 | - | sta $d418 ; Volume to maximum | |
| 85 | - | | |
| 50 | + | sta $d418 ; volume to maximum | |
| 86 | 51 | lda #$00 | |
| 87 | - | sta $d400 ; Frequency low byte | |
| 52 | + | sta $d400 | |
| 88 | 53 | lda #$10 | |
| 89 | - | sta $d401 ; Frequency high byte ($1000 = mid-high pitch) | |
| 90 | - | | |
| 54 | + | sta $d401 | |
| 91 | 55 | 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 | |
| 127 | 57 | lda #$00 | |
| 128 | - | sta game_over | |
| 129 | - | sta death_timer ; not flashing | |
| 130 | - | sta frame_count | |
| 58 | + | sta $d406 | |
| 131 | 59 | | |
| 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 | |
| 133 | 62 | ldx #$00 | |
| 134 | 63 | init_star_loop: | |
| 135 | 64 | lda star_init_row,x | |
| 136 | 65 | sta star_row,x | |
| 137 | 66 | lda star_init_col,x | |
| 138 | 67 | sta star_col,x | |
| 139 | - | jsr draw_star | |
| 140 | 68 | inx | |
| 141 | 69 | cpx #12 | |
| 142 | 70 | bne init_star_loop | |
| 71 | + | | |
| 72 | + | ; Open on the title screen | |
| 73 | + | jsr enter_title | |
| 143 | 74 | | |
| 144 | 75 | ; ------------------------------------------------ | |
| 145 | 76 | ; Game loop — runs once per frame | |
| ... | |||
| 150 | 81 | - lda $d012 | |
| 151 | 82 | cmp #$ff | |
| 152 | 83 | 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: | |
| 162 | 84 | | |
| 163 | - | ; --- Parallax starfield: three depths move at three speeds --- | |
| 85 | + | ; --- Parallax starfield: scrolls in every state (title, play, over) --- | |
| 164 | 86 | inc frame_count | |
| 165 | 87 | ldx #$00 | |
| 166 | 88 | star_loop: | |
| ... | |||
| 192 | 114 | inx | |
| 193 | 115 | cpx #12 | |
| 194 | 116 | 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: | |
| 195 | 143 | | |
| 196 | 144 | ; --- Read joystick and move ship --- | |
| 197 | 145 | | |
| ... | |||
| 535 | 483 | lda lives | |
| 536 | 484 | bne life_lost ; lives remain -> respawn and play on | |
| 537 | 485 | | |
| 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) | |
| 541 | 487 | lda #$02 | |
| 542 | - | sta $d027 ; ship turns red | |
| 488 | + | sta state | |
| 489 | + | sta $d027 ; ship turns red ($02 = red, reused here) | |
| 543 | 490 | jsr show_game_over | |
| 544 | 491 | jmp death_sound | |
| 545 | 492 | | |
| ... | |||
| 652 | 599 | lda #$01 | |
| 653 | 600 | ldx #$00 | |
| 654 | 601 | - 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 | |
| 655 | 728 | inx | |
| 656 | 729 | 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 | |
| 657 | 756 | bne - | |
| 658 | 757 | rts | |
| 659 | 758 | |
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:
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.
| 136 | 136 | lda $dc00 | |
| 137 | 137 | and #%00010000 | |
| 138 | 138 | bne loop_again | |
| 139 | - | jsr enter_game ; fire -> play again | |
| 139 | + | jsr enter_title ; fire -> back to the title screen | |
| 140 | 140 | jmp game_loop | |
| 141 | 141 | | |
| 142 | 142 | 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.
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–$07fcsit in screen RAM, so the screen clear blanks them to spaces. They must be written inenter_game, after the clear — set them at boot and the first clear destroys them. - The title shows the ship and enemies.
enter_titledidn't disable the sprites. It must write$00to$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.
statemust be0/1/2, set byenter_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 routines —
enter_titleandenter_gameeach 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.