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

Scoring Points

Write a score to screen RAM — character output and colour RAM for the readout, then BCD arithmetic and nybble extraction to count each hit.

56% of Starfield

Hits flash and boom now, but the game forgets them the instant they happen. Destroy ten enemies and the screen looks exactly as it did before the first. This unit gives the player something to play for: a score in the corner that ticks up with every kill. Two new ideas carry it — writing characters to the screen, and counting in a way that displays with no conversion.

Where we start

Unit 8's hit lands with a flash and an explosion, then the enemy respawns. Nothing is recorded.

Milestone 1 — a readout on screen

You've written to screen RAM since Unit 1 — the clear loop fills $0400 onward with spaces ($20). The screen is a 40×25 grid of character cells starting at $0400; write a byte, the matching character appears at once. The difference now is that we write a visible character instead of a blank.

Characters have screen codes, and the digits are conveniently in order: $30 is '0', $31 is '1', on to $39 for '9'. So a digit 0–9 becomes its character by adding $30. Each cell also has a colour, in a parallel grid at $d800 (same layout — $d800 colours the cell at $0400). We write "00" into the first two cells and colour them white:

Step 1: a static 00 in the top-left
+12
1212 enemy_x = $06 ; Enemy X position
1313 enemy_y = $07 ; Enemy Y position
1414 flash_timer = $08 ; Frames of hit-flash remaining (0 = idle)
15+score = $09 ; Two-digit score, BCD (one decimal digit per nybble)
1516
1617 ; ------------------------------------------------
1718 ; BASIC stub
...
8788 sta $d405 ; Attack=0, Decay=6 (a short, snappy fall)
8889 lda #$00
8990 sta $d406 ; Sustain=0, Release=0
91+
92+ ; Score readout: "00" in the top-left, white. (We've written to screen
93+ ; RAM since Unit 1's clear loop — now we write characters, not spaces.)
94+ lda #$00
95+ sta score
96+ lda #$30 ; screen code for '0'
97+ sta $0400 ; tens digit (row 0, col 0)
98+ sta $0401 ; ones digit (row 0, col 1)
99+ lda #$01
100+ sta $d800 ; colour both digits white
101+ sta $d801
90102
91103 ; ------------------------------------------------
92104 ; Game loop — runs once per frame
The complete step 1 program
; Starfield - Unit 9: Scoring Points
; Cumulative steps: step-00 (no score) -> step-01 (+ a "00" readout) -> step-02 (+ count each hit)
; 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)
enemy_x       = $06     ; Enemy X position
enemy_y       = $07     ; Enemy Y position
flash_timer   = $08     ; Frames of hit-flash remaining (0 = idle)
score         = $09     ; Two-digit score, BCD (one decimal digit per nybble)

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

; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
        ; Black screen
        lda #$00
        sta $d020           ; Border colour
        sta $d021           ; Background colour

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

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

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

        ; Sprite 2 setup (enemy)
        lda #130
        sta $07fa           ; Data pointer (block 130 = $2080)
        lda #$05
        sta $d029           ; Colour (green)
        lda #100
        sta enemy_x
        sta $d004           ; X position
        lda #$32
        sta enemy_y
        sta $d005           ; Y position (top of the play area)

        ; Bullet starts inactive
        lda #$00
        sta bullet_active

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

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

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

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

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

        ; --- 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 enemy ---
        ; While the flash timer runs the enemy is frozen white; otherwise it drifts.
        lda flash_timer
        beq enemy_move          ; not flashing -> normal movement
        dec flash_timer
        bne no_respawn          ; still flashing -> stay frozen, white
        ; flash just ended -> restore colour and respawn at the top
        lda #$05
        sta $d029               ; back to green
        lda #$32
        sta enemy_y
        sta $d005
        lda $d012
        and #$7f
        clc
        adc #$30
        sta enemy_x
        sta $d004
        jmp no_respawn

enemy_move:
        ; drift down 1 pixel per frame
        lda enemy_y
        clc
        adc #$01            ; clc before adc, the addition from the Primer
        sta enemy_y
        sta $d005           ; sprite 2 Y

        ; Off the bottom? (Y >= 248) -> respawn at the top in a new column
        cmp #$f8
        bcc no_respawn      ; Y < 248, still on screen
        lda #$32
        sta enemy_y
        sta $d005           ; back to the top
        lda $d012           ; raster line — changes constantly, our pseudo-random
        and #$7f            ; range 0-127
        clc
        adc #$30            ; shift to 48-175, keeping it within the visible width
        sta enemy_x
        sta $d004           ; new X column
no_respawn:

        ; --- Bullet vs enemy: same row AND same column? ---
        lda bullet_active
        beq no_hit              ; no bullet in flight, nothing to hit

        ; Y distance (8-bit subtract wraps, so two ranges count as close)
        lda bullet_y
        sec
        sbc enemy_y
        cmp #$10
        bcc y_close             ; 0..15 apart: close
        cmp #$f0
        bcc no_hit              ; 16..239 apart: too far
y_close:
        ; Same row — now the columns must match too.
        ; A bullet in the right portion (9th bit set) is past X=255, far from
        ; any enemy (they stay under X=176), so rule it out before comparing.
        lda $d010
        and #%00000010          ; bullet's 9th X bit (sprite 1)
        bne no_hit

        ; X distance (low byte; the bullet's 9th bit is clear here)
        lda $d002
        sec
        sbc enemy_x
        cmp #$10
        bcc hit_enemy           ; 0..15 apart: close
        cmp #$f0
        bcc no_hit              ; 16..239 apart: too far
        ; 240..255: close from the other side -> fall through

hit_enemy:
        ; Remove the bullet
        lda #$00
        sta bullet_active
        lda $d015
        and #%11111101          ; sprite 1 (bullet) off
        sta $d015

        ; Flash the enemy white and start an 8-frame timer. The enemy update
        ; freezes it white until the timer runs out, then restores green and
        ; respawns it — so the respawn now waits for the flash instead of
        ; happening instantly.
        lda #$01
        sta $d029               ; sprite 2 colour = white
        lda #$08
        sta flash_timer

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

no_hit:

        jmp game_loop

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

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

The write happens after the clear loop — otherwise the clear would wipe it. The readout sits in the top-left corner, doing nothing yet:

A white 00 in the top-left corner of the screen, above the ship and the descending green enemy — the score readout, not yet counting.

Milestone 2 — count each hit

We could store the score as an ordinary number and convert it to digits, but the 6502 offers a shortcut built for exactly this: decimal mode. sed turns it on, cld turns it off, and while it's on adc carries at 10 instead of 16 — so $09 + $01 gives $10, not $0a. This is BCD (binary-coded decimal): each nybble (4 bits) holds one decimal digit, so the stored byte already is the two digits, no conversion needed.

Score byteHigh nybble (tens)Low nybble (ones)Shows
$050505
$424242

To display it, split the byte into its two nybbles. Four lsr shifts slide the high nybble down into the low four bits (the tens); and #$0f masks off the high nybble to leave the ones. Add $30 to each and write them to the two screen cells. We do this in the hit handler, right after the flash and explosion:

Step 2: increment in BCD and refresh the digits
+24
373373 sta $d40b ; noise, gate OFF (reset the envelope)
374374 lda #$81
375375 sta $d40b ; noise, gate ON (trigger the burst)
376+
377+ ; Score one hit. In decimal mode ADC carries at 10, so the byte stays
378+ ; readable as two decimal digits (BCD) — no conversion needed.
379+ sed ; decimal mode on
380+ lda score
381+ clc
382+ adc #$01
383+ sta score
384+ cld ; decimal mode off (every later ADC/SBC needs it off)
385+
386+ ; Refresh the two digits: high nybble -> tens, low nybble -> ones
387+ lda score
388+ lsr
389+ lsr
390+ lsr
391+ lsr ; high nybble down to 0-9
392+ clc
393+ adc #$30 ; to screen code
394+ sta $0400 ; tens digit
395+ lda score
396+ and #$0f ; low nybble, 0-9
397+ clc
398+ adc #$30
399+ sta $0401 ; ones digit
376400
377401 no_hit:
378402
The complete program
; Starfield - Unit 9: Scoring Points
; Cumulative steps: step-00 (no score) -> step-01 (+ a "00" readout) -> step-02 (+ count each hit)
; 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)
enemy_x       = $06     ; Enemy X position
enemy_y       = $07     ; Enemy Y position
flash_timer   = $08     ; Frames of hit-flash remaining (0 = idle)
score         = $09     ; Two-digit score, BCD (one decimal digit per nybble)

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

; ------------------------------------------------
; Initialisation
; ------------------------------------------------
*= $080d
        ; Black screen
        lda #$00
        sta $d020           ; Border colour
        sta $d021           ; Background colour

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

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

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

        ; Sprite 2 setup (enemy)
        lda #130
        sta $07fa           ; Data pointer (block 130 = $2080)
        lda #$05
        sta $d029           ; Colour (green)
        lda #100
        sta enemy_x
        sta $d004           ; X position
        lda #$32
        sta enemy_y
        sta $d005           ; Y position (top of the play area)

        ; Bullet starts inactive
        lda #$00
        sta bullet_active

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

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

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

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

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

        ; --- 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 enemy ---
        ; While the flash timer runs the enemy is frozen white; otherwise it drifts.
        lda flash_timer
        beq enemy_move          ; not flashing -> normal movement
        dec flash_timer
        bne no_respawn          ; still flashing -> stay frozen, white
        ; flash just ended -> restore colour and respawn at the top
        lda #$05
        sta $d029               ; back to green
        lda #$32
        sta enemy_y
        sta $d005
        lda $d012
        and #$7f
        clc
        adc #$30
        sta enemy_x
        sta $d004
        jmp no_respawn

enemy_move:
        ; drift down 1 pixel per frame
        lda enemy_y
        clc
        adc #$01            ; clc before adc, the addition from the Primer
        sta enemy_y
        sta $d005           ; sprite 2 Y

        ; Off the bottom? (Y >= 248) -> respawn at the top in a new column
        cmp #$f8
        bcc no_respawn      ; Y < 248, still on screen
        lda #$32
        sta enemy_y
        sta $d005           ; back to the top
        lda $d012           ; raster line — changes constantly, our pseudo-random
        and #$7f            ; range 0-127
        clc
        adc #$30            ; shift to 48-175, keeping it within the visible width
        sta enemy_x
        sta $d004           ; new X column
no_respawn:

        ; --- Bullet vs enemy: same row AND same column? ---
        lda bullet_active
        beq no_hit              ; no bullet in flight, nothing to hit

        ; Y distance (8-bit subtract wraps, so two ranges count as close)
        lda bullet_y
        sec
        sbc enemy_y
        cmp #$10
        bcc y_close             ; 0..15 apart: close
        cmp #$f0
        bcc no_hit              ; 16..239 apart: too far
y_close:
        ; Same row — now the columns must match too.
        ; A bullet in the right portion (9th bit set) is past X=255, far from
        ; any enemy (they stay under X=176), so rule it out before comparing.
        lda $d010
        and #%00000010          ; bullet's 9th X bit (sprite 1)
        bne no_hit

        ; X distance (low byte; the bullet's 9th bit is clear here)
        lda $d002
        sec
        sbc enemy_x
        cmp #$10
        bcc hit_enemy           ; 0..15 apart: close
        cmp #$f0
        bcc no_hit              ; 16..239 apart: too far
        ; 240..255: close from the other side -> fall through

hit_enemy:
        ; Remove the bullet
        lda #$00
        sta bullet_active
        lda $d015
        and #%11111101          ; sprite 1 (bullet) off
        sta $d015

        ; Flash the enemy white and start an 8-frame timer. The enemy update
        ; freezes it white until the timer runs out, then restores green and
        ; respawns it — so the respawn now waits for the flash instead of
        ; happening instantly.
        lda #$01
        sta $d029               ; sprite 2 colour = white
        lda #$08
        sta flash_timer

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

        jmp game_loop

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

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

The cld immediately after the add matters as much as the sed before it: leave decimal mode on and every later adc/sbc in the game — bullet movement, the laser sweep — quietly counts in tens. Now each kill ticks the counter:

Every hit adds one. Across this run five enemies fall and the readout climbs in step — 00, 01, on up to 05 — each a real BCD count poked into screen RAM.

When it's wrong, see why

A score bug shows on screen as the wrong glyph or the wrong number — read what's in the cell:

  • Nothing in the corner. The write is landing before the clear, or in the wrong place. The "00" must be written after the clear loop, to $0400/$0401.
  • Letters or symbols instead of digits. You skipped the + $30. A raw 0 is screen code $00 (@); the digit '0' is $30. If 9 + 1 shows a letter, decimal mode was off — $0a plus $30 is a screen code past the digits.
  • The score jumps in strange steps. The sed/cld pair. Without sed, $09 + $01 is $0a and the ones digit skips into the letters; without the cld afterwards, the rest of the game miscounts.
  • The number never moves. The increment isn't in the hit path. It belongs inside hit_enemy, alongside the flash and the boom — read score and confirm it changes only when a shot connects.

Before and after

We started with hits that vanished without a trace and finished with a running score in the corner — characters poked straight into screen RAM, counted in BCD so the byte and the display are the same thing. The game now keeps a record, and the player has a number to push.

Try this

  • Move the readout. The corner is just two addresses. The top-right is $0400 + 38 and + 39 ($0426/$0427); change them in both the init and the update, and the colour cells to match ($d826/$d827).
  • Cap it at 99. Before the increment, cmp #$99 and beq past the adc — the score holds at the top instead of rolling over to 00. One compare, one branch.

What you've learnt

  • Character output — screen RAM at $0400 shows whatever byte you write; digits are screen codes $30$39, a 0–9 value plus $30.
  • Colour RAM — the parallel grid at $d800 sets each cell's colour, same layout as the screen.
  • BCD / decimal modesed makes adc carry at ten, so one decimal digit lives in each nybble and the byte displays directly. Always cld after.
  • Nybble extraction — four lsr for the high nybble, and #$0f for the low.

What's next

One enemy at a time is target practice, not a game. Next you'll use indexed addressing to drive a whole wave — several enemies descending at once, each with its own position, from one shared loop.