Watch the border change, then hold Space. The deliberate CPU workload slows the updates. The ROM clock keeps advancing while that work runs, so the next update can see that more than one frame passed.
Use the wait in your project
Call initialise_clock once, then wait_frame once per update. AF and B change; other registers survive. Interrupts must be enabled on entry and are enabled on return. The helper briefly disables them to sample state safely. last_frame belongs to this consumer. frame_delta is the number of serviced ROM interrupts since its previous accepted sample.
; 48K ROM IM1 service must stay enabled, including during game work.
; ROM FRAMES low byte counts serviced interrupts, not game updates.
; AF/B change, other registers survive. IFF must be enabled; returns enabled.
; Work must take fewer than 256 frames; no unbounded catch-up replay.
initialise_clock:
ld a,($5c78)
ld (last_frame),a
ret
wait_frame:
di
ld a,($5c78)
ld b,a
ld a,(last_frame)
cp b
jr nz,accept_frame
ei
halt
jr wait_frame
accept_frame:
ld a,(last_frame)
neg
add a,b
ld (frame_delta),a
ld a,b
ld (last_frame),a
ei
ret
last_frame: defb 0
frame_delta: defb 0Start the complete example
Load the TAP through the 48K ROM or open the SNA. The complete caller uses IM1, keeps ROM interrupts enabled, sets its stack in high RAM and reads QAOP/Space with the existing reader. It owns the border. Reset to leave. The ROM keyboard and timing service remains active; this is not the custom-IM2 approach used later in Meteor Storm.
With Asm198x available, keep the sample folders together and run:
make -C sinclair-zx-spectrum/patterns/assembly/framework/game-loopThe Amiga Makefile also needs Build198x. Open the generated file under build using the machine’s normal loading route.
The loop keeps four jobs visible: wait, read input, update state, draw. expensive_work is an optional CPU workload to expose missed deadlines; remove it when building your own game. The changing colour or digit is a small observable result, not a rendering engine.
; 48K PAL, ROM IM1 interrupts intact. Space holds deliberately slow CPU work.
; SNA or TAP. Code/stack in uncontended RAM; reset to leave.
org $8000
start:
di
im 1
ld sp,$ff00
xor a
ld (updates),a
ld (updates+1),a
ld (slow),a
call initialise_clock
ei
main_loop:
call wait_frame
call read_controls
ld a,(controls)
and $10
ld (slow),a
call update_game
call draw_screen
call expensive_work
frame_done:
jr main_loop
update_game:
ld hl,(updates)
inc hl
ld (updates),hl
ret
draw_screen:
ld a,(updates)
rrca
rrca
rrca
and 7
out ($fe),a
ret
expensive_work:
ld a,(slow)
or a
ret z
ld c,64
work_outer:
ld b,0
work_inner:
djnz work_inner
dec c
jr nz,work_outer
ret
updates: defw 0
slow: defb 0
include "frame.inc"
include "../input-to-sound/input.inc"
end startWhat happens when work runs late
The clock uses the low byte of ROM FRAMES and wraps modulo 256. Fewer than 256 frames may pass between samples. A delay of exactly 256 would be indistinguishable from no change. The example counts one game update for the accepted sample; it does not replay every missed update or automatically multiply motion by frame_delta. A HALT with interrupts disabled would wait forever.
Check this combination
Code status: assembled and emulator-executed. 48K PAL Spectrum. The recorded checks identify sources, firmware and observer. In three 30-frame windows the caller made 30 normal, 9 deliberately slowed and 30 recovered updates. An unpaced negative control is rejected by the same rate guard. This is configuration-specific emulator evidence, not an original-hardware or whole-emulator claim.
The loop comparison puts the ownership choices side by side. Each implementation keeps its own timing and resource contract.
See the idea inside a complete game.
The separate fresh-ROM tape load also reaches the loop and sustains 30 updates in 30 native frames; the record distinguishes that from snapshot startup.