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
git clone https://github.com/code198x/code-samples.git
cd code-samples/commodore-64/patterns/assembly/input/joystick-reading
makemake 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.
; 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.
; 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 0joy1 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.
Related
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
; 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 0Include 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
; 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_edgesSample once; let several consumers read that result
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 0Run 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 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
; 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_edgesChanging 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
; Deliberately broken: forget the previous sample before every poll.
joy_wrong_poll:
lda #0
sta joy1
sta joy2
jmp joy_poll_edgesThe 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.

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.