Skip to content
Game 1 Unit 2 of 128 1 hr learning time

Controller Input

Read the NES controller with the strobe-and-serial protocol. AND masks test individual buttons, BEQ branches on the result. The figure walks left and right.

2% of Dash

The figure stood still. Now it moves.

The NES controller has eight buttons — A, B, Select, Start, Up, Down, Left, Right — but only one data pin. The CPU reads them one at a time through a serial protocol: pulse a strobe signal, then read $4016 eight times. Each read returns one button. This unit reads the controller and moves the player left and right — the first interaction in Dash.

How the Controller Works

The NES controller is a shift register. When you write 1 then 0 to $4016, the controller latches the current state of all eight buttons. Then each read from $4016 returns the next button as bit 0 of the byte.

The button order is fixed: A, B, Select, Start, Up, Down, Left, Right. Eight reads, eight buttons, always in this order.

Reading All Eight Buttons

    ; --- Read controller ---
    lda #1
    sta JOYPAD1             ; Strobe on: latch button states
    lda #0
    sta JOYPAD1             ; Strobe off: begin serial output

    ldx #8                  ; 8 buttons to read
@read_pad:
    lda JOYPAD1             ; Read next button (bit 0)
    lsr a                   ; Shift bit 0 into carry
    rol buttons             ; Roll carry into buttons byte
    dex
    bne @read_pad

The strobe is a two-write sequence: write 1 to $4016 (latch button states), then write 0 (begin serial output). After that, each LDA JOYPAD1 returns the next button.

Packing Bits with LSR and ROL

Each read from $4016 returns a full byte, but only bit 0 matters — 1 if the button is pressed, 0 if not. We need to collect all eight bits into a single byte.

LSR A (Logical Shift Right) shifts A right by one position. Bit 0 drops into the carry flag. Everything else about A is irrelevant — we only wanted that one bit.

ROL buttons (Rotate Left through carry) shifts the buttons byte left by one position, and the carry flag enters as the new bit 0. Each iteration pushes one more button into the byte.

After eight iterations, buttons contains all eight button states packed into one byte:

Bit:  7    6    5      4     3    2    1    0
      A    B    Sel    Sta   Up   Dn   L    R

A is read first and ends up in bit 7 (pushed left by the seven subsequent rotates). Right is read last and lands in bit 0.

Testing Individual Buttons

With all eight buttons in one byte, testing any button is a single AND:

    ; --- Move player left/right ---
    lda buttons
    and #BTN_LEFT           ; Left pressed?
    beq @not_left
    lda player_x
    beq @not_left           ; Already at left edge (X = 0)
    dec player_x
@not_left:

    lda buttons
    and #BTN_RIGHT          ; Right pressed?
    beq @not_right
    lda player_x
    cmp #RIGHT_WALL
    bcs @not_right          ; At or past right edge
    inc player_x
@not_right:

AND #BTN_LEFT masks out every bit except bit 1 (left). If left is pressed, the result is non-zero. If not, it’s zero. BEQ (Branch if Equal — meaning the zero flag is set) skips the movement code when the button isn’t pressed.

Boundary Checking

Before moving left, we check if player_x is already 0. Moving left from 0 would wrap to 255 — the sprite would teleport to the right edge. BEQ @not_left catches this: if the comparison result is zero (X is 0), skip the decrement.

Before moving right, we compare against RIGHT_WALL (248 — the rightmost X that keeps an 8-pixel sprite fully on screen). CMP #RIGHT_WALL sets the carry flag if player_x >= 248. BCS @not_right (Branch if Carry Set) skips the increment.

DEC player_x and INC player_x move the sprite one pixel per frame. At 60fps, that’s a steady walk across the screen.

Frame Synchronisation

In Unit 1, the main loop was an empty JMP. Now it has work to do — but it must only run once per frame, synchronised with the PPU’s vblank.

; In the main loop: wait for NMI to signal a new frame
main_loop:
    lda nmi_flag            ; Has a new frame started?
    beq main_loop           ; No — keep waiting
    lda #0
    sta nmi_flag            ; Clear the flag

    ; ... game logic runs here, once per frame ...

    jmp main_loop

; In the NMI handler: set the flag after OAM DMA
nmi:
    ; ... save registers, do OAM DMA ...
    lda #1
    sta nmi_flag            ; Signal the main loop
    ; ... restore registers ...
    rti

The NMI handler sets nmi_flag to 1 after the OAM DMA. The main loop spins on BEQ until it sees the flag, clears it, runs the game logic, and waits again. One iteration per frame. The game ticks at exactly 60Hz, locked to the PPU’s vblank.

This pattern — NMI signals, main loop processes — is the standard NES game loop. The NMI handler stays short (just DMA and a flag write). All the real work happens in the main loop, safely outside the interrupt.

Why Not Put Logic in the NMI?

The NMI interrupts whatever the CPU is doing. If the game logic ran inside the NMI, it would need to save and restore every piece of state it touches. Worse, if the logic takes longer than one frame, the next NMI fires before the previous one finishes — chaos. Keeping the NMI tiny and the logic in the main loop avoids both problems.

Updating the Sprite

After the movement code runs, two writes copy the current position into the OAM buffer:

    lda player_y
    sta oam_buffer+0        ; Y position
    lda player_x
    sta oam_buffer+3        ; X position

On the next NMI, the DMA transfer sends this updated buffer to the PPU, and the sprite appears at its new position. The flow:

NMI fires → DMA copies OAM → sets flag
Main loop wakes → reads controller → updates position → writes OAM buffer
NMI fires → DMA copies new position → ...

Every frame: read input, update state, prepare for the next DMA. The player moves smoothly because the PPU redraws the sprite at its new position every 1/60th of a second.

The Complete Code

; =============================================================================
; DASH - Unit 2: Controller Input
; =============================================================================
; The figure moves. Read the NES controller to walk left and right.
; =============================================================================

; -----------------------------------------------------------------------------
; NES Hardware Addresses
; -----------------------------------------------------------------------------
PPUCTRL   = $2000           ; PPU control register
PPUMASK   = $2001           ; PPU mask register
PPUSTATUS = $2002           ; PPU status register
OAMADDR   = $2003           ; OAM address
PPUADDR   = $2006           ; PPU address
PPUDATA   = $2007           ; PPU data
OAMDMA    = $4014           ; OAM DMA register
JOYPAD1   = $4016           ; Controller port 1

; -----------------------------------------------------------------------------
; Button Masks
; -----------------------------------------------------------------------------
; After reading all 8 buttons into one byte, each bit maps to a button:
BTN_A      = %10000000
BTN_B      = %01000000
BTN_SELECT = %00100000
BTN_START  = %00010000
BTN_UP     = %00001000
BTN_DOWN   = %00000100
BTN_LEFT   = %00000010
BTN_RIGHT  = %00000001

; -----------------------------------------------------------------------------
; Game Constants
; -----------------------------------------------------------------------------
PLAYER_X    = 124           ; Starting X position
PLAYER_Y    = 120           ; Starting Y position
PLAYER_TILE = 1             ; Tile number for the player sprite
RIGHT_WALL  = 248           ; Rightmost X position (256 - 8px sprite width)

; -----------------------------------------------------------------------------
; Memory
; -----------------------------------------------------------------------------
.segment "ZEROPAGE"
player_x:   .res 1          ; Player X position
player_y:   .res 1          ; Player Y position
buttons:    .res 1          ; Current button state (packed byte)
nmi_flag:   .res 1          ; Set by NMI, cleared by main loop

.segment "OAM"
oam_buffer: .res 256        ; Sprite data (DMA'd to PPU each frame)

.segment "BSS"

; =============================================================================
; iNES Header
; =============================================================================
.segment "HEADER"
    .byte "NES", $1A        ; Magic number
    .byte 2                 ; 2 x 16KB PRG-ROM = 32KB
    .byte 1                 ; 1 x 8KB CHR-ROM = 8KB
    .byte $01               ; Vertical mirroring, Mapper 0
    .byte $00               ; Mapper 0 (NROM)
    .byte 0,0,0,0,0,0,0,0  ; Padding

; =============================================================================
; Code
; =============================================================================
.segment "CODE"

; --- Reset ---
reset:
    sei
    cld
    ldx #$40
    stx $4017
    ldx #$FF
    txs
    inx
    stx PPUCTRL
    stx PPUMASK
    stx $4010

@vblank1:
    bit PPUSTATUS
    bpl @vblank1

    lda #0
@clear_ram:
    sta $0000, x
    sta $0100, x
    sta $0200, x
    sta $0300, x
    sta $0400, x
    sta $0500, x
    sta $0600, x
    sta $0700, x
    inx
    bne @clear_ram

@vblank2:
    bit PPUSTATUS
    bpl @vblank2

    ; Load palette
    bit PPUSTATUS
    lda #$3F
    sta PPUADDR
    lda #$00
    sta PPUADDR

    ldx #0
@load_palette:
    lda palette_data, x
    sta PPUDATA
    inx
    cpx #32
    bne @load_palette

    ; Set up player sprite
    lda #PLAYER_Y
    sta oam_buffer+0
    lda #PLAYER_TILE
    sta oam_buffer+1
    lda #0
    sta oam_buffer+2
    lda #PLAYER_X
    sta oam_buffer+3

    lda #PLAYER_X
    sta player_x
    lda #PLAYER_Y
    sta player_y

    ; Hide other sprites
    lda #$EF
    ldx #4
@hide_sprites:
    sta oam_buffer, x
    inx
    bne @hide_sprites

    ; Enable rendering
    lda #%10000000          ; Enable NMI
    sta PPUCTRL
    lda #%00011110          ; Show background and sprites
    sta PPUMASK

; =============================================================================
; Main Loop
; =============================================================================
main_loop:
    lda nmi_flag            ; Has a new frame started?
    beq main_loop           ; No — keep waiting
    lda #0
    sta nmi_flag            ; Clear the flag

    ; --- Read controller ---
    lda #1
    sta JOYPAD1             ; Strobe on: latch button states
    lda #0
    sta JOYPAD1             ; Strobe off: begin serial output

    ldx #8                  ; 8 buttons to read
@read_pad:
    lda JOYPAD1             ; Read next button (bit 0)
    lsr a                   ; Shift bit 0 into carry
    rol buttons             ; Roll carry into buttons byte
    dex
    bne @read_pad

    ; --- Move player left/right ---
    lda buttons
    and #BTN_LEFT           ; Left pressed?
    beq @not_left
    lda player_x
    beq @not_left           ; Already at left edge (X = 0)
    dec player_x
@not_left:

    lda buttons
    and #BTN_RIGHT          ; Right pressed?
    beq @not_right
    lda player_x
    cmp #RIGHT_WALL
    bcs @not_right          ; At or past right edge
    inc player_x
@not_right:

    ; --- Update sprite position in OAM buffer ---
    lda player_y
    sta oam_buffer+0        ; Y position
    lda player_x
    sta oam_buffer+3        ; X position

    jmp main_loop

; =============================================================================
; NMI Handler
; =============================================================================
nmi:
    pha
    txa
    pha
    tya
    pha

    ; OAM DMA
    lda #0
    sta OAMADDR
    lda #>oam_buffer
    sta OAMDMA

    ; Signal the main loop that a frame has passed
    lda #1
    sta nmi_flag

    pla
    tay
    pla
    tax
    pla
    rti

; --- IRQ: not used ---
irq:
    rti

; =============================================================================
; Data
; =============================================================================

palette_data:
    ; Background palettes
    .byte $0F, $00, $10, $20
    .byte $0F, $00, $10, $20
    .byte $0F, $00, $10, $20
    .byte $0F, $00, $10, $20
    ; Sprite palettes
    .byte $0F, $30, $16, $27   ; Palette 0: white, red, orange
    .byte $0F, $30, $16, $27
    .byte $0F, $30, $16, $27
    .byte $0F, $30, $16, $27

; =============================================================================
; Vectors
; =============================================================================
.segment "VECTORS"
    .word nmi
    .word reset
    .word irq

; =============================================================================
; CHR-ROM
; =============================================================================
.segment "CHARS"

; Tile 0: Empty
.byte $00,$00,$00,$00,$00,$00,$00,$00
.byte $00,$00,$00,$00,$00,$00,$00,$00

; Tile 1: Running figure
;
;   ..##....    Head
;   ..##....    Head
;   .####...    Arms + body
;   ..##....    Torso
;   ..##....    Hips
;   ..#.#...    Legs mid-stride
;   .#...#..    Legs apart
;   .#...#..    Feet
;
.byte %00110000
.byte %00110000
.byte %01111000
.byte %00110000
.byte %00110000
.byte %00101000
.byte %01000100
.byte %01000100
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000

; Fill rest of CHR-ROM
.res 8192 - 32, $00

Dash Unit 2

The same running figure on a black screen — but now press left or right on the controller and it moves. The sprite glides smoothly across the screen, stopping at the edges.

Try This: Add Up and Down

The button masks for Up and Down are already defined:

BTN_UP     = %00001000
BTN_DOWN   = %00000100

Add two more movement blocks after the left/right checks, using player_y instead of player_x. Clamp to a sensible range — Y values 8 to 224 keep the sprite visible on NTSC. The movement code is identical in structure: AND mask, BEQ skip, boundary check, INC/DEC.

Try This: Move Faster

One pixel per frame is a steady walk. For a run, increment twice:

    lda buttons
    and #BTN_RIGHT
    beq @not_right
    lda player_x
    cmp #RIGHT_WALL
    bcs @not_right
    inc player_x
    lda player_x
    cmp #RIGHT_WALL
    bcs @not_right
    inc player_x
@not_right:

Two INC instructions per frame doubles the speed. The second boundary check prevents overshooting the edge. In Unit 3, we’ll replace this with proper velocity — a variable that accumulates rather than a fixed step.

Try This: Different Button for Movement

Swap the button masks:

    and #BTN_A              ; A button moves right instead of d-pad

Any button can trigger any action. The mask is just a bit position in the buttons byte.

If It Doesn’t Work

  • No movement? Check the NMI handler sets nmi_flag. Without it, the main loop spins forever on BEQ main_loop.
  • Sprite doesn’t move? Make sure oam_buffer+3 is updated from player_x after the movement code, not before. The DMA reads whatever is in the buffer when NMI fires.
  • Movement only in one direction? Check both AND masks. BTN_LEFT is %00000010, BTN_RIGHT is %00000001. Swapping them reverses the controls.
  • Sprite teleports at edge? The boundary check must come BEFORE DEC/INC. Without it, X wraps from 0 to 255 or from 255 to 0.
  • Jittery movement? Make sure nmi_flag is cleared immediately after detecting it. If the flag stays set, the game logic runs multiple times per frame.
  • Controller not responding? The strobe sequence must write 1 then 0 to $4016. Missing either write means the controller never latches.

What You’ve Learnt

  • Controller protocol — write 1 then 0 to $4016 to latch buttons. Read $4016 eight times to get A, B, Select, Start, Up, Down, Left, Right, one per read.
  • LSR/ROL bit packingLSR A shifts bit 0 into carry. ROL buttons rotates carry into a byte. Eight iterations pack eight buttons into one byte.
  • AND bit maskingAND #mask isolates a single bit. If the result is zero, the button isn’t pressed. If non-zero, it is.
  • BEQ/BNE branchingBEQ branches when the zero flag is set (result was zero). BNE branches when it’s clear. These are the fundamental decision instructions.
  • BCS for unsigned comparisonCMP #value sets carry if A >= value. BCS branches when carry is set. This is how you check boundaries on unsigned numbers.
  • NMI flag synchronisation — NMI sets a flag, main loop waits for it. Game logic runs exactly once per frame, locked to 60Hz.
  • Boundary checking — test the position BEFORE moving. Prevents wrap-around at the edges of the screen.

What’s Next

Left and right. But the runner needs to do more than walk — in Unit 3, the A button makes the character jump. Velocity, gravity, and the core verb of every platformer.