Level 5 · In the Wild

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()
⏱️ Multiply movement by delta time

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

ToolBest forNote
pygame-ce2D games, learning, jamsThe maintained community fork. Use this, not the original pygame
Arcade2D with a more modern APIBuilt on OpenGL, nice sprite handling
PyxelTiny retro games16 colours, 4 channels, delightful constraints
Ren'PyVisual novelsA whole engine, and genuinely popular commercially
GodotSerious 2D and 3DGDScript is Python-like; a real engine with an editor
Bevy (Rust)Performance-critical gamesThe sister school covers it
DRAMA[Medium: Success]

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

Exercise 1

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.

Exercise 2

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 frame

Writing 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.

Exercise 3

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 += 2
Reveal 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 * delta

clock.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.

+100 XP