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

Keyboard Input

Read the Spectrum's keyboard matrix with IN A,($FE) — the instruction that connects the player to the game.

3% of Shadowkeep

The room exists. Walls, floor, treasure, hazard — all drawn with loops. But press a key and nothing happens. The game doesn’t know you’re there.

That changes now. The Spectrum’s keyboard is wired to port $FE — the same port that controls the border. IN A,($FE) reads a row of keys. BIT tests which key is pressed. By the end of this unit, the game responds to your input. Not movement yet — that’s Unit 6 — but the connection between player and machine.

How the Keyboard Works

The Spectrum has 40 keys arranged in 8 rows of 5. Each row is connected to a different address line on the data bus. To read a row, you put the row selector in A, then read port $FE:

            ld      a, $7f          ; Select a keyboard row
            in      a, ($fe)        ; Read it — result goes into A

The IN A,($FE) instruction is the reverse of OUT ($FE),A. Where OUT sends data to hardware, IN reads data from hardware. The value in A selects which row to scan — it goes on the high byte of the address bus, giving a 16-bit port address of $7FFE.

The result comes back in A. Bits 0–4 each represent one key. A bit is 0 when the key is pressed, 1 when released. This is active low — the opposite of what you might expect.

Reading a Single Key

The simplest keyboard program — press Space, the border turns red:

; Shadowkeep — Keyboard: Read the Space bar

            org     32768

start:
            ld      a, 0
            out     ($fe), a        ; Black border

.loop:      halt                    ; Wait for next frame

            ld      a, $7f          ; Select row: Space, Sym, M, N, B
            in      a, ($fe)        ; Read keyboard row into A
            bit     0, a            ; Test bit 0 (Space key)
            jr      z, .pressed     ; 0 = pressed (active low)

            ; Not pressed — black border
            ld      a, 0
            out     ($fe), a
            jr      .loop

.pressed:   ; Space held — red border
            ld      a, 2
            out     ($fe), a
            jr      .loop

            end     start

$7F selects the bottom row of the keyboard: Space, Symbol Shift, M, N, B. After IN A,($FE), bit 0 holds the Space key state.

BIT 0, A tests bit 0 without changing A. If the bit is 0 (key pressed), the Z80 sets the Zero flag and JR Z takes the branch. If the bit is 1 (not pressed), the Zero flag is clear and execution falls through.

Run this program and press Space. The border turns red while you hold it, black when you release. The program checks 50 times per second — once per frame, after each HALT.

Why Active Low?

When no key is pressed, the data line is pulled high by a resistor — it reads 1. Pressing a key connects the line to ground — it reads 0. This is how most 8-bit keyboards work. The hardware is simpler, even if the logic feels backwards.

You’ll get used to it: 0 = pressed, 1 = released. BIT n, A followed by JR Z means “jump if pressed.”

The Keyboard Matrix

Here’s the full matrix. Each row is selected by clearing one bit of the high address byte:

Row SelectBit 0Bit 1Bit 2Bit 3Bit 4
$FEShiftZXCV
$FDASDFG
$FBQWERT
$F712345
$EF09876
$DFPOIUY
$BFEnterLKJH
$7FSpaceSymMNB

The row select values look odd until you see the pattern: each one has a single bit cleared. $FE = 11111110 (bit 0 clear), $FD = 11111101 (bit 1 clear), $FB = 11111011 (bit 2 clear), and so on. The cleared bit selects which row to scan.

Notice the right half of the keyboard is in reverse order — the number row goes 0, 9, 8, 7, 6, not 6, 7, 8, 9, 0. This matches the physical wiring of the Spectrum’s membrane keyboard.

QAOP — Four Directions

Shadowkeep uses QAOP for movement — a classic Spectrum control scheme:

  • Q = up (row $FB, bit 0)
  • A = down (row $FD, bit 0)
  • O = left (row $DF, bit 1)
  • P = right (row $DF, bit 0)
            ; Check Q (up)
            ld      a, $fb          ; Row: Q, W, E, R, T
            in      a, ($fe)        ; Read keyboard row
            bit     0, a            ; Q = bit 0
            jr      z, .up          ; 0 = pressed

            ; Check A (down)
            ld      a, $fd          ; Row: A, S, D, F, G
            in      a, ($fe)
            bit     0, a            ; A = bit 0
            jr      z, .down

            ; Check O (left)
            ld      a, $df          ; Row: P, O, I, U, Y
            in      a, ($fe)
            bit     1, a            ; O = bit 1
            jr      z, .left

            ; Check P (right)
            ld      a, $df          ; Row: P, O, I, U, Y
            in      a, ($fe)
            bit     0, a            ; P = bit 0
            jr      z, .right

Each key needs its own IN read because Q and A are in different rows. O and P share a row ($DF) but need separate bit tests — O is bit 1, P is bit 0. Since IN A,($FE) overwrites A with the keyboard data, each test starts with a fresh read.

The pattern is always the same: load the row selector, read the port, test the bit. Four keys, four reads.

The Complete Code

The program draws the Unit 3 room and enters a keyboard loop. Each direction key changes the border to a different colour — blue for Q, red for A, green for O, yellow for P. No key held means black border.

; ============================================================================
; SHADOWKEEP — Unit 4: Keyboard Input
; ============================================================================
; Read the keyboard using IN A,($FE) and respond to QAOP keys.
;
; The Spectrum's keyboard is a matrix read through port $FE.
; The high byte of the port address selects which row to scan.
; Each row returns 5 key states in bits 0–4 (0 = pressed).
;
; QAOP mapping:
;   Q (up)    — row $FB, bit 0
;   A (down)  — row $FD, bit 0
;   O (left)  — row $DF, bit 1
;   P (right) — row $DF, bit 0
; ============================================================================

            org     32768

; Attribute values
WALL        equ     $09             ; PAPER 1 (blue) + INK 1
FLOOR       equ     $38             ; PAPER 7 (white) + INK 0
TREASURE    equ     $70             ; BRIGHT + PAPER 6 (yellow)
HAZARD      equ     $90             ; FLASH + PAPER 2 (red)

; Room dimensions
ROOM_TOP    equ     10
ROOM_LEFT   equ     12
ROOM_WIDTH  equ     9
ROOM_INNER  equ     7

; Keyboard rows (active-low — 0 on the address line selects the row)
KEY_ROW_QT  equ     $fb             ; Q, W, E, R, T
KEY_ROW_AG  equ     $fd             ; A, S, D, F, G
KEY_ROW_PY  equ     $df             ; P, O, I, U, Y

; ----------------------------------------------------------------------------
; Entry point
; ----------------------------------------------------------------------------

start:
            ; Black border
            ld      a, 0
            out     ($fe), a

            ; Clear screen
            ld      hl, $4000
            ld      de, $4001
            ld      bc, 6911
            ld      (hl), 0
            ldir

            ; ==================================================================
            ; Draw the room (same as Unit 3)
            ; ==================================================================

            ; --- Top wall ---
            ld      hl, $594c       ; Row 10, col 12
            ld      b, ROOM_WIDTH
            ld      a, WALL
.top:       ld      (hl), a
            inc     hl
            djnz    .top

            ; --- Row 11: wall, floor, wall ---
            ld      hl, $596c
            ld      a, WALL
            ld      (hl), a
            inc     hl
            ld      a, FLOOR
            ld      b, ROOM_INNER
.r11:       ld      (hl), a
            inc     hl
            djnz    .r11
            ld      a, WALL
            ld      (hl), a

            ; --- Row 12: wall, floor, wall ---
            ld      hl, $598c
            ld      a, WALL
            ld      (hl), a
            inc     hl
            ld      a, FLOOR
            ld      b, ROOM_INNER
.r12:       ld      (hl), a
            inc     hl
            djnz    .r12
            ld      a, WALL
            ld      (hl), a

            ; --- Row 13: wall, floor, wall ---
            ld      hl, $59ac
            ld      a, WALL
            ld      (hl), a
            inc     hl
            ld      a, FLOOR
            ld      b, ROOM_INNER
.r13:       ld      (hl), a
            inc     hl
            djnz    .r13
            ld      a, WALL
            ld      (hl), a

            ; --- Bottom wall ---
            ld      hl, $59cc
            ld      b, ROOM_WIDTH
            ld      a, WALL
.bot:       ld      (hl), a
            inc     hl
            djnz    .bot

            ; --- Treasure and hazard ---
            ld      a, TREASURE
            ld      ($5990), a      ; Row 12, col 16
            ld      a, HAZARD
            ld      ($59af), a      ; Row 13, col 15

            ; ==================================================================
            ; Main loop — read keyboard, change border colour
            ; ==================================================================

.loop:      halt                    ; Wait for next frame

            ; Reset border to black (no key held = black)
            ld      a, 0
            out     ($fe), a

            ; --- Check Q (up) ---
            ld      a, KEY_ROW_QT   ; Select row: Q, W, E, R, T
            in      a, ($fe)        ; Read keyboard row
            bit     0, a            ; Test Q (bit 0)
            jr      nz, .not_q      ; 1 = not pressed
            ld      a, 1            ; Q pressed — blue border
            out     ($fe), a
.not_q:

            ; --- Check A (down) ---
            ld      a, KEY_ROW_AG   ; Select row: A, S, D, F, G
            in      a, ($fe)
            bit     0, a            ; Test A (bit 0)
            jr      nz, .not_a
            ld      a, 2            ; A pressed — red border
            out     ($fe), a
.not_a:

            ; --- Check O (left) ---
            ld      a, KEY_ROW_PY   ; Select row: P, O, I, U, Y
            in      a, ($fe)
            bit     1, a            ; Test O (bit 1)
            jr      nz, .not_o
            ld      a, 4            ; O pressed — green border
            out     ($fe), a
.not_o:

            ; --- Check P (right) ---
            ld      a, KEY_ROW_PY   ; Same row as O
            in      a, ($fe)
            bit     0, a            ; Test P (bit 0)
            jr      nz, .not_p
            ld      a, 6            ; P pressed — yellow border
            out     ($fe), a
.not_p:

            jr      .loop

            end     start

Shadowkeep Unit 4

The screenshot shows the idle state — black border, no key pressed. Run it yourself and press the keys. Each direction gives immediate visual feedback. This is the polling loop that will drive all of Shadowkeep’s controls.

The border resets to black at the start of each frame (ld a, 0 / out ($fe), a), then each key check can override it. If multiple keys are held simultaneously, the last one checked wins — P overrides O overrides A overrides Q. That’s fine for now. A more sophisticated approach would combine the results, but for character-cell movement (Unit 6) you only need one direction per frame.

Try This: Different Keys

Change the controls to cursor keys (5, 6, 7, 8 on the Spectrum):

            ; 5 (left) — row $F7, bit 4
            ld      a, $f7
            in      a, ($fe)
            bit     4, a
            jr      z, .left

            ; 8 (right) — row $EF, bit 2
            ld      a, $ef
            in      a, ($fe)
            bit     2, a
            jr      z, .right

Look up the row and bit from the keyboard matrix table. The pattern is identical — only the row selector and bit number change.

Try This: Two Keys at Once

O and P share the same row. You can test both from a single read if you save the result:

            ld      a, $df          ; Row: P, O, I, U, Y
            in      a, ($fe)
            bit     1, a            ; O = bit 1
            jr      z, .left
            bit     0, a            ; P = bit 0
            jr      z, .right

This works because BIT doesn’t change A — it only sets the Zero flag. After testing O, A still holds the full row data and you can test P immediately. No need to read the port again.

This only works when both keys are in the same row. Q and A are in different rows, so they always need separate IN reads.

Try This: Space to Reset

Add a Space bar check that resets the room colours. After the QAOP checks:

            ld      a, $7f          ; Row: Space, Sym, M, N, B
            in      a, ($fe)
            bit     0, a            ; Space = bit 0
            jr      nz, .no_space
            ; Reset the treasure to normal
            ld      a, TREASURE
            ld      ($5990), a
.no_space:

Pressing Space rewrites the treasure cell. Combine this with Unit 2’s bit manipulation to change cells based on key presses — that’s the road toward an interactive game.

If It Doesn’t Work

  • Key doesn’t respond? Check the row selector value against the matrix table. Q is $FB, not $BF. The values are easy to confuse.
  • Wrong key triggers? Check the bit number. Q is bit 0, W is bit 1, E is bit 2. Within a row, bits count left to right for the left half of the keyboard, but the right half is reversed.
  • Border stays black? Make sure OUT ($FE), A comes after setting A to a colour. If A still holds the keyboard data, the border gets a random value.
  • Border flickers? The code resets to black each frame. If the key check happens too slowly, you might see one frame of black. This is normal at 50 Hz.
  • Active low confusion? Remember: BIT n, A / JR Z means “jump if key pressed.” Zero flag set = bit was 0 = key is down. It’s the opposite of what “zero” usually means.

What You’ve Learnt

  • IN A,($FE) — read a keyboard row. The value in A selects the row (high byte of port address). The result comes back in A with key states in bits 0–4.
  • Active low — 0 means pressed, 1 means released. This is a hardware convention, not a software choice.
  • BIT n, A — test a single bit without changing A. Sets the Zero flag if the bit is 0. Combined with JR Z, this is “jump if key pressed.”
  • The keyboard matrix — 8 rows of 5 keys each, selected by clearing one bit of the row selector. Port $FE is shared between keyboard input and border output.
  • Polling loop — check keys every frame after HALT. 50 checks per second gives responsive input.
  • EQU for row selectors — named constants like KEY_ROW_QT make the code readable and the mapping explicit.

What’s Next

The keyboard reads work, but there’s nothing to move. In Unit 5, you’ll put a character on screen by writing to bitmap memory — not just attributes. The player’s marker will appear inside the room, ready to respond to the keys you’ve just learnt to read.