Skip to content

Keyboard Reading

Read the ZX Spectrum keyboard matrix by polling I/O ports. Simple, efficient, and no ROM routines required.

Code status: the maintained matrix reader is reused by executed 48K callers. The single-code adapter is an additional caller; its priority and state contracts are shown here.

Audit execution record · Scope and remaining checks

Overview

Read keyboard input by polling the I/O ports directly. The Spectrum’s keyboard is arranged in a matrix of 8 rows, each accessible via a specific port address. Keys are active low (0 = pressed). Use when you need responsive input without relying on the ROM.

Code

input.inc
; QAOP + Space. One bit per held control: U D L R Fire = 1 2 4 8 16.
; read_controls: returns A and controls; clobbers AF/BC, preserves DE/HL/IX/IY.
; Read once per update. Interrupt code must not use this state or these ports.
read_controls:
    ld c,0
    ld a,$fb
    in a,($fe)
    bit 0,a
    jr nz,input_no_q
    set 0,c
input_no_q:
    ld a,$fd
    in a,($fe)
    bit 0,a
    jr nz,input_no_a
    set 1,c
input_no_a:
    ld a,$df
    in a,($fe)
    bit 1,a
    jr nz,input_no_o
    set 2,c
input_no_o:
    bit 0,a
    jr nz,input_no_p
    set 3,c
input_no_p:
    ld a,$7f
    in a,($fe)
    bit 0,a
    jr nz,input_no_space
    set 4,c
input_no_space:
    ld a,c
    ld (controls),a
    ret
controls: defb 0

For a menu that wants one code rather than simultaneous bits, include this adapter as well:

keyboard-code.inc
; Include the maintained framework/input-to-sound/input.inc once.
; read_keyboard: A and key_pressed = 0 none,1 Q,2 A,3 O,4 P,5 Space.
; Priority is Q,A,O,P,Space; use controls for simultaneous held controls.
; Changes AF/BC; preserves DE/HL/IX/IY. No key is a held state, not an edge.
read_keyboard:
    call read_controls
    ld b,a
    xor a
    bit 0,b
    jr z,keyboard_not_q
    ld a,1
    jr keyboard_store
keyboard_not_q:
    bit 1,b
    jr z,keyboard_not_a
    ld a,2
    jr keyboard_store
keyboard_not_a:
    bit 2,b
    jr z,keyboard_not_o
    ld a,3
    jr keyboard_store
keyboard_not_o:
    bit 3,b
    jr z,keyboard_not_p
    ld a,4
    jr keyboard_store
keyboard_not_p:
    bit 4,b
    jr z,keyboard_store
    ld a,5
keyboard_store:
    ld (key_pressed),a
    ret
key_pressed: defb 0

Usage:

Z80 assembly
main_loop:
    halt                        ; Wait for frame
    call    read_keyboard
    ld      a, (key_pressed)
    or      a
    jr      z, main_loop        ; No key pressed

    cp      1
    jr      z, handle_up
    cp      2
    jr      z, handle_down
    ; ... etc
    jr      main_loop

Trade-offs

Aspect Cost
CPU Not measured for this caller; include the selected input, drawing and scheduling costs
Memory Assemble the selected routines and caller to obtain code and state sizes
Limitation No debouncing (keys may auto-repeat)

When to use: Any game needing keyboard input. ROM-free approach works on all Spectrums.

When to avoid: When you need debounced input for menus or single-press detection.

Keyboard Matrix Reference

Port Bit 0 Bit 1 Bit 2 Bit 3 Bit 4
$F7 1 2 3 4 5
$EF 0 9 8 7 6
$FB Q W E R T
$FD A S D F G
$DF P O I U Y
$BF Enter L K J H
$7F Space Sym M N B
$FE Shift Z X C V

Keyboard ghosting

The Spectrum’s keyboard is a passive matrix: switches connect eight half-rows to five shared input bits. A phantom key can appear when three pressed keys occupy three corners of a rectangle in that matrix. The closed switches give the scan a path to the fourth corner, even though that key is released. Three keys in one half-row alone do not create this phantom; neither do two keys alone.

For example, Q and P share bit 0, while P and O share a half-row. Holding Q, P and O can therefore also report W, the fourth corner. This follows from the wiring in the Sinclair service manual, section 1.8. Choosing keys in different rows alone does not prevent ghosting. Check the actual simultaneous chords your game accepts, including direction plus fire; the familiar QAOP/Space layout is not a guarantee for every chord or for other keys read alongside it.

Reading multiple keys at once

The pattern above returns the first key it finds. To check all directions plus fire on the same frame, store each result rather than returning early:

Z80 assembly
read_all_keys:
    ld      a, 0
    ld      (key_state), a

    ; Check Q (up): row $FB, bit 0
    ld      a, $fb
    in      a, ($fe)
    bit     0, a
    jr      nz, .not_q
    ld      hl, key_state
    set     0, (hl)         ; Bit 0 of state = up
.not_q:
    ; ... continue for each key, setting different bits ...
    ret

key_state:  defb    0       ; Bit 0=up, 1=down, 2=left, 3=right, 4=fire

Patterns: Game Loop (HALT)

Vault: ULA — port $FE keyboard matrix | ZX Spectrum

One sample, two representations

read_controls returns A and controls as simultaneous held bits: Q/A/O/P/Space are 1/2/4/8/16. read_keyboard calls it once and returns A plus key_pressed as 0 for none, 1/2/3/4 for directions and 5 for Space. If several are held, its priority is Q, A, O, P, Space. A no-key result is explicitly zero, never the last raw port value.

Both change AF/BC and preserve DE/HL/IX/IY. State must be writable; a valid stack is required. The result is held state, not a new press. Use the edge detector for one action per press. If the frame already sampled input, adapt its stored controls rather than reading the matrix again.

Keep held, pressed and released controls separate

Add release state to the maintained press detector

edges.inc
; Call after read_controls once per update, alongside the maintained presses.inc.
; AF/BC change; DE/HL/IX/IY survive. One private released byte.
detect_control_edges:
    ld a,(previous_controls)
    ld (released_controls),a
    call detect_presses
    ld a,(controls)
    ld b,a
    ld a,(released_controls)
    call release_bits
    ld (released_controls),a
    ret
; Pure entry: A=previous, B=current normalised five-bit masks.
; A=released; B/DE/HL/IX/IY survive. C/flags change.
release_bits:
    ld c,a
    ld a,b
    cpl
    and c
    ret
edges_code_end:
released_controls: defb 0

The unchanged press detector

presses.inc
; Call after read_controls, once per update. AF/B change; other registers survive.
; Initialise previous_controls to zero, or seed it with controls on scene entry.
detect_presses:
    ld a,(previous_controls)
    cpl
    ld b,a
    ld a,(controls)
    and b
    ld (new_presses),a
    ld a,(controls)
    ld (previous_controls),a
    ret
previous_controls: defb 0
new_presses: defb 0

The tape entry and include order

edges-demo.asm
    org $8000
ACTION_MASK equ $10
    include "edges-caller.inc"
    include "../../framework/input-to-sound/input.inc"
    include "../../framework/input-to-sound/presses.inc"
    include "edges.inc"
    include "edges-display.inc"
POLL_ROUTINE:
    jp detect_control_edges
    end start

Sample once; keep action mapping in the caller

edges-caller.inc
; 48K PAL, stock ROM/IY retained for IM1/CLS. Own stack and screen; reset to leave.
start:
    di
    ld sp,$ff00
    call $0daf
    xor a
    ld (previous_controls),a
    ld (released_controls),a
    ld (actions),a
    ld hl,trace
    ld b,4
clear_trace:
    ld (hl),a
    inc hl
    djnz clear_trace
    ld a,165
    ld (ready),a
wait_frame:
    ei
    halt
    di
sample:
    call read_controls
    call POLL_ROUTINE
    ld a,(new_presses)
    or a
    jr z,no_press
    ld (last_new),a
    ld a,(press_events)
    inc a
    ld (press_events),a
no_press:
    ld a,(released_controls)
    or a
    jr z,no_release
    ld (last_release),a
    ld a,(release_events)
    inc a
    ld (release_events),a
no_release:
    ld a,(new_presses)
    and ACTION_MASK
    jr z,show_state
    ld a,(actions)
    inc a
    ld (actions),a
show_state:
    ld de,$4000
    ld a,(controls)
    call show_byte
    ld a,32
    call show_char
    ld a,(new_presses)
    call show_byte
    ld a,32
    call show_char
    ld a,(released_controls)
    call show_byte
    ld a,32
    call show_char
    ld a,(actions)
    call show_byte
frame_done:
    jr wait_frame
trace:
last_new: defb 0
last_release: defb 0
press_events: defb 0
release_events: defb 0
actions: defb 0
ready: defb 0

QAOP and Space are represented as normalised held bits: Up=1, Down=2, Left=4, Right=8, Fire=16. The maintained matrix reader and presses.inc are reused unchanged. edges.inc adds releases as previous AND NOT current, retaining their interfaces.

Include input.inc, presses.inc, edges.inc once, in that order. After read_controls, call detect_control_edges once per update. controls is held, new_presses is newly pressed, released_controls is newly released. AF/BC change; DE/HL/IX/IY survive. The wrapper owns one extra release byte. Its pure release_bits entry takes A=previous, B=current normalised five-bit masks and returns A=released; C/flags change, B/DE/HL/IX/IY survive. One state set is not re-entrant. These are bit sets, not a key-priority code.

Initialise previous_controls/released_controls to zero before the first sample. Held keys on entry then count as presses. To ignore them when a scene opens, read/detect once and discard the result, then let the next sample compare against it. Share one sample with all consumers: polling twice consumes an edge. Short pulses, ghosting, switch bounce, deliberate repeat and opposite-direction policy remain separate issues. The accepted configuration exercises QAOP/Space; it is not a guarantee for every electrical keyboard chord.

Run make ASM198X=/path/to/asm198x. LOAD the generated build/edges-demo.tap through the normal 48K ROM. END start makes its BASIC loader enter the caller through USR. The payload is 297 bytes at $8000; the loader reserves RAM below it. The caller owns a stack below $FF00 and the screen, resets its state, retains the stock IY/IM1 environment and uses EI/HALT/DI around the normal ROM frame interrupt. Reset to leave; it does not promise a BASIC return. The helper itself owns no port, IRQ or display resource. The reader still reads the four relevant matrix half-rows, once each; O/P share one read.

The native caller ends at 00 00 00 03: released keys and three accepted actions.
Edge pulses are short. The last byte preserves the accepted actions, while native event latches retain the transition masks.

Four hexadecimal bytes show held, pressed, released and action count. Space spends one action per press. Diagnostic latches/counters retain one-update edge pulses after the beam has drawn a frame; the current pressed/released display can already be zero in a stable screenshot. The normal sample sequence ends with count 03, including an all-five-key chord; native latches record which bits arrived together.

Make it your own

Map O rather than Space to an action

edges-custom.asm
    org $8000
ACTION_MASK equ $04
    include "edges-caller.inc"
    include "../../framework/input-to-sound/input.inc"
    include "../../framework/input-to-sound/presses.inc"
    include "edges.inc"
    include "edges-display.inc"
POLL_ROUTINE:
    jp detect_control_edges
    end start

edges-custom.asm changes ACTION_MASK from $10 (Space) to $04 (O/Left), with unchanged code/state/payload sizes. The mapping is separate from electrical reading and edge detection. A wider action mask counts one event if any selected bit is new; counting per button is another caller policy.

Common mistake

Deliberately broken: erase the previous mask

edges-wrong.asm
    org $8000
ACTION_MASK equ $10
    include "edges-caller.inc"
    include "../../framework/input-to-sound/input.inc"
    include "../../framework/input-to-sound/presses.inc"
    include "edges.inc"
    include "edges-display.inc"
POLL_ROUTINE:
    xor a
    ld (previous_controls),a
    jp detect_control_edges
    end start

The explicitly labelled edges-wrong.asm clears previous_controls immediately before detecting edges. A held Space becomes new every update, the action count rises during the hold and release events disappear. Restore the normal jump to detect_control_edges. The independently executed repair has the same payload as the working version. The fault adds four bytes. Do not use the wrong build as the normal tape.

The faulty native caller spends extra actions while Space is held.
The counter preserves the repeated actions; release latches also remain empty in the trace.

Costs and qualification

The wrapper/pure release leaf occupy 28 instruction bytes plus one release byte. The existing reader/press detector retain their own three state bytes; the caller adds four diagnostic bytes, an action counter and ready marker. The full 297-byte working payload includes its tables, code and state. Code size is not a frame-budget or contended-memory timing claim. Normalised five-bit state is a precondition for the pure helper.

The caller renders the normalised state

edges-display.inc
show_byte:
    push af
    rrca
    rrca
    rrca
    rrca
    and 15
    call digit
    call show_char
    pop af
    and 15
    call digit
    jp show_char
digit:
    cp 10
    jr c,digit_number
    add a,7
digit_number:
    add a,48
    ret
; A=ASCII, DE=top scanline cell. CHARS points 256 bytes before glyph 32.
; AF/BC/HL clobbered; DE advances one character column.
show_char:
    push de
    ld l,a
    ld h,0
    add hl,hl
    add hl,hl
    add hl,hl
    ld bc,($5C36)
    add hl,bc
    ld b,8
show_scan:
    ld a,(hl)
    ld (de),a
    inc hl
    inc d
    djnz show_scan
    pop de
    inc e
    ret

Six ROM-tape-loaded cases on Emu198x 0.44.2/Asm198x 0.0.72 and the recorded stock 48K PAL ROM cover normal/custom/wrong/repaired callers and a second guest-owned caller with all 1,024 previous/current pairs. A deliberately wrong OR operation completes its guards but fails independent bit-set arithmetic. Native host keys, event counters, complete immutable TAP image, bitmap/font/PNG pixels and source/tool/ROM identities are retained. Thirteen corruptions/pixel controls are required to fail. Read scripts/verify-spectrum-input-edges.py and check-spectrum-input-edges.py. These are configuration-specific native observations, not physical-hardware or all-ROM/region qualification.

Primary source: Sinclair ZX Spectrum BASIC Programming (1982), chapter 23, keyboard port half-rows and active-low input. The state/arbitration policies are this implementation’s choices. Bit-set difference, sampling and one-event actions link naturally to Maths for Games and the shared clock/input discussion.

Maintained code and contract · Native evidence/configuration. Maths for Games explains bit sets and sampling; clock and loop choices keeps input events separate from progression.