The background slides beneath a row of moving-object graphics. The CPU does not copy the whole background each frame: it changes the PPU’s view into two prepared nametables. The same cartridge also demonstrates sprite overload and its costs.
Original Code198x NROM scene captured in Emu198x, NTSC configuration.
Code status: maintained routines and a running NTSC cartridge. Execution record. The scrolling family compares the same job on other machines. No PAL, original-hardware or worst-case NMI-budget qualification is claimed here.
Include the two operations
Include scroll.inc once and supply four bytes: scroll_x, scroll_y, nametable and ppu_ctrl_shadow. Y is 0–239 and nametable is 0–3. scroll_right increments X and flips only the horizontal nametable bit when X wraps from 255 to zero. A and flags change; the other registers survive.
Advance a camera and apply its PPU scroll
; SPDX-License-Identifier: MIT
; Caller owns scroll_x, scroll_y (0..239), nametable (0..3), ppu_ctrl_shadow.
; Set_scroll: call in vblank AFTER all $2006/$2007 writes. Changes A and flags.
; Hardware mirroring decides which logical tables have distinct physical RAM.
set_scroll:
bit $2002
lda scroll_x
sta $2005
lda scroll_y
sta $2005
lda ppu_ctrl_shadow
and #$fc
ora nametable
sta $2000
rts
; Move right by one pixel; preserves vertical table bit. A/flags change.
; Update a private snapshot; do not call concurrently with set_scroll.
scroll_right:
inc scroll_x
bne scroll_right_done
lda nametable
eor #1
sta nametable
scroll_right_done:
rtsset_scroll resets the shared address/scroll write latch, writes X and Y, then combines the nametable selection with the other settings in ppu_ctrl_shadow. It changes A and flags. Call during vblank, after all $2006/$2007 writes; those writes use the same internal state and would otherwise disturb scroll.
Cartridge wiring matters. This caller uses vertical mirroring, giving $2000 and $2400 separate physical tables for a 512-pixel-wide world. Horizontal mirroring aliases those two addresses. Four logical nametable addresses do not guarantee four independent screens of RAM.
Publish a whole picture
Main computes the next scroll state and builds OAM while ready=0, then sets ready=1 last. NMI transfers that finished OAM page, applies the matching scroll snapshot and clears ready. Main waits for that acknowledgement before changing it again. NMI saves A/X/Y and leaves the previous picture alone when no update is ready.
; Main owns the state only after acknowledgement.
jsr scroll_right
jsr build_oam
jsr advance_priority
lda #1
sta readyThis protocol lets main take more than a frame without publishing half a picture. It does not make slow logic faster. NMI owns DMA and scroll; the caller owns NMI, all OAM, palettes, CHR and both nametables. DMC and audio servicing are absent from this example.
The complete cartridge
make in the source folder creates build/demo.nes. The scene boots without external firmware. The new background is deliberately finite and repeating: both nametables are filled once with rendering off. Reset to leave.
Two-screen background and finished-page publication
; Original NROM-256/CHR-ROM scene. Vertical mirroring gives horizontal space.
.segment "HEADER"
.byte "NES",$1a,2,1,1,0
.byte 0,0,0,0,0,0,0,0
.segment "BSS"
scroll_x: .res 1
scroll_y: .res 1
nametable: .res 1
ppu_ctrl_shadow: .res 1
ready: .res 1
frames: .res 1
presented_objects: .res 1
object_count: .res 1
first_object: .res 1
oam_used: .res 1
oam_status: .res 1
objects_left: .res 1
object_index: .res 1
obj_x: .res 64
obj_y: .res 64
obj_tile: .res 64
obj_attr: .res 64
obj_active: .res 64
.segment "CODE"
oam_buffer = $0200
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_one:
bit $2002
bpl warmup_one
warmup_two:
bit $2002
bpl warmup_two
; Full tables loaded with rendering off. Attributes zero use palette 0.
bit $2002
lda #$20
sta $2006
lda #0
sta $2006
ldy #8
fill_page:
ldx #0
fill_byte:
txa
and #1
clc
adc #1
sta $2007
inx
bne fill_byte
dey
bne fill_page
; Clear the two attribute tables.
lda #$23
jsr clear_attributes
lda #$27
jsr clear_attributes
lda #$3f
sta $2006
lda #0
sta $2006
ldx #0
palette_loop:
lda palette,x
sta $2007
inx
cpx #32
bne palette_loop
ldx #0
init_objects:
txa
asl
asl
asl
asl
clc
adc #20
sta obj_x,x
lda #99
sta obj_y,x
lda #3
sta obj_tile,x
lda #0
sta obj_attr,x
lda #1
sta obj_active,x
inx
cpx #12
bne init_objects
lda #12
sta object_count
lda #$80
sta ppu_ctrl_shadow
sta $2000
main:
lda ready
bne main
jsr scroll_right
; Two image frames share the same object state and OAM builder.
lda frames
lsr
lsr
lsr
lsr
and #1
clc
adc #3
ldx #11
animate_objects:
sta obj_tile,x
dex
bpl animate_objects
jsr build_oam
jsr advance_priority
lda #1
sta ready
jmp main
clear_attributes:
sta $2006
lda #$c0
sta $2006
lda #0
ldx #64
clear_attribute_byte:
sta $2007
dex
bne clear_attribute_byte
rts
nmi:
pha
txa
pha
tya
pha
lda ready
beq nmi_done
lda #0
sta $2003
lda #2
sta $4014
jsr set_scroll
lda oam_used
sta presented_objects
lda #$1e
sta $2001
lda #0
sta ready
inc frames
nmi_done:
pla
tay
pla
tax
pla
rti
irq: rti
palette:
.byte $0f,$12,$21,$30,$0f,$12,$21,$30,$0f,$12,$21,$30,$0f,$12,$21,$30
.byte $0f,$27,$30,$16,$0f,$27,$30,$16,$0f,$27,$30,$16,$0f,$27,$30,$16
.include "scroll.inc"
.include "../multi-sprite-oam/oam.inc"
.segment "VECTORS"
.word nmi,reset,irq
.segment "CHARS"
.res 16,0
.byte $aa,$55,$aa,$55,$aa,$55,$aa,$55
.res 8,0
.byte $ff,$81,$81,$81,$81,$81,$81,$ff
.res 8,0
.byte $18,$3c,$7e,$db,$ff,$5a,$24,0
.res 8,0
.byte $18,$3c,$7e,$db,$ff,$24,$42,$81
.res 8,0
.res 8112,0The OAM builder deliberately submits twelve objects at the same Y. The PPU can display only eight sprite entries on one scanline. Changing OAM order shares which ones drop out; it does not raise that capacity.
Beyond the two prepared screens
Column feeding adds new tiles as the camera crosses tile boundaries. It must also merge palette attribute quadrants without destroying their neighbours, and account for DMA, audio and other VRAM writes in the same vblank. That entry remains illustrative; this cartridge does not implement an endless map streamer.
Useful later versions include a fixed status panel, vertical scrolling, a camera dead zone and a bounded VRAM-update queue. Keep camera policy and display service separate so a different scrolling game can reuse the low-level code.
Register and sprite behaviour is documented in Ricoh’s RP2C02 development specification (KA-A002-B), sections on CTLR0, SCC and object evaluation; the NESdev register reference provides an accessible register-by-register account.
Explore the cross-platform scrolling family for camera coordinates, seams and implementations on other machines.