thecodingidiot.com

Writing 6502 AssemblyThe Game Loop

The Game Loop

The previous programs each did one thing: count, light a LED, read a button, make a sound. This one combines all three into a single loop. Press the button: the LED lights and the buzzer sounds. Release it: both stop. That is it. It is also, structurally, identical to every real-time interactive program ever written.

The structure

A game loop has three steps, executed repeatedly as fast as possible:

  1. Read input.
  2. Decide what the output should be.
  3. Write output.

On a modern machine running at hundreds of millions of cycles per second, step 2 includes physics, AI, audio mixing, and rendering. On this 6502 running at 1 MHz, step 2 is a single and and a single bne. The structure is the same.

The loop runs as long as the power is on. There is no menu, no start screen, no score. The CPU checks the button on every pass and writes the appropriate LED and speaker state. At 1 MHz with the delay loops active, each full loop takes roughly 2270 cycles — about 2.3 ms — so the button is checked approximately 430 times per second. The response to a button press is effectively instantaneous.

game.s

PORTB = $4000
DDRB  = $4002
 
DELAY = 227
 
  .org $8000
 
reset:
  lda #$03
  sta DDRB        ; PB0 = LED (output), PB1 = speaker (output)
                  ; PB7 = button (input, internal pull-up)
 
loop:
  lda PORTB
  and #$80        ; test PB7 (0 = pressed, active low)
  bne silence     ; button not held: everything off
 
  lda #$03        ; button held: LED on + speaker high
  sta PORTB
  ldx #DELAY
tone_hi:
  dex
  bne tone_hi
 
  lda #$01        ; speaker low, LED stays on
  sta PORTB
  ldx #DELAY
tone_lo:
  dex
  bne tone_lo
 
  jmp loop
 
silence:
  lda #$00
  sta PORTB       ; LED off, speaker off
  jmp loop
 
  .org $fffc
  .word reset
  .word reset

Walking through each section.

Setup. lda #$03 loads $03 (%00000011) into A. sta DDRB writes it to the DDRB register at $4002. Bit 0 = 1 makes PB0 an output (the LED). Bit 1 = 1 makes PB1 an output (the speaker). Bit 7 = 0 leaves PB7 as an input (the button), and the VIA's internal pull-up holds it high when the button is open.

Reading the button. lda PORTB reads the full Port B byte from $4000 into A. and #$80 isolates bit 7. If the button is not pressed, PB7 is high (pulled up), the AND result is $80 — non-zero, Z clear. bne silence takes the branch to silence. If the button is pressed, PB7 is low, the AND result is $00 — zero, Z set. bne silence does not branch, and execution falls through to the button-held path.

Button held — high half of tone. lda #$03 loads $03 (%00000011). sta PORTB writes it to PORTB: PB0 high (LED on) and PB1 high (speaker high). Both outputs are set in a single write. ldx #DELAY / tone_hi loop: spin for 227 iterations as in tone.s. During this delay the LED stays lit and PB1 stays high.

Button held — low half of tone. lda #$01 loads $01 (%00000001). sta PORTB writes it: PB0 remains high (LED stays on, bit 0 still set) and PB1 goes low (speaker low, bit 1 cleared). ldx #DELAY / tone_lo loop: another 227-iteration wait. The LED is still on during this half-cycle; only the speaker toggles.

jmp loop returns to the top to read the button again.

Button not held. At silence: lda #$00 / sta PORTB writes all zeros to PORTB. PB0 goes low (LED off) and PB1 goes low (speaker off). jmp loop returns to the top.

The key difference from tone.s is the LED behaviour during the tone. In tone.s, PB1 alone is toggled — the LED is not involved. Here, PB0 stays set throughout both halves of each tone cycle: $03 during the high half (both bits set) and $01 during the low half (only PB0 set). The LED never blinks; it stays on continuously while the button is held, while the speaker toggles underneath it.

The byte count

Assemble the program and check the binary size:

vasm6502_oldstyle -Fbin -dotdir game.s -o game.bin
ls -l game.bin

The output binary is 32768 bytes — 32KB, the full size of the EEPROM. That is mostly padding. The actual code is roughly 40 bytes. The AT28C256 is 32KB; vasm fills unused space with $FF (the 6502 reads an immediate $FF which is undefined behaviour on the W65C02S, but execution never reaches the padding because the jmp loop at the end of each path keeps the CPU in the working code). The Combat cartridge for the Atari 2600 is 2KB. The programs in this chapter would fit in the first page of that cartridge with room to spare.

Running it

minipro -p AT28C256 -w game.bin

Power the board. With the button released, the LED is off and the buzzer is silent. Press and hold the button — the LED lights immediately and the buzzer sounds the same 440 Hz tone as in the previous page. Release — both stop. The transition is immediate in both directions because the loop checks the button state on every pass.

What adding a second player would require

The program currently has one input (PB7), one LED output (PB0), and one audio output (PB1). Adding a second player is straightforward to describe and instructive to think through.

A second player needs a second button — wire it to PB6. A second LED needs another VIA pin — PB2 is free. DDRB would change to $07 (bits 0, 1, 2 as outputs; bits 6 and 7 as inputs). The loop would need to read PORTB once, test bit 7 for player one, test bit 6 for player two, and decide what the outputs should be based on both states.

Deciding what a "win" condition means requires tracking state across loop iterations — how long each player has held their button, whether one player has held longer than the other. That state lives in RAM, in a byte or two at 0000and0000 and 0001. Updating it requires loading the current value, adding or comparing, storing back, branching based on the result.

Each addition is more register juggling, more branches, more labels. The logic stays flat — the 6502 has no call stack in use here, no data structures, no abstractions. Every decision is a load, a compare, and a branch. You can hold the entire program in your head. You can also see exactly where it would become unwieldy: a dozen inputs, a dozen outputs, a handful of state variables, and the branch tree becomes a maze.

That is why C exists. Not because the 6502 cannot do it — Atari 2600 programmers built far more complex games in 128 bytes of RAM — but because C gives you the tools to manage that complexity without losing your mind. f03d is next.

up next

Why C

Why C