Skip to content

The Machine Has a Heartbeat

Use a short NMI handler for one visible update, preserve interrupted state, and distinguish a frame from the VBlank transfer window.

  • Nintendo Entertainment System
  • Assembly

We have used jmp forever to keep a finished demonstration still. Now we want one small update each display frame. We will change a counter and use it to select the backdrop colour. First understand that job: advance the counter, write one palette value, return. The interrupt will decide when it runs.

What you’ll see by the end

A single blue frame from the changing-backdrop demonstration.
One frame of the backdrop demonstration. The counter selects a new palette value on each update.

The backdrop cycles while the main loop spins. This example changes colour rapidly; use the slower variation below if that is uncomfortable. We target an NTSC NES here, at approximately 60 display frames per second. PAL timing is different. “60 Hz” is a useful approximation, not an exact timing specification.

Wiring up the heartbeat

After the visible picture, the PPU enters vertical blank, or VBlank. With bit 7 of PPUCTRL ($2000) enabled, the start of VBlank can request a non-maskable interrupt, or NMI. The CPU responds at an instruction boundary, saves its return address and status on the stack, and follows the NMI vector to our handler. rti restores that saved status and address.

This is pre-emption: the main code pauses while the handler runs. We still have one CPU executing one instruction stream, not two simultaneous workers.

heartbeat.asm
; ============================================================================
; Meet the Machine (NES) - Unit 9: The Machine Has a Heartbeat
;
; The PPU pulses once per frame - about 60 times a second on NTSC - and can tell the CPU each
; time, through the NMI. We turn that heartbeat on, and do one small job every
; beat: nudge the backdrop colour. The screen cycles through colours by itself.
; ============================================================================

.segment "HEADER"
    .byte "NES", $1a
    .byte 2
    .byte 1
    .byte $00, $00

COUNTER = $10               ; a box in RAM to count frames

.segment "CODE"

reset:
    sei
    cld
    ldx #$40
    stx $4017
    ldx #$ff
    txs
    inx
    stx $2000
    stx $2001
    stx $4010
warm1:
    bit $2002
    bpl warm1
warm2:
    bit $2002
    bpl warm2

    lda #$00
    sta COUNTER             ; start the frame count at 0

    ; turn the heartbeat ON: bit 7 of PPUCTRL asks for an NMI every frame
    lda #$80
    sta $2000

forever:
    jmp forever             ; idle for this demonstration, not a required game architecture

; --- the heartbeat handler: one bounded update after the VBlank NMI edge ---------
nmi:
    pha                     ; CPU entry saves PC/status, not A; X and Y are untouched
    inc COUNTER             ; one more frame has passed
    bit $2002               ; reset the address latch
    lda #$3f
    sta $2006
    lda #$00
    sta $2006               ; aim at the backdrop, $3F00
    lda COUNTER
    sta $2007               ; backdrop = the frame count (palette keeps low 6 bits)
    ; re-aim at $3F00 so the backdrop we display is the one we just set
    bit $2002
    lda #$3f
    sta $2006
    lda #$00
    sta $2006
    pla                     ; restore the interrupted code's accumulator
    rti

irq:
    rti

.segment "VECTORS"
    .word nmi
    .word reset
    .word irq

.segment "CHARS"
    .byte $00,$00,$00,$00,$00,$00,$00,$00
    .byte $00,$00,$00,$00,$00,$00,$00,$00
    .byte $ff,$ff,$ff,$ff,$ff,$ff,$ff,$ff
    .byte $00,$00,$00,$00,$00,$00,$00,$00
    .res 8192 - 32, $00

The handler increments COUNTER at $10, writes it through $2006/$2007 to palette address $3F00, then re-aims the PPU address there. The palette uses the low six bits. Our renderer is disabled: $2001 remains zero. In this mode, pointing the PPU address into palette memory selects the displayed colour. This is the simple backdrop technique we have already used, not a background made from rendered tiles.

Leave the interrupted code as we found it

CPU interrupt entry saves the program counter and status, not A, X and Y. Our handler changes A, so pha saves it before the work and pla restores it before rti. X and Y are untouched. A handler that uses them must preserve them too. Each pushed value needs its matching restoration in reverse order. rts cannot replace rti: their stack layouts and return behaviour differ.

bit $2002 reads PPUSTATUS to reset the shared $2005/$2006 write latch. That read also clears the VBlank status flag. It is not a separate command that we must issue to acknowledge every NMI. Avoid casually polling $2002 or toggling NMI enable around VBlank: their timing can suppress or produce an NMI edge. sei masks ordinary IRQ requests; it does not prevent NMI.

A frame is not a graphics-update window

A nominal 60 Hz frame lasts about 16.67 ms; at 50 Hz it lasts 20 ms. VBlank occupies only part of that frame. With background or sprite rendering enabled, ordinary VRAM and palette transfers must fit in VBlank, or use a separately understood rendering-disabled technique. Entering NMI does not stop the PPU or reserve the rest of the frame for us.

Our sample keeps rendering disabled, so its palette access has different constraints. Do not copy unit 10’s continuous palette writes into a game with rendering enabled. Special mid-frame effects also exist; they need deliberate hardware timing, not arbitrary writes while the picture is being drawn.

Interrupt entry, register saving, the work itself, restoration and rti all use CPU time. The handler starts after the CPU can respond, not at an infinitely precise instant. A long handler can overrun the display window or delay the main loop. NMI is a timing notification, not a guarantee that our work meets its deadline.

Where the rest of a game goes

forever: jmp forever is deliberately idle in this tiny example. It is not a rule that all game work belongs in NMI. A later game can read input and update rules in the main loop, prepare display changes, and let a short NMI handler transfer a bounded amount during VBlank. We must budget that transfer and agree who owns the data; the handler must not see a half-prepared multi-byte update. Simply adding sei would not protect it from NMI.

Keep display refresh, input sampling, game updates and music-player updates conceptually separate, even when we choose to run several once per frame. A later music routine may update its notes on a scheduled tick while the APU continues generating sound between updates. We will understand that routine’s work before putting it under an interrupt-driven schedule.

Assemble and run

From this sample’s directory, with Asm198x available:

Shell
make

Load heartbeat.nes in an NTSC NES emulator. The handler changes the backdrop; the main loop remains idle.

Try this: slow the colour updates

Predict what happens if we add five lsr a instructions between lda COUNTER and sta $2007. Each shift halves the unsigned value, discarding the remainder. Five shifts divide by 32: we keep one colour for 32 updates, about 0.53 seconds on NTSC. Three shifts would divide by eight, not 32.

The byte counter still wraps after 256 updates. With five shifts its output is only 0–7, so this variation shows eight palette values, not all 64. We changed how we interpret the counter; we did not slow the PPU or the NMI rate.

Check our prediction

The original changes palette value each update. The variation holds each value for 32 updates. On a nominal 50-update schedule that hold would be 0.64 seconds. Counting display updates is not the same as specifying a duration in seconds.

If it doesn’t work

  • No changes: check the NMI vector, the $80 write to $2000 after warm-up, and that we loaded the rebuilt ROM.
  • Unexpected execution after an interrupt: check balanced stack operations and the final rti. A plausible picture does not prove register preservation.
  • A larger game glitches: check whether transfers finish inside their window and whether interrupted code and the handler share registers or data unsafely. Do not assume every glitch means the colour calculation is wrong.

Next, The Joypad in Your Hand isolates controller reading. Its simple polling loop is deliberately different from this interrupt experiment.

Sources

NESdev’s public hardware research: NMI, CPU interrupts, PPU registers and PPU rendering. These describe observed NES hardware behaviour; they are community research, not Nintendo manufacturer documentation.

Run it here

This unit's program, built for you.

Program
heartbeat.nes
Machine
Nintendo NES (NTSC)

Download heartbeat.nes