technique

Backgrounds and parallax

How to stack background layers that scroll at different speeds, tile each one without an obvious repeat, and push the far ones back with color so a flat screen reads as deep.

Before this

This page assumes you are comfortable with:

Why you need this

A side-scroller's play layer is a strip of tiles a few hundred pixels tall. Everything that makes it feel like a place, the hills, the tree line, the sky, is a background, and backgrounds are where a pixel game gets depth for almost nothing. This is stage 4 of the pipeline, build the world. The trick has two halves: layers move at different speeds (parallax), and far layers are drawn as if seen through air (atmospheric perspective). Both are cheap, both are easy to get slightly wrong, and slightly wrong is what makes a background look like wallpaper.

The idea

Layers, back to front

A background is a stack of images drawn in order, farthest first. A typical side-view stack:

Order Layer Holds Parallax ratio
1 sky gradient, sun or moon, maybe stars 0
2 far mountains, a distant city, a horizon tree line 0.25
3 mid hills, big trees, buildings the player cannot reach 0.5
4 near bushes, fences, ground detail just behind the tiles 0.75 to 1.0
5 play the tiles and sprites the player touches 1.0
6 foreground grass blades, branches, fog in front of the player 1.2

Four to six layers is normal. Two is the minimum that still reads as depth: sky plus one.

The parallax ratio

Parallax is the fact that near things slide past faster than far things when you move. The parallax ratio rr of a layer says how far the layer moves for each pixel the camera moves. The play layer has r=1r = 1: the camera follows it exactly. The sky has r=0r = 0: it never moves. A foreground layer has r>1r > 1: it moves faster than the camera, so it feels closer than the player.

If the camera has scrolled xcx_c pixels to the right, a layer with ratio rr has scrolled

xlayer=round(r⋅xc)x_\text{layer} = \mathrm{round}(r \cdot x_c)

pixels, and you draw it shifted left by that amount. The rounding matters. A layer drawn at 62.5 pixels lands between two pixel columns, and the renderer either blurs it or picks a column at random each frame, which shimmers. Round every layer to a whole pixel, every frame.

With xc=250x_c = 250:

Layer rr r⋅xcr \cdot x_c rounded
sky 0 0 0
far 0.25 62.5 63
mid 0.5 125 125
play 1.0 250 250
foreground 1.2 300 300

Far layers move in steps. At xc=249x_c = 249 the far layer is at round(62.25)=62\mathrm{round}(62.25) = 62; one camera pixel later it hops to 63, then sits still for three more. That is correct and invisible at 0.25. At a ratio of 0.1 the hop comes every ten camera pixels and is still fine.

Loop widths

Each layer must tile horizontally: its right edge continues into its left edge, exactly like a ground tile (see Tileable textures and tilesets) but in one direction only. The width of one copy is the layer's loop width WW. The engine draws the layer at −(xlayer mod W)-(x_\text{layer} \bmod W) and again WW pixels to the right, so the viewport is always covered.

Widths can and should differ per layer. A layer repeats once every W/rW / r camera pixels:

Layer WW rr repeats every
far 256 0.25 1024 camera px
mid 384 0.5 768 camera px
near 512 1.0 512 camera px

The same arrangement of silhouettes across all three layers recurs only when all three periods line up, at their least common multiple: lcm(1024,768,512)=3072\mathrm{lcm}(1024, 768, 512) = 3072 camera pixels, nearly ten screens on a 320-pixel viewport. Two rules of thumb: do not make every width the same, and do not make every width a power of two, because powers of two divide each other and line up early. One width of 384 or 320 in the stack breaks the pattern.

const layers = [
  { name: "sky",  ratio: 0,    width: 320 },
  { name: "far",  ratio: 0.25, width: 256 },
  { name: "mid",  ratio: 0.5,  width: 384 },
  { name: "near", ratio: 1.0,  width: 512 },
  { name: "fore", ratio: 1.2,  width: 640 },
];
function layerOffsets(cameraX) {
  return layers.map((L) => {
    const scrolled = Math.round(cameraX * L.ratio);
    const shift = ((scrolled % L.width) + L.width) % L.width; // 0 .. width-1, even for negative cameraX
    return { name: L.name, drawX: -shift };                   // draw at drawX and again at drawX + width
  });
}
layerOffsets(250);

Depth cues that survive in pixels

Parallax reads as depth only while the camera moves. The rest of the time, depth comes from how the layers are drawn.

Atmospheric perspective. Air is not perfectly clear. The farther a thing is, the more its color shifts toward the sky color and the less contrast it has. Both effects come from one formula: mix each color cc toward the sky color ss by a fraction tt that grows with distance,

c′=(1−t) c+t sc' = (1 - t)\,c + t\,s

applied to each of the red, green, and blue channels. Because every step in a ramp moves toward the same ss, the steps also move closer together, which is the loss of contrast.

One green ramp for trees, pushed back twice toward a sky of #b0cfe8:

Step near, t=0t = 0 mid, t=0.4t = 0.4 far, t=0.7t = 0.7
shadow #1e4a24 #587f72 #84a7ad
base #2f7a3a #639c80 #89b6b4
light #58a84c #7bb88a #96c3b9
highlight #9ad46e #a3d29f #a9d1c3

Check one: the shadow's red channel is 30 near and the sky's is 176; at t=0.4t = 0.4 it is 0.6⋅30+0.4⋅176=88.40.6 \cdot 30 + 0.4 \cdot 176 = 88.4, which rounds to 88, hex 58. The near ramp spans 30 to 154 in red; the far ramp spans 132 to 169. The far layer is also where you drop steps: two of the four are usually enough, because there is no contrast left to spend.

Less detail per pixel. A near tree has leaves; a mid tree is a blob with a lighter side; a far tree is one column of the base color. Detail that would be invisible at that distance is noise, and noise on a far layer flickers as it scrolls.

Overlap. A shape that covers part of another is in front of it. Let hills overlap mountains and trees overlap hills, and never leave a gap of sky between layers at the horizon.

Size. Things on a layer with ratio rr are roughly rr times the size they would be on the play layer. A 32-pixel tree at r=0.5r = 0.5 is about 16 pixels tall.

The horizon and vertical parallax

The horizon line is the screen height where the ground meets the sky on the far layer. Put it where the game needs the room: around 40 percent from the top for a ground-heavy platformer, higher for a game about jumping. Then keep it there. Cameras in a side-scroller also move vertically, and if the far layer used the same ratio vertically as horizontally, the horizon would climb and dive with every jump.

Give each layer a separate vertical ratio ryr_y, smaller than its horizontal one, often half or a quarter of it, and 0 for the sky. Then ylayer=round(ry⋅yc)y_\text{layer} = \mathrm{round}(r_y \cdot y_c). Each layer image must be tall enough to cover the viewport plus the full vertical camera range times ryr_y. A 180-pixel viewport with a camera that moves 400 pixels vertically needs a far layer at least 180+400⋅0.1=220180 + 400 \cdot 0.1 = 220 pixels tall at ry=0.1r_y = 0.1.

Sky gradients with a four-band dither

A smooth gradient needs hundreds of colors and pixel art has a few. Split the sky into four bands, darkest at the top, and blend each pair of neighbors with a one-row checkerboard on each side of the boundary (see Dithering):

1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1
1 2 1 2 1 2 1 2
2 1 2 1 2 1 2 1
2 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2
2 3 2 3 2 3 2 3
3 2 3 2 3 2 3 2
3 3 3 3 3 3 3 3
3 3 3 3 3 3 3 3
3 4 3 4 3 4 3 4
4 3 4 3 4 3 4 3
4 4 4 4 4 4 4 4
4 4 4 4 4 4 4 4

1 #5a7fbf, 2 #7fa0d4, 3 #a3c0e6, 4 #c8dcf2, top of the sky down to the horizon.

Every row is seamless horizontally on its own, so the sky tiles at any even width. Keep the dither to one or two rows per boundary; a tall dithered band reads as texture, not as gradient.

Time of day

A dusk or night background is the same layers with swapped palettes, the same way a tileset gets a winter variant. Change the four sky bands first, then recompute every far layer with the mixing formula and the new sky color as ss, so the far hills at dusk shift toward orange the way the daytime hills shift toward blue. The play layer keeps its ramps, or shifts them least, so the player and the platforms stay readable. With indexed color the whole change is one palette file per time of day.

The running example

A purchased side-scroller pack usually ships its background as separate PNGs, one per layer, each already tileable, with a note in a readme or in the file names saying which is far and which is near. Some state the intended ratios; when a pack does not, order the layers by detail and assign 0, 0.25, 0.5, 1.0 from the back. Keep each PNG as its own layer in the engine, since flattening them into one image throws the depth away. When the pack's layers have a different pixel size or palette from your tiles, see Adapting purchased packs.

Worked example

A three-layer background at a toy scale, 24 pixels wide and 10 tall, one character per pixel:

s s s s s s s s s s s s s s s s s s s s s s s s
s s s s s s s s s s s s s s s s s s s s s s s s
s s s s s f f f s s s s s s s s f f f s s s s s
s s s f f f f f f f s s s s f f f f f f f s s s
f f f f f f f f f f f f f f f f f f f f f f f f
f f m m f f f f f f m m m f f f f f f m m f f f
m m m m m m f f m m m m m m m f f m m m m m m m
m m m m m m m m m m m m m m m m m m m m m m m m
m m m m m m m m m m m m m m m m m m m m m m m m
p p p p p p p p p p p p p p p p p p p p p p p p

s sky #a3c0e6, f far hills #89b6b4 (r=0.25r = 0.25), m mid tree line #639c80 (r=0.5r = 0.5), p play-layer ground #2f7a3a (r=1r = 1).

Three things to notice. The far hills are one flat color, taken from the far column of the ramp table. The mid trees are one darker color and overlap the hills, with no sky showing between the two layers. The horizon, row 4, where the hills first span the full width, sits at 40 percent of the height.

Now scroll the camera by xc=8x_c = 8. The play layer shifts 8, the mid layer round(0.5⋅8)=4\mathrm{round}(0.5 \cdot 8) = 4, the far layer round(0.25⋅8)=2\mathrm{round}(0.25 \cdot 8) = 2. The two hill peaks that were 11 pixels apart stay 11 pixels apart; only their position relative to the gaps in the tree line changes, and that changing alignment is the whole depth effect.

In a game's art pipeline

Backgrounds are stage 4 alongside tilesets. They depend on stage 1 more than anything else on the page: the palette decides the sky bands, and the far ramps are derived from the near ramps and the sky by the mixing formula, so pick the sky before drawing a single hill. Stage 5 exports each layer as its own PNG with its ratio and loop width recorded in a manifest next to it, because the engine cannot guess either. The map cluster's layered generation can pick which background set a chunk uses; it never draws one.

Common mistakes

  • The background shimmers while scrolling. Layer positions are not rounded to whole pixels, or the game is displayed at a non-integer scale. Round every layer, and display at 2x, 3x, or 4x.
  • A wallpaper feel, the same mountain every screen. All loop widths are equal or all are powers of two, so the layers line up early. Change one width to 384 or 320.
  • Far layers look like near layers painted blue. Detail was kept while color was pushed back. Drop the detail and the ramp steps too.
  • The horizon jumps when the player jumps. The vertical ratio equals the horizontal ratio. Give the far layers a small ryr_y and the sky 0.
  • A seam of sky between hills and trees. Layers were drawn without overlap. Extend the nearer layer's bottom edge down past the farther layer's top edge.
  • Banding in the sky. Four flat bands with hard edges. Add the one-row checkerboard at each boundary.

Cost

Each layer is one more full-width drawing, one more PNG in the build, and one more draw call per frame, two when the wrap copy is on screen. An artist spends about as long on one detailed near layer as on a small tileset, and much less on each layer behind it, because far layers have less detail by design. File size scales with width times height: a 512 by 180 indexed layer is 92,160 pixels, 90 KB uncompressed and far less as PNG. Four to six layers is normal for a side-scroller. The hurt starts when every level wants its own set, since each set is four to six drawings plus the time-of-day variants, and a game with eight biomes and three times of day is looking at over a hundred layer images unless most of them are palette swaps.

Going further

  • Dithering, for gradients beyond four bands.
  • Light and form, for why far things lose contrast and shift toward the sky.
  • Tileable textures and tilesets, for the same edge rules applied in two directions.
  • Adapting purchased packs, for pushing a bought background behind tiles from a different pack.

Back to Pixel art for games