Learning Goals
3 minBy the end of this lesson you can:
- Rotate an Actor by changing
actor.anglein response to left/right key presses. - Convert the ship's angle to an
(vx, vy)velocity usingmath.cosandmath.sin, so thrust always points forward. - Wrap the ship around all four screen edges so it never disappears off-screen.
Warm-Up · Angles and the Unit Circle
5 minIn Pygame Zero, actor.angle is in degrees. The standard maths functions use radians. What does this print?
import math angle_deg = 90 angle_rad = math.radians(angle_deg) print(round(math.cos(angle_rad), 2)) print(round(math.sin(angle_rad), 2))
Show the answer
Output
0.0 1.0
At 90 ° the cosine is 0 and the sine is 1. That means "pointing right" in maths corresponds to a purely vertical thrust in screen space — we need to account for Pygame Zero's coordinate system where y grows downward. We will handle that in the concept section.
New Concept · Rotate, Thrust, Wrap
12 minThink of the ship like a compass needle. Turning the needle does not move the compass — you still have to step forward. In code: changing actor.angle rotates the sprite image, but the ship only moves when you add to its velocity.
Rotating the actor
Pygame Zero's actor.angle starts at 0 (pointing right). Increasing the angle rotates counter-clockwise. To turn left, add to the angle; to turn right, subtract:
TURN_SPEED = 4 def update(): if keyboard.left: ship.angle += TURN_SPEED if keyboard.right: ship.angle -= TURN_SPEED
Thrust in the facing direction
To move the ship forward we decompose the angle into vx and vy components. Because y grows downward on screen, we negate the sine term:
import math THRUST = 0.3 def update(): global vx, vy if keyboard.up: rad = math.radians(ship.angle) vx += math.cos(rad) * THRUST vy -= math.sin(rad) * THRUST ship.x += vx ship.y += vy
Screen wrapping
When the ship drifts past one edge it should reappear on the opposite side:
def wrap(actor): if actor.x > WIDTH: actor.x = 0 if actor.x < 0: actor.x = WIDTH if actor.y > HEIGHT: actor.y = 0 if actor.y < 0: actor.y = HEIGHT
Worked Example · The Full Movement Sketch
12 minWei Jie wants to recreate the Asteroids ship feel. He uses a plain white triangle as the ship image (save a white triangle PNG as images/ship.png, or use any small Actor image you have). Save as asteroids1.py:
# asteroids1.py — rotation, thrust, and screen wrap (Part 1) import pgzrun import math WIDTH = 600 HEIGHT = 600 TITLE = "Asteroids Lite — Part 1" TURN_SPEED = 4 THRUST = 0.25 DRAG = 0.99
ship = Actor("ship", center=(300, 300)) vx = 0.0 vy = 0.0 def wrap(actor): if actor.x > WIDTH: actor.x = 0 elif actor.x < 0: actor.x = WIDTH if actor.y > HEIGHT: actor.y = 0 elif actor.y < 0: actor.y = HEIGHT
def update(): global vx, vy if keyboard.left: ship.angle += TURN_SPEED if keyboard.right: ship.angle -= TURN_SPEED if keyboard.up: rad = math.radians(ship.angle) vx += math.cos(rad) * THRUST vy -= math.sin(rad) * THRUST # apply drag so the ship doesn't accelerate forever vx *= DRAG vy *= DRAG ship.x += vx ship.y += vy wrap(ship) def draw(): screen.fill("black") ship.draw() screen.draw.text( "LEFT/RIGHT rotate UP thrust", topleft=(10, 10), fontsize=18, color="grey", ) pgzrun.go()
The DRAG = 0.99 multiplier shrinks velocity by 1 % each frame, giving the floating-in-space feel without infinite drift.
Try It Yourself
13 minAdd a constant MAX_SPEED = 6. After updating vx and vy, cap the speed so the ship never goes faster than MAX_SPEED. Hint: use math.hypot(vx, vy) to get the current speed.
MAX_SPEED = 6 speed = math.hypot(vx, vy) if speed > MAX_SPEED: scale = MAX_SPEED / speed vx *= scale vy *= scale
When the player holds UP, draw a small orange filled circle just behind the ship to simulate exhaust. Calculate the flame position using the opposite direction to thrust.
if keyboard.up: rad = math.radians(ship.angle) fx = ship.x - math.cos(rad) * 20 fy = ship.y + math.sin(rad) * 20 screen.draw.filled_circle((int(fx), int(fy)), 5, "orange")
Mini-Challenge — Add Static Asteroids
8 minCombine today's ship movement with the random module from Level 1. Spawn 5 grey circles at random positions on the screen. They don't move yet — that's Part 2's job. Apply the same wrap logic to each circle so they stay on screen.
It works if…
5 grey circles appear at random positions, the ship flies around them without crashing the program
Show one possible solution
import random NUM_ASTEROIDS = 5 asteroids = [ {"x": random.randint(0, WIDTH), "y": random.randint(0, HEIGHT)} for _ in range(NUM_ASTEROIDS) ] def draw(): screen.fill("black") for a in asteroids: screen.draw.filled_circle((int(a["x"]), int(a["y"])), 24, "grey") ship.draw()
Placing asteroids in a list of dicts sets you up perfectly for adding movement in Part 2.
Recap
3 minYou now have a ship that rotates with actor.angle, thrusts in the facing direction using math.cos/math.sin, and wraps around all four screen edges. A small drag multiplier keeps the physics feeling like outer space. Part 2 adds moving asteroids, bullets, and game states.
Vocabulary Card
- actor.angle
- The rotation of the sprite in degrees. 0 = facing right; increases counter-clockwise.
- math.radians(deg)
- Converts degrees to radians so
math.cos/math.sinwork correctly. - screen wrap
- When an object exits one edge it reappears on the opposite edge — keeps objects always on screen.
- drag
- Multiplying velocity by a value slightly below 1 (e.g. 0.99) each frame slows the object gradually, simulating friction or space resistance.
Homework
4 minAdd a brake key to the Part-1 ship. When the player holds DOWN, multiply both vx and vy by 0.92 each frame (stronger drag) so the ship slows down quickly. Save as asteroids1_brake.py and bring the file to class.
Sample · asteroids1_brake.py
# asteroids1_brake.py — adds a brake key to Part 1 import pgzrun import math WIDTH = 600 HEIGHT = 600 TURN_SPEED = 4 THRUST = 0.25 DRAG = 0.99 BRAKE = 0.92 ship = Actor("ship", center=(300, 300)) vx = 0.0 vy = 0.0 def wrap(actor): if actor.x > WIDTH: actor.x = 0 elif actor.x < 0: actor.x = WIDTH if actor.y > HEIGHT: actor.y = 0 elif actor.y < 0: actor.y = HEIGHT def update(): global vx, vy if keyboard.left: ship.angle += TURN_SPEED if keyboard.right: ship.angle -= TURN_SPEED if keyboard.up: rad = math.radians(ship.angle) vx += math.cos(rad) * THRUST vy -= math.sin(rad) * THRUST if keyboard.down: vx *= BRAKE vy *= BRAKE vx *= DRAG vy *= DRAG ship.x += vx ship.y += vy wrap(ship) def draw(): screen.fill("black") ship.draw() screen.draw.text("DOWN to brake", topleft=(10, 10), fontsize=18, color="grey") pgzrun.go()
The brake just applies a stronger drag multiplier on the DOWN frame — same maths as DRAG, just a smaller value.