Lesson 12: Capstone Part 1: Design & Core Loop
Everything you have learned so far, from the game loop to sprite animation, now comes together in one game of your own. In this lesson you plan Space Salvage on a single page, then build the heart of it: a ship you fly, salvage you collect, asteroids you dodge and a score that tells you how well you did.
đ¯ Learning Objectives
By the end of this lesson, you will be able to:
- Write a one-page design doc with a pitch, a core loop, controls, win and lose rules, and a scope list split into must, should and could.
- Explain how each verb of a core loop (move, collect, dodge, score) maps to code you already know.
- Build frame-rate-independent movement, circle-based pickups and hits, and a spawn timer that speeds up over time.
- Organize a game so its tuning numbers sit at the top and everything that changes during one run lives in one dictionary.
- Debug a bigger program by building and testing one verb at a time.
Project: Space Salvage, Part 1: a playable core loop you will polish, playtest and publish in the next two lessons.
In This Lesson
đ Your Capstone: Space Salvage
Think of the arcade games that hooked people for hours with almost no rules: Asteroids, Pac-Man, the snake game on an old phone. Each one is a tiny loop you can explain in one breath. Space Salvage is that kind of game:
You pilot a salvage ship through a debris field. Grab the floating crates for points and dodge the asteroids. Three hits and your run is over. How long can you last, and how much can you haul?
A capstone is the project that ties a course together. Nothing in Space Salvage is new: every piece is something you have already built in a smaller program. The new skill is putting pieces together and deciding what to build first.
| You learned it in | Space Salvage uses it for |
|---|---|
| The Game Loop | The main loop and dt in seconds |
| Drawing Shapes & Surfaces | The star field, text, and a color clamp for computed shades |
| Keyboard, Mouse & Gamepad | Arrow keys and WASD with pygame.key.get_pressed() |
| Coordinate Systems | Y-down screen space, spawning just outside the edges |
| Vectors with pygame.math.Vector2 | Float positions, normalized directions, velocities in px/s |
| Trigonometry for Games | Turning the ship to face where it flies |
| Images & Sprite Classes | Sprite subclasses, Groups, rotating images |
| Collision Detection | Circle collisions for pickups and hits |
| Sprite Sheets, Sound & Music, Sprite Animation | Part 2: thruster flames, puffs, sound effects and music |
The capstone runs over three lessons. Today you design it and build the core loop. Next, you polish and playtest it. Last, you put it on the web so friends can play it with one click.
đ¨ About the art
The starter includes a few sprites from a free pack by Kenney (Space Shooter Extension, kenney.nl), released under CC0: you may use them in any project, even one you sell, without asking. The lab folder's CREDITS.md lists every file and where it came from. The stars and text are drawn in code. If an image file goes missing, the starter draws a pink circle instead of crashing, so you always get a clue rather than a traceback.
đ The One-Page Design Doc
Before a builder pours concrete, someone draws the house. A design doc is that drawing for a game. Big studios write long ones; for a one-person arcade game, one page is the right size. Its real job is to help you decide what not to build yet.
Fill in these seven headings. Keep each to a few lines.
- Pitch: the game in one or two sentences, like the box above.
- Core loop: the three or four verbs the player repeats every few seconds.
- Controls: every key or button, and what it does.
- Win and lose: how a run ends and what counts as doing well.
- Feel: three words for how it should feel to play (for example "tense, quick, satisfying").
- Scope: a list sorted into must (the game is broken without it), should (makes it good) and could (nice if there is time).
- Questions for playtesters: what you are not sure about yet.
Here is the design doc for Space Salvage. The lab folder has the same headings as design_doc_template.md for your own version.
đī¸ Space Salvage: design doc
- Pitch: Fly a salvage ship, grab crates for points, dodge asteroids. Three hits and the run ends.
- Core loop: move â collect â dodge â score (and the danger slowly rises).
- Controls: arrow keys or WASD to fly; Esc quits.
- Win and lose: no win screen; you chase a high score. Lose your third life and the run is over.
- Feel: tense, quick, fair.
- Must: flying, crates that score, asteroids that cost lives, a game-over. Should: rising difficulty, a short safety blink after a hit, a title screen and restart, sounds. Could: power-ups, a saved high score, a boss rock.
- Questions: Is 3 lives enough? Is the ship too fast to control? When does it get too hard?
The core loop is worth drawing, because every line of code you write this lesson belongs to one of its boxes:
đĄ Why this matters
Unfinished games almost never fail for lack of ideas; they fail because the idea list keeps growing. The must/should/could list lets you say "yes, later" to a great idea without derailing today's work. When Part 1 is done, the whole must column works, and that is already a real game.
đ§ A Tour of the Starter
Open space_salvage_starter.py and the assets folder next to it. Run it: you see stars, your ship and three crates, but the ship cannot move, no asteroids arrive yet and nothing can be collected. The to-dos fix each of those. The starter is organized top to bottom in the same order every time you look for something:
- Tuning constants in CAPITALS: speeds, lives, timers. When a playtester says "too hard", this is where you go.
- Loading helpers:
load_image()andload_assets(), which load every image once, after the window exists, becauseconvert_alpha()needs a display. - Sprite classes:
Player,AsteroidandSalvage, each with aVector2position and anFRectthat follows it. - Game rules:
spawn_asteroid(),fill_salvage(),spawn_gap(),new_game()andupdate_game(). - Drawing: stars, HUD and world.
main(): the game loop, which just calls the pieces above.
Everything that changes during one run lives in a single dictionary, made by new_game():
def new_game(assets, rng):
"""Everything that changes during one run, in one dictionary."""
game = {
"player": Player(assets["ship"], (WIDTH / 2, HEIGHT / 2)),
"asteroids": pygame.sprite.Group(),
"salvage": pygame.sprite.Group(),
"score": 0,
"lives": START_LIVES,
"time": 0.0,
"spawn_timer": FIRST_SPAWN_GAP,
}
fill_salvage(game, assets, rng)
return game
Why one dictionary instead of a dozen loose variables? Because in Part 2 you will add a restart key, and restarting then takes one line: game = new_game(assets, rng). Nothing from the old run can leak into the new one.
The random numbers come from rng = random.Random(), a random generator object the game owns. Every spawn function takes rng as a parameter, so a test can pass random.Random(1) and get the same crates and asteroids every time.
Each frame, main() does the same three jobs you met in the first lesson; update_game() is the "update" box, broken into the core-loop verbs:
đšī¸ Move: Flying the Ship
Movement is three small steps you have done before, joined up. First, turn the held keys into a direction. Subtracting one key from its opposite gives -1, 0 or 1, and holding both cancels out:
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)
Second, move in pixels per second. Holding right and down gives (1, 1), which is about 1.41 long, so without normalizing, diagonal flying would be 41% faster. normalize() fixes that, but it raises an error on a zero-length vector, so check first:
if direction.length_squared() > 0:
direction = direction.normalize() # diagonals are not faster
self.pos += direction * PLAYER_SPEED * dt
self.angle = pygame.Vector2(0, -1).angle_to(direction)
The last line points the ship. The ship image faces up, which is Vector2(0, -1) on a Y-down screen. angle_to() measures the angle from "up" to the flight direction in degrees; because y points down, a positive angle looks clockwise on screen. pygame.transform.rotate() turns images counterclockwise for positive angles, so the ship is drawn with rotate(self.base_image, -self.angle). Rotating from the untouched base_image every frame (never from the last rotated image) keeps the art sharp.
Third, keep the ship on the screen by clamping each coordinate between the edges:
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))
đŽ Predict, then run
Before you add the normalize() line, predict: will you notice the diagonal speed-up? Fly corner to corner with and without it and count the seconds. Then keep it in.
đĨ Collect and Dodge
The ship is round-ish, the asteroids are round, and the crates are small squares. Rectangles would make a hit land when a corner of the ship's image grazes a corner of an asteroid's image, even with empty space between the shapes. That feels unfair. Circle collision fixes it: each sprite gets a radius, and pygame.sprite.collide_circle checks the distance between centers. The player's radius (20) is a little smaller than its art, which errs on the player's side.
Collect. spritecollide() returns every sprite in a group that touches the player. Passing True as the third argument (dokill) also removes them from the group, so a crate can only be collected once:
for crate in pygame.sprite.spritecollide(player, game["salvage"], True,
pygame.sprite.collide_circle):
game["score"] += SALVAGE_POINTS
events.append("collect")
fill_salvage(game, assets, rng) # top the crates back up to 3
Dodge. For asteroids you only need to know whether any is touching, so spritecollideany() is enough. It returns the first sprite it finds, or None:
if player.invulnerable <= 0 and pygame.sprite.spritecollideany(
player, game["asteroids"], pygame.sprite.collide_circle):
game["lives"] -= 1
player.invulnerable = INVULNERABLE_TIME # 1.5 seconds of safety
events.append("hit")
if game["lives"] <= 0:
events.append("game_over")
Why invulnerable? An asteroid overlaps the ship for many frames in a row. Without a safety window, one touch would cost all three lives in a twentieth of a second. After a hit, the ship blinks for 1.5 seconds and cannot be hit again; Player.update() counts the timer down with max(0.0, self.invulnerable - dt), and draw() skips every other tenth of a second so it flashes.
Notice that update_game() returns a list of what happened ("collect", "hit", "game_over"). Part 1 only checks for "game_over", but in Part 2 this list is exactly where sounds and effects plug in, without touching the rules.
â Growth Mindset: Big Programs Are Small Programs in a Row
Three hundred lines can feel like a wall. It isn't one; it is a row of ten-line pieces you already know how to write. If your game doesn't work yet, don't read the whole file hunting for the bug. Ask which verb is broken (move, collect or dodge), go to that one function, and add a print() of the values it uses. Build one verb, run it, see it work, then start the next. That habit is how professionals finish big games too.
â˛ī¸ A Spawn Timer That Speeds Up
New asteroids arrive on a timer. It works like the animation timer from Sprite Animation: count down by dt, and when the timer runs out, add the gap back instead of resetting to a fixed value, so leftover time is never lost. A while loop (not if) also copes with a long frame that should have spawned two asteroids.
game["spawn_timer"] -= dt
while game["spawn_timer"] <= 0:
game["asteroids"].add(spawn_asteroid(assets, rng))
game["spawn_timer"] += spawn_gap(game["time"])
The gap shrinks the longer you survive, down to a floor, so the danger rises without ever becoming impossible:
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)
You can check the numbers without opening a window. This complete program prints the gap at a few moments in a run:
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 # seconds less per second survived
def spawn_gap(elapsed):
return max(MIN_SPAWN_GAP, FIRST_SPAWN_GAP - GAP_SHRINK * elapsed)
for seconds in (0, 15, 30, 45, 60, 90):
gap = spawn_gap(seconds)
print(f"After {seconds:3} s: one asteroid every {gap:.2f} s ({1 / gap:.1f} per second)")
It prints a gap of 1.20 s at the start, 0.75 s after 30 seconds, and 0.35 s (the floor) from about 57 seconds on. Change GAP_SHRINK and run it again: that one number decides how fast the game gets hard. Each asteroid is aimed at a random point in the middle of the screen from just outside a random edge, and removes itself (self.kill()) once it is well past the far side, so the group never fills up with rocks nobody can see.
đĄ Why this matters
A difficulty ramp is what turns "a thing that moves" into a game with tension. Keeping it in one small function with named constants means a playtest note like "it gets hard too fast" becomes a one-number change, which is exactly what you will do in Part 2.
đī¸ Practice Exercise: Space Salvage, Part 1
Objective: write your one-page design doc, then turn the starter into a playable game where you fly, collect crates for points and dodge asteroids until your third hit.
Time: about 55 minutes. Starter files: space_salvage_starter.py, the assets folder and design_doc_template.md (your instructor has them; keep assets next to the .py file). The numbered to-do comments in the starter (To-do 1 to To-do 7) match steps 2 to 8.
- Fill in
design_doc_template.mdfor Space Salvage, or for your own twist on it (a bee collecting pollen and dodging birds works just as well). Keep it to one page. (â 10 min) - To-do 1: finish
read_direction()for the arrow keys and WASD. (â 5 min) - To-do 2: in
Player.update(), normalize the direction, move byPLAYER_SPEED * dtand setself.angle. Fly around. (â 5 min) - To-do 3: clamp the ship to the screen. (â 3 min)
- To-do 4: count the spawn timer down and add asteroids, keeping the remainder. (â 7 min)
- To-do 5: collect crates with
spritecollide(..., True, pygame.sprite.collide_circle)and add to the score. (â 7 min) - To-do 6: detect asteroid hits with
spritecollideany(), lose a life, start the invulnerable blink, and report"game_over"on the last life. (â 10 min) - To-do 7: make
spawn_gap()shrink over time down toMIN_SPAWN_GAP. Play three runs and write your scores on your design doc. (â 8 min)
You are done when:
- your design doc fits on one page and has a must/should/could list;
- the ship flies in all eight directions at the same speed, faces where it flies and cannot leave the screen;
- touching a crate adds 10 to the score and a new crate appears somewhere else;
- an asteroid costs exactly one life, the ship blinks for a moment, and the third hit shows GAME OVER;
- asteroids clearly arrive faster after a minute than at the start.
đĄ Hint
Work on one to-do at a time and run the game after each. If all three lives vanish at once, the hit code is running every frame while the asteroid overlaps: check that you set player.invulnerable and test player.invulnerable <= 0 first. If asteroids never appear, print game["spawn_timer"] once per frame: it should fall toward 0, then jump back up. If normalize() raises "Can't normalize Vector of length zero", the length_squared() > 0 check is missing.
â 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 1: Intro Lesson 12 capstone core loop (solution).
Fly the salvage ship with the arrow keys or WASD, collect the crates for
points, and dodge the asteroids. Three hits and the run is over. In
Part 2 you add animation, sound, a title screen and a restart.
Art: Kenney (www.kenney.nl), CC0. See CREDITS.md.
"""
import random
from pathlib import Path
import pygame
# --- Tuning: every number you might want to change lives here ----------------
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
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 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)],
}
class Player(pygame.sprite.Sprite):
def __init__(self, image, 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
def update(self, dt, direction):
if direction.length_squared() > 0:
direction = direction.normalize() # diagonals are not faster
self.pos += direction * PLAYER_SPEED * dt
self.angle = pygame.Vector2(0, -1).angle_to(direction)
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
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
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"], (WIDTH / 2, HEIGHT / 2)),
"asteroids": pygame.sprite.Group(),
"salvage": 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 this frame."""
events = []
game["time"] += dt
player = game["player"]
player.update(dt, direction)
game["asteroids"].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
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
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)
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()
rng = random.Random()
stars = make_stars(rng)
game = new_game(assets, rng)
game_over = 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 and event.key == pygame.K_ESCAPE:
running = False
update_stars(stars, dt)
if not game_over:
happened = update_game(game, dt, read_direction(pygame.key.get_pressed()), assets, rng)
if "game_over" in happened:
game_over = True
draw_world(screen, game, stars, fonts, assets)
if game_over:
draw_centered(screen, fonts["big"], "GAME OVER", HEIGHT / 2 - 30, ACCENT_COLOR)
draw_centered(screen, fonts["small"], "Close the window to quit", HEIGHT / 2 + 30)
pygame.display.flip()
pygame.quit()
print(f"Space Salvage closed. Score: {game['score']}, lives left: {game['lives']}.")
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:
- Which idea did you move to the could column, and how did it feel to say "later" to it?
- Which earlier lesson did you lean on most today? Was there a skill you had to look up again?
- After three runs, what is the one thing you most want a playtester to tell you about your game?
đ Summary
You started your capstone the way real projects start: with a one-page plan that names the core loop and sorts ideas into must, should and could. Then you built the must column. The ship turns key presses into a normalized direction and flies in pixels per second, facing where it goes. Circle collisions let it scoop up crates and take hits fairly, a short invulnerable blink stops one asteroid from costing every life, and a spawn timer that keeps its remainder brings rocks faster and faster. Everything that changes in a run lives in one dictionary, and every number you might tune sits at the top of the file.
đ Key Takeaways
- A one-page design doc (pitch, core loop, controls, win/lose, feel, scope, questions) keeps a project finishable.
- Build the core loop first, one verb at a time, and run the game after each step.
- Normalize the direction before moving, or diagonal movement is about 41% faster.
- Circle collisions with a slightly generous radius feel fairer than image rectangles for round objects.
- After a hit, a short invulnerable window turns "touching for many frames" into one lost life.
- Timers subtract
dtand add the gap back, keeping the remainder; amax()floor keeps a difficulty ramp possible to survive.
đ Looking Ahead
Your game works; next it needs to feel good. In Capstone Part 2: Polish & Playtest you add thruster and puff animations, sound and music, a title screen with restart, and then watch real people play it to find out what to change.
â Common Questions
Can I make a different game instead of Space Salvage?
Yes, as long as it keeps a move-collect-dodge-score loop, so the starter still fits: a bee collecting pollen and dodging birds, a diver collecting pearls and dodging jellyfish. Swap the images in assets (keep the file names, or change them in load_assets()) and write your own pitch. A brand-new genre is a great idea for the could column of your next project.
Why is the game state a dictionary and not a class?
Either works. A dictionary keeps everything visible in one place with tools you have used since your first Python course, and it makes restarting a single line. The sprites are classes because each one carries its own image, position and update code, just like in Images & Sprite Classes.
Why is dt capped with min(..., 0.05)?
If the window is dragged or the computer stalls, one frame can take half a second. Without a cap, the ship and asteroids would jump a long way in that frame, sometimes straight through each other. Capping dt at 0.05 s means the game briefly runs in slow motion instead.
My ship points the wrong way. What happened?
Check two signs. angle_to() should be measured from Vector2(0, -1) (up), and the image should be rotated with -self.angle. If the art itself faces a different direction than up, rotate the base image once at load time so its nose points up.
Where did the art come from, and can I use my own?
From Kenney's free packs, released under CC0 (see CREDITS.md). You can replace any file with your own drawings. If you use someone else's art or sound, check its license first and add it to CREDITS.md; CC0 needs no credit, but many other licenses require it.
đ¯ Quick Quiz
Question 1: Why does Space Salvage keep everything that changes during a run in one game dictionary made by new_game()?
Question 2: Why does Player.update() call direction.normalize() before moving?
Question 3: spawn_timer is 0.1, this frame's dt is 0.25, and spawn_gap() returns 1.2. After the while loop runs, what is spawn_timer?
Question 4: What does passing True as the third argument of pygame.sprite.spritecollide() do?
Question 5: Why does the ship become invulnerable for 1.5 seconds after a hit?
đ Going Further
- Crate values: give each crate image its own points (the red one is worth 25). Store a
pointsattribute on eachSalvagesprite and add it instead ofSALVAGE_POINTS. - Drifting crates: give salvage a slow velocity in px/s and wrap it around the screen edges.
- Seeded runs: make
rng = random.Random(7)and play twice. The asteroids come the same way both times, which is useful for comparing a change fairly. - Read the docs: the pygame-ce pages for pygame.sprite (look up
collide_circle_ratio) and pygame.math.Vector2 (look upangle_toandclamp_magnitude). - Thanks: the ship, asteroid and crate art is by Kenney (Space Shooter Extension, CC0).
- Coming up in Game Dev II: Intermediate: Game States & Scenes turns string states into a proper scene system, and Saving & Loading keeps a high score between runs.