Skip to content

Nametables and Scrolling

Move through a two-screen NES background with reusable scroll routines and a complete cartridge. Publish a finished display update before NMI uses it.

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.

A scrolling tiled NES background with sprites sharing one row

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

scroll.inc
; 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:
    rts

set_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.

Assembly
    ; Main owns the state only after acknowledgement.
    jsr scroll_right
    jsr build_oam
    jsr advance_priority
    lda #1
    sta ready

This 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

demo.asm
; 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,0

The 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.