Skip to content

A game loop with an NMI clock

Let NMI announce elapsed frames while the main loop reads controls and updates game state.

Load the NTSC NROM cartridge. The background colour advances with the update counter. Hold controller 1 A to add excessive CPU work; release it to restore the normal update pace.

Use the wait in your project

Call initialise_clock before enabling NMI, then wait_frame once per update. A/X/flags change. NMI owns frame_clock; main owns last_frame and frame_delta. The ISR changes no general register and RTI restores flags. The helper accepts a single captured clock value, preventing one frame from being consumed repeatedly.

frame.inc
; NMI owns frame_clock; main owns last_frame/frame_delta.
; Call initialise_clock before enabling NMI. NMI handler must preserve registers.
; A/X/flags change. NMI must remain enabled. No other reader consumes this state.
; Work must take fewer than 256 frames between accepted samples.
.segment "CODE"
initialise_clock:
    lda frame_clock
    sta last_frame
    rts
wait_frame:
    lda frame_clock
    cmp last_frame
    beq wait_frame
    tax
    sec
    sbc last_frame
    sta frame_delta
    stx last_frame
    rts
.segment "BSS"
frame_clock: .res 1
last_frame: .res 1
frame_delta: .res 1

Start the complete example

Reset disables rendering, DMC, APU channels and the frame IRQ, clears writable RAM and waits through PPU warm-up. It initialises the clock before enabling NMI. NMI only increments frame_clock: it does not read controls, update the game or draw. Reset to leave.

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

Shell
make -C nintendo-entertainment-system/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
; NTSC NROM. A holds excessive CPU work. NMI only increments the frame clock.
; No rendering/DMC; background palette changes show updates. Reset to leave.
.segment "HEADER"
    .byte "NES",$1a
    .byte 2,1,0,0
    .byte 0,0,0,0,0,0,0,0
.segment "BSS"
updates: .res 2
slow: .res 1
.segment "CODE"
reset:
    sei
    cld
    ldx #$ff
    txs
    inx
    stx $2000
    stx $2001
    stx $4010
    stx $4015
    lda #$40
    sta $4017
    lda #0
clear_ram:
    sta $0000,x
    sta $0100,x
    sta $0200,x
    sta $0300,x
    sta $0400,x
    sta $0500,x
    sta $0600,x
    sta $0700,x
    inx
    bne clear_ram
warmup_1:
    bit $2002
    bpl warmup_1
warmup_2:
    bit $2002
    bpl warmup_2
    jsr initialise_clock
    lda #$80
    sta $2000
main_loop:
    jsr wait_frame
    jsr read_controls
    lda controls
    and #$80
    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:
    bit $2002
    lda #$3f
    sta $2006
    lda #0
    sta $2006
    lda updates
    lsr
    lsr
    lsr
    and #$0f
    sta $2007
    lda #0
    sta $2006
    sta $2006
    rts
expensive_work:
    lda slow
    beq work_done
    ldy #64
work_outer:
    ldx #0
work_inner:
    dex
    bne work_inner
    dey
    bne work_outer
work_done:
    rts
nmi:
    inc frame_clock
    rti
irq:
    rti
.include "frame.inc"
.include "../input-to-sound/input.inc"
.segment "VECTORS"
    .word nmi,reset,irq
.segment "CHARS"
    .res 8192,0

What happens when work runs late

Both clocks are bytes: keep the gap between samples below 256 frames. The returned delta records missed announcements, but the sample deliberately runs one update instead of an unbounded catch-up loop. Rendering is off, so its palette writes are not a template for unrestricted PPU writes during a rendered game. OAM DMA, buffered nametable work and their VBlank budgets belong in the next composition.

Check this combination

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