Making Games with pygame
The game loop, drawing, movement, keyboard input, collision detection and sprites, ending with a complete playable game.
The game loop
import pygame
pygame.init()
WIDTH, HEIGHT = 800, 600
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("My Game")
clock = pygame.time.Clock()
TEAL = (3, 150, 157)
LILAC = (202, 171, 213)
DEEP = (3, 92, 88)
running = True
while running:
# 1. HANDLE EVENTS
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# 2. UPDATE
# game logic goes here
# 3. DRAW
screen.fill(DEEP)
pygame.draw.circle(screen, LILAC, (400, 300), 40)
pygame.display.flip()
clock.tick(60) # cap at 60 frames per second
pygame.quit()Every game does these three things, in this order
- Handle events. What did the player do? Keys, mouse, closing the window.
- Update. Move everything, check collisions, update the score, apply the rules.
- Draw. Clear the screen, draw everything in its new position, show it.
If you do not call screen.fill(), everything from the previous frame is still there, so a moving object leaves a smear behind it. Clear, draw, show, every single frame.
Coordinates
Pygame's origin (0, 0) is the top left corner. x increases to the right and y increases downwards, which is the opposite of maths graphs. Moving something down means increasing y.
- Handle events: keys, mouse and quit.
- Update: move things and check for collisions.
- Draw: clear the screen, draw everything, flip the display.
- Tick: hold the game at 60 frames a second.
Drawing and movement
Shapes
pygame.draw.rect(screen, TEAL, (x, y, width, height))
pygame.draw.rect(screen, TEAL, (x, y, w, h), 3) # outline, 3 pixels
pygame.draw.circle(screen, LILAC, (cx, cy), radius)
pygame.draw.line(screen, TEAL, (x1, y1), (x2, y2), 2)
pygame.draw.polygon(screen, LILAC, [(0, 0), (50, 0), (25, 40)])Text
font = pygame.font.Font(None, 36)
text = font.render(f"Score: {score}", True, (255, 255, 255))
screen.blit(text, (10, 10))Images
player_image = pygame.image.load("player.png").convert_alpha()
player_image = pygame.transform.scale(player_image, (48, 48))
screen.blit(player_image, (x, y))Keyboard input, two ways
Continuous, for movement, checked every frame:
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
player_x -= speed
if keys[pygame.K_RIGHT]:
player_x += speed
if keys[pygame.K_UP]:
player_y -= speed
if keys[pygame.K_DOWN]:
player_y += speedSingle press, for actions that should happen once:
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
fire_bullet()If firing is handled with get_pressed(), holding the key fires sixty bullets a second. If movement is handled with KEYDOWN, the player moves one pixel per press.
Keeping things on screen
player_x = max(0, min(WIDTH - player_width, player_x))
player_y = max(0, min(HEIGHT - player_height, player_y))Rects, collisions and sprites
Rect, the most useful object in pygame
player = pygame.Rect(100, 100, 40, 40) # x, y, width, height
player.x += 5
player.centerx = WIDTH // 2
player.bottom = HEIGHT
pygame.draw.rect(screen, TEAL, player)Rects have left, right, top, bottom, centerx, centery, center, size and more, and setting any of them moves the rectangle sensibly.
Collision detection
if player.colliderect(enemy):
lives -= 1
if player.collidepoint(mouse_x, mouse_y):
print("Clicked the player")
hit_index = player.collidelist(coin_rects)
if hit_index != -1:
score += 10
coin_rects.pop(hit_index)Sprites
For anything beyond a few objects, use pygame's sprite system.
class Player(pygame.sprite.Sprite):
def __init__(self, x, y):
super().__init__()
self.image = pygame.Surface((40, 40))
self.image.fill((3, 150, 157))
self.rect = self.image.get_rect(topleft=(x, y))
self.speed = 5
def update(self, keys):
if keys[pygame.K_LEFT]:
self.rect.x -= self.speed
if keys[pygame.K_RIGHT]:
self.rect.x += self.speed
self.rect.x = max(0, min(WIDTH - self.rect.width, self.rect.x))
class Coin(pygame.sprite.Sprite):
def __init__(self, x, y):
super().__init__()
self.image = pygame.Surface((20, 20), pygame.SRCALPHA)
pygame.draw.circle(self.image, (202, 171, 213), (10, 10), 10)
self.rect = self.image.get_rect(topleft=(x, y))
def update(self, keys):
self.rect.y += 3
if self.rect.top > HEIGHT:
self.kill()
all_sprites = pygame.sprite.Group()
coins = pygame.sprite.Group()
player = Player(380, 540)
all_sprites.add(player)
# in the loop
all_sprites.update(keys)
collected = pygame.sprite.spritecollide(player, coins, dokill=True)
score += len(collected) * 10
all_sprites.draw(screen)Group.update() calls update() on every sprite, and Group.draw() draws them all. This is polymorphism doing real work.
- A Rect has x, y, width and height. The origin is the top left, and y increases downwards.
- colliderect is true only if all four tests are true: the rectangles overlap on both the x axis and the y axis.
- If any one test is false there is a gap, so there is no collision.
A complete game
import pygame
import random
pygame.init()
WIDTH, HEIGHT = 800, 600
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Catcher")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 36)
big_font = pygame.font.Font(None, 72)
DEEP = (3, 92, 88)
TEAL = (3, 150, 157)
LILAC = (202, 171, 213)
AQUA = (107, 196, 202)
WHITE = (244, 241, 248)
class Player(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
self.image = pygame.Surface((90, 18))
self.image.fill(AQUA)
self.rect = self.image.get_rect(midbottom=(WIDTH // 2, HEIGHT - 20))
self.speed = 8
def update(self, keys):
if keys[pygame.K_LEFT]:
self.rect.x -= self.speed
if keys[pygame.K_RIGHT]:
self.rect.x += self.speed
self.rect.x = max(0, min(WIDTH - self.rect.width, self.rect.x))
class Falling(pygame.sprite.Sprite):
def __init__(self, good=True):
super().__init__()
self.good = good
size = 22 if good else 26
self.image = pygame.Surface((size, size), pygame.SRCALPHA)
colour = LILAC if good else (163, 48, 73)
pygame.draw.circle(self.image, colour, (size // 2, size // 2), size // 2)
self.rect = self.image.get_rect(topleft=(random.randint(0, WIDTH - size), -size))
self.speed = random.randint(3, 7)
def update(self, keys):
self.rect.y += self.speed
if self.rect.top > HEIGHT:
self.kill()
def draw_text(text, font_obj, colour, y):
surface = font_obj.render(text, True, colour)
screen.blit(surface, surface.get_rect(center=(WIDTH // 2, y)))
def run_game():
player = Player()
all_sprites = pygame.sprite.Group(player)
falling = pygame.sprite.Group()
score = 0
lives = 3
spawn_timer = 0
running = True
game_over = False
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
return False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
return False
if game_over and event.key == pygame.K_RETURN:
return True
keys = pygame.key.get_pressed()
if not game_over:
spawn_timer += 1
if spawn_timer >= 30:
spawn_timer = 0
item = Falling(good=random.random() > 0.25)
falling.add(item)
all_sprites.add(item)
all_sprites.update(keys)
for item in pygame.sprite.spritecollide(player, falling, dokill=True):
if item.good:
score += 10
else:
lives -= 1
if lives <= 0:
game_over = True
screen.fill(DEEP)
all_sprites.draw(screen)
screen.blit(font.render(f"Score: {score}", True, WHITE), (14, 12))
screen.blit(font.render(f"Lives: {lives}", True, WHITE), (WIDTH - 130, 12))
if game_over:
draw_text("Game Over", big_font, LILAC, HEIGHT // 2 - 30)
draw_text(f"Final score: {score}", font, WHITE, HEIGHT // 2 + 30)
draw_text("Enter to play again, Escape to quit", font, AQUA, HEIGHT // 2 + 70)
pygame.display.flip()
clock.tick(60)
return False
play_again = True
while play_again:
play_again = run_game()
pygame.quit()What to notice
- One class per kind of thing, each with its own
update - Groups handle updating and drawing everything, whatever it is
dokill=Trueremoves the caught item automatically- Off screen sprites call
kill(), so they do not accumulate forever and slow the game down - The game over state stays inside the same loop rather than needing a separate one
clock.tick(60)caps the frame rate, so the game runs at the same speed on every machine
Key terms you must be able to define
- Game loop
- The loop that repeatedly handles events, updates the game state and draws the frame.
- Surface
- An image in memory that can be drawn onto and then drawn onto the screen.
- blit
- Copying one surface onto another, which is how images and text are drawn.
- Rect
- A rectangle object holding position and size, used for placement and collision detection.
- Sprite
- An object in a game with an image and a rect, usually with its own update method.
- Group
- A collection of sprites that can be updated and drawn together.
- colliderect
- A Rect method that returns True when two rectangles overlap.
- Frame rate
- How many times per second the game loop runs, capped with clock.tick.
- Event queue
- The list of things the player has done since the last frame, read with pygame.event.get().
Flashcards
Say the definition out loud before you flip the card. Retrieving it from memory is what builds recall, reading it again is not.
Reach the top grade on this topic
- Always structure the loop as events, then update, then draw, in that order
- Clear the screen at the start of every draw phase or you get smearing
- Use get_pressed for continuous movement and KEYDOWN for one off actions
- Remove sprites that leave the screen with kill(), or the game slows down over time
- Cap the frame rate with clock.tick so the game runs the same speed on any machine
- Put each kind of object in its own class so a group can update everything polymorphically
Mistakes that cost marks here
- Forgetting screen.fill(), so moving objects leave trails behind them.
- Forgetting pygame.display.flip(), so nothing ever appears.
- Using KEYDOWN for movement, so the player moves one pixel per key press.
- Never removing off screen sprites, so the game gradually slows to a crawl.
- Forgetting that y increases downwards, so everything moves the wrong way vertically.
- Leaving out clock.tick, so the game runs at a completely different speed on a faster computer.
Every game ever made runs the same three step loop thousands of times a second: handle input, update the world, draw the world. Once you have seen it, you cannot unsee it.
Knowledge check
screen.fill() be called each frame?player.colliderect(enemy) return?clock.tick(60) do?pygame.display.flip() do?all_sprites.update(keys) do?Exam-style questions
Five written questions in the style of the real paper. Write a full answer, then let the marker check it against the mark scheme.
- LHandle events such as key presses and closing the window
- LUpdate the game state, moving objects and checking collisions
- LDraw everything to the screen and display the frame
The first stage handles events, reading everything the player has done since the last frame, such as key presses, mouse clicks and the window being closed. The second stage updates the game state: objects are moved according to input and their own behaviour, collisions are checked, and things such as the score and the number of lives are adjusted. The third stage draws the frame, clearing the screen and then drawing every object in its new position before calling display.flip to make the completed frame visible. These three stages repeat, typically sixty times a second.
- LPygame does not clear the screen automatically between frames
- LWithout clearing, previous frames remain visible so moving objects leave trails
Pygame does not clear the display between frames, so whatever was drawn last time is still there when the next frame is drawn on top of it. If the screen is not cleared with screen.fill at the start of the drawing stage, an object that has moved will be drawn in its new position while its old position is still visible, so it leaves a continuous trail across the screen rather than appearing to move.
- Lget_pressed reports which keys are currently held down
- LIt is checked every frame so the action repeats while the key is held
- LKEYDOWN fires once at the moment a key is pressed
- LUse get_pressed for movement and KEYDOWN for one off actions such as firing or jumping
The get_pressed function returns the current state of every key, so checking it inside the game loop tells you which keys are being held down at that moment, and any action taken repeats on every frame the key remains down. The KEYDOWN event, by contrast, appears in the event queue once at the instant a key is pressed and does not repeat while it is held. Movement should use get_pressed, so that holding the left arrow moves the player smoothly and continuously. One off actions such as firing a shot, jumping or selecting a menu item should use KEYDOWN, because using get_pressed for firing would produce sixty shots a second while the key was held.
- LDefines a class inheriting from pygame.sprite.Sprite
- LCalls super().__init__() in the constructor
- LCreates an image and a rect for the sprite
- LDefines update to increase the y coordinate
- LRemoves the sprite with kill() when it passes the bottom of the screen
class Coin(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image = pygame.Surface((20, 20), pygame.SRCALPHA) pygame.draw.circle(self.image, (202, 171, 213), (10, 10), 10) self.rect = self.image.get_rect(topleft=(x, y)) self.speed = 4 def update(self): self.rect.y += self.speed if self.rect.top > HEIGHT: self.kill() The class inherits from pygame.sprite.Sprite so it can be added to a group, and super().__init__() must be called or the sprite machinery will not be set up. Every sprite needs an image and a rect, because groups use those to draw and position it. The update method increases y each frame, since y increases downwards in pygame, and calls kill once the top of the coin has passed the bottom of the screen, which removes it from every group it belongs to so it is no longer updated or drawn.
- LIt limits the loop to a maximum of 60 iterations per second
- LThis makes the game run at the same speed on different computers
- LWithout it the loop runs as fast as possible, so the game is far too fast on a powerful machine
The tick method limits the game loop to a maximum number of iterations per second, in this case sixty, by pausing briefly at the end of each pass if the frame was completed early. This matters because the amount an object moves per frame is fixed in the code, so the speed things appear to travel depends entirely on how many frames happen each second. Without the cap, the loop would run as fast as the hardware allows, which might be several hundred frames a second on a modern machine, and the game would be completely unplayable while running at a totally different speed on a slower computer. Capping the frame rate makes the game behave identically regardless of the hardware it is running on.