Skip to content
Techniques & Technology

Interrupt-Driven Music

Sound without blocking

Interrupt-driven music used timer interrupts to update sound chips at regular intervals, allowing music playback while the CPU handled game logic.

commodore-64sinclair-zx-spectrumcommodore-amiganintendo-entertainment-system audioprogrammingtechnique 1980–present

Overview

Early games faced a choice: play music or run the game. Interrupt-driven music solved this by using hardware interrupts to call music routines at fixed intervals. The main game loop ran uninterrupted while music played in the background, enabling the rich soundtracks that defined 8-bit gaming.

The problem

Without interrupts:

main_loop:
    jsr update_game
    jsr play_music_note    ; Blocks game
    jsr update_graphics
    jmp main_loop

Music timing depends on game speed.

The solution

With interrupts:

; Interrupt handler (called by hardware)
irq_handler:
    jsr update_music       ; Quick update
    rti

; Main loop runs freely
main_loop:
    jsr update_game
    jsr update_graphics
    jmp main_loop

C64 implementation

Two common patterns

C64 game music driver IRQs come from one of two sources, both clocked off the same φ2 = 985,248 Hz on PAL (1,022,727 Hz on NTSC):

  • Raster IRQ from VIC-II — set the raster compare register $D012 and enable raster IRQs in $D01A. Triggers once per frame at the chosen scanline. This is the most common pattern in games because it costs nothing extra (the system already has VIC running) and lets you do per-line tricks alongside the music tick.
  • CIA Timer A — programmable interval, useful for non-50/60 Hz playback rates (e.g. multi-speed tunes that update twice per frame for finer modulation).

The KERNAL routes IRQs through the RAM vector at $0314/$0315, so installing a custom handler is a write to those two bytes — but you must mask interrupts first.

Raster IRQ skeleton (the typical game pattern)

install_irq:
    sei                   ; Mask IRQs while we rewire the vector
    lda #<irq_handler
    sta $0314
    lda #>irq_handler
    sta $0315

    lda #$7f              ; Disable CIA #1 timer IRQs (we want VIC, not CIA)
    sta $dc0d
    lda $dc0d             ; Acknowledge any pending CIA IRQ

    lda #$01              ; Enable raster IRQ in VIC-II
    sta $d01a
    lda #100              ; Trigger at scanline 100 (anywhere in lower border works)
    sta $d012
    lda $d011
    and #$7f              ; Clear bit 7 (raster MSB) — line < 256
    sta $d011

    cli                   ; Re-enable IRQs
    rts

irq_handler:
    pha                   ; Save A/X/Y
    txa
    pha
    tya
    pha

    jsr update_music

    lda #$01              ; Acknowledge VIC raster IRQ
    sta $d019

    pla                   ; Restore Y/X/A
    tay
    pla
    tax
    pla
    rti                   ; Return from interrupt — must be RTI, not RTS

CIA Timer A skeleton

If you do want a CIA-driven IRQ, the timer must be started via $DC0E — the most common bug here is setting the latch and enabling the IRQ but never actually starting the timer.

install_cia_irq:
    sei
    lda #<irq_handler
    sta $0314
    lda #>irq_handler
    sta $0315

    ; PAL φ2 = 985248 Hz. For 50 Hz, latch = 985248/50 = 19705 = $4CF9
    lda #$f9
    sta $dc04             ; Timer A latch lo
    lda #$4c
    sta $dc05             ; Timer A latch hi

    lda #$81              ; %10000001: bit 7 set = "set mask", bit 0 = Timer A IRQ
    sta $dc0d             ; Enable Timer A interrupts

    lda #%00010001        ; Force latch load + start timer in continuous mode
    sta $dc0e             ; Control register A — without this, timer never runs

    cli
    rts

Music routine requirements

RequirementReason
Fast executionThe IRQ steals CPU from the main game; long handlers cause frame drops
Save/restore registersThe handler runs asynchronously; trashing A/X/Y corrupts whatever code was interrupted
End with RTINot RTS — the stack frame includes the saved P register
Acknowledge the sourceWrite to $D019 (VIC) or read $DC0D (CIA) so the IRQ line drops

NES implementation

Using NMI

The NES uses NMI (Non-Maskable Interrupt) at the start of VBlank — the PPU asserts NMI automatically every frame if PPUCTRL ($2000) bit 7 is set. Music drivers run in the NMI handler:

nmi_handler:
    pha                   ; Save registers
    txa
    pha
    tya
    pha

    jsr update_music      ; Call music routine

    pla                   ; Restore registers
    tay
    pla
    tax
    pla
    rti

Amiga implementation

Using CIA or VBlank

SourceFrequencyTypical use
VBlank (Level 3 IRQ)50/60 HzThe standard MOD player tick
CIA Timer A or BProgrammableMulti-speed playback, non-frame-rate-locked drivers

Paula handles the actual sample playback continuously via DMA; the music driver only needs to load new sample/period/volume values into Paula's audio registers when each row advances. ProTracker and most MOD replayers tick at 50 Hz on PAL by default.

Music routine structure

Typical update routine:

update_music:
    dec frame_counter
    bne .no_update

    lda #speed
    sta frame_counter

    ; Update each channel
    jsr update_channel_1
    jsr update_channel_2
    jsr update_channel_3

.no_update:
    rts

Timing considerations

FactorImpact
Interrupt frequencyMusical tempo
Routine lengthCPU availability
Nested interruptsComplexity

Notable music drivers

DriverPlatformNotes
Rob Hubbard's custom driversC64Hand-rolled per game; Monty on the Run, Commando etc. each have their own engine
Martin Galway's driversC64Distinct envelope engine, used in Wizball, Times of Lore
GoatTracker / SID-WizardC64Modern community-standard SID drivers
Future ComposerC64 / AmigaOne of the cross-platform pattern drivers
FamiTracker / FamiStudioNESDe facto modern NES driver
ProTrackerAmigaThe MOD-format replayer; basis for most Amiga game music
TFMXAmigaChris Hülsbeck's driver for the Turrican series

See also