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

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.
; ============================================================================
; 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, $00The 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:
makeLoad 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
$80write to$2000after 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)