HUD & Diagnostics
What you'll learn:
- Render score, timer, and debug counters via assembly text routines.
- Reuse zero-page mailboxes to expose performance metrics on screen.
- Keep HUD updates within the frame budget by batching writes.
Introduction
The score display breaks at 10 - it shows :
instead of 10 because we’re just adding $30 to the score value.
By the end of this lesson, Skyfall will display scores from 0 to 99 correctly!
The Current Problem
Our display code:
lda SCORE
adc #$30 ; Convert to PETSCII digit
sta SCREEN_RAM
This works for 0-9 because:
- 0 + $30 = $30 (PETSCII ‘0’)
- 9 + $30 = $39 (PETSCII ‘9’)
But for 10:
- 10 + $30 = $3A (PETSCII ’:’)
We need to split 10 into digits: “1” and “0”.
Converting to Decimal Digits
To display 57 as “5” and “7”, we need:
- Tens digit = 57 ÷ 10 = 5
- Ones digit = 57 mod 10 = 7
The 6502 has no divide instruction. We use repeated subtraction:
; Divide A by 10
; Returns: A = quotient (tens), X = remainder (ones)
divide_by_10:
ldx #$00 ; Remainder starts at 0
divide_loop:
cmp #10 ; Is A >= 10?
bcc done_divide ; If less than 10, done
sbc #10 ; Subtract 10 (carry is set from cmp)
inx ; Increment tens counter
jmp divide_loop
done_divide:
; A now holds ones digit
; X holds tens digit
rts
Example with 57:
- Start with 57 in A, 0 in X
- 57 >= 10? Yes. 57 - 10 = 47. X = 1
- 47 >= 10? Yes. 47 - 10 = 37. X = 2
- 37 >= 10? Yes. 37 - 10 = 27. X = 3
- 27 >= 10? Yes. 27 - 10 = 17. X = 4
- 17 >= 10? Yes. 17 - 10 = 7. X = 5
- 7 >= 10? No. Done!
- Result: A = 7 (ones), X = 5 (tens)
Displaying Two Digits
Update display_score
:
display_score:
lda SCORE
sec ; Set carry for subtraction
jsr divide_by_10
; X = tens, A = ones
; Save ones digit
sta $fb
; Display tens digit (only if non-zero)
txa ; Transfer X to A
beq skip_tens ; If tens = 0, skip
clc
adc #$30 ; Convert to PETSCII
sta SCREEN_RAM ; Display at position 0
; Display ones digit at position 1
lda $fb
clc
adc #$30
sta SCREEN_RAM + 1
; Color both digits
lda #WHITE
sta COLOR_RAM
sta COLOR_RAM + 1
rts
skip_tens:
; No tens digit, just show ones at position 0
lda $fb
clc
adc #$30
sta SCREEN_RAM
lda #WHITE
sta COLOR_RAM
rts
This displays:
- 0-9 as single digits
- 10-99 as two digits
Handling Leading Zeros
Our current code skips the tens digit if it’s zero. But what if we want to show “07” instead of “7”?
For Skyfall, showing “7” is fine. But here’s how you’d always show two digits:
display_score_padded:
lda SCORE
sec
jsr divide_by_10
sta $fb ; Save ones
; Always display tens
txa
clc
adc #$30
sta SCREEN_RAM
; Always display ones
lda $fb
clc
adc #$30
sta SCREEN_RAM + 1
; Color both
lda #WHITE
sta COLOR_RAM
sta COLOR_RAM + 1
rts
This would show “07”, “08”, “09”, etc.
Updating Misses Display
Let’s apply the same fix to misses:
display_misses:
lda MISSES
sec
jsr divide_by_10
sta $fb
txa
beq skip_miss_tens
clc
adc #$30
sta SCREEN_RAM + 5
lda $fb
clc
adc #$30
sta SCREEN_RAM + 6
lda #WHITE
sta COLOR_RAM + 5
sta COLOR_RAM + 6
rts
skip_miss_tens:
lda $fb
clc
adc #$30
sta SCREEN_RAM + 5
lda #WHITE
sta COLOR_RAM + 5
rts
Clearing Old Digits
Problem: if the score goes from “10” to “9”, the “1” remains on screen showing “19”.
Solution: always clear the display area first:
display_score:
; Clear score area first
lda #$20 ; Space
sta SCREEN_RAM
sta SCREEN_RAM + 1
; Now display the score
lda SCORE
sec
jsr divide_by_10
; ... rest of code
Complete Lesson 14 Code
; SKYFALL - Lesson 14
; Multi-digit score display
* = $0801
; BASIC stub: SYS 2061
!byte $0c,$08,$0a,$00,$9e
!byte $32,$30,$36,$31 ; "2061" in ASCII
!byte $00,$00,$00
* = $080d
; ===================================
; Constants
; ===================================
SCREEN_RAM = $0400
COLOR_RAM = $d800
VIC_BORDER = $d020
VIC_BACKGROUND = $d021
CIA1_PORT_A = $dc01
CIA1_PORT_B = $dc00
VIC_RASTER = $d012
SID_V1_FREQ_LO = $d400
SID_V1_FREQ_HI = $d401
SID_V1_CONTROL = $d404
SID_V1_AD = $d405
SID_V1_SR = $d406
SID_V3_FREQ_LO = $d40e
SID_V3_FREQ_HI = $d40f
SID_V3_CONTROL = $d412
SID_V3_OSC = $d41b
SID_VOLUME = $d418
BLACK = $00
WHITE = $01
YELLOW = $07
DARK_GREY = $0b
PLAYER_CHAR = $1e
PLAYER_COLOR = WHITE
PLAYER_ROW = 23
PLAYER_START_COL = 20
OBJECT_CHAR = $2a
OBJECT_COLOR = YELLOW
MAX_OBJECTS = 3
SPAWN_DELAY = 60
; Variables
PLAYER_COL = $02
MOVE_COUNTER = $03
FALL_COUNTER = $07
SCORE = $08
MISSES = $09
SPAWN_COUNTER = $0a
SOUND_TIMER = $0b
; Object arrays
OBJ_COL = $10 ; 3 bytes
OBJ_ROW = $13 ; 3 bytes
OBJ_ACTIVE = $16 ; 3 bytes
; ===================================
; Main Program
; ===================================
main:
jsr init_screen
jsr init_random
jsr init_sound
lda #PLAYER_START_COL
sta PLAYER_COL
lda #3
sta MOVE_COUNTER
lda #5
sta FALL_COUNTER
lda #SPAWN_DELAY
sta SPAWN_COUNTER
lda #$00
sta SCORE
sta MISSES
sta SOUND_TIMER
; Clear all objects
ldx #$00
clear_objects:
lda #$00
sta OBJ_ACTIVE,x
inx
cpx #MAX_OBJECTS
bne clear_objects
jsr draw_player
jsr spawn_object
jsr display_score
jsr display_misses
game_loop:
jsr wait_for_raster
; Update sound timer
lda SOUND_TIMER
beq no_sound_playing
dec SOUND_TIMER
bne no_sound_playing
jsr stop_sound
no_sound_playing:
; Player movement
dec MOVE_COUNTER
bne skip_player_movement
lda #3
sta MOVE_COUNTER
jsr check_keys
skip_player_movement:
; Object falling
dec FALL_COUNTER
bne skip_object_fall
lda #5
sta FALL_COUNTER
jsr update_all_objects
jsr draw_player ; Redraw player (in case object erased it)
skip_object_fall:
; Spawn new objects
dec SPAWN_COUNTER
bne skip_spawn
lda #SPAWN_DELAY
sta SPAWN_COUNTER
jsr spawn_object
skip_spawn:
jmp game_loop
; ===================================
; Subroutines
; ===================================
wait_for_raster:
lda VIC_RASTER
cmp #250
bne wait_for_raster
rts
init_screen:
jsr wait_for_raster
lda #BLACK
sta VIC_BACKGROUND
lda #DARK_GREY
sta VIC_BORDER
jsr clear_screen
rts
init_random:
lda #$ff
sta SID_V3_FREQ_LO
sta SID_V3_FREQ_HI
lda #$80
sta SID_V3_CONTROL
rts
init_sound:
lda #$0f
sta SID_VOLUME
rts
play_catch_sound:
lda #$3e
sta SID_V1_FREQ_LO
lda #$11
sta SID_V1_FREQ_HI
lda #$09
sta SID_V1_AD
lda #$00
sta SID_V1_SR
lda #$11
sta SID_V1_CONTROL
lda #10
sta SOUND_TIMER
rts
play_miss_sound:
lda #$f7
sta SID_V1_FREQ_LO
lda #$04
sta SID_V1_FREQ_HI
lda #$00
sta SID_V1_AD
lda #$a8
sta SID_V1_SR
lda #$21
sta SID_V1_CONTROL
lda #20
sta SOUND_TIMER
rts
stop_sound:
lda SID_V1_CONTROL
and #$fe
sta SID_V1_CONTROL
rts
divide_by_10:
; Input: A = number to divide
; Output: X = tens, A = ones
; Carry must be set before calling
ldx #$00
divide_loop:
cmp #10
bcc done_divide
sbc #10
inx
jmp divide_loop
done_divide:
rts
get_random:
lda SID_V3_OSC
rts
get_random_column:
try_again:
jsr get_random
and #%00111111
cmp #40
bcs try_again
rts
clear_screen:
lda #$20
ldx #$00
clear_screen_loop:
sta SCREEN_RAM,x
sta SCREEN_RAM + $100,x
sta SCREEN_RAM + $200,x
sta SCREEN_RAM + $300,x
inx
bne clear_screen_loop
lda #WHITE
ldx #$00
clear_color_loop:
sta COLOR_RAM,x
sta COLOR_RAM + $100,x
sta COLOR_RAM + $200,x
sta COLOR_RAM + $300,x
inx
bne clear_color_loop
rts
check_keys:
; Check D key (column 2, row 2)
lda #%11111011
sta CIA1_PORT_B
lda CIA1_PORT_A
and #%00000100
beq move_right
; Check A key (column 1, row 2)
lda #%11111101
sta CIA1_PORT_B
lda CIA1_PORT_A
and #%00000100
beq move_left
rts
move_left:
lda PLAYER_COL
beq skip_left
jsr erase_player
dec PLAYER_COL
jsr draw_player
skip_left:
rts
move_right:
lda PLAYER_COL
cmp #39
beq skip_right
jsr erase_player
inc PLAYER_COL
jsr draw_player
skip_right:
rts
erase_player:
ldx PLAYER_COL
lda #$20
sta SCREEN_RAM + (PLAYER_ROW * 40),x
rts
draw_player:
ldx PLAYER_COL
lda #PLAYER_CHAR
sta SCREEN_RAM + (PLAYER_ROW * 40),x
lda #PLAYER_COLOR
sta COLOR_RAM + (PLAYER_ROW * 40),x
rts
spawn_object:
ldx #$00
find_slot:
lda OBJ_ACTIVE,x
beq found_slot
inx
cpx #MAX_OBJECTS
bne find_slot
rts
found_slot:
lda #$01
sta OBJ_ACTIVE,x
lda #$00
sta OBJ_ROW,x
stx $fd
jsr get_random_column
ldx $fd
sta OBJ_COL,x
jsr draw_object
rts
update_all_objects:
ldx #$00
update_loop:
stx $fe
lda OBJ_ACTIVE,x
beq skip_this_object
jsr erase_object
ldx $fe
inc OBJ_ROW,x
lda OBJ_ROW,x
cmp #24
bne still_falling_check
ldx $fe
lda #$00
sta OBJ_ACTIVE,x
inc MISSES
jsr display_misses
jsr play_miss_sound
jsr spawn_object
jmp next_object
still_falling_check:
ldx $fe
jsr draw_object
skip_this_object:
next_object:
ldx $fe
inx
cpx #MAX_OBJECTS
bne update_loop
rts
check_all_collisions:
ldx #$00
collision_loop:
stx $fe
lda OBJ_ACTIVE,x
beq skip_collision
lda OBJ_ROW,x
cmp #PLAYER_ROW
bne skip_collision
ldx $fe
lda OBJ_COL,x
cmp PLAYER_COL
bne skip_collision
ldx $fe
jsr handle_catch
skip_collision:
ldx $fe
inx
cpx #MAX_OBJECTS
bne collision_loop
rts
handle_catch:
jsr erase_object
lda #$00
sta OBJ_ACTIVE,x
inc SCORE
jsr display_score
jsr play_catch_sound
jsr spawn_object
rts
; Row offset lookup table (low bytes)
row_offset_lo:
!byte <(0*40), <(1*40), <(2*40), <(3*40), <(4*40)
!byte <(5*40), <(6*40), <(7*40), <(8*40), <(9*40)
!byte <(10*40), <(11*40), <(12*40), <(13*40), <(14*40)
!byte <(15*40), <(16*40), <(17*40), <(18*40), <(19*40)
!byte <(20*40), <(21*40), <(22*40), <(23*40), <(24*40)
; Row offset lookup table (high bytes)
row_offset_hi:
!byte >(0*40), >(1*40), >(2*40), >(3*40), >(4*40)
!byte >(5*40), >(6*40), >(7*40), >(8*40), >(9*40)
!byte >(10*40), >(11*40), >(12*40), >(13*40), >(14*40)
!byte >(15*40), >(16*40), >(17*40), >(18*40), >(19*40)
!byte >(20*40), >(21*40), >(22*40), >(23*40), >(24*40)
draw_object:
; Save current X
stx $fd
; Get row offset from table
ldy OBJ_ROW,x
lda row_offset_lo,y
sta $fb
lda row_offset_hi,y
sta $fc
; Add column
ldx $fd
lda $fb
clc
adc OBJ_COL,x
sta $fb
bcc +
inc $fc
+
; Add SCREEN_RAM base
lda $fb
clc
adc #<SCREEN_RAM
sta $fb
lda $fc
adc #>SCREEN_RAM
sta $fc
; Draw character
ldy #0
lda #OBJECT_CHAR
sta ($fb),y
; Calculate COLOR_RAM address
lda $fb
clc
adc #<(COLOR_RAM-SCREEN_RAM)
sta $fb
lda $fc
adc #>(COLOR_RAM-SCREEN_RAM)
sta $fc
; Draw color
lda #OBJECT_COLOR
sta ($fb),y
ldx $fd
rts
erase_object:
; Save current X
stx $fd
; Get row offset from table
ldy OBJ_ROW,x
lda row_offset_lo,y
sta $fb
lda row_offset_hi,y
sta $fc
; Add column
ldx $fd
lda $fb
clc
adc OBJ_COL,x
sta $fb
bcc +
inc $fc
+
; Add SCREEN_RAM base
lda $fb
clc
adc #<SCREEN_RAM
sta $fb
lda $fc
adc #>SCREEN_RAM
sta $fc
; Erase with space
ldy #0
lda #$20
sta ($fb),y
ldx $fd
rts
display_score:
; Clear score area
lda #$20
sta SCREEN_RAM
sta SCREEN_RAM + 1
; Convert and display
lda SCORE
sec
jsr divide_by_10
sta $fb ; Save ones
txa ; Get tens
beq skip_tens ; If zero, skip tens digit
clc
adc #$30
sta SCREEN_RAM
lda $fb
clc
adc #$30
sta SCREEN_RAM + 1
lda #WHITE
sta COLOR_RAM
sta COLOR_RAM + 1
rts
skip_tens:
lda $fb
clc
adc #$30
sta SCREEN_RAM
lda #WHITE
sta COLOR_RAM
rts
display_misses:
; Clear misses area
lda #$20
sta SCREEN_RAM + 5
sta SCREEN_RAM + 6
; Convert and display
lda MISSES
sec
jsr divide_by_10
sta $fb
txa
beq skip_miss_tens
clc
adc #$30
sta SCREEN_RAM + 5
lda $fb
clc
adc #$30
sta SCREEN_RAM + 6
lda #WHITE
sta COLOR_RAM + 5
sta COLOR_RAM + 6
rts
skip_miss_tens:
lda $fb
clc
adc #$30
sta SCREEN_RAM + 5
lda #WHITE
sta COLOR_RAM + 5
rts
Build and run. Play until you get 10+ points. The score displays correctly!
New Concepts
Opcodes Used in This Lesson
sbc #value - Subtract with Carry (also called borrow)
- Subtracts value from accumulator, accounting for carry flag
- If carry clear, subtracts an extra 1 (borrow)
- Always use
sec
before subtraction to set carry - Example:
sec
thensbc #10
subtracts exactly 10
bcc label - Branch if Carry Clear
- Opposite of
bcs
(branch if carry set) - After
cmp
, carry is clear if A < compared value - Example:
cmp #10
thenbcc done
branches if A < 10
txa - Transfer X to A
- Copies X register value into accumulator
- Doesn’t change X
- Example:
txa
thenadc #$30
converts X to PETSCII digit
Division by Repeated Subtraction
Since the 6502 has no divide instruction, we simulate it:
57 ÷ 10:
57 - 10 = 47 (count 1)
47 - 10 = 37 (count 2)
37 - 10 = 27 (count 3)
27 - 10 = 17 (count 4)
17 - 10 = 7 (count 5)
7 < 10, stop
Result: 5 tens, 7 ones
This is slower than hardware division but fast enough for occasional score updates.
The Carry Flag in Subtraction
The carry flag works differently for subtraction:
- Set (
sec
) = no borrow, normal subtraction - Clear (
clc
) = borrow 1, subtracts an extra 1
Always sec
before subtraction unless you specifically want the borrow.
Number-to-Digits Algorithm
The general pattern for any base:
- Divide by base (10 for decimal)
- Quotient = higher-order digit
- Remainder = current digit
- Repeat with quotient for more digits
We only need 2 digits (0-99), so one division suffices.
- Build and run - Score past 10 and verify it displays correctly
- Test edge cases - Get exactly 10, 20, 50, 99
- Add padding - Modify to always show 2 digits (“07” instead of “7”)
- Three digits - Can you extend to show 100-255? (Hint: divide tens by 10 again)
Next Steps
Now scores display properly up to 99! The game is more professional.
In Lesson 15, we’ll add a proper game over screen and restart functionality!