Learning Goals
3 minBy the end of this lesson you can:
- Scroll a ground strip left by decreasing its x offset each frame, and wrap it seamlessly.
- Apply gravity and an upward velocity so the player jumps on
SPACE. - Run the complete Part 1 game and see the player bounce on a scrolling ground.
Warm-Up · Gravity Recap
5 minIn PZ-35 you applied gravity with vy += GRAVITY each frame. What does this print after 3 frames if GRAVITY = 0.5 and vy starts at 0?
vy = 0 GRAVITY = 0.5 for frame in range(3): vy += GRAVITY print(vy)
Show the answer
Output
0.5 1.0 1.5
Gravity adds the same amount each frame, so the player falls faster every frame — just like real life.
Instead of moving the player right, we move the ground left. The player appears to run without us ever changing their x position.
New Concept · The Scrolling World Trick
12 minThink of a treadmill. The runner stays in the same spot while the belt moves under their feet. Our game works the same way: the player's x position never changes, but every frame we subtract a few pixels from the ground's x offset.
Ground scrolling
We keep one variable, ground_x, that starts at 0. Each frame we subtract SPEED. We draw the ground rectangle twice — once at ground_x and once at ground_x + WIDTH — so there is always something on screen. When ground_x falls below -WIDTH we reset it to 0 and the loop is invisible.
# update() — ground scroll ground_x -= SPEED if ground_x <= -WIDTH: ground_x = 0
Jump with gravity
The player has a vertical velocity vy. Pressing SPACE sets it to a negative number (upward in Pygame Zero's coordinate system — remember, y=0 is the top). Each frame gravity adds to it; when the player reaches the ground, we stop the fall.
# on_key_down — jump def on_key_down(key): if key == keys.SPACE and player_y >= GROUND_Y: globals()["vy"] = -JUMP_SPEED
Worked Example · Runner Part 1
12 minFaiz wants to build a side-scrolling runner. Save this as runner.py. We will extend it in PZ-39 to add obstacles and scoring.
Part A — constants and state
# runner.py — Endless Runner Part 1 import pgzrun WIDTH = 600 HEIGHT = 300 TITLE = "Endless Runner" GROUND_Y = 240 # y where the player stands GRAVITY = 0.6 JUMP_SPEED = 12 SPEED = 4 # pixels the ground moves left per frame GROUND_H = 60 # height of the ground strip player_x = 80 player_y = GROUND_Y vy = 0.0 # vertical velocity ground_x = 0 # scroll offset
Part B — draw
def draw(): screen.fill("lightyellow") # draw player as a coloured rectangle screen.draw.filled_rect( Rect((player_x - 15, player_y - 30), (30, 30)), "royalblue", ) # draw scrolling ground (two copies side by side) screen.draw.filled_rect( Rect((ground_x, GROUND_Y), (WIDTH, GROUND_H)), "saddlebrown", ) screen.draw.filled_rect( Rect((ground_x + WIDTH, GROUND_Y), (WIDTH, GROUND_H)), "saddlebrown", )
Part C — update and jump
def update(): global player_y, vy, ground_x # gravity vy += GRAVITY player_y += vy # land on the ground if player_y >= GROUND_Y: player_y = GROUND_Y vy = 0.0 # scroll ground ground_x -= SPEED if ground_x <= -WIDTH: ground_x = 0 def on_key_down(key): global vy if key == keys.SPACE and player_y >= GROUND_Y: vy = -JUMP_SPEED pgzrun.go()
What you will see
Try It Yourself
13 minChange SPEED from 4 to 7 and run the game. How does it feel? Try GRAVITY = 0.4 and JUMP_SPEED = 10 for a lighter, floatier jump.
SPEED = 7 GRAVITY = 0.4 JUMP_SPEED = 10
Add a jumps_left counter. Allow up to 2 jumps before the player must land. Reset jumps_left to 2 on landing.
jumps_left = 2 # add this global # in on_key_down: if key == keys.SPACE and jumps_left > 0: vy = -JUMP_SPEED jumps_left -= 1
Mini-Challenge · Aiman's Broken Runner
8 minAiman wrote this runner but the ground never wraps — it just scrolls off-screen and disappears. Find and fix the two bugs.
# aiman_runner.py — buggy
import pgzrun
WIDTH = 600
HEIGHT = 300
SPEED = 4
GROUND_Y = 240
ground_x = 0
def draw():
screen.fill("lightyellow")
screen.draw.filled_rect(Rect((ground_x, GROUND_Y), (WIDTH, 60)), "green")
screen.draw.filled_rect(Rect((ground_x + WIDTH, GROUND_Y), (WIDTH, 60)), "green")
def update():
ground_x -= SPEED # bug 1
if ground_x < WIDTH: # bug 2
ground_x = 0
pgzrun.go()It works if…
the ground scrolls continuously with no gap or jump
Show the fix
# aiman_runner.py — fixed import pgzrun WIDTH = 600 HEIGHT = 300 SPEED = 4 GROUND_Y = 240 ground_x = 0 def draw(): screen.fill("lightyellow") screen.draw.filled_rect(Rect((ground_x, GROUND_Y), (WIDTH, 60)), "green") screen.draw.filled_rect(Rect((ground_x + WIDTH, GROUND_Y), (WIDTH, 60)), "green") def update(): global ground_x # fix 1: declare global so assignment works ground_x -= SPEED if ground_x <= -WIDTH: # fix 2: wrap when the full strip has scrolled off ground_x = 0 pgzrun.go()
Bug 1: missing global ground_x meant the local copy was modified, not the module variable. Bug 2: the condition should be <= -WIDTH, not < WIDTH.
Recap
3 minAn endless runner keeps the player at a fixed x position and scrolls the world leftward. Drawing the ground strip twice — at ground_x and ground_x + WIDTH — gives a seamless loop. Gravity plus an upward velocity on SPACE delivers a smooth jump. In Part 2 we add obstacles and a score.
Vocabulary Card
- scroll offset
- A variable that tracks how far a background has shifted. Decreasing it each frame moves the world left.
- seamless wrap
- Drawing a strip twice so that when the first copy leaves the screen the second copy fills the gap.
- vertical velocity (vy)
- How fast the player moves up or down. Negative = up; positive = down (y increases downward).
- GRAVITY
- A small positive constant added to
vyevery frame, pulling the player toward the ground.
Homework
4 minGive the ground a grassy top layer. Below GROUND_Y draw a dark brown rectangle, and at exactly GROUND_Y draw a thin green rectangle (height 8) that also scrolls. Both strips must wrap seamlessly. Bring a screenshot to Part 2.
Sample · runner.py draw() update
def draw(): screen.fill("lightyellow") # player screen.draw.filled_rect( Rect((player_x - 15, player_y - 30), (30, 30)), "royalblue" ) # dirt layer screen.draw.filled_rect( Rect((ground_x, GROUND_Y + 8), (WIDTH, 52)), "saddlebrown" ) screen.draw.filled_rect( Rect((ground_x + WIDTH, GROUND_Y + 8), (WIDTH, 52)), "saddlebrown" ) # grass strip on top screen.draw.filled_rect( Rect((ground_x, GROUND_Y), (WIDTH, 8)), "forestgreen" ) screen.draw.filled_rect( Rect((ground_x + WIDTH, GROUND_Y), (WIDTH, 8)), "forestgreen" )
Draw the dirt first (wider), then the grass strip (8 px tall) on top at exactly GROUND_Y. Both use the same ground_x offset so they scroll together.