Skip to main content

Lesson 27: Advanced Capstone: Build, Playtest, Ship

  • Module 14: Capstone Studio
  • Lesson 27 of 27
  • โฑ๏ธ About 3 h (instruction + lab)

This is where the whole course comes together: you plan, build, playtest and ship a polished game of your own that runs on at least two of the engine-level systems you built in Game Dev III. By the end of the studio you will have a playable build, real feedback from real players, and a release checklist to finish it.

๐ŸŽฏ Learning Objectives

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

  • Plan a scoped game on one page, choosing at least two Advanced systems that do real work in it.
  • Build on a studio skeleton with scenes, a fixed timestep, an event bus, safe saves and telemetry.
  • Run a peer playtest with silent observation, think-aloud and telemetry, then triage what you learn.
  • Prepare a release build with a checklist, credits and a short devlog.
  • Evaluate your own and a peer's game against a shared rubric.

Project: your Advanced capstone game, built from studio_starter.py, with a sample finished capstone (Drone Salvage) as a reference.

In This Lesson

๐ŸŽฌ The Brief

Game studios run "game jams" and vertical-slice sprints for the same reason this capstone exists: a tight constraint forces good decisions. Your brief:

๐Ÿ“œ Capstone brief

  • Make a polished, complete game in Python with pygame-ce: a title screen, a clear goal, a way to lose or finish, and a way to play again.
  • Use at least two Advanced systems from this course, and make each one do real work (remove it and the game changes).
  • Keep one core loop that a new player understands within a minute; a full session should take roughly 3 to 10 minutes.
  • Playtest it with at least two classmates using the methods from Playtesting & Telemetry, and make at least one change because of what you saw.
  • Ship a build other people can run, with a CREDITS.md listing every asset you did not make.

Choose systems that suit your idea, not the other way round. Some pairings that work well:

Game ideaAdvanced systems it uses
Roguelike dungeon crawlerDungeons & Caves, Utility AI or Behavior Trees, Robust Saves
Atmospheric stealth game2D Lighting, Behavior Trees, Adaptive Audio & DSP
Swarm survival arenaEntity-Component-System, Steering & Flocking, Difficulty & DDA
Two-player online duelSockets, TCP & UDP, Client Prediction & Reconciliation, Lobby Server & Client
Physics puzzleBuild a Physics Engine (+ pymunk), Wave Function Collapse, Post-processing
Top-down racer or mini RTSRacing Physics or Real-time Strategy, Advanced Pathfinding, Playtesting & Telemetry

The sample capstone in the lab, Drone Salvage, is deliberately small: grab salvage, dodge drones. It combines Event Systems, Steering & Flocking, Difficulty & DDA and Playtesting & Telemetry, and saves the best score the way Robust Saves taught. Use it as a reference for structure, not as a template for your idea.

๐Ÿ“ Plan on One Page

Before any code, fill in the one-page design doc (design_doc_template.md in the lab folder). Its most important section is scope, split three ways:

  • Must have: at most five things without which the game is not done.
  • Nice to have: only after every must-have works.
  • Not in this version: ideas you are deliberately parking. Writing them down is what lets you stop thinking about them.

For each Advanced system, the doc asks "how will you know it works?" For Drone Salvage the answers were concrete: "drones never overlap in a clump" (separation), "the level rises when I play well and falls when I get hit" (DDA), "the CSV has one row per pickup and hit" (telemetry). A system you can't check is a system you can't finish.

โœ… Growth Mindset: Cutting Scope Is a Skill, Not a Failure

Almost every first capstone plan is too big, and that's normal. Professional teams cut features all the time; the skill is cutting early and on purpose instead of late and in a panic. If your "not in this version" list is longer than your "must have" list, you're doing it right. A small game that is finished and polished teaches you more, and impresses players more, than a big one that is half-built.

๐Ÿ—๏ธ The Studio Skeleton

studio_starter.py gives you the plumbing every finished game needs, already working, so your time goes into your game. It is one file to start; split it into modules once it grows.

my_capstone/
    main.py                # starts the game (from studio_starter.py)
    scenes.py              # TitleScene, PlayScene, GameOverScene (split out when main.py grows)
    systems/               # your Advanced systems, one module each
    assets/                # images, sounds, fonts (loaded once, relative to __file__)
    CREDITS.md             # every asset you did not make: source, license, files
    requirements.txt       # pygame-ce, plus moderngl / numpy / pymunk if you use them
    README.md              # how to run it, controls, known issues

Four pieces of the skeleton are worth understanding before you build on them.

Scenes

Each scene has the same three methods, as in Game States & Scenes, and Game.change() swaps them:

class TitleScene:
    def __init__(self, game):
        self.game = game

    def handle(self, event):
        if event.type == pygame.KEYDOWN and event.key == pygame.K_RETURN:
            self.game.change(PlayScene(self.game))

    def update(self, dt):
        pass

    def draw(self, screen):
        screen.fill((16, 20, 30))

A fixed timestep

The accumulator from Velocity, Acceleration & Timesteps runs the simulation in whole steps of 1/120 s, and caps a stall at 0.25 s so a dragged window can't trigger hundreds of catch-up steps:

def advance(self, dt):
    """Fixed timestep: run whole STEPs of simulation for the real time that passed."""
    self.accumulator = min(self.accumulator + dt, MAX_FRAME)
    steps = 0
    while self.accumulator >= STEP:
        self.telemetry.clock += STEP
        self.scene.update(STEP)
        self.accumulator -= STEP
        steps += 1
    return steps

Events that feed telemetry

Gameplay code publishes events; it never writes log files itself. The Game subscribes the telemetry logger to the event names your playtest needs, and the bus isolates a broken listener so a logging bug can't crash a playtest:

for name in ("run_started", "salvage", "hit", "game_over"):
    self.bus.subscribe(name, lambda _n=name, **data: self.telemetry.log(_n, **data))

# ...and in gameplay code:
g.bus.publish("hit", shield=self.shield, level=round(self.director.level, 2))

The _n=name default argument captures each name when the lambda is created; without it, every lambda would see the loop's last value.

Saves that survive crashes and upgrades

The best score and run count are saved as versioned JSON. Writing to a temporary file and then calling os.replace() means a crash in the middle of saving leaves the old save intact, and migrate() upgrades older layouts instead of rejecting them (to-do 6 in the skeleton marks where your own upgrade code goes once your save layout changes):

def write_save(path, data):
    """Write to a temporary file, then atomically replace the old save."""
    tmp = path.with_suffix(".tmp")
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f)
    os.replace(tmp, path)

๐Ÿ—“๏ธ Milestones and the Studio Session

The capstone is one project spread over the whole studio session and the time after it. Four milestones keep it moving; each ends with something you can show.

MilestoneDone when
1. Vertical sliceThe core loop is playable start to finish, with placeholder art. Ugly is fine.
2. Systems integratedBoth (or all) Advanced systems do real work, and you can show how you know each works.
3. Content and polishFeedback (sound, juice, clear HUD), tuning from the playtest, no known crash.
4. ReleaseA build someone else ran successfully, plus README.md, CREDITS.md and a short devlog.

A suggested plan for the three-hour studio session (your instructor may adjust it):

TimeActivity
0:00 โ€“ 0:20Pitch round: one minute each, design doc on screen. Peers ask "how will you know it works?"
0:20 โ€“ 1:30Build toward Milestone 1 on the studio skeleton. Commit (or copy) a working version every 20 minutes.
1:30 โ€“ 2:15Peer playtest rounds (next section): each game gets at least two testers.
2:15 โ€“ 2:45Triage and fix the top issue from your playtest.
2:45 โ€“ 3:00Show and tell, and write the plan for Milestones 2โ€“4.

๐Ÿงช The Peer Playtest

Run the protocol from Playtesting & Telemetry, compressed to fit a class:

  1. Before: write your one question and the one-sentence task you'll say to every tester. Make sure your telemetry logs the events that answer it.
  2. During (8 minutes per tester): say the task, then stay silent. Ask the tester to think aloud. Note the time and what they did whenever they hesitate, die or look confused.
  3. After (2 minutes): ask "what was the hardest part?" and "what would you change?", then optionally the ten SUS questions.
  4. Swap roles and repeat, so every game gets at least two testers.

Triage: what to fix first

You will collect more problems than you can fix. Sort them by how many testers hit each one and how badly it hurt:

Most testers hit itOne tester hit it
Blocks progress or crashesFix nowFix soon (and try to reproduce it)
Annoying but playableFix before releaseNote it; revisit after more tests

Change one thing at a time, then test again. If you change five things and the game gets better, you don't know which change did it.

โœ… Growth Mindset: Feedback Is About the Game, Not About You

Hearing "I didn't get it" about something you spent hours on stings. Every designer feels that. Write the comment down exactly as it was said, thank the tester, and decide what to do with it later, calmly. The testers who confuse you most are giving you the most valuable information.

๐Ÿš€ Ship It

A game that only runs on your machine isn't shipped. Use what you learned in Game Dev II:

  • Desktop build: Building Executables covers PyInstaller, __file__-relative asset paths and a user folder for saves. It is the most direct route for games that use moderngl, numpy, pymunk or sockets; test the build on a machine that doesn't have your Python setup.
  • Web build: Share Your Game (pygbag + zip) from Game Dev I puts pure pygame-ce games in the browser. Check that every library your game uses works there before you promise a web version.
  • Release: Launch & Live Ops covers the itch.io page, uploading builds with butler, version numbers and patch notes.

Release checklist

  • Someone else ran the build on their own machine, start to finish.
  • No debug keys, cheats or test prints left on by accident; the window title is the game's name.
  • Saves and logs go to a sensible folder, and a missing or damaged save doesn't crash the game.
  • README.md: how to run it, controls, known issues, and the Advanced systems it uses.
  • CREDITS.md: every asset you didn't make, with source, license and files. Kenney publishes many game assets under CC0 1.0, and thanking the creator is good practice even when the license doesn't require it.
  • A short devlog: what you set out to make, what you cut, what the playtest changed.

๐Ÿ† The Rubric

You, a peer and your instructor score the game against the same rubric, 4 points per row, 20 in total. Read it before you start: it is also a checklist.

Criterion4: Excellent3: Solid2: Developing1: Beginning
Core loop and polishClear goal, satisfying feedback, restart, no rough edgesComplete loop, a few rough edgesPlayable but unclear or unfinishedNot yet playable start to finish
Advanced systemsTwo or more, each essential and verifiedTwo, working, one lightly usedOne working, one partialNone working yet
Code and architectureScenes, dt/fixed step, events; readable modules; no crashMostly organized; rare issuesWorks but tangled or fragileHard to follow or unstable
Playtest and iterationQuestion, telemetry, notes, triage; changes justified by evidencePlaytest run and one change madePlaytest run, no change yetNo playtest yet
Shipping and documentationBuild runs elsewhere; README, CREDITS, devlogBuild runs; docs mostly completeRuns only from sourceNot yet shareable

๐Ÿ‹๏ธ Practice Exercise: Your Capstone Studio

Objective: turn the studio skeleton into the vertical slice of your own game, playtest it with classmates, and leave the session with a fix made and a plan for the remaining milestones.

Time: the full studio session (about 3 hours), then the remaining milestones at home. Starter files: studio_starter.py and design_doc_template.md (your instructor has them). The skeleton already runs: a title screen, a placeholder play scene, game over, saves and telemetry. Its numbered to-do comments mark where your game goes.

  1. Fill in the one-page design doc, including two Advanced systems and how you'll verify each. (โ‰ˆ 20 min)
  2. Run the skeleton, then give it your game's name (to-do 1) and create your game objects in PlayScene (to-do 2). (โ‰ˆ 15 min)
  3. Build your core loop in PlayScene.update (to-do 3) and draw it (to-do 4), with every timer in seconds. Replace the SPACE placeholder with your real win or lose rule. (โ‰ˆ 50 min)
  4. Publish events for everything your playtest needs and add their names to the telemetry list (to-do 5). (โ‰ˆ 10 min)
  5. Run the peer playtest: at least two testers, silent observation, notes, the telemetry CSV. (โ‰ˆ 45 min)
  6. Triage, fix the top issue, and test the fix. (โ‰ˆ 30 min)
  7. Write your plan for Milestones 2โ€“4 in the design doc. (โ‰ˆ 10 min)

You are done (for the session) when:

  • your core loop is playable from the title screen to game over and back;
  • at least one Advanced system is working and you know how you will verify the second;
  • two classmates have played it, and you have their notes and a telemetry CSV;
  • you made one change because of the playtest, and can say why;
  • your design doc lists dates for the remaining milestones.
๐Ÿ’ก Hint

If you are stuck on what to build first, build the thing the player does every few seconds, with rectangles and circles, and nothing else. Open the sample solution below and find Drone Salvage's PlayScene.update: notice that it is just "read input, move things, check collisions, publish events". Your first version can be that simple.

โœ… Example Solution

Your capstone is your own game, so there is no single answer. This is the lab's sample capstone, Drone Salvage, built on the same skeleton with four Advanced systems. 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 it automatically and do nothing when you run it yourself.

"""Drone Salvage: Advanced Lesson 27 sample capstone (solution).

A small but complete game built the way the capstone brief asks: one core
loop (grab salvage, dodge drones), scenes, a fixed-timestep update, and four
Advanced systems working together:
  - an event bus that isolates failing listeners (Event Systems),
  - steering drones that seek the player and keep apart (Steering & Flocking),
  - dynamic difficulty in levels per second (Difficulty & DDA),
  - telemetry saved as CSV for playtests (Playtesting & Telemetry),
plus a versioned save file written atomically (Robust Saves).

Keys: ENTER starts, WASD or arrows fly, ESC returns to the title.
Close the window to quit.
"""
import csv
import json
import os
import random
import time
from collections import defaultdict, deque
from pathlib import Path

import pygame


WIDTH, HEIGHT = 960, 600
STEP = 1 / 120                  # fixed simulation step, seconds
MAX_FRAME = 0.25                # never simulate more than this per frame (after a stall)
SAVE_PATH = Path(__file__).parent / "drone_salvage_save.json"
LOG_DIR = Path(__file__).parent / "playtest_logs"
SAVE_VERSION = 2
PLAYER_SPEED = 260              # px/s
INVULNERABLE = 1.0              # seconds after a hit
TEXT = (235, 235, 235)
ACCENT = (120, 210, 255)


def clamp(v, lo, hi):
    return max(lo, min(hi, v))


# --- events (Event Systems) -----------------------------------------------------------------
class EventBus:
    def __init__(self):
        self.handlers = defaultdict(list)
        self.errors = 0

    def subscribe(self, name, handler):
        self.handlers[name].append(handler)

    def publish(self, name, **data):
        """Call every listener; one broken listener must not stop the others (or the game)."""
        for handler in list(self.handlers[name]):
            try:
                handler(**data)
            except Exception as exc:        # isolate listener bugs, but report them
                self.errors += 1
                print(f"listener error on {name!r}: {exc!r}")


# --- saves (Robust Saves) ----------------------------------------------------------------------
DEFAULT_SAVE = {"version": SAVE_VERSION, "best_score": 0, "runs": 0}


def migrate(data):
    """Upgrade old save layouts one version at a time."""
    if data.get("version", 1) == 1:              # version 1 was {"best": n}
        data = {"version": 2, "best_score": int(data.get("best", 0)), "runs": 0}
    return data


def load_save(path):
    """Missing or damaged saves fall back to defaults instead of crashing the game."""
    try:
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data, dict):           # valid JSON, but not a save (a list, a number...)
            return dict(DEFAULT_SAVE)
        data = migrate(data)
    except (OSError, ValueError, TypeError):
        return dict(DEFAULT_SAVE)
    if not (isinstance(data.get("best_score"), int) and isinstance(data.get("runs"), int)):
        return dict(DEFAULT_SAVE)
    return data


def write_save(path, data):
    """Write to a temporary file, then atomically replace the old save."""
    tmp = path.with_suffix(".tmp")
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f)
    os.replace(tmp, path)


# --- telemetry (Playtesting & Telemetry) --------------------------------------------------------
class Telemetry:
    def __init__(self, tester="T01"):
        self.session = time.strftime("%Y%m%d-%H%M%S")
        self.tester = tester
        self.clock = 0.0
        self.rows = []

    def log(self, kind, **data):
        self.rows.append({"t": round(self.clock, 3), "session": self.session, "tester": self.tester,
                          "kind": kind, "data": json.dumps(data, sort_keys=True)})

    def save(self, folder):
        folder.mkdir(parents=True, exist_ok=True)
        path = folder / f"session_{self.session}_{self.tester}.csv"
        with open(path, "w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=["t", "session", "tester", "kind", "data"])
            writer.writeheader()
            writer.writerows(self.rows)
        return path


# --- difficulty (Difficulty & DDA) -------------------------------------------------------------------
class Director:
    def __init__(self, level=3.0, target=0.6, gain=1.5, max_rate=0.4):
        self.level, self.target, self.gain, self.max_rate = level, target, gain, max_rate
        self.recent = deque()                   # (time, +1 salvage / -1 hit)
        self.clock = 0.0

    def record(self, value):
        self.recent.append((self.clock, value))

    def performance(self):
        while self.recent and self.clock - self.recent[0][0] > 20.0:
            self.recent.popleft()
        good = sum(1 for _, v in self.recent if v > 0)
        bad = sum(1 for _, v in self.recent if v < 0)
        return clamp(0.5 + 0.05 * good - 0.2 * bad, 0.0, 1.0)

    def update(self, dt):
        self.clock += dt
        error = self.performance() - self.target
        rate = clamp(self.gain * error, -self.max_rate, self.max_rate) if abs(error) > 0.05 else 0.0
        self.level = clamp(self.level + rate * dt, 1.0, 10.0)

    def params(self):
        mult = self.level / 5
        return {"drone_speed": 120 * mult ** 0.8, "drone_count": 2 + round(self.level),
                "salvage_value": max(1, round(10 * mult ** -0.3))}


# --- steering (Steering & Flocking) --------------------------------------------------------------------
def seek(pos, vel, target, max_speed, max_force):
    """Steering force toward target, limited to max_force (px/s^2)."""
    desired = target - pos
    if desired.length_squared() == 0:
        return pygame.Vector2()
    force = desired.normalize() * max_speed - vel
    if force.length() > max_force:
        force.scale_to_length(max_force)
    return force


def separation(pos, others, radius):
    """Push away from neighbors closer than radius; stronger when closer."""
    push = pygame.Vector2()
    for other in others:
        offset = pos - other
        d = offset.length()
        if 0 < d < radius:
            push += offset / d * (radius - d) / radius
    return push


class Drone:
    def __init__(self, pos):
        self.pos = pygame.Vector2(pos)
        self.vel = pygame.Vector2()

    def update(self, target, neighbors, speed, dt):
        force = seek(self.pos, self.vel, target, speed, 400) + separation(self.pos, neighbors, 60) * 600
        self.vel += force * dt
        if self.vel.length() > speed:
            self.vel.scale_to_length(speed)
        self.pos += self.vel * dt


# --- scenes (Game States & Scenes) --------------------------------------------------------------------------
class TitleScene:
    def __init__(self, game):
        self.game = game

    def handle(self, event):
        if event.type == pygame.KEYDOWN and event.key == pygame.K_RETURN:
            self.game.change(PlayScene(self.game))

    def update(self, dt):
        pass

    def draw(self, screen):
        screen.fill((16, 20, 30))
        g = self.game
        title = g.big.render("DRONE SALVAGE", True, ACCENT)
        screen.blit(title, title.get_rect(center=(WIDTH / 2, HEIGHT / 2 - 40)))
        info = g.font.render(f"ENTER to start   best {g.save['best_score']}   runs {g.save['runs']}", True, TEXT)
        screen.blit(info, info.get_rect(center=(WIDTH / 2, HEIGHT / 2 + 20)))


class PlayScene:
    def __init__(self, game, seed=None):
        self.game = game
        self.rng = random.Random(seed)
        self.player = pygame.Vector2(WIDTH / 2, HEIGHT / 2)
        self.move = pygame.Vector2()
        self.shield = 3
        self.invulnerable = 0.0
        self.score = 0
        self.drones = []
        self.salvage = [self.random_point() for _ in range(4)]
        self.director = Director()
        game.bus.publish("run_started")

    def random_point(self):
        return pygame.Vector2(self.rng.uniform(40, WIDTH - 40), self.rng.uniform(60, HEIGHT - 40))

    def handle(self, event):
        if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
            self.game.change(TitleScene(self.game))

    def update(self, dt):
        g = self.game
        self.director.update(dt)
        p = self.director.params()
        held = g.held
        self.move.update((pygame.K_RIGHT in held or pygame.K_d in held) - (pygame.K_LEFT in held or pygame.K_a in held),
                         (pygame.K_DOWN in held or pygame.K_s in held) - (pygame.K_UP in held or pygame.K_w in held))
        if self.move.length_squared() > 0:
            self.player += self.move.normalize() * PLAYER_SPEED * dt
        self.player.x = clamp(self.player.x, 12, WIDTH - 12)
        self.player.y = clamp(self.player.y, 12, HEIGHT - 12)

        while len(self.drones) < p["drone_count"]:
            edge = self.rng.choice([(0, self.rng.uniform(0, HEIGHT)), (WIDTH, self.rng.uniform(0, HEIGHT))])
            self.drones.append(Drone(edge))
        positions = [d.pos for d in self.drones]
        for d in self.drones:
            d.update(self.player, positions, p["drone_speed"], dt)

        for s in list(self.salvage):
            if s.distance_to(self.player) < 22:
                self.salvage.remove(s)
                self.salvage.append(self.random_point())
                self.score += p["salvage_value"]
                self.director.record(+1)
                g.bus.publish("salvage", value=p["salvage_value"], x=round(s.x), y=round(s.y))

        self.invulnerable = max(0.0, self.invulnerable - dt)
        if self.invulnerable == 0 and any(d.pos.distance_to(self.player) < 24 for d in self.drones):
            self.shield -= 1
            self.invulnerable = INVULNERABLE
            self.director.record(-1)
            g.bus.publish("hit", shield=self.shield, level=round(self.director.level, 2))
            if self.shield <= 0:
                g.bus.publish("game_over", score=self.score)
                g.change(GameOverScene(g, self.score))

    def draw(self, screen):
        g = self.game
        screen.fill((12, 16, 26))
        for s in self.salvage:
            pygame.draw.rect(screen, (240, 200, 80), (s.x - 7, s.y - 7, 14, 14), border_radius=3)
        for d in self.drones:
            pygame.draw.circle(screen, (230, 80, 90), d.pos, 11)
        blink = self.invulnerable > 0 and int(self.invulnerable * 10) % 2 == 0
        if not blink:
            pygame.draw.circle(screen, ACCENT, self.player, 12)
        hud = g.font.render(f"score {self.score}   shield {self.shield}   level {self.director.level:.1f}", True, TEXT)
        screen.blit(hud, (12, 10))


class GameOverScene:
    def __init__(self, game, score):
        self.game = game
        self.score = score
        game.save["runs"] += 1
        self.new_best = score > game.save["best_score"]
        if self.new_best:
            game.save["best_score"] = score
        write_save(game.save_path, game.save)

    def handle(self, event):
        if event.type == pygame.KEYDOWN and event.key == pygame.K_RETURN:
            self.game.change(PlayScene(self.game))

    def update(self, dt):
        pass

    def draw(self, screen):
        g = self.game
        screen.fill((26, 14, 20))
        lines = ["GAME OVER", f"score {self.score}" + ("   NEW BEST!" if self.new_best else ""), "ENTER to fly again"]
        for i, text in enumerate(lines):
            img = (g.big if i == 0 else g.font).render(text, True, TEXT)
            screen.blit(img, img.get_rect(center=(WIDTH / 2, HEIGHT / 2 - 50 + i * 50)))


class Game:
    def __init__(self, save_path=SAVE_PATH):
        self.bus = EventBus()
        self.telemetry = Telemetry()
        self.save_path = save_path
        self.save = load_save(save_path)
        self.held = set()
        self.font = pygame.font.Font(None, 28)      # fonts created once, shared by every scene
        self.big = pygame.font.Font(None, 72)
        self.accumulator = 0.0
        for name in ("run_started", "salvage", "hit", "game_over"):
            self.bus.subscribe(name, lambda _n=name, **data: self.telemetry.log(_n, **data))
        self.scene = TitleScene(self)

    def change(self, scene):
        self.scene = scene

    def advance(self, dt):
        """Fixed timestep: run whole STEPs of simulation for the real time that passed."""
        self.accumulator = min(self.accumulator + dt, MAX_FRAME)
        steps = 0
        while self.accumulator >= STEP:
            self.telemetry.clock += STEP
            self.scene.update(STEP)
            self.accumulator -= STEP
            steps += 1
        return steps


def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Drone Salvage")
    clock = pygame.time.Clock()
    game = Game()

    running = True
    while running:
        dt = clock.tick(60) / 1000
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                game.held.add(event.key)
            elif event.type == pygame.KEYUP:
                game.held.discard(event.key)
            game.scene.handle(event)
        game.advance(dt)
        game.scene.draw(screen)
        pygame.display.flip()

    pygame.quit()
    log = game.telemetry.save(LOG_DIR)
    print(f"Telemetry: {len(game.telemetry.rows)} events in {log.name}")
    print(f"Best score: {game.save['best_score']}  runs: {game.save['runs']}")
    print("Studio closed cleanly.")


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 did your testers do that surprised you most? What did you change because of it?
  2. Which item did you move to "not in this version", and how did that feel?
  3. Look back at the first lesson of Game Dev I. What can you build now that you couldn't imagine then?

๐Ÿ“ Summary

The capstone asks for one thing above all: a finished, polished game. You planned it on one page with a ruthless scope list and a way to verify each Advanced system. You built on a skeleton that already handles scenes, a fixed timestep, events, telemetry and crash-safe saves, so your effort went into your core loop. You ran a peer playtest the way a studio does, triaged what you learned, changed one thing at a time, and you now have a milestone plan and a checklist for shipping.

๐ŸŽ“ Key Takeaways

  • Choose Advanced systems that serve your idea, and decide up front how you'll know each works.
  • Scope in three lists: must have, nice to have, not in this version.
  • Build the vertical slice first: the core loop, start to finish, with placeholder art.
  • Gameplay publishes events; telemetry, audio and UI subscribe to them.
  • Playtest silently, triage by frequency and severity, change one thing at a time.
  • Shipped means someone else ran it: build, README, CREDITS, devlog.

๐Ÿ”ญ Looking Ahead

This is the final lesson of the three-course series. Finish your remaining milestones, publish your game, and share the link: then pick a system from this course and take it further in your next project.

โ“ Common Questions

Can I reuse one of my labs as my capstone?

You can start from one, as long as the capstone becomes a complete game with a title screen, a goal, an ending and a restart, and it uses at least two Advanced systems doing real work. Tiny RTS or Lap Racer grown into a full game would qualify.

Can I work in a team?

If your instructor allows it, yes, with a bigger scope and a clear split of who owns which system. Each team member should be able to explain every system in the game.

Do I have to use the studio skeleton?

No, but your game needs the same things it provides: scenes, frame-rate independent updates, safe saves and a way to log playtest events. The skeleton just saves you the time.

What if my Advanced system is too hard to finish?

Cut it down, not out: a simpler version that works (flocking with only separation and seek, lighting with one light) beats an ambitious one that doesn't. If it truly can't work in time, swap to another system and note the change in your devlog.

Can I use art and sound I found online?

Only with a license that allows it, and every item goes in CREDITS.md. CC0 assets (such as many packs on kenney.nl) are the simplest choice. Code-drawn shapes are always fine.

๐ŸŽฏ Quick Quiz

Question 1: What does the capstone brief ask for about Advanced systems?

Question 2: Why does the design doc have a "not in this version" list?

Question 3: During the peer playtest, your tester can't find the exit for two minutes. What should you do?

Question 4: Why does the skeleton write the save to a temporary file and then call os.replace()?

Question 5: After your playtest you have a list of issues. Which should you fix first?

๐ŸŒŸ Going Further

  • A second playtest round: after your fix, test with two new players and compare the telemetry from both rounds using the tools from Playtesting & Telemetry.
  • Profile before you polish: run your game under cProfile as in Profiling & Performance and fix the slowest function you find, if any.
  • Accessibility pass: add at least one assist (Difficulty & DDA) and remappable keys.
  • Game jam: enter a public game jam on itch.io with a new idea built on your skeleton; a deadline is a great teacher.
  • Assets: browse Kenney's CC0 asset packs for art and sound that you can use freely (list them in CREDITS.md anyway, with thanks).