Skip to content

Move, collect and respond on the ZX Spectrum

Combine input, bounded movement, box collision, drawing and sound in a small program you can take apart.

Move onto the star. The count changes from 0 to 1, and you hear a short sound. Stay there: the star has gone, so it cannot score again. Press restart and it returns.

Those few seconds bring several useful routines together. Here is the ZX Spectrum version, with each part kept small enough to lift into a project of your own.

The ZX Spectrum example at its starting position, with the player, collectible and zero count.
Captured from the running Emu198x example.

Run it first

Target: 48K PAL Spectrum. Controls: Q/A/O/P; Space restarts. Reset the machine to leave.

With Asm198x available, keep the code-sample folders together and build:

Shell
make -C sinclair-zx-spectrum/patterns/assembly/framework/collect-and-respond

Open the SNA for inspection, or load the generated auto-running TAP through the ROM tape path. The sample folder contains the complete program, build recipe and integration notes. The comparison explains the choices across all five versions.

The player moves one cell per six accepted updates. Hold a direction to keep moving. Restart reacts to a new press, so holding it will not trap the player at the starting position.

Connect the pieces

Read the controls once. Calculate the new and released bits from that sample. Update the world, then draw its new state. That order is visible in the caller:

The jobs in one update

demo.asm, excerpt
main_loop:
    call wait_frame
    call read_controls
    call detect_edges
    call update_player
    call draw_screen
    ld hl,(updates)
    inc hl
    ld (updates),hl

Startup owns the machine-specific work. It establishes the memory and hardware assumptions that the smaller routines rely on:

Initialise this caller

demo.asm, excerpt
start:
    di
    im 1
    ld sp,$ff00
    ld hl,$4000
    ld de,$4001
    ld bc,6143
    ld (hl),0
    ldir
    ld hl,$5800
    ld de,$5801
    ld bc,767
    ld (hl),7
    ldir
    xor a
    out ($fe),a
    call reset_game
    call initialise_clock
    ei

This program owns the bitmap, attributes and ULA output. ROM IM1 service stays enabled outside the brief beep; code starts at $8000 and the stack at $FF00. The routines together change AF/BC/DE/HL. Keep competing writers out of those resources; adding a music player or IRQ handler requires an explicit agreement about who writes what.

Move, test and consume

The playfield is 16 × 10 cells. The player begins at (2,5); the star occupies (10,5). The rules keep X between 0 and 15 and Y between 0 and 9. Diagonals move on both axes. Opposing directions are processed left/right, then up/down; choose cancellation or last-direction priority if that better suits your game.

previous, pressed and released belong to input sampling. player_x/player_y, move_delay, item_active and score belong to the rules. Reset preserves input history: clearing it while a button is held would invent another press.

The collision uses half-open boxes. A cell at (x,y) has edges (x,y,x+1,y+1), so merely touching the star’s edge leaves it alone. The routine can also compare larger rectangles or pixel coordinates, provided both boxes use the same units and edge arithmetic does not wrap.

Reusable box overlap

collision.inc
; Half-open unsigned byte edges [left,top,right,bottom].
; Reject empty/reversed boxes and edge-only contact. C=1 means overlap.
; Changes AF/B; preserves C/DE/HL/IX/IY. Caller forms edges without wrapping.
boxes_overlap:
    ld a,(rect_a+2)
    ld b,a
    ld a,(rect_a)
    cp b
    jr nc,boxes_apart
    ld a,(rect_a+3)
    ld b,a
    ld a,(rect_a+1)
    cp b
    jr nc,boxes_apart
    ld a,(rect_b+2)
    ld b,a
    ld a,(rect_b)
    cp b
    jr nc,boxes_apart
    ld a,(rect_b+3)
    ld b,a
    ld a,(rect_b+1)
    cp b
    jr nc,boxes_apart
    ld a,(rect_b+2)
    ld b,a
    ld a,(rect_a)
    cp b
    jr nc,boxes_apart
    ld a,(rect_a+2)
    ld b,a
    ld a,(rect_b)
    cp b
    jr nc,boxes_apart
    ld a,(rect_b+3)
    ld b,a
    ld a,(rect_a+1)
    cp b
    jr nc,boxes_apart
    ld a,(rect_a+3)
    ld b,a
    ld a,(rect_b+1)
    cp b
    jr nc,boxes_apart
    scf
    ret
boxes_apart:
    or a
    ret
rect_a: defb 0,0,0,0
rect_b: defb 10,5,11,6

The update consumes the star before starting feedback. That single state change is what prevents the next frame from collecting it again.

Input edges, movement and collection

rules.inc
; State: bytes. Grid x=0..15,y=0..9. Controls U/D/L/R/action=1/2/4/8/16.
; Call once after read_controls; changes AF/BC/DE/HL, preserves IX/IY.
detect_edges:
    ld a,(previous)
    cpl
    ld b,a
    ld a,(controls)
    and b
    ld (pressed),a
    ld a,(controls)
    cpl
    ld b,a
    ld a,(previous)
    and b
    ld (released),a
    ld a,(controls)
    ld (previous),a
    ret
reset_game:
    ld a,2
    ld (player_x),a
    ld a,5
    ld (player_y),a
    ld a,1
    ld (item_active),a
    xor a
    ld (score),a
    ld (move_delay),a
    ret
update_player:
    ld a,(pressed)
    and 16
    jp nz,reset_game
    ld a,(move_delay)
    or a
    jr z,move_now
    dec a
    ld (move_delay),a
    ret
move_now:
    ld a,5
    ld (move_delay),a
    ld a,(controls)
    ld b,a
    ld hl,player_x
    bit 2,b
    jr z,move_right
    ld a,(hl)
    or a
    jr z,move_right
    dec (hl)
move_right:
    bit 3,b
    jr z,move_up
    ld a,(hl)
    cp 15
    jr z,move_up
    inc (hl)
move_up:
    ld hl,player_y
    bit 0,b
    jr z,move_down
    ld a,(hl)
    or a
    jr z,move_down
    dec (hl)
move_down:
    bit 1,b
    jr z,test_collection
    ld a,(hl)
    cp 9
    jr z,test_collection
    inc (hl)
test_collection:
    ld a,(item_active)
    or a
    ret z
    ld a,(player_x)
    ld (rect_a),a
    inc a
    ld (rect_a+2),a
    ld a,(player_y)
    ld (rect_a+1),a
    inc a
    ld (rect_a+3),a
    call boxes_overlap
    ret nc
    xor a
    ld (item_active),a
    ld hl,score
    inc (hl)
    jp trigger_sound

For this exact one-cell scene, coordinate equality would do less work. The general box routine earns its extra comparisons when objects have different sizes. Box collision and its alternatives explains when a more precise second test helps. A fast object can skip over a target between samples; that calls for a path or swept test as well.

Show the new state

This version uses eight-byte glyphs copied into the ULA bitmap. old_x/old_y remember which cell to erase. The item is drawn before the player, so the player wins the draw order when they overlap.

Draw, erase and show the count

draw.inc
; Draw 8x8 monochrome glyph: B=x,C=y,DE=eight bytes; all AF/BC/DE/HL change.
; Logical grid is inset at character column 8,row 6. ULA bitmap interleave
; means successive glyph scanlines differ by $100, not 32 bytes.
put_cell:
    ld a,c
    add a,6
    ld c,a
    and 24
    or $40
    ld h,a
    ld a,c
    and 7
    rrca
    rrca
    rrca
    add a,b
    add a,8
    ld l,a
    ld b,8
glyph_row:
    ld a,(de)
    ld (hl),a
    inc de
    inc h
    djnz glyph_row
    ret
draw_screen:
    ld a,(old_x)
    ld b,a
    ld a,(old_y)
    ld c,a
    ld de,blank
    call put_cell
    ld b,10
    ld c,5
    ld de,blank
    ld a,(item_active)
    or a
    jr z,target_shape
    ld de,target
target_shape:
    call put_cell
    ld a,(player_x)
    ld b,a
    ld (old_x),a
    ld a,(player_y)
    ld c,a
    ld (old_y),a
    ld de,player
    call put_cell
    ld b,0
    ld c,0
    ld de,zero
    ld a,(score)
    or a
    jr z,score_shape
    ld de,one
score_shape:
    jp put_cell
blank: defb 0,0,0,0,0,0,0,0
player: defb 24,60,126,219,255,90,36,0
target: defb 0,24,90,60,90,24,0,0
zero: defb 60,66,70,74,82,98,60,0
one: defb 24,56,24,24,24,24,126,0

Painting a blank over the old position works because the background is empty. With a map behind the player, restore its tile. With overlapping objects, redraw the affected scene or choose another renderer. Keep that decision in the drawing routine so the rules can stay useful.

Make the event audible

On this machine, the short ULA beep occupies the CPU and briefly disables interrupts. The count also changes visibly, so sound carries useful feedback without being the only signal.

Collection sound

sound.inc
; Short blocking ULA beep; caller keeps border black/MIC low.
; DI is required by sound_beep; restore IM1 service immediately afterwards.
; Changes AF/B/D/H/L; sound lasts about 9 ms excluding interrupts/contention.
trigger_sound:
    di
    xor a
    ld hl,$640c
    call sound_beep
    ei
    ret

Take it into your game

Copy the routine you need, its state and its maintained includes. Read the declaration and caller together: the caller shows initialisation, call frequency and ownership. The sample README lists register effects and resource limits. Preserve the relative include layout, or update those paths deliberately when moving files.

These examples use whole-cell movement and a frame-based repeat counter. Fractional motion, scrolling, multiple items and sound priorities are useful next steps when a game needs them. A 50 Hz and a 60 Hz machine give this counter different wall-clock speeds.

Code status: assembled and emulator-executed for the named configuration. Checks drive real input, exercise all four bounds, reject edge-only contact, collect once, restart while held and capture the picture and PCM. A broken collection caller must fail the same guard. The execution record identifies the exact sources, binary, emulator and firmware; it establishes this caller rather than every feature of the machine.

Read the execution record · Understand the frame wait · Read the controls · Explore the sound routine

The main integration check starts from the SNA. A separate fresh-ROM check also loaded the TAP and reached the running scene; both results are retained in the execution record.

Game states and restart separate a fresh session from resuming a paused one, while preserving input history.