Games and Programming in the Wild

A Galaga-Style Shooter


Learning Objectives

  • You can maintain two lists of objects that interact — bullets and enemies.
  • You can detect collisions between every member of one list and every member of another.
  • You can safely remove objects from a list using a filtering rebuild, and explain why editing a list while looping over it is dangerous.
  • You can move a group of objects together as a formation that reverses at the edges.

This is one of the most elaborate games in the part, and it earns its place by combining everything so far into a recognisable Galaga-style arcade shooter. You are a ship at the bottom of the screen. A formation of aliens marches back and forth above you, edging downward. You fire bullets upward; bullets that hit aliens destroy them. Clear the formation to win; let the aliens reach the bottom and you lose.

The new ideas are two: two lists that interact (bullets and aliens, where each bullet must be checked against each alien) and formation movement (a group of objects moving together as one). Along the way we meet a genuinely important bug — modifying a list while looping over it — and the standard way to avoid it.

Two Lists

The game holds two lists, both of the “list of dictionaries” shape you have used since the rocks:

self.bullets = [] # starts empty -- the player has not fired
self.aliens = make_aliens() # a full formation

Bullets come and go constantly — created when the player fires, destroyed when they leave the screen or hit something. Aliens start as a fixed formation and are removed as they are shot. Each bullet is {"x": ..., "y": ...}; each alien is {"x": ..., "y": ..., "alive": ...}.

The aliens are built as a grid, the same nested-loop construction as Breakout’s bricks:

def make_aliens():
aliens = []
for row in range(3):
for col in range(6):
aliens.append({
"x": 20 + col * 20,
"y": 10 + row * 14,
"alive": True,
})
return aliens

Firing Bullets

When the player presses space, a new bullet appears just above the ship and travels upward. Firing adds to the list; flight subtracts from y each frame:

if pyxel.btnp(pyxel.KEY_SPACE):
self.bullets.append({"x": self.ship_x + 4, "y": 110})
for bullet in self.bullets:
bullet["y"] = bullet["y"] - 4

btnp again — one bullet per press. Subtracting from y moves the bullet up the screen (remember, up is toward y = 0). This is the rocks’ movement, restricted to one direction.

Removing Bullets That Leave the Screen — and a Bug

A bullet that flies off the top should be removed; otherwise the list grows without bound. Your first instinct might be to loop over the list and remove bullets as you find them:

# DO NOT DO THIS
for bullet in self.bullets:
if bullet["y"] < 0:
self.bullets.remove(bullet) # modifying the list while looping over it

This is buggy, and the bug is subtle. When you remove an item from a list while looping over it, the loop’s internal position gets confused — the loop skips the item right after the one you removed, because everything shifted down by one and the loop’s index moved on regardless. Some bullets that should be removed survive. This is one of the classic mistakes, and it is exactly the kind of code that the Plausible but Wrong chapter warned about: it runs, it mostly works, and it is wrong in a way that hides.

The safe fix is to build a new list containing only the bullets you want to keep. List comprehensions keep this compact and readable:

self.bullets = [bullet for bullet in self.bullets if bullet["y"] > 0]

“The bullets, keeping only those still on screen.” This never modifies the list it is reading — it reads the old list and assigns a fresh one. It is the same “read from old, build new” discipline that the Game of Life chapter used for the grid, here applied to a list of bullets.

Why Marking-as-Dead Sometimes Wins

Breakout marked bricks with an alive flag instead of removing them; this chapter rebuilds the bullet list with a filter. Both avoid the modify-while-looping bug, and which one to use is a judgement call. Marking is simplest when the number of objects is fixed and you mostly care about drawing the live ones (bricks never multiply). Filtering is cleanest when objects genuinely come and go and you do not want the list to grow forever (bullets are created and destroyed constantly). The aliens below use marking — the formation count is fixed — and the bullets use filtering. Seeing both in one game is a good way to feel the difference.

Formation Movement

The aliens move as a group. They all slide in the same direction, and when the formation reaches a screen edge, the whole group reverses direction and drops down a step. This is the iconic Space Invaders motion, and it works in two passes each step.

First, check whether moving would take any alien past an edge:

edge_hit = False
for alien in self.aliens:
if alien["alive"]:
next_x = alien["x"] + self.alien_dx
if next_x < 4 or next_x > 148:
edge_hit = True

self.alien_dx is the formation’s horizontal speed and direction — +1 moving right, -1 moving left. We look ahead: if any living alien’s next position would be off the edge, the formation has hit the wall.

Then act on what we found:

if edge_hit:
self.alien_dx = -self.alien_dx # reverse the whole formation
for alien in self.aliens:
alien["y"] = alien["y"] + 6 # and drop everyone down
else:
for alien in self.aliens:
alien["x"] = alien["x"] + self.alien_dx

If the edge was hit, flip the direction (the same sign-flip as Breakout’s bounce, applied to a whole group) and move every alien down. Otherwise, slide every alien sideways. Because all the aliens share the single alien_dx, they move in lockstep — that shared piece of state is what makes them a formation rather than independent drifters.

This is the difference the chapter is built around: the rocks each had their own velocity; the aliens share one.

The Two Lists Meet: Bullet-Alien Collisions

Here is the interaction at the heart of the game. Every bullet must be checked against every alien. That is a loop inside a loop — for each bullet, look at each alien — the same loop-inside-a-loop shape introduced in Summaries by Category:

for bullet in self.bullets:
for alien in self.aliens:
if not alien["alive"]:
continue
if (alien["x"] <= bullet["x"] <= alien["x"] + 12
and alien["y"] <= bullet["y"] <= alien["y"] + 8):
alien["alive"] = False # the alien is destroyed
bullet["y"] = -10 # send the bullet off-screen to be filtered out
self.score = self.score + 10
break

The collision test is the point-in-rectangle check from Breakout, asking whether the bullet’s position falls inside the alien’s box. On a hit, we mark the alien dead and move the bullet off-screen (so the bullet-filter on the next line removes it). The break stops checking that bullet after its first hit, so one bullet can destroy only one alien even if alien rectangles overlap.

Marking the alien and moving the bullet — rather than removing either from its list mid-loop — keeps us clear of the modify-while-looping bug while we are in the middle of two nested loops over those very lists.

After the collision pass, the same filter as before clears the spent bullets:

self.bullets = [bullet for bullet in self.bullets if bullet["y"] > 0]

Winning and Losing

You win when no aliens remain, using the same list-of-values and any check as Breakout. You lose if any living alien reaches the bottom of the screen:

for alien in self.aliens:
if alien["alive"] and alien["y"] > 100:
self.playing = False
alive_values = [alien["alive"] for alien in self.aliens]
if not any(alive_values):
self.playing = False
self.won = True # all aliens cleared -- you win

The won flag lets the draw step tell the two endings apart: both stop the game, but one deserves a happier message than the other.

The Complete Game

import pyxel
def make_aliens():
aliens = []
for row in range(3):
for col in range(6):
aliens.append({
"x": 20 + col * 20,
"y": 10 + row * 14,
"alive": True,
})
return aliens
class App:
def __init__(self):
self.reset()
pyxel.init(160, 120, title="Invaders")
pyxel.run(self.update, self.draw)
def reset(self):
self.ship_x = 76
self.bullets = []
self.aliens = make_aliens()
self.alien_dx = 1
self.score = 0
self.playing = True
self.won = False
def update(self):
if not self.playing:
if pyxel.btnp(pyxel.KEY_R):
self.reset()
return
# move and fire the ship
if pyxel.btn(pyxel.KEY_LEFT):
self.ship_x = self.ship_x - 2
if pyxel.btn(pyxel.KEY_RIGHT):
self.ship_x = self.ship_x + 2
self.ship_x = max(0, min(self.ship_x, 152))
if pyxel.btnp(pyxel.KEY_SPACE):
self.bullets.append({"x": self.ship_x + 4, "y": 110})
# move bullets up, drop any that leave the screen
for bullet in self.bullets:
bullet["y"] = bullet["y"] - 4
self.bullets = [b for b in self.bullets if b["y"] > 0]
# move the formation
edge_hit = False
for alien in self.aliens:
if alien["alive"]:
next_x = alien["x"] + self.alien_dx
if next_x < 4 or next_x > 148:
edge_hit = True
if edge_hit:
self.alien_dx = -self.alien_dx
for alien in self.aliens:
alien["y"] = alien["y"] + 6
else:
for alien in self.aliens:
alien["x"] = alien["x"] + self.alien_dx
# bullet-alien collisions
for bullet in self.bullets:
for alien in self.aliens:
if not alien["alive"]:
continue
if (alien["x"] <= bullet["x"] <= alien["x"] + 12
and alien["y"] <= bullet["y"] <= alien["y"] + 8):
alien["alive"] = False
bullet["y"] = -10
self.score = self.score + 10
break
self.bullets = [b for b in self.bullets if b["y"] > 0]
# lose / win
for alien in self.aliens:
if alien["alive"] and alien["y"] > 100:
self.playing = False
alive_values = [alien["alive"] for alien in self.aliens]
if not any(alive_values):
self.playing = False
self.won = True
def draw(self):
pyxel.cls(0)
for alien in self.aliens:
if alien["alive"]:
pyxel.rect(alien["x"], alien["y"], 12, 8, 8)
for bullet in self.bullets:
pyxel.rect(bullet["x"], bullet["y"], 2, 4, 10)
pyxel.rect(self.ship_x, 112, 8, 6, 11)
pyxel.text(4, 4, f"Score: {self.score}", 7)
if not self.playing:
if self.won:
pyxel.text(55, 60, "YOU WIN! - R", 11)
else:
pyxel.text(55, 60, "GAME OVER - R", 8)
if __name__ == "__main__":
App()

This is a real game, and it is mostly assembled from parts you already had: bullets and aliens are lists of records, the collision test came from Breakout, the formation reversal is a group sign-flip, and bullet removal is the filtering rebuild. The genuinely new lessons are narrow but important — never modify a list while looping over it, and shared state (the single alien_dx) is what turns a pile of objects into a coordinated group.

Galaga-style shooter with classes

0 / 40 points

Refactor the Galaga-style shooter from the chapter into a class-based Pyxel program.

Keep the chapter mechanics:

  • the ship moves left and right and stays on screen,
  • pressing space fires one bullet,
  • bullets move upward and are removed when they leave the screen,
  • aliens move as a shared formation,
  • the formation reverses and drops when it reaches an edge,
  • bullets destroy aliens and add score,
  • clearing all aliens wins,
  • aliens reaching the bottom loses,
  • and pressing R after the game ends starts over.

Game rules to implement:

  • the game window is 160 by 120,
  • the ship starts at (76, 112), is 8 by 6, and moves 2 pixels per frame,
  • firing creates one bullet at the ship’s horizontal centre (x + width / 2), starting 2 pixels above the ship’s top — y 110 for the default ship,
  • bullets are 2 by 4, move upward 4 pixels per frame, and are removed when their y-coordinate is not above 0,
  • the default formation has 3 rows and 6 columns of aliens,
  • aliens are 12 by 8, starting at x positions 20 + col * 20 and y positions 10 + row * 14,
  • the formation moves sideways by 1 pixel per frame,
  • if a living alien’s next x-coordinate would go below 4 or above 148, the formation reverses direction and all aliens drop by 6 pixels instead of moving sideways,
  • a bullet destroys the first live alien it hits and is then removed,
  • each destroyed alien adds 10 points,
  • the game is lost when any living alien has y-coordinate greater than 100,
  • and the game is won when no living aliens remain.

The mechanics must live in model classes that can be tested without Pyxel. The Pyxel App should only translate input into method calls and draw the current model.

Required classes:

ClassContract
Ship(x=76, y=112, width=8, height=6, speed=2)stores the ship rectangle and speed
Ship.move(self, direction, screen_width)moves horizontally and clamps to the screen
Ship.fire(self)returns a new Bullet starting above the ship
Bullet(x, y, speed=4, width=2, height=4)stores bullet rectangle and speed
Bullet.step(self)moves the bullet upward
Bullet.is_on_screen(self)returns whether the bullet should remain in play
Alien(x, y, width=12, height=8, alive=True)stores alien rectangle and alive flag
Alien.contains_point(self, x, y)returns whether a point is inside the alien
Alien.destroy(self)marks the alien as not alive
Formation(aliens=None, dx=1)owns aliens and the shared horizontal direction
Formation.step(self, screen_width)moves sideways, or reverses and drops when a living alien would cross an edge
Formation.all_destroyed(self)returns whether no living aliens remain
Formation.reached_bottom(self, limit)returns whether a living alien has reached the bottom limit
InvadersGame(ship=None, bullets=None, formation=None, width=160, height=120)owns the ship, bullets, formation, score, and state
InvadersGame.step(self, left=False, right=False, fire=False)updates one frame of ship movement, firing, bullet movement, formation movement, collisions, win, and loss
Appowns one InvadersGame, handles Pyxel input, restart, and drawing

Pyxel integration requirements:

  • App.update() passes held left and right arrow state plus pyxel.btnp(pyxel.KEY_SPACE) into game.step(...),
  • when game.state is not "playing", App.update() listens for pyxel.KEY_R and resets the model,
  • App.draw() draws live aliens, bullets, the ship, score, and end-state text from the model,
  • do not call pyxel from the model classes,
  • do not start the app when the file is imported,
  • and guard the launch with if __name__ == "__main__": App().

Check Your Understanding

  1. Why does the game keep bullets and aliens in two separate lists rather than one?
  2. What goes wrong if you remove items from a list while looping over that same list? What is the safe alternative?
  3. Why does a destroyed alien get its alive flag set, while a spent bullet gets filtered out — two different removal strategies in one game?
  4. What single piece of shared state makes the aliens move as a formation rather than independently?
  5. Checking every bullet against every alien is a loop inside a loop. If there were 10 bullets and 18 aliens, how many collision checks would run in one frame?

AI Study Aid

Create a chapter diagram

We're looking into to what extent AI-generated diagrams could help with studying.

Use this study aid to generate an AI-generated visual summary of the material you just studied. Each diagram style emphasizes the content in a different way, so you can choose the focus that feels most useful.

Using the diagram generator is voluntary and does not affect your course progress. We encourage you to try it out and see if it helps, but it's totally up to you! Your answers help us understand how to make better study aids in the future.

Diagrams tried: 0