Games and Programming in the Wild

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 + dx
y = y + dy

That 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"]) % 160
rock["y"] = (rock["y"] + rock["dy"]) % 120

Taking 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.x
gap_y = rock["y"] - self.y
distance_squared = gap_x * gap_x + gap_y * gap_y
if distance_squared < (rock_radius + ship_radius) ** 2:
# they overlap -- game over
self.alive = False

If 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 R after a crash starts over.

Game rules to implement:

  • the game window is 160 by 120,
  • the ship starts at (80, 60), has radius 3, and moves 2 pixels per frame in each held direction,
  • rocks have radius 4 and move by their own dx and dy every frame,
  • the app starts each game with 5 random 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 1 after 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:

ClassContract
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
Appowns one AsteroidsGame, handles Pyxel input, restart, and drawing

Pyxel integration requirements:

  • App.reset() creates a new AsteroidsGame with one starting ship and five random rocks,
  • random rock creation may use pyxel.rndi and pyxel.rndf,
  • App.update() passes held arrow-key state into game.step(...),
  • when game.alive is false, App.update() listens for pyxel.KEY_R and resets the model,
  • App.draw() draws the rocks, ship, score, and crash message from the model,
  • do not call pyxel from Ship, Rock, or AsteroidsGame,
  • do not start the app when the file is imported,
  • and guard the launch with if __name__ == "__main__": App().

Check Your Understanding

  1. What four numbers describe a moving rock, and what does each one mean?
  2. What does (rock["x"] + rock["dx"]) % 160 accomplish, and why use modulo specifically?
  3. Why does the collision check compare squared distances instead of taking a square root?
  4. What does the return in the “if not alive” branch prevent from happening?
  5. Why do both __init__ and the restart call the same reset method?

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