Skip to content
Techniques & Technology

Software Scrolling

When hardware won't help

On systems without scroll registers, software must move every byte of screen data—a CPU-intensive technique that defines the feel of ZX Spectrum games.

sinclair-zx-spectrum graphicsscrollingzx-spectrum 1982–present

Overview

The ZX Spectrum has no hardware scroll registers. Moving the screen means copying thousands of bytes every frame. This fundamental limitation shaped Spectrum game design: many games used flip-screen progression, while those that scrolled did so slowly or in limited areas.

The challenge

The Spectrum screen is 6,144 bytes of pixel data plus 768 bytes of attributes. Moving everything takes thousands of instructions:

6,144 bytes ÷ 21 cycles per LDIR byte = ~129,024 cycles
At 3.5 MHz, that's ~37 ms—nearly two frames!

Full-screen software scrolling at 50 fps isn't possible.

Basic horizontal scroll

Shift screen data one pixel left:

scroll_left:
    ld hl, $4000        ; screen start
    ld b, 192           ; 192 lines

.line_loop:
    push bc
    ld b, 32            ; 32 bytes per line
    or a                ; clear carry

.byte_loop:
    rl (hl)             ; rotate left through carry
    inc hl
    djnz .byte_loop

    pop bc
    djnz .line_loop
    ret

This shifts pixels left, bringing in zeros from the right.

Faster alternatives

Unrolled loops

Remove DJNZ overhead:

scroll_line:
    rl (hl)
    inc hl
    rl (hl)
    inc hl
    ; ... repeat 32 times
    ret

Stack abuse

Use PUSH/POP for 16-bit moves:

; Save stack pointer
    ld (save_sp), sp

; Point stack at screen
    ld sp, $5800        ; end of screen

; Pop in reverse, shift, push forward
    pop de
    ; shift de left
    push de

; Restore stack
    ld sp, (save_sp)

Character-based scrolling

Faster than pixel scrolling—move whole characters. Each pixel row is 32 bytes; a 1-character left scroll copies bytes 1-31 down to bytes 0-30, then clears or refills byte 31:

; Scroll screen left by 8 pixels (1 character) — pixel-row by pixel-row
scroll_char_left:
    ld a, 192               ; 192 pixel rows
    ld hl, $4001            ; first row source (1 byte right of dest)
    ld de, $4000            ; first row destination
.line_loop:
    push af
    ld bc, 31               ; 31 bytes per row (32 - 1)
    ldir                    ; move this row
    ; Now HL = $4020, DE = $401F. We want HL/DE = next row's source/dest.
    ; Pixel row N+1's address jumps by + $0100 within a third, OR
    ; back to start of third + $0020 when crossing character row.
    ; The simplest robust approach is to recompute from a row counter.
    ; (See `y_to_address` below for the formula.)
    ; Skip-over for inline use: increment to next row by +1 byte (HL was at +31 after LDIR, +1 puts it at +32 = next row's start IN MEMORY ORDER but NOT in display order).
    inc hl
    inc de
    pop af
    dec a
    jr nz, .line_loop
    ret

Note that "next row in memory" and "next row on screen" are different on the Spectrum — see the address-formula section below. A correct char scroll either iterates the row counter through y_to_address for each pixel row, or unrolls the third-by-third structure. The simple inc hl / inc de form above scrolls memory in linear order, which produces visual artefacts at the third boundaries (lines 64 and 128) unless the screen content is uniform.

Colour attribute challenges

The attribute grid doesn't align with pixel scrolling:

Scroll amountAttribute handling
8 pixelsAttribute scroll matches
1-7 pixelsAttributes can't match precisely

Most games either:

  • Scroll by 8 pixels at a time
  • Accept colour clash at scroll boundaries
  • Use monochrome scrolling areas

Window scrolling

Scroll only part of the screen:

; Scroll a 16-character wide window
scroll_window:
    ld hl, window_start
    ld de, window_start - 1
    ld bc, 16           ; window width in bytes

    ld a, 192           ; lines
.loop:
    push af
    push bc
    ldir

    ; Move to next line (Spectrum screen layout)
    ; ... complex address calculation

    pop bc
    pop af
    dec a
    jr nz, .loop
    ret

Smaller windows scroll faster.

Spectrum screen layout

The screen isn't linear—understanding this is crucial:

Lines 0-7:   $4000, $4100, $4200, $4300, $4400, $4500, $4600, $4700
Lines 8-15:  $4020, $4120, $4220, ...
Lines 16-23: $4040, $4140, ...
...
Lines 64-71: $4800, $4900, ...

Third of screen = different base address.

Address calculation

The full formula: addr = $4000 | ((y & $C0) << 5) | ((y & $07) << 8) | ((y & $38) << 2). Y[7-6] selects which third of the screen (high byte gets +$00, +$08, or +$10). Y[2-0] picks the pixel row within a character row (high byte +$00 to +$07). Y[5-3] picks the character row within a third (low byte +$00 to +$E0 in steps of $20).

; Convert Y coordinate to screen address (column 0)
; Input: B = Y (0-191)
; Output: HL = screen address of byte 0 of that pixel row

y_to_address:
    ld a, b
    and %00000111       ; Y[2:0]
    or $40              ; high byte base = $40
    ld h, a             ; H = $40 | Y[2:0]

    ld a, b
    and %11000000       ; Y[7:6]
    rrca
    rrca
    rrca                ; Y[7:6] >> 3 (puts bits in high byte's bits 4-3)
    or h                ; merge into high byte
    ld h, a             ; H = $40 | Y[2:0] | (Y[7:6] >> 3)

    ld a, b
    and %00111000       ; Y[5:3]
    rlca
    rlca                ; Y[5:3] << 2 (gives byte offset 0, $20, $40, ..., $E0)
    ld l, a             ; L = column 0 of this pixel row
    ret

Verify: Y=0 → HL=$4000 ✓; Y=8 → HL=$4020 ✓; Y=64 → HL=$4800 ✓; Y=191 → HL=$57E0 (last visible row, column 0) ✓.

Common solutions

ApproachSpeedSmoothness
Flip-screenFastInstant
Character scrollMediumJerky
Pixel scrollSlowSmooth
Window scrollVariableArea-limited
Attribute-onlyFastColour-based

Games that scrolled

GameTechnique
DeathchaseFull horizontal scroll
SidewizeWindow-based
R-TypeCharacter-based
Head Over HeelsFlip-screen

See also