Skip to content

A game loop on the raster clock

Wait for a fresh raster boundary, then read, update and draw. Keep timing separate from the work your game does.

The border changes as the update counter advances. Hold Fire on joystick port 2 and the colour changes slow down: we have deliberately given the CPU more work than fits in a frame. Release it and the normal pace returns.

Use the wait in your project

No clock initialisation is needed for the wait itself. Call wait_frame once per update. It changes flags and preserves A/X/Y. It first waits for raster bit 8 to be low, then for it to become high. Both phases matter: a short update cannot consume the same high interval repeatedly.

frame.inc
; Wait for the next low-to-high transition of raster bit 8.
; Own no IRQ source. Flags change; A/X/Y survive. I/O must be visible.
; Both phases matter: a quick caller cannot consume the same boundary twice.
wait_frame:
    bit $d011
    bmi wait_frame
wait_frame_high:
    bit $d011
    bpl wait_frame_high
    rts

Start the complete example

This caller takes over CIA1 and the border, disables IRQs, banks out the ROMs and supplies a minimal NMI return. Keep I/O visible and keyboard keys released. Reset to leave. The joystick reader is the existing maintained routine, not another incompatible input format.

With Asm198x available, keep the sample folders together and run:

Shell
make -C commodore-64/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
; PAL C64. RUN. Port 2 Fire holds an intentionally excessive CPU workload.
; Exclusive CIA1/border ownership, interrupts disabled, ROM banked out.
; Reuses the maintained joystick reader. Reset to leave.
*=$0801
    !byte $0b,$08,$0a,$00,$9e
    !text "2064"
    !byte 0,0,0
*=$0810
start:
    sei
    lda #$7f
    sta $dc0d
    sta $dd0d
    lda $dc0d
    lda $dd0d
    lda #$35
    sta $01
    lda #<nmi_return
    sta $fffa
    lda #>nmi_return
    sta $fffb
    lda #0
    sta $dc0e
    sta $dc0f
    sta updates
    sta updates+1
    sta slow
    jsr joy_init
main_loop:
    jsr wait_frame
    jsr joy_poll
    lda joy2
    and #$10
    sta slow
    jsr update_game
    jsr draw_screen
    jsr expensive_work
frame_done:
    jmp main_loop
update_game:
    inc updates
    bne update_done
    inc updates+1
update_done:
    rts
draw_screen:
    lda updates
    lsr
    lsr
    lsr
    and #7
    sta $d020
    rts
expensive_work:
    lda slow
    beq work_done
    ldy #48
work_outer:
    ldx #0
work_inner:
    dex
    bne work_inner
    dey
    bne work_outer
work_done:
    rts
nmi_return: rti
updates: !word 0
slow: !byte 0
!source "frame.inc"
!source "../../input/joystick-reading/joystick.inc"

What happens when work runs late

This is a fixed-step loop. Work that overruns a display interval reduces the update rate; missed updates are not replayed. The sample checks PAL only. NTSC has different timing and needs its own measurements. Reaching the lower part of the screen does not guarantee that arbitrary drawing will avoid tearing.

Check this combination

Code status: assembled and emulator-executed. PAL C64. The recorded checks identify sources, firmware and observer. In three 30-frame windows the caller made 30 normal, 8 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.