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 firedself.aliens = make_aliens() # a full formationBullets 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 aliensFiring 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"] - 4btnp 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 THISfor bullet in self.bullets: if bullet["y"] < 0: self.bullets.remove(bullet) # modifying the list while looping over itThis 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 = Falsefor alien in self.aliens: if alien["alive"]: next_x = alien["x"] + self.alien_dx if next_x < 4 or next_x > 148: edge_hit = Trueself.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 downelse: for alien in self.aliens: alien["x"] = alien["x"] + self.alien_dxIf 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 breakThe 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 winThe 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
Rafter the game ends starts over.
Game rules to implement:
- the game window is
160by120, - the ship starts at
(76, 112), is8by6, and moves2pixels per frame, - firing creates one bullet at the ship’s horizontal centre (
x + width / 2), starting2pixels above the ship’s top — y110for the default ship, - bullets are
2by4, move upward4pixels per frame, and are removed when their y-coordinate is not above0, - the default formation has
3rows and6columns of aliens, - aliens are
12by8, starting at x positions20 + col * 20and y positions10 + row * 14, - the formation moves sideways by
1pixel per frame, - if a living alien’s next x-coordinate would go below
4or above148, the formation reverses direction and all aliens drop by6pixels instead of moving sideways, - a bullet destroys the first live alien it hits and is then removed,
- each destroyed alien adds
10points, - 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:
| Class | Contract |
|---|---|
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 |
App | owns one InvadersGame, handles Pyxel input, restart, and drawing |
Pyxel integration requirements:
App.update()passes held left and right arrow state pluspyxel.btnp(pyxel.KEY_SPACE)intogame.step(...),- when
game.stateis not"playing",App.update()listens forpyxel.KEY_Rand resets the model, App.draw()draws live aliens, bullets, the ship, score, and end-state text from the model,- do not call
pyxelfrom 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
- Why does the game keep bullets and aliens in two separate lists rather than one?
- What goes wrong if you remove items from a list while looping over that same list? What is the safe alternative?
- Why does a destroyed alien get its
aliveflag set, while a spent bullet gets filtered out — two different removal strategies in one game? - What single piece of shared state makes the aliens move as a formation rather than independently?
- 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.