Skip to content

NMI Game Loop

Frame-synchronised game loop using the NMI interrupt for VBlank timing and OAM DMA transfer.

Taught in Game 1, Unit 1 game-loopnmivblankoam-dma

Overview

The NES generates an NMI (Non-Maskable Interrupt) at the start of each VBlank period. This is your signal that it’s safe to update the PPU. The NMI handler copies sprite data via DMA and updates positions. Game logic runs in the main loop and waits for the NMI to complete each frame.

Code

; =============================================================================
; NMI GAME LOOP - NES
; VBlank-synchronised game loop with OAM DMA
; Taught: Game 1 (Neon Nexus), Unit 1
; CPU: Variable | Memory: ~50 bytes
; =============================================================================

PPUCTRL   = $2000
PPUMASK   = $2001
PPUSTATUS = $2002
OAMADDR   = $2003
PPUSCROLL = $2005
OAMDMA    = $4014

.segment "ZEROPAGE"
player_x:  .res 1
player_y:  .res 1
nmi_ready: .res 1               ; Flag: main loop sets, NMI clears

.segment "OAM"
oam_buffer: .res 256            ; Sprite data at $0200

.segment "CODE"

; === MAIN LOOP ===
main_loop:
    ; Wait for NMI to complete
    lda #1
    sta nmi_ready
@wait:
    lda nmi_ready
    bne @wait

    ; Game logic runs here (after NMI)
    jsr read_controller
    jsr update_game

    jmp main_loop

; === NMI HANDLER ===
nmi:
    pha
    txa
    pha
    tya
    pha

    ; Copy sprites to PPU via DMA
    lda #0
    sta OAMADDR
    lda #>oam_buffer            ; High byte of $0200
    sta OAMDMA                  ; Triggers 256-byte DMA transfer

    ; Update sprite positions from game variables
    lda player_y
    sta oam_buffer+0            ; Sprite 0 Y position
    lda player_x
    sta oam_buffer+3            ; Sprite 0 X position

    ; Reset scroll position
    lda #0
    sta PPUSCROLL
    sta PPUSCROLL

    ; Clear the ready flag
    lda #0
    sta nmi_ready

    pla
    tay
    pla
    tax
    pla
    rti

Enable NMI during initialisation:

    ; After all setup is complete...
    lda #%10010000              ; Enable NMI (bit 7)
    sta PPUCTRL
    lda #%00011110              ; Enable sprites and background
    sta PPUMASK

Trade-offs

AspectCost
CPU~513 cycles for DMA + overhead
Memory256 bytes OAM buffer at $0200
LimitationAll PPU updates must happen in VBlank

When to use: Every NES game. This is the foundation of NES programming.

When to avoid: Never - you always need NMI-synchronised updates.

How It Works

  1. The PPU generates NMI at the start of VBlank (~2270 scanlines per second on NTSC)
  2. Your NMI handler runs automatically
  3. Write sprite data via OAM DMA (takes 513 CPU cycles, fully automatic)
  4. Update any other PPU state (palettes, nametables) if needed
  5. Reset scroll position (important after any $2006/$2007 access)
  6. Main loop continues with game logic

OAM Buffer Layout

The OAM buffer at $0200 holds 64 sprites, 4 bytes each:

OffsetPurpose
+0Y position
+1Tile index
+2Attributes (palette, flip, priority)
+3X position

To hide unused sprites, set their Y position to $FF (off-screen).

Vector Table

.segment "VECTORS"
    .word nmi                   ; NMI vector ($FFFA)
    .word reset                 ; Reset vector ($FFFC)
    .word irq                   ; IRQ vector ($FFFE)

Patterns: Controller Reading, Palette Loading

Vault: NES