Skip to content

Joystick Reading

Read both C64 joystick ports, distinguish holds, presses and releases, and map one event to one action.

Code status: assembled and emulator-executed. Both joystick ports on PAL and NTSC C64 in Emu198x and VICE, with the caller owning CIA1 and keyboard keys released. The recorded checks identify the sources and observations. A separate reverse-keyboard-scan discrepancy is recorded below; these checks do not establish complete keyboard coexistence or original-hardware behaviour.

See held controls and new presses

The example displays one row for each joystick port. White blocks under U D L R F show which controls are held. NEW FIRE counts presses in hexadecimal: hold Fire and the number stays still; release and press again and it increases.

These are different questions. Movement usually needs “is Left held?” A menu action often needs “was Fire newly pressed?” Sampling the same closed switch every frame should not automatically mean a new press every frame.

Run it

Shell
git clone https://github.com/code198x/code-samples.git
cd code-samples/commodore-64/patterns/assembly/input/joystick-reading
make

make uses Asm198x to create build/demo.prg. Load it in a PAL or NTSC C64 emulator, type RUN, and configure joystick controls for either port. Release keyboard keys while using the joysticks. Reset to leave.

The standalone program owns the screen, CIA1 port directions and latches, CIA1 timer controls and CIA interrupt masks. It maps out BASIC/KERNAL and disables keyboard scanning. It does not return to BASIC or preserve the normal keyboard service.

demo.asm
; Load and RUN. Reset to leave. Use either joystick port; release keyboard keys.
; White blocks show held inputs; NEW FIRE counts sampled presses in hexadecimal.
* = $0801
    !byte $0b, $08, $0a, $00, $9e
    !text "2064"
    !byte 0, 0, 0
* = $0810
start:
    sei
    lda #$7f
    sta $dc0d
    sta $dd0d
    lda $dc0d
    lda $dd0d
    lda #$35
    sta $01
    lda #<nmi_return
    sta $fffa
    lda #>nmi_return
    sta $fffb
    lda #0
    sta $dc0e
    sta $dc0f                ; no timer output on keyboard/joystick pins
    sta $d015
    sta $d020
    sta $d021
    lda $dd02
    ora #3
    sta $dd02
    lda $dd00
    ora #3
    sta $dd00
    lda #$14
    sta $d018
    lda #$1b
    sta $d011
    lda #8
    sta $d016
    ldx #0
clear_screen:
    lda #32
    sta $0400, x
    sta $0500, x
    sta $0600, x
    sta $06e8, x
    lda #1
    sta $d800, x
    sta $d900, x
    sta $da00, x
    sta $dae8, x
    inx
    bne clear_screen
    ldx #5
titles:
    lda title1, x
    sta $0458, x
    lda title2, x
    sta $0598, x
    dex
    bpl titles
    ldx #8
counter_labels:
    lda counter_label, x
    sta $0520, x
    sta $0660, x
    dex
    bpl counter_labels
    ldx #4
input_labels:
    ldy columns, x
    lda labels, x
    sta $04a0, y
    sta $05e0, y
    dex
    bpl input_labels
    jsr joy_init
wait_top:
    bit $d011
    bmi wait_top
wait_bottom:
    bit $d011
    bpl wait_bottom
read_inputs:
    jsr joy_poll
    jsr show_inputs
    inc frames
frame_done:
    jmp wait_top
nmi_return:
    rti
frames: !byte 0

show_inputs:
    ldx #4
show_bit:
    ldy columns, x
    lda joy1
    and masks, x
    beq blank1
    lda #160                ; reversed space is a solid character cell
    bne store1
blank1:
    lda #32
store1:
    sta $04c8, y
    lda joy2
    and masks, x
    beq blank2
    lda #160
    bne store2
blank2:
    lda #32
store2:
    sta $0608, y
    dex
    bpl show_bit
    lda joy1_fires
    lsr
    lsr
    lsr
    lsr
    tay
    lda hex_digits, y
    sta $052a
    lda joy1_fires
    and #15
    tay
    lda hex_digits, y
    sta $052b
    lda joy2_fires
    lsr
    lsr
    lsr
    lsr
    tay
    lda hex_digits, y
    sta $066a
    lda joy2_fires
    and #15
    tay
    lda hex_digits, y
    sta $066b
    rts

title1: !byte 16, 15, 18, 20, 32, 49
title2: !byte 16, 15, 18, 20, 32, 50
counter_label: !byte 14, 5, 23, 32, 6, 9, 18, 5, 58
labels: !byte 21, 4, 12, 18, 6
columns: !byte 8, 12, 16, 20, 24
masks: !byte 1, 2, 4, 8, 16
hex_digits: !byte 48,49,50,51,52,53,54,55,56,57,1,2,3,4,5,6

!source "joystick.inc"

The caller polls once per video frame. The hexadecimal counters wrap from FF to 00. Their purpose is to make press/hold/release behaviour visible.

Read the five switches

The joystick switches are active low: a closed switch reads as 0. The reader inverts the byte and masks it with $1F, returning only the five pressed bits.

Bit Value Control
0 1 Up
1 2 Down
2 4 Left
3 8 Right
4 16 Fire

The case-labelled port 1 is CIA1 port B at $DC01. Port 2 is CIA1 port A at $DC00. The upper three bits are not part of this direction/fire result.

Call joy_init once with I/O visible, keyboard scanning stopped and timer outputs on CIA1 PB6/PB7 disabled. It configures both ports as inputs, leaves released values in their output latches and clears the six state bytes. joy_poll then reads each port once. Both routines change A, X and flags and preserve Y.

joystick.inc
; Exclusive CIA1 port ownership. Stop keyboard scanning and timer PB outputs
; before calling joy_init. Read once per update with joy_poll.
; Both routines change A/X and flags, preserve Y. I/O must remain visible.
joy_init:
    lda #0
    sta $dc02                ; both ports input: no driven keyboard columns
    sta $dc03
    lda #$ff
    sta $dc00                ; leave the output latches released too
    sta $dc01
    lda #0
    ldx #5
joy_clear:
    sta joy1, x
    dex
    bpl joy_clear
    rts

joy_poll:
    lda $dc01                ; case-labelled port 1 is CIA1 port B
    eor #$ff
    and #$1f                 ; only directions and fire
    tax
    lda joy1
    eor #$ff
    sta joy1_new
    txa
    and joy1_new             ; current AND NOT previous
    sta joy1_new
    stx joy1
    lda $dc00                ; case-labelled port 2 is CIA1 port A
    eor #$ff
    and #$1f
    tax
    lda joy2
    eor #$ff
    sta joy2_new
    txa
    and joy2_new
    sta joy2_new
    stx joy2
    lda joy1_new
    and #$10
    beq joy_check2
    inc joy1_fires           ; one increment per sampled fire press
joy_check2:
    lda joy2_new
    and #$10
    beq joy_done
    inc joy2_fires
joy_done:
    rts
joy_code_end:

; Pressed bits: up=1, down=2, left=4, right=8, fire=16.
; Counters wrap from $ff to $00. First poll compares against released state.
joy1:       !byte 0
joy2:       !byte 0
joy1_new:   !byte 0
joy2_new:   !byte 0
joy1_fires: !byte 0
joy2_fires: !byte 0

joy1 and joy2 contain the held bits. Opposite directions remain separate bits; the reader does not decide whether movement cancels them, prioritises one or rejects the combination. Sprite Movement with Bounds makes that choice in its movement rule.

Find a new press

joy1_new and joy2_new use current AND NOT previous. A bit appears when it changes from released to pressed. Continuing to hold it produces no further new-press bits; releasing and pressing again produces another.

The first poll compares against released state. Starting with Fire already held therefore counts as a press. Resetting this software state while the button remains held has the same policy. A game that requires release before accepting input would need a different initialisation rule.

This edge rule is not physical switch debouncing. Contact bounce can produce several sampled transitions, and an entire press/release between samples can be missed. The example also provides no automatic menu repeat. PAL and NTSC sample at their respective frame rates.

Share the CIA deliberately

The joysticks and keyboard share CIA1 pins. Writing $7F to $DC00 while its bits are outputs drives keyboard column 7 low; it does not disable scanning. In the recorded probes, holding 1 then looks like joystick-1 Up, and holding Space looks like Fire, with no joystick pressed. Both emulators reproduce these cases on PAL and NTSC.

This caller releases both port drivers and keeps keyboard scanning stopped. That prevents the program from selecting a keyboard column during its reads. It cannot disconnect the physical keyboard: held keys can connect shared lines, including lines grounded by another control. The demonstrated operating condition is therefore joystick input with keyboard keys released.

If a program also needs a keyboard reader, agree who owns the port directions, output latches and interrupt timing, and verify that combined configuration. A routine that temporarily borrows the ports must restore the state expected by its owner; writing one constant is not a universal isolation trick.

Costs and limits

Aspect Cost or limit
Code 100 assembled bytes for initialisation and polling, excluding the caller and display routines
State Six bytes: two held masks, two new-press masks and two fire counters
CPU Two CIA reads per poll, plus state and counter work; elapsed time also depends on VIC-II bus contention
Timing One sample per frame; short pulses may be missed
Ownership Exclusive CIA1 port use, with keyboard scanning and timer port outputs disabled

For continuous movement, use the held masks. For one action per sampled press, use the new-press masks. Keep repeat timing and physical debouncing as separate policies with their own requirements.

What has been checked

The verification recipe compares Asm198x and ACME output. It checks all 32 masks on each port, simultaneous controls and hold/release/repress history against an independent calculation. The matrix gates input sampling for observation; a separate run executes the unchanged once-per-frame demonstration.

The matrix provides 412 state/display checks across the two engines and video standards. Together with the unchanged demonstration, the runs check 48 native captures of the input indicators. Emu198x receives input events through normal execution; VICE uses simulated external control-port pins. Deliberately breaking the previous-state mask must fail the held-fire check.

Held-key probes use Emu198x key events and VICE keyboard-matrix snapshots, with selected-column reads as controls. They expose one remaining Emu198x discrepancy: with PB1 driven low, PA configured as input and Return held, VICE sees PA0 low but Emu198x does not. A forward scan detects Return in both engines, confirming that the key event arrived. This reverse-scan case is marked as a mismatch in the record. It is outside the standalone caller’s configuration and prevents a claim of complete keyboard/joystick parity.

Patterns: Sprite Movement with Bounds, Game Loop (Basic)

Vault: CIA | Commodore 64

Sources

Commodore, Commodore 64 Programmer’s Reference Guide (1983), p. 94 (keyboard matrix), pp. 320–322 (I/O assignments) and pp. 343–344 (game ports). MOS Technology, 6526 Complex Interface Adapter, sheet 5/8 (port direction and pin reads). New-press state and reset behaviour are the example’s software choices.

Act when a button is released

Charging a shot and firing it on release needs one more question: which bits were held before this sample but are clear now? The wrapper below keeps the original reader’s held/new/counter interface and adds release masks.

Add releases without replacing the existing reader

edges.inc
; Extends the maintained reader without changing its held/new/counter interface.
; Exclusive CIA ownership and five-bit masks as documented in joystick.inc.
; Call once per update instead of joy_poll. A/X/flags change; Y survives.
joy_edges_init:
    jsr joy_init
    lda #0
    sta joy1_released
    sta joy2_released
    rts
joy_poll_edges:
    lda joy1
    sta joy1_released
    lda joy2
    sta joy2_released
    jsr joy_poll
    lda joy1_released
    ldx joy1
    jsr joy_release_bits
    sta joy1_released
    lda joy2_released
    ldx joy2
    jsr joy_release_bits
    sta joy2_released
    rts
; Pure second-caller entry: A=previous five-bit mask, X=current five-bit mask.
; A=previous minus current as a bit set. X/Y survive; flags/scratch change.
joy_release_bits:
    sta joy_edge_scratch
    txa
    eor #$ff
    and joy_edge_scratch
    rts
joy_edges_code_end:
joy1_released: !byte 0
joy2_released: !byte 0
joy_edge_scratch: !byte 0

Include joystick.inc once, then edges.inc. With the same exclusive CIA1 ownership, call joy_edges_init once and joy_poll_edges once per update instead of joy_poll. Use joy1/joy2 for held controls, joy1_new/joy2_new for presses and joy1_released/joy2_released for releases. A/X/flags change; Y survives. The wrapper owns two release bytes and one scratch byte, uses the existing JSR stack and is not re-entrant on one state set.

The pure joy_release_bits entry takes A=previous and X=current five-bit masks; A returns the released bits, X/Y survive and flags/private scratch change. Its operation is set difference: previous AND NOT current. This is another useful link between bit masks and Maths for Games.

Put the events into a caller

The include and call order

edges-demo.asm
; PAL C64; joystick port 2; keyboard released. Reset to leave.
* = $0801
    !byte $0b,$08,$0a,$00,$9e
    !text "2064"
    !byte 0,0,0
* = $0810
ACTION_MASK = $10
!source "edges-caller.inc"
!source "joystick.inc"
!source "edges.inc"
POLL_ROUTINE:
    jmp joy_poll_edges

Sample once; let several consumers read that result

edges-caller.inc
start:
    sei
    lda #$7f
    sta $dc0d
    sta $dd0d
    lda $dc0d
    lda $dd0d
    lda #$35
    sta $01
    lda #<nmi_return
    sta $fffa
    lda #>nmi_return
    sta $fffb
    lda #0
    sta $dc0e
    sta $dc0f
    sta $d015
    sta $d020
    sta $d021
    sta actions
    ldx #7
clear_trace:
    sta edge_trace,x
    dex
    bpl clear_trace
    lda $dd02
    ora #3
    sta $dd02
    lda $dd00
    ora #3
    sta $dd00
    lda #$14
    sta $d018
    lda #$1b
    sta $d011
    lda #8
    sta $d016
    ldx #0
clear_screen:
    lda #32
    sta $0400,x
    sta $0500,x
    sta $0600,x
    sta $06e8,x
    lda #1
    sta $d800,x
    sta $d900,x
    sta $da00,x
    sta $dae8,x
    inx
    bne clear_screen
    jsr joy_edges_init
    lda #$a5
    sta ready
wait_top:
    bit $d011
    bmi wait_top
wait_bottom:
    bit $d011
    bpl wait_bottom
sample:
    jsr POLL_ROUTINE
    lda joy1_new
    beq no_press1
    sta last_new1
    inc press_events1
no_press1:
    lda joy1_released
    beq no_release1
    sta last_release1
    inc release_events1
no_release1:
    lda joy2_new
    beq no_press2
    sta last_new2
    inc press_events2
no_press2:
    lda joy2_released
    beq no_release2
    sta last_release2
    inc release_events2
no_release2:
    lda joy2_new
    and #ACTION_MASK
    beq no_action
    inc actions
no_action:
    ldy #0
    lda joy1
    jsr draw_byte
    lda joy1_new
    jsr draw_byte
    lda joy1_released
    jsr draw_byte
    lda joy2
    jsr draw_byte
    lda joy2_new
    jsr draw_byte
    lda joy2_released
    jsr draw_byte
    lda actions
    jsr draw_byte
frame_done:
    jmp wait_top
nmi_return:
    rti
draw_byte:
    pha
    lsr
    lsr
    lsr
    lsr
    tax
    lda digits,x
    sta $0400,y
    iny
    pla
    and #15
    tax
    lda digits,x
    sta $0400,y
    iny
    lda #32
    sta $0400,y
    iny
    rts
digits: !byte 48,49,50,51,52,53,54,55,56,57,1,2,3,4,5,6
actions: !byte 0
ready: !byte 0
; Diagnostic latches/counters make one-update pulses observable after a frame.
edge_trace:
last_new1: !byte 0
last_release1: !byte 0
last_new2: !byte 0
last_release2: !byte 0
press_events1: !byte 0
release_events1: !byte 0
press_events2: !byte 0
release_events2: !byte 0

Run make edges ASM198X=/path/to/asm198x, load build/edges-demo.prg and RUN. Fire in port 2 spends one action. Hold it and the count stays still; release and press again for another. Reset to leave. Seven hexadecimal bytes show port 1 held/new/released, port 2 held/new/released, then the action count.

The native caller ends with action count 02 after two Fire presses.
After release, all current masks are zero. The final 02 preserves the two accepted actions.

The caller owns CIA1 directions/latches/timer controls/interrupt masks, CPU banking, the display and an NMI return vector. It deliberately leaves the KERNAL keyboard service stopped. Keep keyboard keys released. The diagnostic event latches and counters make one-update pulses observable after the beam has drawn a frame; a stable still cannot show every transition.

Initial history is zero, so a button held on entry counts as a first press. To ignore already-held buttons at a scene boundary, initialise, take one sample and discard it before accepting actions. Share that one result: a second poll in the same update consumes an edge. Very short pulses, bounce, deliberate repeat and opposite-direction policy remain separate jobs. Clock and game-loop choices explains why input sampling and game progression need not be identical.

Make it your own

Map a new Left press to the action

edges-custom.asm
; PAL C64; joystick port 2; keyboard released. Reset to leave.
* = $0801
    !byte $0b,$08,$0a,$00,$9e
    !text "2064"
    !byte 0,0,0
* = $0810
ACTION_MASK = $04
!source "edges-caller.inc"
!source "joystick.inc"
!source "edges.inc"
POLL_ROUTINE:
    jmp joy_poll_edges

Changing ACTION_MASK from $10 to $04 makes Left spend the action and Fire do nothing. The reader/wrapper and payload sizes stay unchanged. A mask with several selected bits would count one event if any of them were newly pressed; it would not count one action per button. Choose that policy deliberately.

Common mistake: forgetting the history

Deliberately broken: clear history before every sample

edges-wrong.inc
; Deliberately broken: forget the previous sample before every poll.
joy_wrong_poll:
    lda #0
    sta joy1
    sta joy2
    jmp joy_poll_edges

The separate edges-wrong.asm driver selects this fault. Held Fire becomes a new press every update, the action count rises while the button is held, and releases disappear. Restore the normal caller’s POLL_ROUTINE jump to joy_poll_edges. The independently executed repair has exactly the working payload. The wrong build adds eleven bytes and is kept outside the normal build target.

The faulty native caller has spent more than two actions from the same presses and holds.
The counter records the repeated actions; the still alone does not establish their timing.

Extra costs and verification

The wrapper adds 62 instruction bytes and 3 state bytes to the maintained reader’s 100 instruction bytes and 6 state bytes. The complete diagnostic PRG payload is 490 bytes, including its BASIC header, caller, tables and state. Diagnostic latches add 8 bytes, with a separate action counter and ready marker. The hardware reader still performs two port reads per poll. Wrapper/call work has a cost; byte counts do not establish a whole-frame budget or remove VIC-II contention.

Six cases on Emu198x 0.44.2, Asm198x 0.0.72 and the recorded stock PAL ROMs cover working/custom/wrong/repaired callers and a second guest-owned release caller. All 1,024 previous/current masks are checked against independent bit-set arithmetic. A wrong OR operation completes its guards but fails that oracle. Real input events, native masks/counters, screen cells, ROM-glyph/PNG agreement and source/tool/firmware identities are retained. Thirteen corrupted observations/pixel controls must fail.

The new wrapper has this named PAL qualification; the earlier reader’s separate PAL/NTSC observations remain separate. Opposite-bit fixtures prove arithmetic, not physical joystick capability. There is no new original-hardware, keyboard-coexistence or universal timing claim.

Maintained wrapper and callers · Native records and configuration.