Making Games 🎮
Games are the best way to learn programming, because the feedback is instant and the bugs are funny. Here is the loop that every game ever written is built on.
Every game, ever
def game_loop():
"""The structure under Doom, Tetris and Elden Ring alike."""
running = True
while running:
# 1. handle input what did the player just do?
# 2. update state move everything, apply rules, check collisions
# 3. draw paint the current state
# 4. wait hold a steady frame rate
running = False
return "game over"
print(game_loop())
game over
That is it. Sixty times a second, forever. Everything else is detail, and the detail is where the fun is.
A complete text adventure, no libraries
ROOMS = {
"beach": {
"description": "A beach. A rubber chicken lies in the sand.",
"exits": {"north": "jungle"},
"items": ["rubber chicken"],
},
"jungle": {
"description": "Thick jungle. Something rustles.",
"exits": {"south": "beach", "east": "clearing"},
"items": [],
},
"clearing": {
"description": "A clearing with a locked chest.",
"exits": {"west": "jungle"},
"items": ["chest"],
},
}
def play(commands):
"""Run a scripted game so the example is reproducible."""
here = "beach"
carrying = []
output = []
for command in commands:
room = ROOMS[here]
match command.lower().split():
case ["look"]:
output.append(room["description"])
if room["items"]:
output.append("You see: " + ", ".join(room["items"]))
case ["go", direction] if direction in room["exits"]:
here = room["exits"][direction]
output.append(f"You go {direction}. {ROOMS[here]['description']}")
case ["go", direction]:
output.append(f"You cannot go {direction} from here.")
case ["take", *words] if " ".join(words) in room["items"]:
item = " ".join(words)
room["items"].remove(item)
carrying.append(item)
output.append(f"Taken: {item}.")
case ["inventory"] | ["i"]:
output.append("Carrying: " + (", ".join(carrying) or "nothing"))
case _:
output.append(f"I do not understand {command!r}.")
return output
for line in play(["look", "take rubber chicken", "inventory", "go north", "go up", "look"]):
print(line)
A beach. A rubber chicken lies in the sand.
You see: rubber chicken
Taken: rubber chicken.
Carrying: rubber chicken
You go north. Thick jungle. Something rustles.
You cannot go up from here.
Thick jungle. Something rustles.
Dictionaries for the world, match for the parser, a list for the inventory.
Every technique in there came from Levels 2 and 4, and this is a real game. The workshop
builds it out properly with saving, locked doors and a win condition.
pygame: actual graphics
import pygame # pip install pygame-ce
WIDTH, HEIGHT = 640, 480
PLAYER_SPEED = 300 # pixels per second, not per frame
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Escape from Melee Island")
clock = pygame.time.Clock()
font = pygame.font.SysFont(None, 28)
player = pygame.Rect(WIDTH // 2, HEIGHT // 2, 32, 32)
treasure = pygame.Rect(500, 100, 24, 24)
score = 0
running = True
while running:
delta = clock.tick(60) / 1000 # seconds since the last frame
# 1. input
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False
keys = pygame.key.get_pressed()
dx = (keys[pygame.K_RIGHT] - keys[pygame.K_LEFT]) * PLAYER_SPEED * delta
dy = (keys[pygame.K_DOWN] - keys[pygame.K_UP]) * PLAYER_SPEED * delta
# 2. update
player.x = max(0, min(WIDTH - player.width, player.x + int(dx)))
player.y = max(0, min(HEIGHT - player.height, player.y + int(dy)))
if player.colliderect(treasure):
score += 1
treasure.topleft = (
(treasure.x * 7 + 113) % (WIDTH - 24),
(treasure.y * 5 + 71) % (HEIGHT - 24),
)
# 3. draw
screen.fill((18, 22, 27))
pygame.draw.rect(screen, (255, 222, 87), treasure)
pygame.draw.rect(screen, (69, 132, 182), player)
screen.blit(font.render(f"Treasure: {score}", True, (233, 238, 244)), (10, 10))
pygame.display.flip()
pygame.quit()
if __name__ == "__main__":
main()
player.x += 5 moves five pixels per frame, so the game runs at double speed on a 120Hz monitor. speed * delta moves a fixed distance per second, so it behaves identically everywhere. This is the single most common beginner bug in game programming, and it is why old PC games become unplayable on modern hardware.
Sprites and groups
import pygame
class Coin(pygame.sprite.Sprite):
def __init__(self, x, y):
super().__init__()
self.image = pygame.Surface((16, 16))
self.image.fill((255, 222, 87))
self.rect = self.image.get_rect(topleft=(x, y))
self.bob = 0.0
def update(self, delta):
self.bob += delta
self.rect.y += int(2 * (self.bob % 1 < 0.5) - 1)
coins = pygame.sprite.Group(Coin(100, 100), Coin(200, 150))
# in the loop:
# coins.update(delta)
# coins.draw(screen)
# collected = pygame.sprite.spritecollide(player_sprite, coins, dokill=True)
Sprite and Group are pygame's answer to "I now have four
hundred coins". A group updates and draws everything in one call, and
spritecollide handles the collisions. This is the point where the classes
from Lesson 31 stop being an exercise and start being load-bearing.
The wider landscape
| Tool | Best for | Note |
|---|---|---|
| pygame-ce | 2D games, learning, jams | The maintained community fork. Use this, not the original pygame |
| Arcade | 2D with a more modern API | Built on OpenGL, nice sprite handling |
| Pyxel | Tiny retro games | 16 colours, 4 channels, delightful constraints |
| Ren'Py | Visual novels | A whole engine, and genuinely popular commercially |
| Godot | Serious 2D and 3D | GDScript is Python-like; a real engine with an editor |
| Bevy (Rust) | Performance-critical games | The sister school covers it |
Let us be honest with the student. Nobody ships a commercial 3D game in Python, and pretending otherwise would be a disservice.
But Python is arguably the best language in existence for learning game programming, and for game jams, prototypes and tools. Every studio has Python somewhere in its pipeline. And a finished small game teaches more than an unfinished large one, in any language.
Finishing a game is the hard part
- Make the smallest playable thing first. One screen, one mechanic. If it is not fun with rectangles, art will not save it.
- Playtest early on someone else. Watch without helping. Everything they struggle with is your bug, not theirs.
- Scope down twice. Then again. Almost every unfinished hobby game died of ambition.
- Ship it. A tiny finished game beats a magnificent unfinished one, every single time.
Add a feature to the adventure
Add a locked chest that opens only if the player is carrying a key, with a key hidden in the jungle.
Reveal solution
ROOMS = {
"jungle": {"description": "Thick jungle.", "exits": {"east": "clearing"},
"items": ["rusty key"]},
"clearing": {"description": "A clearing with a locked chest.", "exits": {"west": "jungle"},
"items": []},
}
CHEST_OPEN = False
def play(commands):
global CHEST_OPEN
here = "jungle"
carrying = []
output = []
for command in commands:
room = ROOMS[here]
match command.lower().split():
case ["take", *words] if " ".join(words) in room["items"]:
item = " ".join(words)
room["items"].remove(item)
carrying.append(item)
output.append(f"Taken: {item}.")
case ["go", direction] if direction in room["exits"]:
here = room["exits"][direction]
output.append(f"You go {direction}.")
case ["open", "chest"] if here != "clearing":
output.append("There is no chest here.")
case ["open", "chest"] if "rusty key" not in carrying:
output.append("The chest is locked. You have no key.")
case ["open", "chest"]:
CHEST_OPEN = True
output.append("The key turns. Inside: the Secret of Monkey Island.")
case _:
output.append(f"You cannot do that.")
return output
for line in play(["open chest", "go east", "open chest", "go west",
"take rusty key", "go east", "open chest"]):
print(line)
There is no chest here.
You go east.
The chest is locked. You have no key.
You go west.
Taken: rusty key.
You go east.
The key turns. Inside: the Secret of Monkey Island.Note the order of the case clauses: the most specific guard first. Put the unguarded open chest earlier and it would swallow the other two, which is the same ordering trap as FizzBuzz in Lesson 7.
Design a game loop on paper
For a game of Pong, write out what happens in each of the four loop phases.
Reveal solution
1. INPUT
read up/down keys for player 1 and player 2 (or read the AI's decision)
check for quit and pause
2. UPDATE
move paddles by speed * delta, clamped to the screen
move ball by velocity * delta
if ball hits top or bottom wall: invert vertical velocity
if ball overlaps a paddle: invert horizontal velocity, add spin from
paddle movement, increase speed slightly
if ball passes the left or right edge: award a point, reset to centre,
serve towards the player who conceded
if either score reaches 11: state = game over
3. DRAW
clear the screen
draw both paddles, the ball, the centre line, both scores
if game over: draw the winner and 'press space'
flip the buffer
4. WAIT
clock.tick(60), and capture delta for the next frameWriting this out before coding is worth twenty minutes. Nearly every difficult game bug is really an ordering problem: collision checked before movement, or the score checked after the reset.
Fix the frame-rate bug
This game is unplayably fast on one machine and sluggish on another.
while running:
clock.tick(60)
player.x += 5
enemy.y += 2Reveal solution
while running:
delta = clock.tick(60) / 1000 # seconds since the last frame
player.x += PLAYER_SPEED * delta # pixels per second
enemy.y += ENEMY_SPEED * deltaclock.tick(60) caps the frame rate but cannot guarantee it: a slow machine drops to 30fps and everything moves at half speed, while an uncapped 144Hz display runs at more than double. Multiplying by elapsed time makes movement depend on the clock rather than on the hardware.
The same principle applies to every animation, physics step and timer in any real-time program, not just games.