Skip to content
Techniques & Technology

Hardware Scrolling

Smooth movement without moving data

Hardware scroll registers let the display shift smoothly pixel-by-pixel without copying memory—essential for fast, smooth side-scrolling games.

commodore-64commodore-amiganintendo-entertainment-systemgraphicsscrollingoptimisation1977–present

Moving a game world pixel-by-pixel by copying screen memory is impossibly slow on 8-bit hardware. Hardware scrolling solves this: dedicated registers shift the display position, and you only update the newly-revealed edge. The result is butter-smooth scrolling at full frame rate.

Commodore 64

The VIC-II provides smooth scroll registers:

Horizontal scroll (0-7 pixels)

The standard idiom is “load → mask away the scroll bits → OR in the new ones → store”:

; $D016 bits 0-2 = X scroll offset
scroll_x:   .byte 0

smooth_scroll_right:
    dec scroll_x
    lda scroll_x
    and #$07            ; keep only bits 0-2
    sta scroll_x

    ; Read $D016, clear bits 0-2, OR in new scroll, store
    lda $d016
    and #$f8
    ora scroll_x
    sta $d016

    ; When scroll_x wraps from 0 to 7,
    ; shift screen memory left by 1 character
    lda scroll_x
    cmp #$07
    bne .no_coarse
    jsr shift_screen_left
.no_coarse:
    rts

Vertical scroll (0-7 pixels)

; $D011 bits 0-2 = Y scroll offset
scroll_y:   .byte 0

smooth_scroll_down:
    inc scroll_y
    lda scroll_y
    and #$07
    sta scroll_y

    lda $d011
    and #$f8
    ora scroll_y
    sta $d011

    ; When scroll_y wraps, shift screen up
    lda scroll_y
    bne .no_coarse
    jsr shift_screen_up
.no_coarse:
    rts

Colour RAM challenge

Colour RAM at $D800 is a separate chip, and $D018 cannot page-flip it: “The color memory is quite ugly to work with because it can’t be doublebuffered.” It has to be shifted in place while the rows being shifted are off-screen. PAL leaves time for 20–21 rows in the border; for NTSC compatibility Cadaver splits the job, shifting the upper half while the lower half is still being drawn and the lower half once the display ends, with the row at the split point buffered first (Metal Warrior 2 and 3). The other ways games cut the cost:

  • One colour per block, not per character — the SEUCK approach. With 4-character-wide blocks only every fourth column changes on a one-character shift, which “can cut color memory accesses to 1/4th portion” (Metal Warrior 1). Achim’s summary: per-tile colour “is very useful when you want to save rastertime for the main program, but is obviously less colourful.”
  • Rebuild it from a lookup table — read each screen code and look up its colour (ldy screen,x / lda charcolortable,y / sta colormemory,x). Cadaver names Nobby the Aardvark, Darkman and Cool World as users and calls the method “abominable”: four cycles more per byte than a plain shift, and too slow to leave room for NTSC.
  • Keep a spare colour map and copy it in halves — Malcolm Bamber’s 4-way scroller copies the odd colour lines from a spare map on one frame and the even lines on the next, alongside its two screens at 1024 and 3072.
  • Split colour RAM into even and odd lines — Sokrates’ demo-side double buffer, built from 8×16 characters, two charsets and linecrunch. It costs “a significant amount of rasterlines in each frame”, which is why it stays a demo technique.

Contention impact

C64 ULA-style memory contention doesn’t apply, but the VIC-II’s badlines still steal CPU cycles every 8th raster line during display. Coarse-scroll routines that copy screen memory are best run during the border or VBlank to avoid badline-induced timing variance. See VIC-II.

NES

The NES has excellent scroll hardware:

Scroll registers

; Write scroll position during VBlank
set_scroll:
    lda scroll_x
    sta $2005           ; X scroll
    lda scroll_y
    sta $2005           ; Y scroll
    rts

Nametable switching

The NES has 2-4 nametables. Scrolling spans them:

; $2000 bits 0-1 select base nametable
; Combined with $2005, allows seamless scrolling

    lda #%00000000      ; nametable 0
    sta $2000
    ; or
    lda #%00000001      ; nametable 1
    sta $2000

Mid-screen scroll changes

Using sprite 0 hit or mapper IRQ:

; Status bar at top (no scroll)
    lda #0
    sta $2005
    sta $2005

; Wait for sprite 0 hit
.wait:
    lda $2002
    and #$40
    beq .wait

; Game area (scrolled)
    lda game_scroll_x
    sta $2005
    lda game_scroll_y
    sta $2005

Amiga

The Amiga scrolls by adjusting bitplane pointers:

Coarse scroll (16-pixel steps)

; Change BPLxPT to scroll whole words
scroll_coarse:
    ; Move display start forward by 2 bytes (16 pixels)
    add.l   #2,bitplane_ptr
    move.l  bitplane_ptr,bpl1pt
    ...

Fine scroll (0-15 pixels)

; BPLCON1 contains scroll delay values
; Bits 0-3: playfield 1 scroll
; Bits 4-7: playfield 2 scroll

scroll_fine:
    move.w  scroll_x,d0
    and.w   #$0f,d0         ; 0-15
    move.w  d0,$dff102      ; BPLCON1

Dual playfield parallax

; Playfield 1 scrolls at full speed
; Playfield 2 scrolls at half speed

update_scroll:
    add.w   #2,pf1_scroll   ; fast
    add.w   #1,pf2_scroll   ; slow

    move.w  pf1_scroll,d0
    and.w   #$0f,d0
    move.w  pf2_scroll,d1
    and.w   #$0f,d1
    lsl.w   #4,d1
    or.w    d1,d0
    move.w  d0,$dff102

Coarse scroll techniques

When smooth scroll wraps, the screen must shift:

Character-based (C64)

; Shift screen left by 1 character
shift_screen_left:
    ldx #0
.loop:
    lda $0401,x         ; source (one char right)
    sta $0400,x         ; destination
    inx
    cpx #39             ; 40 columns - 1
    bne .loop

    ; Draw new column at right edge
    jsr draw_right_column
    rts

Tile-based (NES)

; Update single column of nametable
; Done gradually during gameplay, not all at once
update_column:
    lda column_to_update
    ; Set VRAM address
    ; Write 30 tiles (one column)
    ; Spread across multiple frames if needed

Bidirectional scrolling

Scrolling both directions requires:

  • Tracking scroll position (potentially >256)
  • Buffer around visible area
  • Updating both leading edges
; 16-bit scroll position
scroll_x_lo:    .byte 0
scroll_x_hi:    .byte 0

update_scroll:
    lda direction
    beq .scroll_right

.scroll_left:
    ; Decrement scroll, wrap at 0
    ...
    jsr update_left_edge
    rts

.scroll_right:
    ; Increment scroll, wrap at map width
    ...
    jsr update_right_edge
    rts

Performance comparison

Method Speed Memory Smoothness
Software scroll Very slow Low Depends
Hardware scroll Fast Medium Excellent
Page flipping Medium High Good

See also

Not yet fact-checked. This entry was drafted by an AI and nobody has verified it. The dates, figures and technical details may be wrong. Use it to find your bearings, then confirm anything that matters against a primary source.