Press 1: a turn passes. Hold it: nothing more happens until you release. Waiting takes no turn until 2 enables timing. Then the ROM clock supplies a deadline: 200 ticks, about four seconds on this PAL Spectrum. Q returns to BASIC.

Load the example
Run make ASM198X=/path/to/asm198x in the maintained sample folder. Load build/demo.tap through the normal 48K ROM with LOAD “”. Its generated BASIC loader reserves RAM with CLEAR 32767, loads CODE at $8000 and enters through USR. No firmware is bundled.
The complete include order
; SPDX-License-Identifier: MIT
org $8000
DURATION equ 200
include "caller.inc"
include "clock.inc"
include "input.inc"
include "../../../../../cross-platform/patterns/framework/elapsed-deadline/z80.inc"
include "dispatch.inc"
TURN: db 0
EXPIRIES: db 0
KEY: db 0
HELD: db 0
DEADLINE: defs 10,0
READY: dw 0
code_end:
end startThe whole image occupies $8000–$8188, 393 bytes including its writable tail. TURN/EXPIRIES/KEY/HELD own four bytes, DEADLINE ten and READY two. Keep the entire image free of BASIC, buffers and other code. Relocate the origin and loader reservation together. The existing BASIC stack, IY workspace and normal IM1 service remain part of this entry contract.
Take the countdown
The maintained shared countdown
; DEADLINE owns 10 bytes. HL=low 16 clock bits. AF/DE/HL clobbered.
; Poll at least once per 65536 clock ticks. BC/IX/IY preserved.
deadline_init:
ld (DEADLINE+8),hl
ld hl,0
ld (DEADLINE+2),hl
ld (DEADLINE+6),hl
xor a
ld (DEADLINE+4),a
ld (DEADLINE+5),a
ret
deadline_start:
ld hl,(DEADLINE)
ld (DEADLINE+2),hl
ld a,h
or l
jr z,deadline_off
ld a,1
deadline_off:
ld (DEADLINE+4),a
xor a
ld (DEADLINE+5),a
ret
deadline_poll:
ld de,(DEADLINE+8)
ld (DEADLINE+8),hl
or a
sbc hl,de
ld (DEADLINE+6),hl
xor a
ld (DEADLINE+5),a
ld a,(DEADLINE+4)
or a
ret z
ld hl,(DEADLINE+2)
ld de,(DEADLINE+6)
or a
sbc hl,de
jr c,deadline_expired
ld a,h
or l
jr z,deadline_expired
ld (DEADLINE+2),hl
ret
deadline_expired:
ld hl,0
ld (DEADLINE+2),hl
xor a
ld (DEADLINE+4),a
inc a
ld (DEADLINE+5),a
retInclude it once and provide DEADLINE for ten writable bytes. Words are little-endian: duration +0, remaining +2, active +4, one-poll event +5, elapsed +6, previous +8.
Set duration to whole ticks 0..65535. Pass the current clock in HL to deadline_init once: it records the baseline and starts disabled. deadline_start copies duration to remaining and enables only when nonzero. It clears the event but keeps the most recent baseline; poll immediately before starting if that instant matters.
Each service iteration, including while disabled, passes the next sample in HL to deadline_poll. Read +5 before another poll/start clears it. Expiry clamps remaining to zero and disables the context; the caller decides what follows. AF/DE/HL change; BC/IX/IY survive. The helper uses the existing return stack, owns no I/O or IRQ resource and is not re-entrant on one context.
Unsigned subtraction makes 65526 → 10 mean twenty ticks. Poll less than one complete cycle apart, roughly 21 minutes 51 seconds at 50 Hz: an extra whole cycle cannot be recovered from two samples. Reinitialise after an external FRAMES reset. Zero duration means off; late polling emits once without catch-up. Restarting at observation time can drift. Maths for Games develops the same units, ranges and modular arithmetic.
Read time and commands separately
clock.inc
; Stock 48K ROM IM1 owns FRAMES. Atomic word instruction; IRQ between instructions.
; HL=low sixteen ticks, flags and all other registers preserved.
clock_read:
ld hl,($5C78)
retinput.inc
; Nonblocking sampled gate. 1=action, 2=toggle, Q=quit. Observe release to re-arm.
; A/BC/flags clobbered. Spectrum active-low keyboard rows.
read_command:
ld bc,$FBFE
in a,(c)
bit 0,a
jr z,input_quit
ld b,$F7
in a,(c)
bit 1,a
jr z,input_toggle
bit 0,a
jr z,input_action
xor a
ld (HELD),a
ret
input_quit:
ld b,3
jr input_gate
input_toggle:
ld b,2
jr input_gate
input_action:
ld b,1
input_gate:
ld a,(HELD)
or a
jr nz,input_none
inc a
ld (HELD),a
ld a,b
ret
input_none:
xor a
retThe clock adapter reads the low FRAMES word at $5C78 in one LD HL instruction. The stock maskable interrupt is accepted between instructions, so the word is coherent. HL changes; flags and other registers survive. Keep normal ROM/IM1 service available. Interrupt suppression can lose time; this is a nominal game clock, not a precision stopwatch.
The input adapter reuses the existing Ace reader’s row/bit logic with a Spectrum contract. It samples 1/2/Q, prioritising Q over 2 over 1. A=0 means none, 1 action, 2 toggle, 3 quit. AF/BC change and HELD belongs to the reader. All command keys must be observed released to re-arm. Short taps between polls can be missed; moving directly between held commands does not re-arm. This is sampled physical input, not buffered character delivery.
Let the caller choose the rules
dispatch.inc
; Fixed labelled game state. AF/DE/HL clobbered; BC/IX/IY preserved.
dispatch:
ld a,(DEADLINE+5)
or a
jr z,dispatch_key
xor a
ld (KEY),a
ld hl,EXPIRIES
inc (hl)
ld hl,TURN
inc (hl)
jp deadline_start
dispatch_key:
ld a,(KEY)
cp 2
jr nz,dispatch_action
ld a,(DEADLINE+4)
or a
jp z,deadline_start
xor a
ld (DEADLINE+4),a
ld hl,0
ld (DEADLINE+2),hl
ret
dispatch_action:
cp 1
ret nz
ld hl,TURN
inc (hl)
ld a,(DEADLINE+4)
or a
ret z
jp deadline_startcaller.inc
; 48K BASIC USR entry, existing stack/IM1/IY/ROM retained.
start:
push af
push bc
push de
push hl
push ix
push iy
call $0DAF
xor a
ld (TURN),a
ld (EXPIRIES),a
ld (HELD),a
ld hl,DURATION
ld (DEADLINE),hl
call clock_read
call deadline_init
ld hl,$CAFE
ld (READY),hl
main:
call clock_read
call deadline_poll
call read_command
ld (KEY),a
call dispatch
call show_state
ld a,(KEY)
cp 3
jr nz,main
quit_release:
call read_command
ld a,(HELD)
or a
jr nz,quit_release
halt
pop iy
pop ix
pop hl
pop de
pop bc
pop af
ret
show_state:
ld de,$4000
ld a,(TURN)
call show_byte
ld a,32
call show_char
ld a,(EXPIRIES)
call show_byte
ld a,32
call show_char
ld a,(DEADLINE+4)
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
retThe dispatcher performs no I/O. Expiry wins over that iteration’s command, including quit/toggle. Otherwise 1 advances and restarts only active timing; untimed actions stay untimed. Disabling discards the remainder; enabling starts a full duration. Byte counters wrap. Replace the increment with your world update. Preserved pause, queued commands and carried lateness are useful alternatives with different state and policy.
The wrapper saves AF/BC/DE/HL/IX/IY, calls the ROM CLS routine and draws eight text cells through CHARS. It uses nested return-stack bytes; the ROM IRQ needs its own stack space too. Q waits for release and a normal interrupt before restoring registers and RET. The screen is not restored. IRQs must be enabled on entry. Continuous drawing is simple feedback, not frame pacing: a game can redraw on changes or add an independent wait.
Change it, break it, repair it
Half the duration, the same helper
; SPDX-License-Identifier: MIT
org $8000
DURATION equ 100
include "caller.inc"
include "clock.inc"
include "input.inc"
include "../../../../../cross-platform/patterns/framework/elapsed-deadline/z80.inc"
include "dispatch.inc"
TURN: db 0
EXPIRIES: db 0
KEY: db 0
HELD: db 0
DEADLINE: defs 10,0
READY: dw 0
code_end:
end startDeliberately broken: erase the baseline before polling
; Stock 48K ROM IM1 owns FRAMES. Atomic word instruction; IRQ between instructions.
; HL=low sixteen ticks, flags and all other registers preserved.
clock_read:
ld hl,($5C78)
ld (DEADLINE+8),hl
retThe custom build changes DURATION=200 to 100, about four seconds to two, with the same code/state sizes. The faulty adapter stores now as previous before polling: elapsed stays zero, remaining stays at 200 and the automatic turn never arrives. Restore clock.inc and rebuild; a fresh repaired run reaches 03 01 01. The fault adds three bytes. Never include both adapters in a working image.

There is a separate loader trap: END start supplies the address for RANDOMIZE USR. Without it, the exact code still loads, then BASIC reports 0 OK, 30:1. A load address does not prove execution.
Deliberately broken: no USR entry in the generated loader
; SPDX-License-Identifier: MIT
org $8000
DURATION equ 200
include "caller.inc"
include "clock.inc"
include "input.inc"
include "../../../../../cross-platform/patterns/framework/elapsed-deadline/z80.inc"
include "dispatch.inc"
TURN: db 0
EXPIRIES: db 0
KEY: db 0
HELD: db 0
DEADLINE: defs 10,0
READY: dw 0
code_end:Costs and qualification
The countdown is 97 emitted bytes plus ten context bytes; the clock adds four bytes, input fifty, dispatcher sixty-one and caller/presentation 165. Sixteen writable image bytes bring the whole payload to 393. These are byte counts, not cycle measurements, peak-stack bounds or a whole-game budget.
Seven independent ROM-tape-loaded cases on Emu198x 0.44.2 with the recorded stock 48K PAL ROM cover working/custom/wrong/repaired callers, arithmetic boundaries, wrong equality and missing entry. The debugger stops naturally at USR to compare every loaded byte before execution; it does not set the PC or write game state. A second caller supplies twelve clock pairs and three policy cases. Its wrong-equality control completes its guards but fails the unsigned oracle. Supplied samples do not establish an observed 22-minute rollover.
Native bitmap/font bytes and the saved PNG pixels agree. Real keys include a long held action; return to BASIC is followed by arithmetic. Source/tool/ROM/capture identities and falsification controls are retained. This is configuration-specific execution evidence, not physical-hardware or complete-emulator qualification.
Sources and related patterns
Sinclair Research, ZX Spectrum BASIC Programming, chapter 18, explains FRAMES and the interrupted-read hazard; Appendix A documents the workspace. The adapters, countdown and game policy here are our implementation choices.
Maintained source · Native execution record · Independent basic route · Choosing progression and clocks