Learning Goals
3 minBy the end of this lesson you can:
- Draw two paddles using
screen.draw.filled_rectand move them with keyboard input. - Move a ball with
vx/vyand make it bounce off the top, bottom, and both paddles. - Add a score that updates whenever the ball passes a paddle for an endless rally game.
Warm-Up · Two Keys, Two Paddles
5 minYou already know keyboard.left / keyboard.right for horizontal movement. Pong needs vertical movement. Predict what this snippet does when W is held down:
p1_y = 200 SPEED = 4 if keyboard.w: p1_y -= SPEED if keyboard.s: p1_y += SPEED print(p1_y)
Show the answer
Output
196
Holding W subtracts 4 from p1_y each frame, moving the paddle upward (remember: y=0 is the top).
Player 1 uses W / S. Player 2 uses UP / DOWN arrow keys (keyboard.up / keyboard.down).
New Concept · Paddle + Ball Mechanics
12 minThink of Pong as two independent systems glued together: the paddle system (player input → rect movement) and the ball system (velocity → position → collision → velocity change).
Drawing a paddle with Rect
A paddle is a tall thin rectangle. Store its top-left corner and draw it each frame.
PAD_W = 12 PAD_H = 70 # left paddle at x=20, centre y stored as p1_y screen.draw.filled_rect(Rect((20, p1_y - PAD_H // 2), (PAD_W, PAD_H)), "white")
Ball bounce off a paddle
Build a Rect for the paddle, then use colliderect with a small ball rect. On hit, reverse vx.
ball_rect = Rect((int(bx) - BR, int(by) - BR), (BR * 2, BR * 2)) left_pad = Rect((20, p1_y - PAD_H // 2), (PAD_W, PAD_H)) if ball_rect.colliderect(left_pad): vx = abs(vx) # always move rightward after left-pad hit
Worked Example · Pong Lite (full build)
12 minStage 1 · Paddles only
Start here to check the controls work before adding the ball.
# pong_lite.py — stage 1: paddles only import pgzrun WIDTH = 600 HEIGHT = 400 TITLE = "Pong Lite" PAD_W, PAD_H = 12, 70 SPEED = 5 p1_y, p2_y = HEIGHT // 2, HEIGHT // 2 def draw(): screen.fill("black") screen.draw.filled_rect( Rect((20, p1_y - PAD_H // 2), (PAD_W, PAD_H)), "white") screen.draw.filled_rect( Rect((WIDTH - 20 - PAD_W, p2_y - PAD_H // 2), (PAD_W, PAD_H)), "white") def update(): global p1_y, p2_y if keyboard.w and p1_y > PAD_H // 2: p1_y -= SPEED if keyboard.s and p1_y < HEIGHT - PAD_H // 2: p1_y += SPEED if keyboard.up and p2_y > PAD_H // 2: p2_y -= SPEED if keyboard.down and p2_y < HEIGHT - PAD_H // 2: p2_y += SPEED pgzrun.go()
Stage 2 · Add the ball and scoring
Replace the update() function with this version. Keep everything else from Stage 1.
bx, by = WIDTH // 2, HEIGHT // 2 vx, vy = 4, 3 BR = 8 score_l, score_r = 0, 0 def update(): global p1_y, p2_y, bx, by, vx, vy, score_l, score_r if keyboard.w and p1_y > PAD_H // 2: p1_y -= SPEED if keyboard.s and p1_y < HEIGHT - PAD_H // 2: p1_y += SPEED if keyboard.up and p2_y > PAD_H // 2: p2_y -= SPEED if keyboard.down and p2_y < HEIGHT - PAD_H // 2: p2_y += SPEED bx += vx by += vy if by <= BR or by >= HEIGHT - BR: vy = -vy ball_r = Rect((int(bx) - BR, int(by) - BR), (BR * 2, BR * 2)) left_pad = Rect((20, p1_y - PAD_H // 2), (PAD_W, PAD_H)) right_pad = Rect((WIDTH - 20 - PAD_W, p2_y - PAD_H // 2), (PAD_W, PAD_H)) if ball_r.colliderect(left_pad): vx = abs(vx) if ball_r.colliderect(right_pad): vx = -abs(vx) if bx < 0: score_r += 1 bx, by, vx, vy = WIDTH // 2, HEIGHT // 2, 4, 3 if bx > WIDTH: score_l += 1 bx, by, vx, vy = WIDTH // 2, HEIGHT // 2, -4, 3
Also update draw() to show the scores:
def draw(): screen.fill("black") screen.draw.filled_rect( Rect((20, p1_y - PAD_H // 2), (PAD_W, PAD_H)), "white") screen.draw.filled_rect( Rect((WIDTH - 20 - PAD_W, p2_y - PAD_H // 2), (PAD_W, PAD_H)), "white") screen.draw.filled_circle((int(bx), int(by)), BR, "white") screen.draw.text(str(score_l), center=(WIDTH // 4, 30), fontsize=40, color="white") screen.draw.text(str(score_r), center=(3 * WIDTH // 4, 30), fontsize=40, color="white")
What you'll see
Try It Yourself
13 minGive each paddle a different colour — try "deepskyblue" for Player 1 and "tomato" for Player 2. Update both filled_rect calls in draw().
screen.draw.filled_rect( Rect((20, p1_y - PAD_H // 2), (PAD_W, PAD_H)), "deepskyblue") screen.draw.filled_rect( Rect((WIDTH - 20 - PAD_W, p2_y - PAD_H // 2), (PAD_W, PAD_H)), "tomato")
Each time the ball bounces off a paddle, increase vx by 0.4 (in the direction it is already going). Add a check so it never exceeds 12.
if ball_r.colliderect(left_pad): vx = min(abs(vx) + 0.4, 12) # move right, faster if ball_r.colliderect(right_pad): vx = -min(abs(vx) + 0.4, 12) # move left, faster
Mini-Challenge · First to 5 Wins
8 minRight now Pong Lite is an endless rally. Extend it so the game ends when either player reaches 5 points. Display a "Player 1 Wins!" or "Player 2 Wins!" message and stop moving the ball. Combine the scoring logic from today with the if not active: guard pattern.
It works if…
the ball freezes and a winner message appears after 5 points; no new points are awarded after that
Show one possible solution
# add near the top: WINNING_SCORE = 5 game_over = False winner_text = "" # inside update(), after score changes: if score_l >= WINNING_SCORE: game_over = True winner_text = "Player 1 Wins!" if score_r >= WINNING_SCORE: game_over = True winner_text = "Player 2 Wins!" # wrap the ball-movement block: if not game_over: bx += vx by += vy # ... rest of ball logic ... # inside draw(), at the end: if game_over: screen.draw.text(winner_text, center=(WIDTH // 2, HEIGHT // 2), fontsize=50, color="gold")
A boolean flag game_over is the cleanest way to freeze the game. Your exact score limit and text can differ.
Recap
3 minPong Lite shows every core game mechanic in one script: paddles controlled by keyboard input, a ball moving with vx/vy, bouncing off walls (negate vy) and paddles (set vx to ±abs(vx) to avoid the trap bug), and a score that resets the ball when it passes a paddle.
Vocabulary Card
- Rect((x, y), (w, h))
- A rectangle object used both for drawing and collision detection. Store it as a variable or build it inline in the collision check.
- abs(vx)
- The speed without direction. Using
absguarantees the ball always moves in the correct direction after a paddle hit — avoids the "stuck-in-the-paddle" bug. - game_over flag
- A boolean variable that stops movement and inputs once a win condition is met.
Homework
4 minAdd a dashed centre line and a "PONG LITE" title to the game's draw function (use several small filled_rect calls for the dashes). Then add a one-player mode: when the game starts press 1 for two-player or 2 for one-player (the right paddle moves automatically to follow the ball). Save as pong_final.py and bring a screenshot.
Sample · pong_final.py
# pong_final.py — with dashed line + AI paddle import pgzrun WIDTH = 600 HEIGHT = 400 TITLE = "Pong Lite" PAD_W, PAD_H = 12, 70 SPEED = 5 BR = 8 p1_y = HEIGHT // 2 p2_y = HEIGHT // 2 bx, by = WIDTH // 2, HEIGHT // 2 vx, vy = 4, 3 score_l, score_r = 0, 0 ai_mode = False def draw(): screen.fill("black") for dash_y in range(10, HEIGHT, 20): screen.draw.filled_rect(Rect((WIDTH // 2 - 2, dash_y), (4, 10)), "grey") screen.draw.filled_rect( Rect((20, p1_y - PAD_H // 2), (PAD_W, PAD_H)), "deepskyblue") screen.draw.filled_rect( Rect((WIDTH - 20 - PAD_W, p2_y - PAD_H // 2), (PAD_W, PAD_H)), "tomato") screen.draw.filled_circle((int(bx), int(by)), BR, "white") screen.draw.text(str(score_l), center=(WIDTH // 4, 30), fontsize=40, color="white") screen.draw.text(str(score_r), center=(3 * WIDTH // 4, 30), fontsize=40, color="white") def update(): global p1_y, p2_y, bx, by, vx, vy, score_l, score_r if keyboard.w and p1_y > PAD_H // 2: p1_y -= SPEED if keyboard.s and p1_y < HEIGHT - PAD_H // 2: p1_y += SPEED if ai_mode: if p2_y < by - 5: p2_y = min(p2_y + SPEED, HEIGHT - PAD_H // 2) elif p2_y > by + 5: p2_y = max(p2_y - SPEED, PAD_H // 2) else: if keyboard.up and p2_y > PAD_H // 2: p2_y -= SPEED if keyboard.down and p2_y < HEIGHT - PAD_H // 2: p2_y += SPEED bx += vx by += vy if by <= BR or by >= HEIGHT - BR: vy = -vy ball_r = Rect((int(bx) - BR, int(by) - BR), (BR * 2, BR * 2)) if ball_r.colliderect(Rect((20, p1_y - PAD_H // 2), (PAD_W, PAD_H))): vx = abs(vx) if ball_r.colliderect(Rect((WIDTH - 20 - PAD_W, p2_y - PAD_H // 2), (PAD_W, PAD_H))): vx = -abs(vx) if bx < 0: score_r += 1 bx, by, vx, vy = WIDTH // 2, HEIGHT // 2, 4, 3 if bx > WIDTH: score_l += 1 bx, by, vx, vy = WIDTH // 2, HEIGHT // 2, -4, 3 def on_key_down(key): global ai_mode if key == keys.K_1: ai_mode = False if key == keys.K_2: ai_mode = True pgzrun.go()
The AI paddle simply tracks the ball's y position each frame. Your dashes and style can differ — the key ideas are the dashed line loop and the ai_mode flag.