Skip to main content

Lesson 13: Capstone Part 2: Polish & Playtest

  • Module 7: Finish & Share
  • Lesson 13 of 14
  • โฑ๏ธ About 1 h 30 min (instruction + lab)

Your core loop works; now you make it feel like a finished game, with a title screen, a restart, animated thrusters, puffs of debris, sound and music. Then you do what every game studio does before release: watch real people play it without saying a word, and improve it one change at a time.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Build a title, playing and game-over flow with a restart, using a simple state variable.
  • Add a looping thruster animation and a one-shot puff animation from sprite sheets, timed with dt.
  • Connect sound effects and looping music to game events, and keep the game running when no audio device exists.
  • Run a silent, qualitative playtest and record what players do, say and struggle with.
  • Improve a game by changing one tuning number at a time and testing again.

Project: Space Salvage, Part 2: a polished, playtested version of your capstone, plus your playtest notes.

In This Lesson

โœจ What Polish Means

Play a game with the sound off and it immediately feels flatter, even though nothing about the rules changed. Polish is everything that tells players what just happened and how they should feel about it: a thruster that flickers when you accelerate, a puff when you grab a crate, a crunch when a rock hits you, a screen that says "GAME OVER" and invites you to try again.

Polish comes after the core loop works, never before. Polishing a game whose rules are still changing means redoing the polish every time the rules change. Your Part 1 game already covers the design doc's must column, so this lesson works through the should column, in this order:

  1. Game flow: title screen, game over, restart. Players need a way in and a way to try again before anything else matters.
  2. Animation: a thruster flame and a puff of debris, from sprite sheets.
  3. Sound: three effects and a quiet engine hum, with a mute key.
  4. Playtest: watch people play, then fix the biggest problem you saw.

The starter for this lesson is the finished Part 1 game plus empty, clearly marked slots for each of these. Every slot does nothing harmful yet, so the game runs after every step.

๐Ÿ” Title, Game Over and Restart

A game is really several screens that take turns: a title, the game itself, and a game-over screen. You already switched between animation states with a variable in Sprite Animation. The same trick works for the whole game: one string, state, decides what the loop updates and draws.

stateDiagram-v2 [*] --> title title --> playing: Enter or Space playing --> game_over: last life lost game_over --> playing: Enter or Space (fresh run)

Key presses change the state in the event loop. Starting a run is where the new_game() dictionary from Part 1 pays off: a restart is a single line, and nothing from the last run leaks into the new one.

for event in pygame.event.get():
    if event.type == pygame.QUIT:
        running = False
    elif event.type == pygame.KEYDOWN:
        if event.key == pygame.K_ESCAPE:
            running = False
        elif event.key == pygame.K_m:
            muted = not muted
            set_muted(muted)
        elif state != "playing" and event.key in (pygame.K_RETURN, pygame.K_SPACE):
            game = new_game(assets, rng)       # a completely fresh run
            state = "playing"

The update step runs the rules only while playing. When update_game() reports "game_over", the loop remembers the best score and switches state:

if state == "playing":
    happened = update_game(game, dt, read_direction(pygame.key.get_pressed()), assets, rng)
    for name in happened:
        if name in sounds and not muted:
            sounds[name].play()
    if "game_over" in happened:
        best = max(best, game["score"])
        state = "game_over"

The draw step checks the state too. The title screen draws only stars and text; the game-over screen draws the frozen world with a message on top, so the player can see where they went wrong. A small helper keeps the text centered:

def draw_centered(screen, font, text, y, color=TEXT_COLOR):
    label = font.render(text, True, color)
    screen.blit(label, label.get_rect(center=(WIDTH / 2, y)))

Both fonts are created once, before the loop, and kept in a small dictionary: fonts = {"small": pygame.font.Font(None, 32), "big": pygame.font.Font(None, 84)}.

๐Ÿ’ก Why this matters

A game without a restart asks players to relaunch it after every death, and most won't. "Press Enter to fly again" turns one run into ten. That is also what makes playtesting possible: your testers can try again without asking you what to do.

๐Ÿ”ฅ Animation from Sprite Sheets

The assets folder now has two one-row sprite sheets: flame_sheet.png (4 frames, each 16 ร— 32) and puff_sheet.png (9 frames, each 52 ร— 52). Cutting them uses the subsurface() idea from Sprite Sheets. A subsurface shares pixels with the sheet, so no image data is copied:

def slice_sheet(sheet, frame_width):
    """Cut a one-row sprite sheet into a list of frames."""
    frames = []
    for x in range(0, sheet.get_width() - frame_width + 1, frame_width):
        frames.append(sheet.subsurface((x, 0, frame_width, sheet.get_height())))
    return frames

The thruster loops, but only while the ship is moving. That is a two-state switch (idle and thrusting) like the ones in Sprite Animation. The timer uses the same subtract-and-keep-the-remainder pattern, so the flame flickers at FLAME_FPS frames per second at any frame rate:

self.thrusting = direction.length_squared() > 0
if self.thrusting:
    # ... move and turn the ship, as in Part 1 ...
    self.flame_timer += dt
    while self.flame_timer >= 1 / FLAME_FPS:
        self.flame_timer -= 1 / FLAME_FPS
        self.flame_index = (self.flame_index + 1) % len(self.flame_frames)

To draw the flame behind the ship whichever way it faces, take the point 26 pixels below the ship's center (behind a ship pointing up) and rotate that offset by the ship's angle. Rotate the flame frame by the same angle as the ship:

if self.thrusting:
    flame = pygame.transform.rotate(self.flame_frames[self.flame_index], -self.angle)
    behind = self.pos + pygame.Vector2(0, 26).rotate(self.angle)
    screen.blit(flame, flame.get_frect(center=behind))
screen.blit(self.image, self.rect)

The puff plays once, then removes itself. It is a tiny Sprite that steps through its frames and calls self.kill() after the last one. update_game() adds one to an effects group wherever a crate is collected or the ship is hit, and the group's update() and draw() do the rest:

class Puff(pygame.sprite.Sprite):
    """A one-shot animation that removes itself after its last frame."""

    def __init__(self, frames, pos):
        super().__init__()
        self.frames = frames
        self.index = 0
        self.timer = 0.0
        self.image = frames[0]
        self.rect = self.image.get_frect(center=pos)

    def update(self, dt):
        self.timer += dt
        while self.timer >= 1 / PUFF_FPS:
            self.timer -= 1 / PUFF_FPS
            self.index += 1
            if self.index >= len(self.frames):
                self.kill()
                return
        center = self.rect.center
        self.image = self.frames[self.index]
        self.rect = self.image.get_frect(center=center)

With 9 frames at PUFF_FPS = 24, a puff lasts 9 รท 24 = 0.375 seconds: long enough to notice, short enough not to hide the next asteroid.

๐Ÿ”Š Sound and Music

Three short sound effects and one long loop cover the whole game: collect.ogg, hit.ogg, game_over.ogg, and engine_hum.ogg as quiet background music. The names match the event names that update_game() already returns, so playing the right sound is a dictionary lookup (you saw that loop in the previous section).

As in Sound & Music, short effects are pygame.mixer.Sound objects loaded once, and the long loop streams through pygame.mixer.music. Every step that touches audio sits inside try/except, because some computers (a classroom PC without speakers, a server, some virtual machines) have no audio device. On those, the game should simply be silent, not crash:

def load_sounds():
    """Start the mixer and load the sound effects. Returns {} if audio is unavailable."""
    try:
        pygame.mixer.init()
    except pygame.error:
        return {}
    sounds = {}
    for name, volume in (("collect", 0.4), ("hit", 0.7), ("game_over", 0.8)):
        try:
            sounds[name] = pygame.mixer.Sound(ASSETS / f"{name}.ogg")
            sounds[name].set_volume(volume)
        except (FileNotFoundError, pygame.error):
            print(f"Missing assets/{name}.ogg: that sound is skipped.")
    try:
        pygame.mixer.music.load(ASSETS / "engine_hum.ogg")
        pygame.mixer.music.set_volume(0.25)
        pygame.mixer.music.play(-1)             # -1 = loop forever
    except (FileNotFoundError, pygame.error):
        pass
    return sounds

The mute key sets the music volume to 0, stops any effect that is still playing with pygame.mixer.stop() (the same move as the Sound lesson's SoundManager), and makes the loop skip new effects. Always give players a mute key; many people play with other audio on, or somewhere quiet.

๐ŸŽง Why OGG files?

All the sounds are in the OGG format. pygame-ce plays OGG, WAV and MP3 on the desktop, but the web version you build in the next lesson asks for OGG audio, so starting with OGG now saves converting files later.

๐Ÿ‘€ Playtesting: Watch, Don't Talk

You know exactly how your game works, which makes you the one person who cannot see what is confusing about it. A playtest is a short experiment that answers the questions from your design doc. Think of it like a science experiment:

  • Hypothesis: an assumption you want to check ("three lives is about right").
  • Experiment: someone plays your game while you watch.
  • Observation: you write down what actually happens, without interfering.
  • Iteration: you change the game based on what you saw, and test again.
graph LR A["Plan<br/>questions from your design doc"] --> B["Watch<br/>silently, take notes"] B --> C["Ask<br/>after they finish"] C --> D["Change<br/>one thing"] D --> A

This lesson uses a qualitative playtest: you learn from what people do and say, not from numbers. Five minutes with two or three people will show you more than an hour of testing it yourself. The rules:

  1. Say only one sentence before they start: "Please play this and think out loud; I'm testing the game, not you." Don't explain the controls: the title screen should.
  2. Observe silently. Don't guide, hint or explain, even when they struggle. If they ask "what do I do?", answer "what do you think you should do?" and write that moment down. Every question they ask is a note.
  3. Write down what you see, not what you feel: "pressed Space three times on the title screen", "died at 8 seconds to a rock from the left", "smiled at the puff".
  4. Ask why, afterwards. When they finish, ask two or three open questions: "What was the goal?", "When did it feel unfair?", "What did you want to do that you couldn't?"
  5. Test early and often, with different people. A friend who never plays games will find different problems from one who plays every day. Include people like the players you hope to reach.

The lab folder has playtest_notes_template.md with columns for time, what they did, what they said, and what surprised you.

โœ… Growth Mindset: Feedback Is Data, Not a Grade

Watching someone struggle with a game you worked hard on is uncomfortable, and wanting to jump in and explain is completely normal. Hold on to this: when a tester gets confused, the game has shown you exactly where it isn't finished yet. Professional designers deliberately look for these moments, because each one is a clear, fixable problem. Thank your tester, write it down, and choose one thing to improve.

๐Ÿ”ง One Change at a Time

After a playtest you will have a list of problems. Resist the urge to fix them all at once. If you change the speed, the spawn rate and the lives together and the game feels better, you will not know which change helped, or whether one of them made things worse and was hidden by the others.

  1. Pick the problem that hurt the most players, or hurt them the most.
  2. Make one change, ideally to one number in the tuning block at the top of the file.
  3. Play it, or better, have someone new play it. Did that problem get smaller?
  4. Keep the change or undo it, write down which, and pick the next problem.

Because all the tuning lives in named constants, most notes map straight to one line:

What you sawOne number to try
Players die in the first ten secondsRaise FIRST_SPAWN_GAP (1.2 โ†’ 1.6)
Nobody lasts past a minuteRaise MIN_SPAWN_GAP (0.35 โ†’ 0.5)
The ship overshoots cratesLower PLAYER_SPEED (320 โ†’ 270)
"That rock didn't even touch me!"Lower PLAYER_RADIUS (20 โ†’ 17)
Hit twice in a row by the same clusterRaise INVULNERABLE_TIME (1.5 โ†’ 2.0)
Bored after thirty secondsRaise GAP_SHRINK (0.015 โ†’ 0.02)

Some notes are not about numbers: "I didn't know Enter starts the game" means a clearer title screen; "I didn't notice I got hit" means a stronger hit effect. Those are still one change each.

๐Ÿ’ก Why this matters

Test, change one thing, test again is the loop behind nearly every well-tuned game. It is the same debugging habit you have used since the first lesson, applied to how a game feels instead of whether it crashes.

๐Ÿ‹๏ธ Practice Exercise: Space Salvage, Part 2

Objective: add a title and restart flow, animations and sound to your game, run a silent playtest with at least two people, and make one improvement based on what you saw.

Time: about 60 minutes. Starter files: space_salvage_starter.py, the updated assets folder and playtest_notes_template.md (your instructor has them). If your own Part 1 game has changes you like, copy them in, or add the new pieces to your own file instead. The numbered to-do comments match steps 1 to 6.

  1. To-do 1: finish slice_sheet(). Run it: the flame becomes one small frame instead of the whole strip (it starts flickering in the next step). (โ‰ˆ 5 min)
  2. To-do 2: animate the thruster with a timer that keeps its remainder. (โ‰ˆ 5 min)
  3. To-do 3: make Puff play its frames once and then remove itself. (โ‰ˆ 7 min)
  4. To-do 4: load the sounds and music inside try/except, and play a sound for each name update_game() returns. (โ‰ˆ 8 min)
  5. To-do 5: start in the "title" state, draw the title and game-over text, and let Enter or Space start a fresh run. (โ‰ˆ 10 min)
  6. To-do 6: M toggles mute. (โ‰ˆ 3 min)
  7. Playtest: two people, about five minutes each. Say the one opening sentence, then stay silent and fill in the notes template. Ask your after-questions. (โ‰ˆ 15 min)
  8. Pick the biggest problem, make one change, write it in your notes, and play again. (โ‰ˆ 7 min)

You are done when:

  • the game opens on a title screen, Enter starts a run, and after the third hit a game-over screen shows your score and best score and Enter starts a completely fresh run;
  • the thruster flickers only while you fly, and a puff appears (then disappears) when you grab a crate or get hit;
  • you hear a sound for collecting, getting hit and game over, the engine hum loops, and M mutes it all;
  • the game still runs, silently, if you rename the .ogg files;
  • your playtest notes list at least five observations and the one change you made.
๐Ÿ’ก Hint

If the flame shows the whole strip of four frames, slice_sheet() still returns [sheet]. If puffs never disappear, check that self.kill() runs once self.index reaches len(self.frames), and return right after it so you don't index past the end. If restarting keeps the old score, you changed state but forgot game = new_game(assets, rng). If you hear nothing, print sounds after loading: an empty dictionary means the mixer failed to start or the files weren't found.

โœ… Example Solution

If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top and and frame_budget() in the loop. They let the instructor's checker run the program automatically for a fixed number of frames; when you run it yourself they do nothing. You never need to write them.

"""Space Salvage, Part 2: Intro Lesson 13 capstone (solution).

The finished arcade game: a title screen, thruster and puff animations,
sound effects and music, a game-over screen with restart, and every
tuning number grouped at the top for playtest changes.

Controls: arrow keys or WASD to fly, Enter or Space to start, M to mute,
Esc to quit. Art and sound: Kenney (www.kenney.nl), CC0. See CREDITS.md.
"""
import random
from pathlib import Path

import pygame


# --- Tuning: change ONE number at a time between playtests -------------------
WIDTH, HEIGHT = 960, 540
FPS = 60
PLAYER_SPEED = 320            # pixels per second
PLAYER_RADIUS = 20            # collision circle, a little smaller than the art
START_LIVES = 3
INVULNERABLE_TIME = 1.5       # seconds of blinking safety after a hit
SALVAGE_ON_SCREEN = 3
SALVAGE_POINTS = 10
ASTEROID_MIN_SPEED = 110      # pixels per second
ASTEROID_MAX_SPEED = 210
FIRST_SPAWN_GAP = 1.2         # seconds between asteroids at the start
MIN_SPAWN_GAP = 0.35          # the gap never gets shorter than this
GAP_SHRINK = 0.015            # the gap shrinks this much per second survived
FLAME_FPS = 12                # thruster animation speed (frames per second)
PUFF_FPS = 24                 # puff animation speed
STAR_COUNT = 90

ASSETS = Path(__file__).parent / "assets"
BG_COLOR = (10, 12, 26)
TEXT_COLOR = (235, 235, 245)
ACCENT_COLOR = (255, 200, 80)
KILL_MARGIN = 150             # asteroids this far off screen are removed


def clamp_color(value):
    """Keep a computed color channel inside 0-255."""
    return max(0, min(255, int(value)))


def load_image(name):
    """Load an image from the assets folder, or a pink placeholder if it is missing."""
    try:
        return pygame.image.load(ASSETS / name).convert_alpha()
    except (FileNotFoundError, pygame.error):
        print(f"Missing assets/{name}: using a placeholder circle.")
        surface = pygame.Surface((40, 40), pygame.SRCALPHA)
        pygame.draw.circle(surface, (255, 0, 200), (20, 20), 20)
        return surface


def slice_sheet(sheet, frame_width):
    """Cut a one-row sprite sheet into a list of frames."""
    frames = []
    for x in range(0, sheet.get_width() - frame_width + 1, frame_width):
        frames.append(sheet.subsurface((x, 0, frame_width, sheet.get_height())))
    return frames


def load_assets():
    """Load every image once, after the window exists (convert_alpha needs it)."""
    ship = load_image("ship.png")
    return {
        "ship": ship,
        "life_icon": pygame.transform.smoothscale_by(ship, 0.45),
        "meteors": [load_image(f"meteor_{n}.png") for n in range(1, 5)],
        "salvage": [load_image(f"salvage_{n}.png") for n in range(1, 4)],
        "flame": slice_sheet(load_image("flame_sheet.png"), 16),
        "puff": slice_sheet(load_image("puff_sheet.png"), 52),
    }


def load_sounds():
    """Start the mixer and load the sound effects. Returns {} if audio is unavailable."""
    try:
        pygame.mixer.init()
    except pygame.error:
        return {}
    sounds = {}
    for name, volume in (("collect", 0.4), ("hit", 0.7), ("game_over", 0.8)):
        try:
            sounds[name] = pygame.mixer.Sound(ASSETS / f"{name}.ogg")
            sounds[name].set_volume(volume)
        except (FileNotFoundError, pygame.error):
            print(f"Missing assets/{name}.ogg: that sound is skipped.")
    try:
        pygame.mixer.music.load(ASSETS / "engine_hum.ogg")
        pygame.mixer.music.set_volume(0.25)
        pygame.mixer.music.play(-1)             # -1 = loop forever
    except (FileNotFoundError, pygame.error):
        pass
    return sounds


def set_muted(muted):
    """Silence or restore all sound: the music loop and any effect still playing."""
    if pygame.mixer.get_init():
        pygame.mixer.music.set_volume(0 if muted else 0.25)
        if muted:
            pygame.mixer.stop()                 # cut effects already playing, like Lesson 10's SoundManager


class Player(pygame.sprite.Sprite):
    def __init__(self, image, flame_frames, pos):
        super().__init__()
        self.base_image = image                 # the unrotated ship, nose up
        self.image = image
        self.pos = pygame.Vector2(pos)          # float position
        self.rect = self.image.get_frect(center=self.pos)
        self.radius = PLAYER_RADIUS             # used by collide_circle
        self.angle = 0.0                        # degrees clockwise from "up"
        self.invulnerable = 0.0                 # seconds of safety left
        self.flame_frames = flame_frames
        self.flame_index = 0
        self.flame_timer = 0.0
        self.thrusting = False

    def update(self, dt, direction):
        self.thrusting = direction.length_squared() > 0
        if self.thrusting:
            direction = direction.normalize()   # diagonals are not faster
            self.pos += direction * PLAYER_SPEED * dt
            self.angle = pygame.Vector2(0, -1).angle_to(direction)
            # Thruster animation: subtract the frame time, keep the remainder.
            self.flame_timer += dt
            while self.flame_timer >= 1 / FLAME_FPS:
                self.flame_timer -= 1 / FLAME_FPS
                self.flame_index = (self.flame_index + 1) % len(self.flame_frames)
        self.pos.x = max(PLAYER_RADIUS, min(WIDTH - PLAYER_RADIUS, self.pos.x))
        self.pos.y = max(PLAYER_RADIUS, min(HEIGHT - PLAYER_RADIUS, self.pos.y))
        self.image = pygame.transform.rotate(self.base_image, -self.angle)
        self.rect = self.image.get_frect(center=self.pos)
        self.invulnerable = max(0.0, self.invulnerable - dt)

    def draw(self, screen):
        # Blink while invulnerable: skip drawing on every other tenth of a second.
        if self.invulnerable > 0 and int(self.invulnerable * 10) % 2 == 0:
            return
        if self.thrusting:
            flame = pygame.transform.rotate(self.flame_frames[self.flame_index], -self.angle)
            behind = self.pos + pygame.Vector2(0, 26).rotate(self.angle)
            screen.blit(flame, flame.get_frect(center=behind))
        screen.blit(self.image, self.rect)


class Asteroid(pygame.sprite.Sprite):
    def __init__(self, image, pos, velocity, spin):
        super().__init__()
        self.base_image = image
        self.image = image
        self.pos = pygame.Vector2(pos)
        self.velocity = pygame.Vector2(velocity)   # pixels per second
        self.spin = spin                           # degrees per second
        self.angle = 0.0
        self.radius = image.get_width() * 0.42
        self.rect = self.image.get_frect(center=self.pos)

    def update(self, dt):
        self.pos += self.velocity * dt
        self.angle = (self.angle + self.spin * dt) % 360
        self.image = pygame.transform.rotate(self.base_image, self.angle)
        self.rect = self.image.get_frect(center=self.pos)
        if not (-KILL_MARGIN < self.pos.x < WIDTH + KILL_MARGIN
                and -KILL_MARGIN < self.pos.y < HEIGHT + KILL_MARGIN):
            self.kill()                            # far off screen: forget it


class Salvage(pygame.sprite.Sprite):
    def __init__(self, image, pos):
        super().__init__()
        self.image = image
        self.pos = pygame.Vector2(pos)
        self.rect = self.image.get_frect(center=self.pos)
        self.radius = 18


class Puff(pygame.sprite.Sprite):
    """A one-shot animation that removes itself after its last frame."""

    def __init__(self, frames, pos):
        super().__init__()
        self.frames = frames
        self.index = 0
        self.timer = 0.0
        self.image = frames[0]
        self.rect = self.image.get_frect(center=pos)

    def update(self, dt):
        self.timer += dt
        while self.timer >= 1 / PUFF_FPS:
            self.timer -= 1 / PUFF_FPS
            self.index += 1
            if self.index >= len(self.frames):
                self.kill()
                return
        center = self.rect.center
        self.image = self.frames[self.index]
        self.rect = self.image.get_frect(center=center)


def spawn_asteroid(assets, rng):
    """Make an asteroid just outside a random edge, aimed across the screen."""
    size = rng.randint(40, 90)
    image = pygame.transform.smoothscale(rng.choice(assets["meteors"]), (size, size))
    edge = rng.choice(("top", "bottom", "left", "right"))
    if edge == "top":
        pos = pygame.Vector2(rng.uniform(0, WIDTH), -size)
    elif edge == "bottom":
        pos = pygame.Vector2(rng.uniform(0, WIDTH), HEIGHT + size)
    elif edge == "left":
        pos = pygame.Vector2(-size, rng.uniform(0, HEIGHT))
    else:
        pos = pygame.Vector2(WIDTH + size, rng.uniform(0, HEIGHT))
    target = pygame.Vector2(rng.uniform(WIDTH * 0.2, WIDTH * 0.8),
                            rng.uniform(HEIGHT * 0.2, HEIGHT * 0.8))
    speed = rng.uniform(ASTEROID_MIN_SPEED, ASTEROID_MAX_SPEED)
    velocity = (target - pos).normalize() * speed
    return Asteroid(image, pos, velocity, rng.uniform(-90, 90))


def fill_salvage(game, assets, rng):
    """Top the salvage back up to SALVAGE_ON_SCREEN, away from the player."""
    while len(game["salvage"]) < SALVAGE_ON_SCREEN:
        for _ in range(20):
            pos = pygame.Vector2(rng.uniform(60, WIDTH - 60), rng.uniform(60, HEIGHT - 60))
            if pos.distance_to(game["player"].pos) > 150:
                break
        game["salvage"].add(Salvage(rng.choice(assets["salvage"]), pos))


def spawn_gap(elapsed):
    """Seconds between asteroids: shrinks as the run goes on, down to a floor."""
    return max(MIN_SPAWN_GAP, FIRST_SPAWN_GAP - GAP_SHRINK * elapsed)


def new_game(assets, rng):
    """Everything that changes during one run, in one dictionary."""
    game = {
        "player": Player(assets["ship"], assets["flame"], (WIDTH / 2, HEIGHT / 2)),
        "asteroids": pygame.sprite.Group(),
        "salvage": pygame.sprite.Group(),
        "effects": pygame.sprite.Group(),
        "score": 0,
        "lives": START_LIVES,
        "time": 0.0,
        "spawn_timer": FIRST_SPAWN_GAP,
    }
    fill_salvage(game, assets, rng)
    return game


def read_direction(keys):
    """Turn the arrow keys and WASD into a direction vector (not yet normalized)."""
    x = (keys[pygame.K_RIGHT] or keys[pygame.K_d]) - (keys[pygame.K_LEFT] or keys[pygame.K_a])
    y = (keys[pygame.K_DOWN] or keys[pygame.K_s]) - (keys[pygame.K_UP] or keys[pygame.K_w])
    return pygame.Vector2(x, y)


def update_game(game, dt, direction, assets, rng):
    """Advance one frame of play. Returns a list of what happened, for sound."""
    events = []
    game["time"] += dt
    player = game["player"]
    player.update(dt, direction)
    game["asteroids"].update(dt)
    game["effects"].update(dt)

    # Spawn asteroids on a timer: subtract, keep the remainder.
    game["spawn_timer"] -= dt
    while game["spawn_timer"] <= 0:
        game["asteroids"].add(spawn_asteroid(assets, rng))
        game["spawn_timer"] += spawn_gap(game["time"])

    # Collect: touching salvage removes it (dokill=True) and scores.
    for crate in pygame.sprite.spritecollide(player, game["salvage"], True,
                                             pygame.sprite.collide_circle):
        game["score"] += SALVAGE_POINTS
        game["effects"].add(Puff(assets["puff"], crate.pos))
        events.append("collect")
    fill_salvage(game, assets, rng)

    # Dodge: an asteroid costs a life, then a short blinking safety window.
    if player.invulnerable <= 0 and pygame.sprite.spritecollideany(
            player, game["asteroids"], pygame.sprite.collide_circle):
        game["lives"] -= 1
        player.invulnerable = INVULNERABLE_TIME
        game["effects"].add(Puff(assets["puff"], player.pos))
        events.append("hit")
        if game["lives"] <= 0:
            events.append("game_over")
    return events


def make_stars(rng):
    """Background stars as [x, y, speed] lists; faster stars are brighter."""
    return [[rng.uniform(0, WIDTH), rng.uniform(0, HEIGHT), rng.uniform(15, 70)]
            for _ in range(STAR_COUNT)]


def update_stars(stars, dt):
    for star in stars:
        star[1] += star[2] * dt
        if star[1] > HEIGHT:
            star[1] -= HEIGHT


def draw_stars(screen, stars):
    for x, y, speed in stars:
        shade = clamp_color(60 + speed * 2.6)
        screen.fill((shade, shade, clamp_color(shade + 20)), (x, y, 2, 2))


def draw_centered(screen, font, text, y, color=TEXT_COLOR):
    label = font.render(text, True, color)
    screen.blit(label, label.get_rect(center=(WIDTH / 2, y)))


def draw_hud(screen, game, font, assets):
    screen.blit(font.render(f"Score: {game['score']}", True, TEXT_COLOR), (14, 10))
    screen.blit(font.render(f"Time: {game['time']:.0f}s", True, TEXT_COLOR), (14, 40))
    icon = assets["life_icon"]
    for i in range(game["lives"]):
        screen.blit(icon, (WIDTH - 14 - (i + 1) * (icon.get_width() + 6), 14))


def draw_world(screen, game, stars, fonts, assets):
    screen.fill(BG_COLOR)
    draw_stars(screen, stars)
    game["salvage"].draw(screen)
    game["asteroids"].draw(screen)
    game["player"].draw(screen)
    game["effects"].draw(screen)
    draw_hud(screen, game, fonts["small"], assets)


def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Space Salvage")
    clock = pygame.time.Clock()
    fonts = {"small": pygame.font.Font(None, 32), "big": pygame.font.Font(None, 84)}
    assets = load_assets()
    sounds = load_sounds()
    rng = random.Random()
    stars = make_stars(rng)
    game = new_game(assets, rng)
    state = "title"                             # "title", "playing" or "game_over"
    best = 0
    muted = False

    running = True
    while running:
        dt = min(clock.tick(FPS) / 1000, 0.05)  # seconds; capped after a stall

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_ESCAPE:
                    running = False
                elif event.key == pygame.K_m:
                    muted = not muted
                    set_muted(muted)
                elif state != "playing" and event.key in (pygame.K_RETURN, pygame.K_SPACE):
                    game = new_game(assets, rng)
                    state = "playing"

        update_stars(stars, dt)
        if state == "playing":
            happened = update_game(game, dt, read_direction(pygame.key.get_pressed()), assets, rng)
            for name in happened:
                if name in sounds and not muted:
                    sounds[name].play()
            if "game_over" in happened:
                best = max(best, game["score"])
                state = "game_over"
        elif state == "game_over":
            game["effects"].update(dt)          # let the last puff finish

        if state == "title":
            screen.fill(BG_COLOR)
            draw_stars(screen, stars)
            draw_centered(screen, fonts["big"], "SPACE SALVAGE", HEIGHT / 2 - 70, ACCENT_COLOR)
            draw_centered(screen, fonts["small"], "Collect salvage. Dodge asteroids.", HEIGHT / 2)
            draw_centered(screen, fonts["small"], "Arrows or WASD to fly   M to mute", HEIGHT / 2 + 36)
            draw_centered(screen, fonts["small"], "Press Enter to launch", HEIGHT / 2 + 100, ACCENT_COLOR)
        else:
            draw_world(screen, game, stars, fonts, assets)
            if state == "game_over":
                draw_centered(screen, fonts["big"], "GAME OVER", HEIGHT / 2 - 50, ACCENT_COLOR)
                draw_centered(screen, fonts["small"],
                              f"Score {game['score']}    Best {best}", HEIGHT / 2 + 10)
                draw_centered(screen, fonts["small"], "Press Enter to fly again", HEIGHT / 2 + 50)
        pygame.display.flip()

    pygame.quit()
    print(f"Space Salvage closed in state '{state}'. Last score: {game['score']}, best: {best}.")


if __name__ == "__main__":
    main()

๐Ÿ““ Learning Journal

Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:

  • Key concepts you learned today
  • Techniques that clicked (and the ones that haven't, yet)
  • Questions or confusion to bring to the next session
  • Ideas to try in your own game
  • Progress and feelings: how did this lesson go for you?

โœ๏ธ This lesson's prompts:

  1. What surprised you most about watching someone else play your game? Was it hard to stay silent?
  2. Which one change did you make after the playtest, and did it help? How do you know?
  3. Of all the polish you added (flow, animation, sound), which made the biggest difference to how the game feels?

๐Ÿ“ Summary

You turned a working core loop into a game that feels finished. A single state string gives it a title screen, a game-over screen and a one-line restart. Two sprite sheets give it a thruster that flickers only while you fly and puffs that play once and vanish, both timed with dt. Sound effects hang off the same event list the rules already produced, music loops quietly underneath, and everything audio-related fails gracefully. Then you tested it the way studios do: watch silently, write down what really happens, ask afterwards, and change one thing at a time.

๐ŸŽ“ Key Takeaways

  • Polish after the core loop works: flow first, then animation, then sound.
  • A state variable ("title", "playing", "game_over") decides what the loop updates and draws; a fresh new_game() makes restarts clean.
  • Looping and one-shot animations both use a dt timer that keeps its remainder; one-shots call kill() after their last frame.
  • Wrap audio in try/except pygame.error so the game runs silently without a sound device, and always offer a mute key.
  • In a playtest, observe silently, record facts, and ask open questions only at the end.
  • Change one thing at a time so you know what each change did.

๐Ÿ”ญ Looking Ahead

Your game is ready for an audience. In Share Your Game (pygbag + zip) you make three small changes so it runs in a web browser, put it on itch.io, and package the source so friends can run it on their own computers.

โ“ Common Questions

Why use strings for the states instead of something fancier?

Three states fit comfortably in plain strings, and you can print them while debugging. The one risk is a typo like "gameover", which silently never matches; keep the spellings in one comment near the variable, as the solution does.

My testers are my friends and say everything is great. Is that useful?

Their words, not much; their actions, a lot. Friends are kind, so trust what you see over what you hear. "Great!" said after dying three times in ten seconds is a note about difficulty. Asking "when did it feel unfair?" gets more honest answers than "did you like it?".

Should I fix every problem the testers found?

No. Sort them by how many players hit them and how badly, fix the top one or two, and test again. Some notes are about taste ("I'd prefer a dragon") and belong in the could column of your design doc, if anywhere.

Why do sound effects overlap without cutting each other off?

pygame's mixer plays each Sound.play() on a free channel, and there are 8 by default. If all channels are busy, play() returns None and the new sound is skipped, which in a game this size you won't notice.

The puff appears where the crate was, not where the ship is. Is that right?

Yes, on purpose: the puff is created at crate.pos, so it marks the spot that was collected. For hits it is created at the ship's position. Try swapping them and see which reads better to a tester.

Can I replace the art and sounds with my own?

Yes. Keep the same file names (or change them in load_assets() and load_sounds()), keep sprite-sheet frames the same width, and list anything you didn't make yourself, with its license, in CREDITS.md.

๐ŸŽฏ Quick Quiz

Question 1: On the game-over screen the player presses Enter. What should the code do?

Question 2: The puff sheet has 9 frames and PUFF_FPS is 24. How long does one puff last?

Question 3: Why is pygame.mixer.init() wrapped in try/except pygame.error?

Question 4: During a playtest, your tester stops on the title screen and asks, "What am I supposed to do?" What should you do?

Question 5: All three testers died within ten seconds. What is the best next step?

๐ŸŒŸ Going Further

  • A pause state: add "paused", entered and left with P. While paused, skip update_game() and draw "PAUSED" over the frozen world.
  • Crate sparkle: make crates gently pulse by scaling their image with a sine wave of the game time (as in Trigonometry for Games), scaling from a base image each frame.
  • A warning: play a short sound or flash the screen edge just before an asteroid enters, so fast rocks feel fair.
  • Read the docs: pygame.mixer and pygame.mixer.music (look up fadeout, and try fading the music on game over).
  • Thanks: the flame, puff and ship art is from Kenney's Space Shooter Extension and the sounds from Kenney's Sci-Fi Sounds, both CC0.
  • Coming up in Game Dev II: Intermediate: Screen Shake, Tweens & Juice adds screen shake and easing to effects like these, and Game States & Scenes turns string states into a scene system.
  • Coming up in Game Dev III: Advanced: Playtesting & Telemetry adds the numbers side of playtesting: logging events and comparing versions.