Asteroids, Lite
Learning Objectives
- You can represent a moving object with position and velocity, and update its position each frame.
- You can hold many similar objects in a list and update all of them in a loop.
- You can wrap an object around the screen edges using the modulo operator.
- You can detect a collision between two circles using the distance between their centres.
- You can implement a game-over state and a restart.
The idle clicker had no movement. This game is almost all movement. You are a small ship drifting in space, and the screen is full of asteroids tumbling in every direction. If your ship overlaps one, the game ends. The arrow keys nudge you around. That is the whole game — but to build it you need three new ideas: velocity, a list of objects, and wrapping around the screen edges.
Velocity: Position That Changes Each Frame
In the idle clicker, the only thing that changed each frame was a number going up. Here, a thing moves — and a thing that moves has not just a position but a velocity: how far it travels each frame, in each direction.
A moving object therefore needs four numbers: x and y for where it is, and dx and dy for how fast it is going. The d stands for “delta”, meaning “change” — dx is the change in x per frame. Moving the object is then a single, very simple step you run every frame:
x = x + dxy = y + dyThat is the entire idea of movement in a game. Each frame, add the velocity to the position. A rock with dx = 1.5 and dy = -0.5 drifts right and slightly up, a little more every frame. The smoothness you see on screen is this tiny addition happening thirty times a second.
This is your first taste of an idea that the Breakout and Lunar Lander chapters build on. Position is updated from velocity. Later, velocity will itself be updated from acceleration — gravity, thrust — but the bottom layer is always this: each frame, position += velocity.
A List of Rocks
There is one ship but many rocks, and the rocks are all the same kind of thing: each has an x, y, dx, and dy. This is exactly the situation we’ve learned to use lists for, and exactly the lists of records shape. Each rock is a dictionary with four named fields, and all the rocks live in a list:
rock = {"x": 40, "y": 80, "dx": 1.5, "dy": -0.5}We will make rocks with a small helper that gives each a random position and a random velocity, using the pyxel.rndi (random integer) and pyxel.rndf (random float) functions:
def make_rock(): return { "x": pyxel.rndi(0, 159), "y": pyxel.rndi(0, 119), "dx": pyxel.rndf(-1.5, 1.5), "dy": pyxel.rndf(-1.5, 1.5), }pyxel.rndi(0, 159) returns a random whole number between 0 and 159 — somewhere across the width of the screen. pyxel.rndf(-1.5, 1.5) returns a random decimal between -1.5 and 1.5, so a rock might drift left (negative dx) or right (positive dx), fast or slow. Making five rocks at the start can be done with a compact list comprehension:
self.rocks = [make_rock() for _ in range(5)]Updating all the rocks each frame is a single loop over the list, doing the position-plus-velocity step for each rock:
for rock in self.rocks: rock["x"] = rock["x"] + rock["dx"] rock["y"] = rock["y"] + rock["dy"]One rock or fifty, the code is the same: the loop does not care how many there are.
Wrapping Around the Edges
A rock drifting right will eventually reach x = 160 and sail off the screen. In Asteroids, objects that leave one edge reappear on the opposite edge. Modulo is again useful here:
rock["x"] = (rock["x"] + rock["dx"]) % 160rock["y"] = (rock["y"] + rock["dy"]) % 120Taking the position modulo the screen width keeps it in the range 0 to 159 no matter how large it grows: at x = 160 the modulo wraps it back to 0, the left edge. Negative positions wrap too — in Python, -1 % 160 is 159, so a rock drifting off the left edge reappears on the right. One operator, applied to both coordinates, gives the whole wrapping behaviour for free.
The ship wraps the same way, so you can chase a rock off one edge and meet it coming back.
Collisions Between Circles
The ship and the rocks are drawn as circles, and two circles collide when the distance between their centres is less than the sum of their radii. The distance between two points comes from the Pythagorean theorem: the straight-line distance between (x1, y1) and (x2, y2) is the square root of (x2 - x1)² + (y2 - y1)².
There is a small trick. Computing a square root is slightly slow and entirely unnecessary here, because we only want to compare the distance to a threshold. If the distance is less than r1 + r2, then the distance squared is less than (r1 + r2)² — the comparison gives the same answer either way. So we compare squared distances and skip the square root:
gap_x = rock["x"] - self.xgap_y = rock["y"] - self.ydistance_squared = gap_x * gap_x + gap_y * gap_yif distance_squared < (rock_radius + ship_radius) ** 2: # they overlap -- game over self.alive = FalseIf the ship has radius 3 and each rock has radius 4, they collide when the squared distance is below 7 ** 2, which is 49. This squared-distance comparison is the standard way to test circle collisions in games.
Game Over and Restart
When the ship hits a rock, the game should stop responding to movement and show a message, then let the player restart. This is a small piece of state — a flag, self.alive — and a branch at the top of update:
def update(self): if not self.alive: if pyxel.btnp(pyxel.KEY_R): self.reset() return # ... normal game logic only runs when alive ...The return is the key. When the ship is not alive, update checks for the restart key and then returns immediately, so none of the movement or collision code runs. The game freezes in place until the player presses R. This “if not alive, handle restart and return early” shape appears in almost every game in this part.
The Complete Game
import pyxel
def make_rock(): return { "x": pyxel.rndi(0, 159), "y": pyxel.rndi(0, 119), "dx": pyxel.rndf(-1.5, 1.5), "dy": pyxel.rndf(-1.5, 1.5), }
class App: def __init__(self): self.reset() pyxel.init(160, 120, title="Asteroids Lite") pyxel.run(self.update, self.draw)
def reset(self): self.x = 80 self.y = 60 self.rocks = [make_rock() for _ in range(5)] self.score = 0 self.alive = True
def update(self): if not self.alive: if pyxel.btnp(pyxel.KEY_R): self.reset() return
# move the ship if pyxel.btn(pyxel.KEY_LEFT): self.x = self.x - 2 if pyxel.btn(pyxel.KEY_RIGHT): self.x = self.x + 2 if pyxel.btn(pyxel.KEY_UP): self.y = self.y - 2 if pyxel.btn(pyxel.KEY_DOWN): self.y = self.y + 2 self.x = self.x % 160 self.y = self.y % 120
# move the rocks and check for collisions for rock in self.rocks: rock["x"] = (rock["x"] + rock["dx"]) % 160 rock["y"] = (rock["y"] + rock["dy"]) % 120 gap_x = rock["x"] - self.x gap_y = rock["y"] - self.y if gap_x * gap_x + gap_y * gap_y < (4 + 3) ** 2: self.alive = False
# the collision frame is not a survived frame if self.alive: self.score = self.score + 1
def draw(self): pyxel.cls(0) for rock in self.rocks: pyxel.circ(rock["x"], rock["y"], 4, 13) pyxel.circ(self.x, self.y, 3, 11) pyxel.text(5, 5, f"Score: {self.score}", 7) if not self.alive: pyxel.text(50, 60, "CRASHED - press R", 8)
if __name__ == "__main__": App()Notice that __init__ calls self.reset(), similar to the restart. Putting all the starting state in one reset method means “start a new game” is one call, used both when the program launches and when the player presses R.
The score counts the frames you survive, so a longer survival is a higher score — a tiny incentive that turns “don’t die” into “last as long as you can”. The frame in which the ship collides is not a survived frame, so it adds no point.
Asteroids Lite with classes
0 / 40 points
Refactor the Asteroids Lite game from the chapter into a class-based Pyxel program.
Keep the chapter mechanics:
- the ship moves with the arrow keys,
- the ship and rocks wrap around screen edges,
- rocks drift according to their own velocities,
- overlapping a rock ends the game,
- and pressing
Rafter a crash starts over.
Game rules to implement:
- the game window is
160by120, - the ship starts at
(80, 60), has radius3, and moves2pixels per frame in each held direction, - rocks have radius
4and move by their owndxanddyevery frame, - the app starts each game with
5random rocks, - wrapping uses modulo-style screen wrapping for both the ship and rocks,
- a collision happens when the rock circle and ship circle overlap,
- the score increases by
1after each frame where the player remains alive; the frame in which the ship crashes adds no score, - and after a crash, the model should stop updating until the player restarts.
The mechanics must live in model classes that can be tested without Pyxel. The Pyxel App should create the objects, translate input into method calls, and draw the model.
Required classes:
| Class | Contract |
|---|---|
Ship(x=80, y=60, radius=3, speed=2) | stores position, radius, and movement speed |
Ship.move(self, d_x, d_y, width, height) | moves by d_x * speed and d_y * speed, wrapping around the screen |
Rock(x, y, dx, dy, radius=4) | stores position, velocity, and radius |
Rock.step(self, width, height) | moves by velocity, wrapping around the screen |
Rock.collides_with(self, ship) | returns True when the rock circle overlaps the ship circle |
AsteroidsGame(ship=None, rocks=None, width=160, height=120) | owns ship, rocks, score, alive, width, and height |
AsteroidsGame.step(self, left=False, right=False, up=False, down=False) | updates the ship, rocks, collision state, and score for one frame |
App | owns one AsteroidsGame, handles Pyxel input, restart, and drawing |
Pyxel integration requirements:
App.reset()creates a newAsteroidsGamewith one starting ship and five random rocks,- random rock creation may use
pyxel.rndiandpyxel.rndf, App.update()passes held arrow-key state intogame.step(...),- when
game.aliveis false,App.update()listens forpyxel.KEY_Rand resets the model, App.draw()draws the rocks, ship, score, and crash message from the model,- do not call
pyxelfromShip,Rock, orAsteroidsGame, - do not start the app when the file is imported,
- and guard the launch with
if __name__ == "__main__": App().
Check Your Understanding
- What four numbers describe a moving rock, and what does each one mean?
- What does
(rock["x"] + rock["dx"]) % 160accomplish, and why use modulo specifically? - Why does the collision check compare squared distances instead of taking a square root?
- What does the
returnin the “if not alive” branch prevent from happening? - Why do both
__init__and the restart call the sameresetmethod?
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.