Pygame Movement, Collisions, and Scores
Two boxes, and whether they touch
In this lesson
A window with a moving square is a demo. Adding input, collision and a score makes it a game.
All three are simpler than they look. Movement is changing a number; collision is asking whether two boxes overlap; a score is a variable. The work is in wiring them together cleanly.
Explain it like I’m 5
Movement is just changing a location number. Collision is checking whether two boxes touch. A score is a number that goes up when they do.
Keyboard input that feels right
There are two ways to read the keyboard, and picking the wrong one is why some games feel stuttery.
Events (KEYDOWN) fire once, at the moment a key goes down. Right for things that happen once: jump, shoot, pause.
State (pygame.key.get_pressed()) reports which keys are held down right now. Right for continuous movement, because it is true on every frame the key is held.
Use KEYDOWN for walking and the player moves once, then pauses, then repeats at the operating system's key-repeat rate, exactly the stutter you get when holding a key in a text editor.
import pygame
WIDTH, HEIGHT = 400, 300
SIZE, SPEED = 40, 5
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
clock = pygame.time.Clock()
player = pygame.Rect(50, 130, SIZE, SIZE)
coin = pygame.Rect(300, 130, 20, 20)
score = 0
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# Held keys: smooth, continuous movement.
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
player.x -= SPEED
if keys[pygame.K_RIGHT]:
player.x += SPEED
# Keep the player on screen.
player.x = max(0, min(player.x, WIDTH - SIZE))
# Collision, and a score.
if player.colliderect(coin):
score += 1
coin.x = (coin.x + 137) % (WIDTH - 20)
screen.fill((20, 24, 40))
pygame.draw.rect(screen, (43, 124, 211), player)
pygame.draw.rect(screen, (240, 190, 60), coin)
pygame.display.flip()
clock.tick(60)
print("final score:", score)
pygame.quit()
# Arrow keys slide the blue square left and right. # Touching the yellow coin scores a point and moves the coin. # The player cannot leave the window.
What colliderect actually does
colliderect() is four comparisons, and knowing them is worth more than the method itself: the same logic turns up in every game framework, and in plenty of code that has nothing to do with games.
Two rectangles overlap when they overlap on both axes. Written out: A's left edge is left of B's right edge, and A's right edge is right of B's left edge, and the same pair vertically.
The easier way to think about it is backwards: they miss if there is a gap on either axis. If one box is entirely to the left, or entirely above, they cannot be touching, whatever the other axis says.
def overlaps(a, b):
"""Do two boxes touch? Each box is (x, y, width, height)."""
ax, ay, aw, ah = a
bx, by, bw, bh = b
return (ax < bx + bw and ax + aw > bx and
ay < by + bh and ay + ah > by)
player = (10, 10, 20, 20)
coin = (25, 25, 10, 10)
wall = (100, 100, 30, 30)
print("player hits coin:", overlaps(player, coin))
print("player hits wall:", overlaps(player, wall))
score = 0
for item in [coin, wall, (15, 15, 5, 5)]:
if overlaps(player, item):
score += 10
print("score:", score)
player hits coin: True player hits wall: False score: 20
Use the collision test to collect coins. Keep only the coins that overlap the player, score ten points each, and then check the edge case: does a coin whose left edge sits exactly on the player's right edge count as touching?
def overlaps(a, b):
ax, ay, aw, ah = a
bx, by, bw, bh = b
return (ax < bx + bw and ax + aw > bx and
ay < by + bh and ay + ah > by)
player = (0, 0, 10, 10)
coins = [(5, 5, 4, 4), (50, 50, 4, 4), (9, 9, 4, 4)]
# TODO: keep only the coins that overlap the player
collected = []
print("coins collected:", len(collected))
print("score:", len(collected) * 10)
print("touching at exactly x=10:", overlaps(player, (10, 0, 4, 4)))
A filtered list comprehension: [c for c in coins if overlaps(player, c)]. The last line is already written. Notice it prints False, because the comparisons use < rather than <=, so merely touching edges does not count as overlapping.
def overlaps(a, b):
ax, ay, aw, ah = a
bx, by, bw, bh = b
return (ax < bx + bw and ax + aw > bx and
ay < by + bh and ay + ah > by)
player = (0, 0, 10, 10)
coins = [(5, 5, 4, 4), (50, 50, 4, 4), (9, 9, 4, 4)]
collected = [c for c in coins if overlaps(player, c)]
print("coins collected:", len(collected))
print("score:", len(collected) * 10)
print("touching at exactly x=10:", overlaps(player, (10, 0, 4, 4)))
coins collected: 2
score: 20
touching at exactly x=10: False
Scores, wins, and losses
A score is a variable that goes up. What makes a game is what the score leads to, and that means the program needs to know which phase it is in.
The usual mistake is a tangle of booleans: game_over, won, paused, all separately true or false, which allows nonsense like won and lost at once. A single state variable holding one of "playing", "won", "lost" cannot contradict itself, and the loop simply draws whatever the current phase calls for.
Restarting is then the previous lesson's lesson again: build a fresh state rather than resetting fields one at a time. Forgetting to zero the score on restart is the classic bug, and creating a new state makes it impossible.
Common mistake: Using KEYDOWN events for continuous movement
Events are how the quit button was handled, so they look like the way to read keys.
KEYDOWN fires once per press. Use pygame.key.get_pressed() for held keys and smooth movement.
Common mistake: Letting the player leave the window
Nothing stops a coordinate growing forever.
Clamp after moving: max(0, min(x, WIDTH - size)). Subtracting the object's own size keeps it fully on screen.
Common mistake: Tracking game phase with several booleans
Each flag is added when its feature is, one at a time.
Use one variable holding "playing", "won" or "lost". Separate flags allow impossible combinations.
Common mistake: Forgetting to reset the score when restarting
The score lives outside the round and nothing clears it.
Build a fresh state for a new game instead of resetting fields by hand.
Which is right for smooth, continuous movement?
get_pressed() reports keys held right now, so movement happens on every frame the key is down.
What does colliderect() check?
Four comparisons: overlapping horizontally and overlapping vertically.
How should you keep a player inside the window?
max(0, min(x, WIDTH - size)) caps it at both ends in one line.
Mini exercise (medium)
Write the scoring pass a game runs every frame. Given a player box and a list of coin boxes, return the coins that were not collected along with the points earned, so the caller can replace the coin list and add to the score. Then report whether the level is complete.
Now you. Edit the starter code below, then Run it, everything happens in the browser.
def overlaps(a, b):
ax, ay, aw, ah = a
bx, by, bw, bh = b
return (ax < bx + bw and ax + aw > bx and
ay < by + bh and ay + ah > by)
def collect(player, coins):
"""Return (coins still on screen, points earned)."""
# TODO: sort the coins into collected and remaining
# TODO: 10 points per collected coin
return coins, 0
player = (0, 0, 10, 10)
coins = [(5, 5, 4, 4), (50, 50, 4, 4), (9, 9, 4, 4), (80, 80, 4, 4)]
remaining, points = collect(player, coins)
print("remaining:", len(remaining))
print("points:", points)
print("level complete:", len(remaining) == 0)
One loop over the coins, sorting each into “collected” or “still there”. Return the remaining coins and len(collected) * 10 as a tuple. The level is complete when the remaining list is empty.
def overlaps(a, b):
ax, ay, aw, ah = a
bx, by, bw, bh = b
return (ax < bx + bw and ax + aw > bx and
ay < by + bh and ay + ah > by)
def collect(player, coins):
"""Return (coins still on screen, points earned)."""
remaining = []
collected = 0
for coin in coins:
if overlaps(player, coin):
collected += 1
else:
remaining.append(coin)
return remaining, collected * 10
player = (0, 0, 10, 10)
coins = [(5, 5, 4, 4), (50, 50, 4, 4), (9, 9, 4, 4), (80, 80, 4, 4)]
remaining, points = collect(player, coins)
print("remaining:", len(remaining))
print("points:", points)
print("level complete:", len(remaining) == 0)
remaining: 2
points: 20
level complete: False
r, p = collect((0, 0, 10, 10), [])
assert (r, p) == ([], 0), "no coins means nothing collected"
r, p = collect((0, 0, 10, 10), [(5, 5, 2, 2)])
assert r == [] and p == 10, "a touched coin is removed and scores 10"
r, p = collect((0, 0, 10, 10), [(90, 90, 2, 2)])
assert len(r) == 1 and p == 0, "a distant coin stays on screen"
print("✓ Looks good!")