Learning Goals
3 minBy the end of this lesson you can:
- Store multiple background layers as a list of dictionaries with
offsetandspeedkeys. - Draw each layer twice — at
offsetandoffset + WIDTH— so the wrap is invisible. - Run a parallax scene where far layers move slowly and near layers move fast.
Warm-Up · Ground Scroll Recap
5 minIn PZ-38 you scrolled one ground strip. Predict the offset values after 3 frames for two strips with different speeds:
offsets = [0, 0] speeds = [1, 3] for frame in range(3): for i in range(2): offsets[i] -= speeds[i] print(offsets)
Show the answer
Output
[-3, -9]
Strip 1 moved 3 pixels, strip 2 moved 9 pixels in the same time — that difference in speed is what creates the depth illusion.
New Concept · Parallax Layers
12 minImagine three glass panes stacked in front of you. The back pane has distant mountains painted on it; the middle pane has trees; the front pane has bushes. As you move, the back pane barely shifts, the middle pane shifts noticeably, and the front pane whips past. That is parallax. In our game each pane is a dictionary.
Layer data structure
LAYERS = [ {"colour": (180, 210, 240), "height": 300, "speed": 0.5, "offset": 0.0}, # sky {"colour": ( 80, 140, 80), "height": 180, "speed": 1.5, "offset": 0.0}, # hills {"colour": ( 40, 80, 40), "height": 80, "speed": 3.0, "offset": 0.0}, # trees ]
Draw order matters: paint far layers first so near layers appear on top.
Drawing one layer twice
def draw_layer(layer): x = int(layer["offset"]) screen.draw.filled_rect( Rect((x, HEIGHT - layer["height"]), (WIDTH, layer["height"])), layer["colour"], ) screen.draw.filled_rect( Rect((x + WIDTH, HEIGHT - layer["height"]), (WIDTH, layer["height"])), layer["colour"], )
Updating offsets
def update(): for layer in LAYERS: layer["offset"] -= layer["speed"] if layer["offset"] <= -WIDTH: layer["offset"] = 0.0
Worked Example · Penang Hill Parallax
12 minYi Xuan wants a scenic background for her runner. Save as parallax.py:
Part A — setup
# parallax.py — three-layer parallax background import pgzrun WIDTH = 600 HEIGHT = 320 TITLE = "Parallax Demo" LAYERS = [ {"colour": (173, 216, 230), "height": 320, "speed": 0.3, "offset": 0.0}, {"colour": ( 60, 120, 60), "height": 160, "speed": 1.2, "offset": 0.0}, {"colour": ( 30, 80, 30), "height": 70, "speed": 3.0, "offset": 0.0}, ]
Part B — draw and update
def draw(): screen.fill((173, 216, 230)) # sky fill to avoid gaps for layer in LAYERS: x = int(layer["offset"]) screen.draw.filled_rect( Rect((x, HEIGHT - layer["height"]), (WIDTH, layer["height"])), layer["colour"], ) screen.draw.filled_rect( Rect((x + WIDTH, HEIGHT - layer["height"]), (WIDTH, layer["height"])), layer["colour"], ) def update(): for layer in LAYERS: layer["offset"] -= layer["speed"] if layer["offset"] <= -WIDTH: layer["offset"] = 0.0 pgzrun.go()
What you will see
Try It Yourself
13 minAdd a very near ground strip (height 30, speed 5.0, dark brown colour). It should scroll the fastest and appear at the very bottom of the window.
LAYERS.append({ "colour": (80, 40, 10), "height": 30, "speed": 5.0, "offset": 0.0, })
Add a global WORLD_SPEED = 1.0. In update(), multiply each layer's base speed by WORLD_SPEED. Then increase WORLD_SPEED by 0.001 each frame so the parallax gradually accelerates.
WORLD_SPEED = 1.0 BASE_SPEEDS = [l["speed"] for l in LAYERS] def update(): global WORLD_SPEED WORLD_SPEED += 0.001 for i, layer in enumerate(LAYERS): layer["offset"] -= BASE_SPEEDS[i] * WORLD_SPEED if layer["offset"] <= -WIDTH: layer["offset"] = 0.0
Mini-Challenge · Kavitha's Glitchy Horizon
8 minKavitha's parallax has a visible seam — a black gap appears as each layer wraps. Find the bug.
# kavitha_parallax.py — buggy
LAYERS = [
{"colour": "skyblue", "height": 300, "speed": 0.5, "offset": 0.0},
{"colour": "forestgreen","height": 100, "speed": 2.0, "offset": 0.0},
]
def draw():
for layer in LAYERS:
x = int(layer["offset"])
screen.draw.filled_rect(
Rect((x, HEIGHT - layer["height"]), (WIDTH, layer["height"])),
layer["colour"],
)
# second copy — bug here
screen.draw.filled_rect(
Rect((x + WIDTH - 1, HEIGHT - layer["height"]), (WIDTH, layer["height"])),
layer["colour"],
)
def update():
for layer in LAYERS:
layer["offset"] -= layer["speed"]
if layer["offset"] < -WIDTH: # note: strict less-than
layer["offset"] = 0.0It works if…
layers scroll with no visible gap or seam at any point
Show the fix
def draw(): for layer in LAYERS: x = int(layer["offset"]) screen.draw.filled_rect( Rect((x, HEIGHT - layer["height"]), (WIDTH, layer["height"])), layer["colour"], ) # fix: second copy at exactly x + WIDTH, no -1 offset screen.draw.filled_rect( Rect((x + WIDTH, HEIGHT - layer["height"]), (WIDTH, layer["height"])), layer["colour"], ) def update(): for layer in LAYERS: layer["offset"] -= layer["speed"] if layer["offset"] <= -WIDTH: # fix: <= so it resets exactly on wrap layer["offset"] = 0.0
The -1 in the second rect left a 1-pixel gap when the int conversion rounded differently. The < vs <= difference caused the offset to briefly overshoot, creating a black flash.
Recap
3 minParallax scrolling stacks multiple layers each with its own speed. Drawing each layer at offset and offset + WIDTH creates a seamless loop. Far layers scroll slowly; near layers scroll fast. The speed difference fools the eye into seeing depth on a flat screen.
Vocabulary Card
- parallax
- The visual effect where close objects appear to move faster than distant ones — used to fake depth in 2D games.
- scroll offset
- A float that tracks how far a layer has shifted left. Reset to 0 when it reaches
-WIDTH. - seamless wrap
- Drawing a layer at two positions (offset and offset + WIDTH) so no gap appears when it resets.
- layer order
- Draw far (slow) layers first so near (fast) layers paint on top — same as painting a watercolour background before adding foreground details.
Homework
4 minCombine the parallax background from today with the scrolling ground from PZ-38 into one file. The ground strip (drawn separately for the running surface) should scroll at the same speed as the nearest layer. Add a stationary player rectangle in the foreground. Bring a screenshot showing at least three visible depth layers plus the ground.
Sample · parallax_runner.py
# parallax_runner.py — parallax + runner ground import pgzrun WIDTH = 600 HEIGHT = 320 GROUND_Y = 270 NEAR_SPEED = 4.0 LAYERS = [ {"colour": (173, 216, 230), "height": 320, "speed": 0.5, "offset": 0.0}, {"colour": ( 60, 120, 60), "height": 160, "speed": 1.5, "offset": 0.0}, {"colour": ( 30, 80, 30), "height": 70, "speed": 3.0, "offset": 0.0}, ] ground_x = 0.0 def draw(): screen.fill((173, 216, 230)) for layer in LAYERS: x = int(layer["offset"]) screen.draw.filled_rect( Rect((x, HEIGHT - layer["height"]), (WIDTH, layer["height"])), layer["colour"], ) screen.draw.filled_rect( Rect((x + WIDTH, HEIGHT - layer["height"]), (WIDTH, layer["height"])), layer["colour"], ) gx = int(ground_x) screen.draw.filled_rect(Rect((gx, GROUND_Y), (WIDTH, 50)), "saddlebrown") screen.draw.filled_rect(Rect((gx + WIDTH, GROUND_Y), (WIDTH, 50)), "saddlebrown") screen.draw.filled_rect(Rect((60, GROUND_Y - 30), (28, 28)), "royalblue") def update(): global ground_x for layer in LAYERS: layer["offset"] -= layer["speed"] if layer["offset"] <= -WIDTH: layer["offset"] = 0.0 ground_x -= NEAR_SPEED if ground_x <= -WIDTH: ground_x = 0.0 pgzrun.go()
The ground strip uses the same wrap pattern as each layer. Setting NEAR_SPEED equal to the fastest layer makes the ground feel attached to the nearest scenery.