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.
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 amount | Attribute handling |
|---|---|
| 8 pixels | Attribute scroll matches |
| 1-7 pixels | Attributes 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
| Approach | Speed | Smoothness |
|---|---|---|
| Flip-screen | Fast | Instant |
| Character scroll | Medium | Jerky |
| Pixel scroll | Slow | Smooth |
| Window scroll | Variable | Area-limited |
| Attribute-only | Fast | Colour-based |
Games that scrolled
| Game | Technique |
|---|---|
| Deathchase | Full horizontal scroll |
| Sidewize | Window-based |
| R-Type | Character-based |
| Head Over Heels | Flip-screen |