This tutorial covers SNES background scrolling techniques, from basic offset control to continuous tile streaming.
The SNES PPU has two scroll registers per background layer: one horizontal (BGnHOFS) and one vertical (BGnVOFS). These registers define which pixel of the tilemap appears at the top-left corner of the screen. The PPU reads these registers once per frame during rendering.
OpenSNES provides bgSetScroll() to set these values. The function writes to shadow variables and marks the background as dirty; the NMI handler then commits the values to hardware during VBlank.
Scroll offsets are 10 bits wide (0-1023). The tilemap wraps seamlessly:
| Tilemap Size | Pixel Dimensions | Wrap Point |
|---|---|---|
| SC_32x32 | 256 x 256 | Wraps at 256 |
| SC_64x32 | 512 x 256 | Wraps at 512 horizontally, 256 vertically |
| SC_32x64 | 256 x 512 | Wraps at 256 horizontally, 512 vertically |
| SC_64x64 | 512 x 512 | Wraps at 512 |
When the scroll offset exceeds the tilemap dimensions, the PPU wraps back to the beginning. This means a 32x32 tilemap scrolled to X=260 displays the same content as X=4.
Use bgSetScroll(bg, x, y) to set the scroll offset for a background layer. The bg parameter is 0-indexed (0 = BG1, 1 = BG2, etc.).
You can also set horizontal and vertical scroll independently:
To read back the current scroll position (from the shadow variables):
Read the joypad state with padHeld() and update the scroll position based on which directions are held. Use s16 for scroll variables so negative values wrap correctly.
The tilemap wraps automatically, so the player can scroll in any direction indefinitely on a SC_64x64 map (512x512 pixels). For a larger world, see Continuous Scrolling below.
A common technique is scrolling one background while keeping another fixed. This works well for a moving pattern behind a static logo or HUD.
See examples/graphics/backgrounds/mixed_scroll/ for a complete example where a shader pattern auto-scrolls behind a static logo.
Parallax creates a depth illusion by scrolling background layers at different speeds. Distant layers move slowly; near layers move quickly.
The easiest approach uses separate BG layers, each scrolled at a different rate:
This is limited to the number of available BG layers (Mode 1 has 3 layers).
For more zones than available BG layers, use HDMA to rewrite the scroll register at different scanline positions. This splits a single BG into multiple horizontal bands, each scrolling at its own speed.
See examples/graphics/effects/parallax_scrolling/ for the complete implementation.
Important: The scroll table must live in RAM (not const) because the main loop updates it every frame. HDMA reads from the table during rendering, so always update the values before WaitForVBlank().
For worlds larger than the tilemap (e.g., a side-scroller level), you must stream new tile columns or rows into VRAM as the player scrolls. The tilemap wraps in hardware, so you overwrite the column that just scrolled off-screen with the next column from your map data.
The continuous_scroll example demonstrates this with player-controlled scrolling and a character sprite. Key elements from the example:
The main loop reads input, moves the player, and applies auto-scrolling when the player crosses a screen threshold:
See examples/graphics/backgrounds/continuous_scroll/ for the full implementation including sprite setup, dual-layer parallax, and tilemap loading.
Scroll registers (BG1HOFS, BG1VOFS, etc.) are latched by the PPU at the start of each frame. Writing them during active display produces tearing or no visible effect.
OpenSNES handles this automatically: bgSetScroll() writes to shadow variables and sets a dirty flag. The NMI handler checks the dirty flag during VBlank and commits only the changed values to hardware. You do not need to manually time your scroll writes.
The typical frame loop is:
Important: Always set scroll values before WaitForVBlank(). If you set them after, the update is delayed by one frame.
Also set initial scroll values before setScreenOn() to avoid a single-frame glitch where the tilemap appears at (0, 0) before your intended position takes effect: