Skip to content

A game loop on the ROM clock

Use the 48K ROM interrupt clock to accept one update at a time, including when game work takes longer.

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.

frame.inc
; 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 0

Start 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:

Shell
make -C sinclair-zx-spectrum/patterns/assembly/framework/game-loop

The 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.

demo.asm
; 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 start

What 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.