Introduction

Welcome to Lab 3! 😄

In this lab, I will be creating a simple program in the 6502 assembly language. Before I tell you what program I am writing, there are some requirements:

  • Must work in 6502 Emulator
  • Must output to character screen and graphics screen
  • Must input from user keyboard
  • Must use arithmetic operations

Fulfilling these requirements, the program I am writing is a random number guessing game!

How the game plays: the player tries to guess a random number between 1 and 9. They get 5 tries, and the outcome is a bitmap display filled with a color (green for a win, red for a loss).

✅ The source code is provided at the end of this blog post after the whole thing has been walked through!

Let's start!


📌 Setup & ROM Routines

Before we get into the main game loop, I need to define a few ROM routines. These routines will handle things like screen clearing, keyboard input, and text output.

I also allocate zero page variables for fast access.

; ROM routines entry points
define SCINIT  $ff81    ; initialize/clear screen
define CHRIN   $ffcf    ; input character from keyboard
define CHROUT  $ffd2    ; output character to screen

; Zero Page Variables
define TEXT_PTR   $40  ; pointer for text (low byte)
define TEXT_PTR_H $41  ; pointer for text (high byte)
define GUESS      $42  ; user guess
define ATTEMPTS   $43  ; remaining attempts (5 tries)
define ANSWER     $45  ; correct answer (ASCII value)

* = $0600      ; Program starts here

    JSR SCINIT          ; Clear screen

📌 Random Number Generation

Let's start with this part as I want to make sure the random number is generating properly before continuing on.

We know that 6502 does not have true randomness. So we use pseudo-random behavior from system memory. I used the value at $FE (a common pseudo-random location), but filtered it to keep only ASCII '1' to '9'.

RANDOM:
getRandom:
    LDA $FE         ; Load a value from memory address $FE
    CMP #$31        ; Compare with ASCII '1' (value = $31)
    BCC getRandom   ; If it's less than '1', try again
    CMP #$3A        ; Compare with ASCII ':' (value = $3A, one past '9')
    BCS getRandom   ; If it's '9' or more, try again
    RTS             ; Return with a valid value in A

It's quite simple. We compare the $FE value so that it can be only between 1 and 9.

This loop continues until value is between $31 and $39, which are the ASCII values for '1' to '9'.

Then I use it in the main code (under that JSR SCINIT line):

JSR RANDOM
    STA ANSWER

📌 Welcome Message!

While we're at the main code, let's set the stage for the game. I want to print a nice welcome message and give the player 5 tries. I also reused a simple PRINT subroutine that walks over null-terminated strings.

I want to show something like:

SELECT A NUMBER ( 1 - 9 )
YOU HAVE 5 CHANCES!

This should be straightforward. Please feel free to read the comments to see what it does in details.

Referenced from SPO600: Place a Message on the Character Display

LDA #$05         ; Load 5 into A (the number of tries)
    STA ATTEMPTS     ; Store it in ATTEMPTS variable

    LDA #TITLE      ; Load the high byte of TITLE address
    STA TEXT_PTR_H   ; Store it in TEXT_PTR_H (high part of pointer)

    LDY #$00         ; Set index Y = 0
    JSR PRINT        ; Call the PRINT subroutine to display the message
PRINT:
    LDA (TEXT_PTR), Y    ; Load byte at address (TEXT_PTR + Y)
    BEQ DONE             ; If byte = 0 (null terminator), end of string
    JSR CHROUT           ; Otherwise, print the character
    INY                  ; Move to next character
    BNE PRINT            ; Loop until we hit null (BNE is always true unless Y wraps)
DONE:
    RTS
TITLE:
    DCB "S","E","L","E","C","T",32,"A",32,"N","U","M","B","E","R",32
    DCB "(",32,"1",32,"-",32,"9",32,")",13
    DCB "Y","O","U",32,"H","A","V","E",32,"5",32,"C","H","A","N","C","E","S","!",0

At this point, if you run the code, you should get something like this:
Welcome Message


📌 The Main Game Loop

Now let's finish defining the main loop: it prints the input prompt, takes input, validates it, then compares it to the answer.

Printing the input prompt is basically the same as printing the title:

START:
    LDA #INPUT         
    STA TEXT_PTR_H      
    LDY #$00            
    JSR PRINT
INPUT:
    DCB 13,13
    DCB "E","N","T","E","R",32,"A",32,"N","U","M","B","E","R",32
    DCB "(",32,"1",32,"-",32,"9",32,")",":",32,0

After that, wait for input and then validate it.

WAIT_INPUT:
    JSR CHRIN           ; Wait for player keypress (returns ASCII in A)

    CMP #$31            ; Compare with ASCII '1'
    BMI WAIT_INPUT      ; If below, invalid → wait again
    CMP #$3A            ; Compare with ASCII ':'
    BPL WAIT_INPUT      ; If 9 or above (':'), invalid → wait again

    STA GUESS           ; Save valid guess to GUESS variable
    JSR CHROUT          ; Echo the guess back to screen (user feedback)

    CMP ANSWER          ; Compare guess with the correct answer
    BEQ WIN             ; If equal → player wins!
    BCC HIGHER          ; If less → guess is lower → suggest to go higher
    BCS LOWER           ; If more → guess is higher → suggest to go lower

We validate the input to prevent invalid characters. If it's a match, we jump to WIN. Otherwise, we give feedback.


📌 Feedback: Higher or Lower

You might have noticed HIGHER and LOWER when validating user input. It's basically just printing to the screen whether the number needs to be higher or not.

The condition is already done in the main loop. So just define the text and the printing logic that we have used already before:

HIGHER_TEXT:
    DCB 13,"I","T","'", "S",32,"H","I","G","H","E","R",0

LOWER_TEXT:
    DCB 13,"I","T","'", "S",32,"L","O","W","E","R",0

HIGHER:
    LDA #HIGHER_TEXT
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT
    JMP UPDATE_ATTEMPTS

LOWER:
    LDA #LOWER_TEXT
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT
    JMP UPDATE_ATTEMPTS

📌 Track Attempts

Now, what else is left? We need to track the user's tries. After every wrong guess, we decrement the number of attempts and decide whether to continue or end the game.

We just reuse START if they still have guesses left.

UPDATE_ATTEMPTS:
    DEC ATTEMPTS
    LDA ATTEMPTS
    BEQ LOSE ; Defining this below
    JMP START

📌 Lose State

Okay, almost done. Let's define what happens when a player loses. The game shows a message, reveals the correct answer, then fills the screen with red.

Printing is the same as always. I want to also show what the answer was:

LOSE:
    LDA #LOSE_TEXT
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT

    LDA #ANSWER_TEXT
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT

    LDA ANSWER
    JSR CHROUT
LOSE_TEXT:
    DCB 13,"Y","O","U",32,"L","O","S","T",32,"T","H","E",32,"G","A","M","E",0

ANSWER_TEXT:
    DCB 13,"T","H","E",32,"N","U","M","B","E","R",32,"W","A","S",32,0

And then, call the subroutine to fill the screen with red, after which you can jump to game over.

JSR FILL_RED
    JMP GAME_OVER

Let's define FILL_RED, which is the function that fills the bitmap display with red when the player loses.

FILL_RED:
    LDA #$00
    STA $10              ; Low byte of screen memory pointer
    LDA #$02
    STA $11              ; High byte → $0200 = screen start
    LDX #$06             ; Fill up to $0600
    LDY #$00
    LDA #$02             ; Red color code (system-dependent)
red_loop:
    STA ($10), y         ; Store red color at screen location
    INY                  ; Next byte
    BNE red_loop         ; If Y didn't wrap → loop again
    INC $11              ; Move to next screen row (high byte of address)
    LDY #$00             ; Reset Y
    CPX $11              ; Compare to target memory limit
    BNE red_loop         ; Keep going until we hit limit
    RTS

📌 Win State

Printing to text display:

WIN_TEXT:
    DCB 13,"Y","O","U",32,"W","I","N","!",32,"I","T","'", "S",32,0

WIN:
    LDA #WIN_TEXT
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT

    LDA ANSWER
    JSR CHROUT

    JSR FILL_GREEN

Green fill function:

FILL_GREEN:
    LDA #$00
    STA $10
    LDA #$02
    STA $11
    LDX #$06
    LDY #$00
    LDA #$0D
green_loop:
    STA ($10), y
    INY
    BNE green_loop
    INC $11
    LDY #$00
    CPX $11
    BNE green_loop
    RTS

📌 Game Reset

After a win or loss, wait for a key press, then restart the game from the beginning:

GAME_OVER:
    JSR CHRIN
    JMP $0600

✅ SOURCE CODE ✅

; Number Guessing Game for 6502
; Guess the correct number (1-9). The answer is randomly generated each game.
; On win, the display fills with green; on loss, it fills with red.
; You get 5 tries to guess the number.

; ROM routines entry points
define SCINIT  $ff81    ; initialize/clear screen
define CHRIN   $ffcf    ; input character from keyboard
define CHROUT  $ffd2    ; output character to screen

; Zero Page Variables
define TEXT_PTR   $40  ; pointer for text (low byte)
define TEXT_PTR_H $41  ; pointer for text (high byte)
define GUESS      $42  ; user guess
define ATTEMPTS   $43  ; remaining attempts (5 tries)
define ANSWER     $45  ; correct answer (ASCII value)

* = $0600      ; Program starts here

    JSR SCINIT          ; Clear screen

    ; Generate random answer (1-9) using hardware randomness.
    ; The RANDOM subroutine returns an ASCII digit ('1'-'9') directly.
    JSR RANDOM
    STA ANSWER          ; Store the ASCII value

    LDA #$05            ; Set 5 attempts
    STA ATTEMPTS

    ; Print the title
    LDA #TITLE
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT

START:
    ; Print the input prompt
    LDA #INPUT
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT

WAIT_INPUT:
    JSR CHRIN         ; Wait for user input (result in A)

    CMP #$31          ; Must be between ASCII '1' and '9'
    BMI WAIT_INPUT
    CMP #$3A
    BPL WAIT_INPUT

    STA GUESS         ; Store the user's guess
    JSR CHROUT        ; Echo the input on screen

    ; Compare the guess to the random answer
    CMP ANSWER
    BEQ WIN           ; Correct guess
    BCC HIGHER        ; If guess < answer, show "Higher"
    BCS LOWER         ; If guess > answer, show "Lower"

HIGHER:
    LDA #HIGHER_TEXT
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT
    JMP UPDATE_ATTEMPTS 

LOWER:
    LDA #LOWER_TEXT
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT
    JMP UPDATE_ATTEMPTS 

UPDATE_ATTEMPTS:
    DEC ATTEMPTS      ; Decrease attempt count by 1
    LDA ATTEMPTS
    BEQ LOSE          ; If no attempts left, game over
    JMP START

LOSE:
    ; Print lose message
    LDA #LOSE_TEXT
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT

    ; Show the correct answer
    LDA #ANSWER_TEXT
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT
    LDA ANSWER
    JSR CHROUT        ; Print the answer

    JSR FILL_RED      ; Fill display with red
    JMP GAME_OVER

WIN:
    ; Print win message
    LDA #WIN_TEXT
    STA TEXT_PTR_H
    LDY #$00
    JSR PRINT

    ; Show the correct answer
    LDA ANSWER
    JSR CHROUT        ; Print the answer

    JSR FILL_GREEN    ; Fill display with green

GAME_OVER:
    JSR CHRIN        ; Wait for any key press
    JMP $0600        ; Restart the game (generates new random answer)

; ---------------------------------------------------
; Subroutine RANDOM
; Generates a pseudo-random number using hardware randomness.
; Reads from memory location $FE until a valid candidate is found.
; Returns a valid ASCII digit in the range '1' to '9' (i.e., $31 to $39) in A.
RANDOM:
getRandom:
    LDA $FE         ; Get a pseudo-random candidate from hardware RNG
    CMP #$31        ; Check if candidate is >= '1' (ASCII $31)
    BCC getRandom   ; If candidate is less than '1', try again
    CMP #$3A        ; Check if candidate is less than ':' (ASCII $3A)
    BCS getRandom   ; If candidate is not less than $3A, try again
    RTS             ; Return valid candidate in A

; ---------------------------------------------------
; Subroutine PRINT
; Prints a null-terminated string pointed to by TEXT_PTR.
PRINT:
    LDA (TEXT_PTR), Y
    BEQ DONE        ; Zero terminator reached; stop printing
    JSR CHROUT      ; Output character
    INY
    BNE PRINT
DONE:
    RTS

; ---------------------------------------------------
; Subroutine FILL_GREEN
; Fills the display with green (color code $0D).
FILL_GREEN:
    LDA #$00
    STA $10         ; Set pointer low byte to 0
    LDA #$02
    STA $11         ; Set pointer high byte to 2 (base address $0200)
    LDX #$06        ; X = 6 (max for high-byte pointer)
    LDY #$00        ; Reset column index (Y) to 0
    LDA #$0D        ; Green color code
green_loop:
    STA ($10), y    ; Write green to display at pointer + Y
    INY             ; Increment column index
    BNE green_loop  ; Loop until Y wraps (256 pixels)
    INC $11         ; Move to next page (increment high-byte pointer)
    LDY #$00        ; Reset Y for new page
    CPX $11         ; Compare X with current pointer high byte
    BNE green_loop  ; Continue filling until done
    RTS

; ---------------------------------------------------
; Subroutine FILL_RED
; Fills the display with red (color code $02).
FILL_RED:
    LDA #$00
    STA $10         ; Set pointer low byte to 0
    LDA #$02
    STA $11         ; Set pointer high byte to 2 (base address $0200)
    LDX #$06        ; X = 6
    LDY #$00        ; Reset Y to 0
    LDA #$02        ; Red color code
red_loop:
    STA ($10), y    ; Write red to display at pointer + Y
    INY             ; Increment column index
    BNE red_loop    ; Loop until Y wraps (256 pixels)
    INC $11         ; Move to next page (increment high-byte pointer)
    LDY #$00        ; Reset Y for new page
    CPX $11         ; Compare X with current pointer high byte
    BNE red_loop    ; Continue filling until finished
    RTS

; ---------------------------------------------------
; Text Strings
TITLE:
    DCB "G","U","E","S","S",32
    DCB "A",32
    DCB "N","U","M","B","E","R",32
    DCB "B","E","T","W","E","E","N",32
    DCB "1",32
    DCB "T","O",32
    DCB "9",13
    DCB "Y","O","U",32
    DCB "H","A","V","E",32
    DCB "5",32
    DCB "T","R","I","E","S","!",0

INPUT:
    DCB 13,13
    DCB "E","N","T","E","R",32
    DCB "A",32
    DCB "N","U","M","B","E","R",32
    DCB "(", "1","-","9",")",":",32,0

HIGHER_TEXT:
    DCB 13
    DCB "H","I","G","H","E","R",".",".",".",0

LOWER_TEXT:
    DCB 13
    DCB "L","O","W","E","R",".",".",".",0

WIN_TEXT:
    DCB 13
    DCB "Y","O","U",32
    DCB "W","I","N","!",32
    DCB "T","H","E",32
    DCB "A","N","S","W","E","R",32
    DCB "I","S",32,0

LOSE_TEXT:
    DCB 13
    DCB "Y","O","U",32
    DCB "L","O","S","T",".",32
    DCB "G","A","M","E",32
    DCB "O","V","E","R","!",0

ANSWER_TEXT:
    DCB 13
    DCB "T","H","E",32
    DCB "A","N","S","W","E","R",32
    DCB "W","A","S",32,0

Testing it

Let's run the whole game to make sure that it is running correctly.

On run:

Initial Game

Low number should prompt 'HIGHER...':

Low Number

High number should prompt 'LOWER...' :

High Number

A clip of getting it correct:

Getting it correct

A clip of getting it wrong:

Lose

As you can see the code is running as intended! 😄


Conclusion

Tackling this lab reminded me how brutal and rewarding the 6502 assembly can be. There's no high-level logic, so much so that even printing can take a while to figure out. You really learn to appreciate every instruction.

It was fun though to figure out the game loop. Making subroutine is extremely helpful in keeping everything neat. My challenge was in figuring out the logic for generating random numbers. It was so simple in retrospect but I had to change my mindset on another logic to achieve it.

I hope this breakdown helped you follow the logic and maybe even inspired you to make something of your own in assembly.

See you next time~ 👋