Skip to content

Tutorial 2 — Starship

In this tutorial, you’ll build a small Asteroids-style shooter. It assumes you’ve completed 01-bounce (render loop, input, sub-pixel movement, collision, HUD, and particles). This time you’ll add rotation, vector thrust, object pools, splitting enemies, and a game-state machine.

The full source for this tutorial lives on GitHub: tutorials/02-starship.

Run any step:

Terminal window
python3 sim/run.py tutorials/02-starship/stepN_name.py --hold UP,B --shot /tmp/out.png

step 1 — step1_ship.py · a shaped sprite

Section titled “step 1 — step1_ship.py · a shaped sprite”

Starship step 1 picogame_game.setup prepares the display and returns (scene, buffer_a, buffer_b). On an SPI display, the last two values are reusable strip buffers; on a framebuffer display, they are None. This step only needs the Scene, so it ignores the other two values (scene, _, _). picogame_shapes (imported as shp) builds bitmaps: shp.from_mask turns an ASCII picture into a one-colour Bitmap. We wrap it in a Sprite and anchor=(0.5, 0.5) puts the sprite’s reference point at its centre, the right choice for something that rotates and wraps. You see: a little ship in the middle. Try it: redraw the mask.

18 collapsed lines
# Starship -- step 1: a ship on screen (recap, with a shaped sprite).
#
# This second tutorial assumes you've done Bounce (01-bounce). It builds a top-down
# space shooter and covers what Bounce couldn't: rotation, vector thrust, object
# pools (bullets/enemies), circular collision, explosions, and game states.
#
# What you learn here (recap): a Sprite can be any shape. shp.from_mask turns an
# ASCII picture into a one-colour bitmap. anchor=(0.5, 0.5) puts the sprite's
# reference point at its CENTRE -- the natural choice for something that rotates.
#
# New: shp.from_mask, centre anchor.
#
# Run: python3 sim/run.py tutorials/02-starship/step1_ship.py --shot /tmp/p1.png
import picogame as pg
import picogame_game
import picogame_clock
import picogame_shapes as shp
W, H = 320, 240
BACKGROUND = pg.rgb565(0, 0, 8)
scene, _, _ = picogame_game.setup(background=BACKGROUND)
clock = picogame_clock.Clock(30)
SHIP_MASK = [
" # ",
" # ",
" ### ",
" ### ",
"#####",
"## ##",
]
ship = pg.Sprite(shp.from_mask(SHIP_MASK, pg.rgb565(200, 220, 255)), W // 2, H // 2)
ship.anchor = (0.5, 0.5) # rotate/position about the centre
scene.add(ship)
while True:
scene.refresh()
clock.tick()
▶ Try it in the browser

step 2 — step2_fly.py · rotation, thrust, wrap

Section titled “step 2 — step2_fly.py · rotation, thrust, wrap”

Starship step 2 For a ship that spins constantly, we bake the rotation into frames. This is sharper and cheaper than changing sprite.angle at runtime: shp.poly_frames(size, points, N, colour) renders a polygon at N angles into one multi-frame bitmap, and ship.frame = angle selects one of them. A DIRS table holds each angle’s unit vector; UP accelerates along the facing vector into the velocity, with a top-speed cap and gentle drag. wrap() teleports across the edges. You see: a ship you fly Asteroids-style. Try it: change the thrust 0.25 or the drag 0.99.

22 collapsed lines
# Starship -- step 2: rotate, thrust, and wrap around the screen.
#
# What you learn: pre-baked rotation. For a ship that spins constantly, baking the
# rotations into frames is crisper and cheaper than rotating at runtime (sprite.angle).
# shp.poly_frames(size, points, N, colour) renders a polygon at N angles into
# one multi-frame bitmap; setting ship.frame = angle_index shows that rotation. A
# DIRS table holds the unit vector for each angle. UP thrusts along the facing
# vector into the sub-pixel velocity (velocity_x, velocity_y); we cap top speed and apply a little
# drag so it drifts like a spaceship. wrap() teleports across screen edges.
#
# New vs step 1: shp.poly_frames (pre-rotated frames), ship.frame, vector thrust
# into fx/fy, speed cap + drag, screen wrap.
#
# Run: python3 sim/run.py tutorials/02-starship/step2_fly.py --hold UP --shot /tmp/p2.png
import math
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_shapes as shp
W, H = 320, 240
BACKGROUND = pg.rgb565(0, 0, 8)
FRAMES = 16 # number of baked rotation frames
scene, _, _ = picogame_game.setup(background=BACKGROUND)
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
# a ship polygon (points around the centre, +y is down), baked at FRAMES angles
ship_bitmap = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], FRAMES, pg.rgb565(200, 220, 255))
# facing unit vector for each frame: frame 0 points up (-y)
DIRS = [(math.sin(frame * 2 * math.pi / FRAMES), -math.cos(frame * 2 * math.pi / FRAMES)) for frame in range(FRAMES)]
ship = pg.Sprite(ship_bitmap, W // 2, H // 2)
ship.anchor = (0.5, 0.5)
scene.add(ship)
angle = 0 # current rotation frame
velocity_x = velocity_y = 0.0 # velocity
def wrap(x, y):
return x % W, y % H
while True:
btn.poll()
if btn.is_pressed(btn.LEFT):
angle = (angle - 1) % FRAMES
if btn.is_pressed(btn.RIGHT):
angle = (angle + 1) % FRAMES
delta_x, delta_y = DIRS[angle]
if btn.is_pressed(btn.UP):
velocity_x += delta_x * 0.25 # accelerate along the facing vector
velocity_y += delta_y * 0.25
speed = math.sqrt(velocity_x * velocity_x + velocity_y * velocity_y) # cap top speed
if speed > 5:
velocity_x *= 5 / speed
velocity_y *= 5 / speed
velocity_x *= 0.99 # gentle drag
velocity_y *= 0.99
ship.fx, ship.fy = wrap(ship.fx + velocity_x, ship.fy + velocity_y) # sub-pixel position + wrap
ship.frame = angle # show the matching rotation
scene.refresh()
clock.tick()
▶ Try it in the browser

Starship step 3 Repeatedly creating and discarding sprites can fragment the heap. Instead, picogame_pool’s Pool(scene, bitmap, N) pre-allocates N hidden sprites once; spawn() reveals a free one, free() hides it, and sprite.visible is the alive flag. Each bullet keeps its velocity + remaining life in sprite.data, and its position in fx/fy. A cooldown caps the fire rate; picogame_input’s just_pressed(B) fires once when B is pressed. You see: a stream of bullets that expire. Try it: change the pool size or fire_cooldown.

22 collapsed lines
# Starship -- step 3: fire bullets from an object pool.
#
# What you learn: pooling. Spawning objects (bullets, enemies, sparks) by creating
# Sprites at runtime causes memory churn. Instead, pre-allocate a fixed pool ONCE:
# picogame_pool.Pool makes N hidden sprites in the scene, spawn() reveals the first
# free one, free() hides it, and sprite.visible IS the alive flag. We keep each
# bullet's velocity + remaining life in sprite.data, and its position in fx/fy.
# A cooldown limits the fire rate.
#
# New vs step 2: picogame_pool.Pool, spawn/free, btn.just_pressed (a fresh press),
# per-bullet state in sprite.data, a fire cooldown + bullet lifetime.
#
# Run: python3 sim/run.py tutorials/02-starship/step3_shoot.py --hold B --shot /tmp/p3.png
import math
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_shapes as shp
import picogame_pool
W, H = 320, 240
BACKGROUND = pg.rgb565(0, 0, 8)
FRAMES = 16
scene, _, _ = picogame_game.setup(background=BACKGROUND)
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
ship_bitmap = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], FRAMES, pg.rgb565(200, 220, 255))
DIRS = [(math.sin(frame * 2 * math.pi / FRAMES), -math.cos(frame * 2 * math.pi / FRAMES)) for frame in range(FRAMES)]
bullet_bitmap = shp.circle(4, pg.rgb565(255, 255, 120))
ship = pg.Sprite(ship_bitmap, W // 2, H // 2)
ship.anchor = (0.5, 0.5)
bullets = picogame_pool.Pool(scene, bullet_bitmap, 6, anchor=(0.5, 0.5)) # NEW: 6-bullet pool
scene.add(ship) # add ship AFTER the pool so it draws on top
angle = 0
velocity_x = velocity_y = 0.0
fire_cooldown = 0
def wrap(x, y):
return x % W, y % H
while True:
btn.poll()
fire_cooldown -= 1
if btn.is_pressed(btn.LEFT):
angle = (angle - 1) % FRAMES
if btn.is_pressed(btn.RIGHT):
angle = (angle + 1) % FRAMES
delta_x, delta_y = DIRS[angle]
if btn.is_pressed(btn.UP):
velocity_x += delta_x * 0.25
velocity_y += delta_y * 0.25
speed = math.sqrt(velocity_x * velocity_x + velocity_y * velocity_y)
if speed > 5:
velocity_x *= 5 / speed; velocity_y *= 5 / speed
velocity_x *= 0.99; velocity_y *= 0.99
ship.fx, ship.fy = wrap(ship.fx + velocity_x, ship.fy + velocity_y)
ship.frame = angle
# fire: a fresh B press, if the cooldown has elapsed and a slot is free
if btn.just_pressed(btn.B) and fire_cooldown <= 0:
bullet = bullets.spawn()
if bullet:
bullet.data = {"velocity_x": delta_x * 7, "velocity_y": delta_y * 7, "life": 30}
bullet.move(ship.x, ship.y)
fire_cooldown = 6
# advance live bullets; retire them when their life runs out
for bullet in bullets.items:
if not bullet.visible:
continue
bullet.data["life"] -= 1
if bullet.data["life"] <= 0:
bullets.free(bullet)
continue
bullet.fx, bullet.fy = wrap(bullet.fx + bullet.data["velocity_x"], bullet.fy + bullet.data["velocity_y"])
scene.refresh()
clock.tick()
▶ Try it in the browser

step 4 — step4_rocks.py · a second pool + waves

Section titled “step 4 — step4_rocks.py · a second pool + waves”

Starship step 4 Reuse the pool pattern for enemies. Rocks come in 3 sizes; we keep the size in sprite.data and pick the matching ring bitmap with sprite.bitmap. new_wave(n) spreads n rocks around the screen, each drifting. You see: drifting rock rings. Try it: change the wave count or rock speed.

20 collapsed lines
# Starship -- step 4: asteroids to dodge (a second pool + waves).
#
# What you learn: reuse the pool pattern for enemies, and spawn a "wave". Rocks come
# in 3 sizes (we keep the size in sprite.data and pick a matching ring bitmap with
# sprite.bitmap). A wave spreads N rocks around the screen, each drifting with its
# own velocity. shp.ring draws a hollow circle.
#
# New vs step 3: a rocks Pool with per-rock size/velocity, choosing a bitmap per
# rock (sprite.bitmap), spawning a wave.
#
# Run: python3 sim/run.py tutorials/02-starship/step4_rocks.py --shot /tmp/p4.png
import math
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_shapes as shp
import picogame_pool
W, H = 320, 240
BACKGROUND = pg.rgb565(0, 0, 8)
FRAMES = 16
scene, _, _ = picogame_game.setup(background=BACKGROUND)
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
ship_bitmap = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], FRAMES, pg.rgb565(200, 220, 255))
DIRS = [(math.sin(frame * 2 * math.pi / FRAMES), -math.cos(frame * 2 * math.pi / FRAMES)) for frame in range(FRAMES)]
bullet_bitmap = shp.circle(4, pg.rgb565(255, 255, 120))
ROCK_BITMAP = [shp.ring(40, pg.rgb565(170, 140, 100), 3), # size 0 = big
shp.ring(24, pg.rgb565(170, 140, 100), 3), # size 1 = medium
shp.ring(13, pg.rgb565(170, 140, 100), 2)] # size 2 = small
ship = pg.Sprite(ship_bitmap, W // 2, H // 2)
ship.anchor = (0.5, 0.5)
rocks = picogame_pool.Pool(scene, ROCK_BITMAP[0], 16, anchor=(0.5, 0.5)) # NEW
bullets = picogame_pool.Pool(scene, bullet_bitmap, 6, anchor=(0.5, 0.5))
scene.add(ship)
angle = 0
velocity_x = velocity_y = 0.0
fire_cooldown = 0
wave = 3
def wrap(x, y):
return x % W, y % H
def spawn_rock(size, x, y, velocity_x, velocity_y):
rock = rocks.spawn()
if rock is None:
return
rock.data = {"size": size, "velocity_x": velocity_x, "velocity_y": velocity_y}
rock.bitmap = ROCK_BITMAP[size] # pick the bitmap for this size
rock.fx, rock.fy = float(x), float(y)
def new_wave(count):
for i in range(count):
angle_rad = i * 2 * math.pi / count
spawn_rock(0, (W // 2 + int(140 * math.cos(angle_rad))) % W,
(H // 2 + int(110 * math.sin(angle_rad))) % H,
math.cos(angle_rad) * 1.2, math.sin(angle_rad) * 1.2)
new_wave(wave)
32 collapsed lines
while True:
btn.poll()
fire_cooldown -= 1
if btn.is_pressed(btn.LEFT):
angle = (angle - 1) % FRAMES
if btn.is_pressed(btn.RIGHT):
angle = (angle + 1) % FRAMES
delta_x, delta_y = DIRS[angle]
if btn.is_pressed(btn.UP):
velocity_x += delta_x * 0.25; velocity_y += delta_y * 0.25
speed = math.sqrt(velocity_x * velocity_x + velocity_y * velocity_y)
if speed > 5:
velocity_x *= 5 / speed; velocity_y *= 5 / speed
velocity_x *= 0.99; velocity_y *= 0.99
ship.fx, ship.fy = wrap(ship.fx + velocity_x, ship.fy + velocity_y)
ship.frame = angle
if btn.just_pressed(btn.B) and fire_cooldown <= 0:
bullet = bullets.spawn()
if bullet:
bullet.data = {"velocity_x": delta_x * 7, "velocity_y": delta_y * 7, "life": 30}
bullet.move(ship.x, ship.y)
fire_cooldown = 6
for bullet in bullets.items:
if not bullet.visible:
continue
bullet.data["life"] -= 1
if bullet.data["life"] <= 0:
bullets.free(bullet)
continue
bullet.fx, bullet.fy = wrap(bullet.fx + bullet.data["velocity_x"], bullet.fy + bullet.data["velocity_y"])
# drift the rocks
for rock in rocks.items:
if not rock.visible:
continue
rock.fx, rock.fy = wrap(rock.fx + rock.data["velocity_x"], rock.fy + rock.data["velocity_y"])
scene.refresh()
clock.tick()
▶ Try it in the browser

step 5 — step5_collide.py · circular hits + splitting

Section titled “step 5 — step5_collide.py · circular hits + splitting”

Starship step 5 a.near(b, r) is a fast, no-sqrt distance test on the sprites’ centres, ideal for round things. A bullet that hits a rock frees both; a big/medium rock splits into two smaller ones flying apart. A rock reaching the ship costs a life, grants brief invulnerability (i-frames, shown by blinking ship.visible), and respawns. You see: you can shoot rocks apart and get hit. Try it: change ROCK_RADIUS or the split velocities.

21 collapsed lines
# Starship -- step 5: shooting rocks (and getting hit).
#
# What you learn: circular collision + spawning on destruction. sprite.near(other, r)
# (a, b, r) is a fast no-sqrt distance test reading sprite positions -- ideal for
# round things. A bullet that hits a rock frees both; a big/medium rock SPLITS into
# two smaller rocks flying apart. A rock that reaches the ship costs a life and
# triggers a brief invulnerability (i-frames) + respawn so you don't die instantly.
#
# New vs step 4: sprite.near (circular hit), splitting rocks, lives + i-frames + respawn,
# blinking the ship while invulnerable (ship.visible toggled).
#
# Run: python3 sim/run.py tutorials/02-starship/step5_collide.py --hold B --shot /tmp/p5.png
import math
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_shapes as shp
import picogame_pool
W, H = 320, 240
BACKGROUND = pg.rgb565(0, 0, 8)
FRAMES = 16
scene, _, _ = picogame_game.setup(background=BACKGROUND)
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
ship_bitmap = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], FRAMES, pg.rgb565(200, 220, 255))
DIRS = [(math.sin(frame * 2 * math.pi / FRAMES), -math.cos(frame * 2 * math.pi / FRAMES)) for frame in range(FRAMES)]
bullet_bitmap = shp.circle(4, pg.rgb565(255, 255, 120))
ROCK_BITMAP = [shp.ring(40, pg.rgb565(170, 140, 100), 3),
shp.ring(24, pg.rgb565(170, 140, 100), 3),
shp.ring(13, pg.rgb565(170, 140, 100), 2)]
ROCK_RADIUS = [20, 12, 6] # collision radius per size
ship = pg.Sprite(ship_bitmap, W // 2, H // 2)
ship.anchor = (0.5, 0.5)
rocks = picogame_pool.Pool(scene, ROCK_BITMAP[0], 16, anchor=(0.5, 0.5))
bullets = picogame_pool.Pool(scene, bullet_bitmap, 6, anchor=(0.5, 0.5))
scene.add(ship)
angle = 0
velocity_x = velocity_y = 0.0
fire_cooldown = 0
lives = 3
invincible = 60 # invincibility frames
wave = 3
frame = 0
def wrap(x, y):
return x % W, y % H
def spawn_rock(size, x, y, velocity_x, velocity_y):
rock = rocks.spawn()
if rock is None:
return
rock.data = {"size": size, "velocity_x": velocity_x, "velocity_y": velocity_y}
rock.bitmap = ROCK_BITMAP[size]
rock.fx, rock.fy = float(x), float(y)
def new_wave(count):
for i in range(count):
angle_rad = i * 2 * math.pi / count
spawn_rock(0, (W // 2 + int(140 * math.cos(angle_rad))) % W,
(H // 2 + int(110 * math.sin(angle_rad))) % H,
math.cos(angle_rad) * 1.2, math.sin(angle_rad) * 1.2)
def respawn():
global velocity_x, velocity_y, invincible
ship.fx, ship.fy = float(W // 2), float(H // 2)
velocity_x = velocity_y = 0.0
invincible = 90
new_wave(wave)
while True:
btn.poll()
frame += 1
fire_cooldown -= 1
if invincible > 0:
invincible -= 1
if btn.is_pressed(btn.LEFT):
33 collapsed lines
angle = (angle - 1) % FRAMES
if btn.is_pressed(btn.RIGHT):
angle = (angle + 1) % FRAMES
delta_x, delta_y = DIRS[angle]
if btn.is_pressed(btn.UP):
velocity_x += delta_x * 0.25; velocity_y += delta_y * 0.25
speed = math.sqrt(velocity_x * velocity_x + velocity_y * velocity_y)
if speed > 5:
velocity_x *= 5 / speed; velocity_y *= 5 / speed
velocity_x *= 0.99; velocity_y *= 0.99
ship.fx, ship.fy = wrap(ship.fx + velocity_x, ship.fy + velocity_y)
ship.frame = angle
ship.visible = (invincible <= 0) or (frame & 1) # blink while invincible
if btn.just_pressed(btn.B) and fire_cooldown <= 0:
bullet = bullets.spawn()
if bullet:
bullet.data = {"velocity_x": delta_x * 7, "velocity_y": delta_y * 7, "life": 30}
bullet.move(ship.x, ship.y)
fire_cooldown = 6
for bullet in bullets.items:
if not bullet.visible:
continue
bullet.data["life"] -= 1
if bullet.data["life"] <= 0:
bullets.free(bullet)
continue
bullet.fx, bullet.fy = wrap(bullet.fx + bullet.data["velocity_x"], bullet.fy + bullet.data["velocity_y"])
for rock in rocks.items:
if not rock.visible:
continue
rock.fx, rock.fy = wrap(rock.fx + rock.data["velocity_x"], rock.fy + rock.data["velocity_y"])
size = rock.data["size"]
radius = ROCK_RADIUS[size]
# bullet hits this rock?
for bullet in bullets.items:
if not bullet.visible:
continue
if bullet.near(rock, radius):
bullets.free(bullet)
rocks.free(rock)
if size < 2: # split into two smaller, flying apart
for sign in (-1, 1):
spawn_rock(size + 1, rock.fx, rock.fy,
rock.data["velocity_x"] + sign * 0.8, rock.data["velocity_y"] - sign * 0.8)
break
# rock reaches the ship?
if invincible <= 0 and rock.visible and ship.near(rock, radius + 6):
lives -= 1
respawn()
if lives < 0:
lives = 3
rocks.free_all()
new_wave(wave)
break
scene.refresh()
clock.tick()
▶ Try it in the browser

step 6 — step6_waves.py · score + progression

Section titled “step 6 — step6_waves.py · score + progression”

Starship step 6 Smaller rocks score more. picogame_ui’s SceneLabel shows score + ships. When rocks.count() == 0 the field is clear, so we launch the next, bigger wave, and the game ramps up. You see: a score that climbs and waves that grow. Try it: change the scoring or wave growth.

21 collapsed lines
# Starship -- step 6: score, lives, and escalating waves.
#
# What you learn: a scoring/progression loop. Smaller rocks are worth more. A HUD
# shows score + ships. When the field is clear (rocks.count() == 0) we start the
# next, bigger wave -- the game keeps going and ramps up.
#
# New vs step 5: picogame_ui.SceneLabel, scoring, rocks.count() to detect a cleared
# field, growing waves.
#
# Run: python3 sim/run.py tutorials/02-starship/step6_waves.py --hold B --shot /tmp/p6.png
import math
import terminalio
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_shapes as shp
import picogame_pool
import picogame_ui as ui
W, H = 320, 240
BACKGROUND = pg.rgb565(0, 0, 8)
FRAMES = 16
scene, _, _ = picogame_game.setup(background=BACKGROUND)
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
ship_bitmap = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], FRAMES, pg.rgb565(200, 220, 255))
DIRS = [(math.sin(frame * 2 * math.pi / FRAMES), -math.cos(frame * 2 * math.pi / FRAMES)) for frame in range(FRAMES)]
bullet_bitmap = shp.circle(4, pg.rgb565(255, 255, 120))
ROCK_BITMAP = [shp.ring(40, pg.rgb565(170, 140, 100), 3),
shp.ring(24, pg.rgb565(170, 140, 100), 3),
shp.ring(13, pg.rgb565(170, 140, 100), 2)]
ROCK_RADIUS = [20, 12, 6]
ship = pg.Sprite(ship_bitmap, W // 2, H // 2)
ship.anchor = (0.5, 0.5)
rocks = picogame_pool.Pool(scene, ROCK_BITMAP[0], 16, anchor=(0.5, 0.5))
bullets = picogame_pool.Pool(scene, bullet_bitmap, 6, anchor=(0.5, 0.5))
scene.add(ship)
hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, pg.rgb565(255, 255, 255), BACKGROUND)
angle = 0
velocity_x = velocity_y = 0.0
fire_cooldown = 0
lives = 3
invincible = 60
wave = 3
score = 0
frame = 0
79 collapsed lines
def wrap(x, y):
return x % W, y % H
def spawn_rock(size, x, y, velocity_x, velocity_y):
rock = rocks.spawn()
if rock is None:
return
rock.data = {"size": size, "velocity_x": velocity_x, "velocity_y": velocity_y}
rock.bitmap = ROCK_BITMAP[size]
rock.fx, rock.fy = float(x), float(y)
def new_wave(count):
for i in range(count):
angle_rad = i * 2 * math.pi / count
spawn_rock(0, (W // 2 + int(140 * math.cos(angle_rad))) % W,
(H // 2 + int(110 * math.sin(angle_rad))) % H,
math.cos(angle_rad) * 1.2, math.sin(angle_rad) * 1.2)
def respawn():
global velocity_x, velocity_y, invincible
ship.fx, ship.fy = float(W // 2), float(H // 2)
velocity_x = velocity_y = 0.0
invincible = 90
new_wave(wave)
while True:
btn.poll()
frame += 1
fire_cooldown -= 1
if invincible > 0:
invincible -= 1
if btn.is_pressed(btn.LEFT):
angle = (angle - 1) % FRAMES
if btn.is_pressed(btn.RIGHT):
angle = (angle + 1) % FRAMES
delta_x, delta_y = DIRS[angle]
if btn.is_pressed(btn.UP):
velocity_x += delta_x * 0.25; velocity_y += delta_y * 0.25
speed = math.sqrt(velocity_x * velocity_x + velocity_y * velocity_y)
if speed > 5:
velocity_x *= 5 / speed; velocity_y *= 5 / speed
velocity_x *= 0.99; velocity_y *= 0.99
ship.fx, ship.fy = wrap(ship.fx + velocity_x, ship.fy + velocity_y)
ship.frame = angle
ship.visible = (invincible <= 0) or (frame & 1)
if btn.just_pressed(btn.B) and fire_cooldown <= 0:
bullet = bullets.spawn()
if bullet:
bullet.data = {"velocity_x": delta_x * 7, "velocity_y": delta_y * 7, "life": 30}
bullet.move(ship.x, ship.y)
fire_cooldown = 6
for bullet in bullets.items:
if not bullet.visible:
continue
bullet.data["life"] -= 1
if bullet.data["life"] <= 0:
bullets.free(bullet)
continue
bullet.fx, bullet.fy = wrap(bullet.fx + bullet.data["velocity_x"], bullet.fy + bullet.data["velocity_y"])
for rock in rocks.items:
if not rock.visible:
continue
rock.fx, rock.fy = wrap(rock.fx + rock.data["velocity_x"], rock.fy + rock.data["velocity_y"])
size = rock.data["size"]
radius = ROCK_RADIUS[size]
for bullet in bullets.items:
if not bullet.visible:
continue
if bullet.near(rock, radius):
bullets.free(bullet)
rocks.free(rock)
score += (3 - size) * 20 # smaller rock -> more points
if size < 2:
for sign in (-1, 1):
spawn_rock(size + 1, rock.fx, rock.fy,
rock.data["velocity_x"] + sign * 0.8, rock.data["velocity_y"] - sign * 0.8)
break
if invincible <= 0 and rock.visible and ship.near(rock, radius + 6):
lives -= 1
respawn()
if lives < 0:
lives = 3
score = 0
rocks.free_all()
new_wave(wave)
break
if rocks.count() == 0: # field cleared -> next, bigger wave
wave += 1
new_wave(wave)
hud.set("SCORE %05d SHIPS %d" % (score, max(0, lives)))
scene.refresh()
clock.tick()
▶ Try it in the browser

step 7 — step7_particles.py · explosions, exhaust, sound

Section titled “step 7 — step7_particles.py · explosions, exhaust, sound”

Starship step 7 One Particles(fade=True) system serves two effects: a burst when a rock is destroyed (many fast, fading sparks) and a thrust flame (a couple of short sparks behind the ship each frame while thrusting). fade=True dims particles as they age. tone() gives a fire beep and a lower boom. You see: explosions and an engine trail. Try it: change the explosion count/life or the beep frequencies.

24 collapsed lines
# Starship -- step 7: explosions, thrust flame, and sound.
#
# What you learn: particles for two different effects, plus audio. One Particles
# system gives us BOTH a burst on a rock's destruction (many fast, fading sparks)
# and a thrust flame (a few short-lived sparks behind the ship each frame while
# thrusting). fade=True dims particles as they age. tone() beeps for firing and a
# lower boom for explosions. Audio is optional (try/except) so it's silent but safe
# where there's no audio output.
#
# New vs step 6: pg.Particles(fade=True), emit for explosions AND exhaust,
# picogame_audio for fire/boom beeps.
#
# Run: python3 sim/run.py tutorials/02-starship/step7_particles.py --hold UP,B --shot /tmp/p7.png
import math
import terminalio
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_shapes as shp
import picogame_pool
import picogame_ui as ui
W, H = 320, 240
BACKGROUND = pg.rgb565(0, 0, 8)
FRAMES = 16
scene, _, _ = picogame_game.setup(background=BACKGROUND)
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
try:
import picogame_audio
audio = picogame_audio.Audio()
snd_fire = picogame_audio.tone(880, 25)
snd_boom = picogame_audio.tone(160, 90)
except Exception:
audio = None
ship_bitmap = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], FRAMES, pg.rgb565(200, 220, 255))
DIRS = [(math.sin(frame * 2 * math.pi / FRAMES), -math.cos(frame * 2 * math.pi / FRAMES)) for frame in range(FRAMES)]
bullet_bitmap = shp.circle(4, pg.rgb565(255, 255, 120))
ROCK_BITMAP = [shp.ring(40, pg.rgb565(170, 140, 100), 3),
shp.ring(24, pg.rgb565(170, 140, 100), 3),
shp.ring(13, pg.rgb565(170, 140, 100), 2)]
ROCK_RADIUS = [20, 12, 6]
ship = pg.Sprite(ship_bitmap, W // 2, H // 2)
ship.anchor = (0.5, 0.5)
rocks = picogame_pool.Pool(scene, ROCK_BITMAP[0], 16, anchor=(0.5, 0.5))
bullets = picogame_pool.Pool(scene, bullet_bitmap, 6, anchor=(0.5, 0.5))
sparks = pg.Particles(160, size=2, fade=True) # NEW
scene.add(sparks)
scene.add(ship)
55 collapsed lines
hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, pg.rgb565(255, 255, 255), BACKGROUND)
angle = 0
velocity_x = velocity_y = 0.0
fire_cooldown = 0
lives = 3
invincible = 60
wave = 3
score = 0
frame = 0
def wrap(x, y):
return x % W, y % H
def spawn_rock(size, x, y, velocity_x, velocity_y):
rock = rocks.spawn()
if rock is None:
return
rock.data = {"size": size, "velocity_x": velocity_x, "velocity_y": velocity_y}
rock.bitmap = ROCK_BITMAP[size]
rock.fx, rock.fy = float(x), float(y)
def new_wave(count):
for i in range(count):
angle_rad = i * 2 * math.pi / count
spawn_rock(0, (W // 2 + int(140 * math.cos(angle_rad))) % W,
(H // 2 + int(110 * math.sin(angle_rad))) % H,
math.cos(angle_rad) * 1.2, math.sin(angle_rad) * 1.2)
def respawn():
global velocity_x, velocity_y, invincible
ship.fx, ship.fy = float(W // 2), float(H // 2)
velocity_x = velocity_y = 0.0
invincible = 90
new_wave(wave)
while True:
btn.poll()
frame += 1
fire_cooldown -= 1
if invincible > 0:
invincible -= 1
if btn.is_pressed(btn.LEFT):
angle = (angle - 1) % FRAMES
if btn.is_pressed(btn.RIGHT):
angle = (angle + 1) % FRAMES
delta_x, delta_y = DIRS[angle]
if btn.is_pressed(btn.UP):
velocity_x += delta_x * 0.25; velocity_y += delta_y * 0.25
# thrust flame: a couple of orange sparks shot out the back
sparks.emit(ship.x - int(delta_x * 8), ship.y - int(delta_y * 8), 2, 2, 10, pg.rgb565(255, 150, 40))
speed = math.sqrt(velocity_x * velocity_x + velocity_y * velocity_y)
if speed > 5:
velocity_x *= 5 / speed; velocity_y *= 5 / speed
velocity_x *= 0.99; velocity_y *= 0.99
ship.fx, ship.fy = wrap(ship.fx + velocity_x, ship.fy + velocity_y)
ship.frame = angle
ship.visible = (invincible <= 0) or (frame & 1)
if btn.just_pressed(btn.B) and fire_cooldown <= 0:
bullet = bullets.spawn()
if bullet:
bullet.data = {"velocity_x": delta_x * 7, "velocity_y": delta_y * 7, "life": 30}
bullet.move(ship.x, ship.y)
fire_cooldown = 6
if audio:
audio.sfx(snd_fire)
for bullet in bullets.items:
if not bullet.visible:
continue
bullet.data["life"] -= 1
if bullet.data["life"] <= 0:
bullets.free(bullet)
continue
bullet.fx, bullet.fy = wrap(bullet.fx + bullet.data["velocity_x"], bullet.fy + bullet.data["velocity_y"])
for rock in rocks.items:
if not rock.visible:
continue
rock.fx, rock.fy = wrap(rock.fx + rock.data["velocity_x"], rock.fy + rock.data["velocity_y"])
size = rock.data["size"]
radius = ROCK_RADIUS[size]
for bullet in bullets.items:
if not bullet.visible:
continue
if bullet.near(rock, radius):
bullets.free(bullet)
rocks.free(rock)
score += (3 - size) * 20
sparks.emit(rock.x, rock.y, 18, 3, 26, pg.rgb565(255, 200, 120)) # explosion
if audio:
audio.sfx(snd_boom)
if size < 2:
for sign in (-1, 1):
spawn_rock(size + 1, rock.fx, rock.fy,
rock.data["velocity_x"] + sign * 0.8, rock.data["velocity_y"] - sign * 0.8)
break
if invincible <= 0 and rock.visible and ship.near(rock, radius + 6):
lives -= 1
sparks.emit(ship.x, ship.y, 24, 4, 30, pg.rgb565(120, 200, 255))
respawn()
if lives < 0:
lives = 3
score = 0
rocks.free_all()
new_wave(wave)
break
if rocks.count() == 0:
wave += 1
new_wave(wave)
sparks.tick() # advance all particles
hud.set("SCORE %05d SHIPS %d" % (score, max(0, lives)))
scene.refresh()
clock.tick()
▶ Try it in the browser

step 8 — step8_states.py · the state machine (capstone)

Section titled “step 8 — step8_states.py · the state machine (capstone)”

Starship step 8 A finished game needs more than one mode — and it’s worth tidying the code as it grows. All the run’s mutable values move into one State object (st), re-initialised in place by reset(), and the whole loop moves into a main() function (inside a function its names resolve as fast locals — a small measured win on device). st.mode runs TITLE → PLAY → GAMEOVER; new_game() calls st.reset() to restart; one centred SceneLabel shows the message for the non-play states; death ends the run instead of silently restarting. This is the State + main() shape every bigger game grows into — see Game patterns and the ready-to-run skeleton. You see: title → play → game over → title. Try it: add a high score that survives across games (keep it a module global, not in State; see picogame_save for NVM persistence).

27 collapsed lines
# Starship -- step 8: game states (title / playing / game over) + restart, tidied into State + main().
#
# What you learn: a state machine -- the backbone of a finished game -- built the way picogame
# recommends. All the run's mutable values live in ONE `class State` (st), re-initialised IN PLACE by
# reset() (st is created once and never reassigned). The per-frame loop lives in a main() function --
# inside a function its names resolve as fast locals instead of globals-dict lookups, a measured win on
# device. st.mode runs TITLE -> PLAY -> GAMEOVER; new_game() calls st.reset() to restart. This is the
# difference between a mechanic and a game, in the shape every bigger game grows into
# (see /concepts/patterns/).
#
# New vs step 7: a State object + a mode machine (TITLE/PLAY/GAMEOVER), the loop moved into main(),
# new_game() reset, a centred message label, ending the run on death instead of silently restarting,
# plus a confirm blip on start/menu and a low boom on game over.
#
# Run: python3 sim/run.py tutorials/02-starship/step8_states.py --hold B --shot /tmp/p8.png
import math
import terminalio
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import picogame_shapes as shp
import picogame_pool
import picogame_ui as ui
W, H = 320, 240
BACKGROUND = pg.rgb565(0, 0, 8)
FRAMES = 16
TITLE, PLAY, GAMEOVER = 0, 1, 2
scene, _, _ = picogame_game.setup(background=BACKGROUND)
6 collapsed lines
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(30)
try:
import picogame_audio
audio = picogame_audio.Audio()
snd_fire = picogame_audio.tone(880, 25)
snd_boom = picogame_audio.tone(160, 90)
snd_start = picogame_audio.tone(660, 40) # bright blip: start / menu confirm
snd_die = picogame_audio.tone(120, 260) # low boom: game over (descending = lose)
except Exception:
audio = None
11 collapsed lines
ship_bitmap = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], FRAMES, pg.rgb565(200, 220, 255))
DIRS = [(math.sin(frame * 2 * math.pi / FRAMES), -math.cos(frame * 2 * math.pi / FRAMES)) for frame in range(FRAMES)]
bullet_bitmap = shp.circle(4, pg.rgb565(255, 255, 120))
ROCK_BITMAP = [shp.ring(40, pg.rgb565(170, 140, 100), 3),
shp.ring(24, pg.rgb565(170, 140, 100), 3),
shp.ring(13, pg.rgb565(170, 140, 100), 2)]
ROCK_RADIUS = [20, 12, 6]
ship = pg.Sprite(ship_bitmap, W // 2, H // 2)
ship.anchor = (0.5, 0.5)
rocks = picogame_pool.Pool(scene, ROCK_BITMAP[0], 16, anchor=(0.5, 0.5))
bullets = picogame_pool.Pool(scene, bullet_bitmap, 6, anchor=(0.5, 0.5))
sparks = pg.Particles(160, size=2, fade=True)
scene.add(sparks)
scene.add(ship)
hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, pg.rgb565(255, 255, 255), BACKGROUND)
msg = ui.SceneLabel(scene, pg, terminalio.FONT, 96, 112, pg.rgb565(255, 255, 255), BACKGROUND)
# ALL the run's mutable values in ONE object. reset() lists every field's default in one place, so a
# restart is st.reset() -- and st is created ONCE below and never reassigned (a never-rebound singleton),
# which is what lets main() and new_game() just mutate st.* with no `global`. Engine objects (ship, the
# pools, sparks, clock, the labels) stay module-level names, NOT in State.
class State:
def __init__(self):
self.reset()
def reset(self):
self.mode = TITLE # TITLE -> PLAY -> GAMEOVER
self.angle = 0
self.vx = self.vy = 0.0
self.fire_cooldown = 0
self.lives = 3
self.invincible = 0
self.wave = 3
self.score = 0
st = State()
def wrap(x, y):
return x % W, y % H
17 collapsed lines
def spawn_rock(size, x, y, velocity_x, velocity_y):
rock = rocks.spawn()
if rock is None:
return
rock.data = {"size": size, "velocity_x": velocity_x, "velocity_y": velocity_y}
rock.bitmap = ROCK_BITMAP[size]
rock.fx, rock.fy = float(x), float(y)
def new_wave(count):
for i in range(count):
angle_rad = i * 2 * math.pi / count
spawn_rock(0, (W // 2 + int(140 * math.cos(angle_rad))) % W,
(H // 2 + int(110 * math.sin(angle_rad))) % H,
math.cos(angle_rad) * 1.2, math.sin(angle_rad) * 1.2)
def new_game():
st.reset() # every field back to its default, IN PLACE
st.mode = PLAY
st.invincible = 60 # a mercy window on the fresh run
ship.fx, ship.fy = float(W // 2), float(H // 2)
rocks.free_all()
bullets.free_all()
sparks.clear()
new_wave(st.wave)
def main():
# The per-frame loop lives in a FUNCTION: its names (st, the helpers, the loop's own frame counter)
# resolve as fast array-indexed locals instead of globals-dict lookups.
frame = 0
while True:
btn.poll()
frame += 1
if st.mode == TITLE:
ship.visible = False
msg.set("STARSHIP PRESS A")
if btn.just_pressed(btn.A):
if audio:
audio.sfx(snd_start) # confirm blip on start
new_game()
scene.refresh()
clock.tick()
continue
if st.mode == GAMEOVER:
msg.set("GAME OVER %05d A=MENU" % st.score)
if btn.just_pressed(btn.A):
if audio:
audio.sfx(snd_start) # confirm blip back to menu
st.mode = TITLE
sparks.tick()
scene.refresh()
clock.tick()
continue
if st.mode == PLAY:
msg.set(" ")
st.fire_cooldown -= 1
if st.invincible > 0:
st.invincible -= 1
if btn.is_pressed(btn.LEFT):
st.angle = (st.angle - 1) % FRAMES
if btn.is_pressed(btn.RIGHT):
st.angle = (st.angle + 1) % FRAMES
delta_x, delta_y = DIRS[st.angle]
if btn.is_pressed(btn.UP):
st.vx += delta_x * 0.25; st.vy += delta_y * 0.25
sparks.emit(ship.x - int(delta_x * 8), ship.y - int(delta_y * 8), 2, 2, 10, pg.rgb565(255, 150, 40))
speed = math.sqrt(st.vx * st.vx + st.vy * st.vy)
if speed > 5:
st.vx *= 5 / speed; st.vy *= 5 / speed
st.vx *= 0.99; st.vy *= 0.99
ship.fx, ship.fy = wrap(ship.fx + st.vx, ship.fy + st.vy)
ship.frame = st.angle
ship.visible = (st.invincible <= 0) or (frame & 1)
if btn.just_pressed(btn.B) and st.fire_cooldown <= 0:
bullet = bullets.spawn()
if bullet:
bullet.data = {"velocity_x": delta_x * 7, "velocity_y": delta_y * 7, "life": 30}
bullet.move(ship.x, ship.y)
st.fire_cooldown = 6
if audio:
audio.sfx(snd_fire)
for bullet in bullets.items:
if not bullet.visible:
continue
bullet.data["life"] -= 1
if bullet.data["life"] <= 0:
bullets.free(bullet)
continue
bullet.fx, bullet.fy = wrap(bullet.fx + bullet.data["velocity_x"], bullet.fy + bullet.data["velocity_y"])
for rock in rocks.items:
if not rock.visible:
continue
rock.fx, rock.fy = wrap(rock.fx + rock.data["velocity_x"], rock.fy + rock.data["velocity_y"])
size = rock.data["size"]
radius = ROCK_RADIUS[size]
for bullet in bullets.items:
if not bullet.visible:
continue
if bullet.near(rock, radius):
bullets.free(bullet)
rocks.free(rock)
st.score += (3 - size) * 20
sparks.emit(rock.x, rock.y, 18, 3, 26, pg.rgb565(255, 200, 120))
if audio:
audio.sfx(snd_boom)
if size < 2:
for sign in (-1, 1):
spawn_rock(size + 1, rock.fx, rock.fy,
rock.data["velocity_x"] + sign * 0.8, rock.data["velocity_y"] - sign * 0.8)
break
if st.invincible <= 0 and rock.visible and ship.near(rock, radius + 6):
st.lives -= 1
sparks.emit(ship.x, ship.y, 24, 4, 30, pg.rgb565(120, 200, 255))
ship.fx, ship.fy = float(W // 2), float(H // 2)
st.vx = st.vy = 0.0
st.invincible = 90
if st.lives < 0:
if audio:
audio.sfx(snd_die) # low boom on game over
st.mode = GAMEOVER
break
if rocks.count() == 0:
st.wave += 1
new_wave(st.wave)
sparks.tick()
hud.set("SCORE %05d SHIPS %d" % (st.score, max(0, st.lives)))
scene.refresh()
clock.tick()
main()
▶ Try it in the browser

Where to go next: the web editor + picogame_scene, to design levels as data and load them, instead of hand-coding placement. See the scene format for how that data is structured, and try the sibling tutorials: 01-bounce and 03-quest.