Tutorial 2 — Starship
Builds a small Asteroids-style shooter. Assumes you’ve done 01-bounce (render loop, input, sub-pixel movement, collision, HUD, particles). Here we add the things a paddle game can’t teach: rotation, vector thrust, object pools, splitting enemies, and a proper game-state machine.
The full source for this tutorial lives on GitHub: tutorials/02-starship.
Run any step:
python3 sim/run.py tutorials/02-starship/stepN_name.py --hold UP,B --shot /tmp/out.pngstep 1 — step1_ship.py · a shaped sprite (recap)
Section titled “step 1 — step1_ship.py · a shaped sprite (recap)”
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 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 pgimport picogame_gameimport picogame_clockimport picogame_shapes as shp
W, H = 320, 240BG = pg.rgb565(0, 0, 8)
scene, bufA, bufB = picogame_game.setup(background=BG)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 centrescene.add(ship)
while True: scene.refresh() clock.tick()step 2 — step2_fly.py · rotation, thrust, wrap
Section titled “step 2 — step2_fly.py · rotation, thrust, wrap”
For a ship that spins constantly we bake the rotation into frames — crisper and cheaper than runtime sprite.angle: shp.poly_frames(size, points, N, colour) renders a polygon at N angles into one multi-frame bitmap, and ship.frame = angle shows the right one. 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.
21 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 (vx, vy); 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 mathimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_shapes as shp
W, H = 320, 240BG = pg.rgb565(0, 0, 8)NF = 16 # number of baked rotation frames
scene, bufA, bufB = picogame_game.setup(background=BG)btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
# a ship polygon (points around the centre, +y is down), baked at NF anglesship_bm = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], NF, pg.rgb565(200, 220, 255))# facing unit vector for each frame: frame 0 points up (-y)DIRS = [(math.sin(f * 2 * math.pi / NF), -math.cos(f * 2 * math.pi / NF)) for f in range(NF)]
ship = pg.Sprite(ship_bm, W // 2, H // 2)ship.anchor = (0.5, 0.5)scene.add(ship)
ang = 0 # current rotation framevx = vy = 0.0 # velocity
def wrap(x, y): return x % W, y % H
while True: btn.poll() if btn.is_pressed(btn.LEFT): ang = (ang - 1) % NF if btn.is_pressed(btn.RIGHT): ang = (ang + 1) % NF dx, dy = DIRS[ang] if btn.is_pressed(btn.UP): vx += dx * 0.25 # accelerate along the facing vector vy += dy * 0.25
speed = math.sqrt(vx * vx + vy * vy) # cap top speed if speed > 5: vx *= 5 / speed vy *= 5 / speed vx *= 0.99 # gentle drag vy *= 0.99
ship.fx, ship.fy = wrap(ship.fx + vx, ship.fy + vy) # sub-pixel position + wrap ship.frame = ang # show the matching rotation
scene.refresh() clock.tick()step 3 — step3_shoot.py · object pools
Section titled “step 3 — step3_shoot.py · object pools”
Creating sprites at runtime churns memory. Instead, picogame_pool.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, its position in fx/fy. A cooldown caps the fire rate; just_pressed(B)
fires on a fresh press. You see: a stream of bullets that expire. Try it: change the
pool size or fire_cd.
21 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 mathimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_shapes as shpimport picogame_pool
W, H = 320, 240BG = pg.rgb565(0, 0, 8)NF = 16
scene, bufA, bufB = picogame_game.setup(background=BG)btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
ship_bm = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], NF, pg.rgb565(200, 220, 255))DIRS = [(math.sin(f * 2 * math.pi / NF), -math.cos(f * 2 * math.pi / NF)) for f in range(NF)]bullet_bm = shp.circle(4, pg.rgb565(255, 255, 120))
ship = pg.Sprite(ship_bm, W // 2, H // 2)ship.anchor = (0.5, 0.5)bullets = picogame_pool.Pool(scene, bullet_bm, 6, anchor=(0.5, 0.5)) # NEW: 6-bullet poolscene.add(ship) # add ship AFTER the pool so it draws on top
ang = 0vx = vy = 0.0fire_cd = 0
def wrap(x, y): return x % W, y % H
while True: btn.poll() fire_cd -= 1 if btn.is_pressed(btn.LEFT): ang = (ang - 1) % NF if btn.is_pressed(btn.RIGHT): ang = (ang + 1) % NF dx, dy = DIRS[ang] if btn.is_pressed(btn.UP): vx += dx * 0.25 vy += dy * 0.25 speed = math.sqrt(vx * vx + vy * vy) if speed > 5: vx *= 5 / speed; vy *= 5 / speed vx *= 0.99; vy *= 0.99 ship.fx, ship.fy = wrap(ship.fx + vx, ship.fy + vy) ship.frame = ang
# fire: a fresh B press, if the cooldown has elapsed and a slot is free if btn.just_pressed(btn.B) and fire_cd <= 0: b = bullets.spawn() if b: b.data = {"vx": dx * 7, "vy": dy * 7, "life": 30} b.move(ship.x, ship.y) fire_cd = 6
# advance live bullets; retire them when their life runs out for b in bullets.items: if not b.visible: continue b.data["life"] -= 1 if b.data["life"] <= 0: bullets.free(b) continue b.fx, b.fy = wrap(b.fx + b.data["vx"], b.fy + b.data["vy"])
scene.refresh() clock.tick()step 4 — step4_rocks.py · a second pool + waves
Section titled “step 4 — step4_rocks.py · a second pool + waves”
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.
19 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 mathimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_shapes as shpimport picogame_pool
W, H = 320, 240BG = pg.rgb565(0, 0, 8)NF = 16
scene, bufA, bufB = picogame_game.setup(background=BG)btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
ship_bm = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], NF, pg.rgb565(200, 220, 255))DIRS = [(math.sin(f * 2 * math.pi / NF), -math.cos(f * 2 * math.pi / NF)) for f in range(NF)]bullet_bm = shp.circle(4, pg.rgb565(255, 255, 120))ROCK_BM = [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_bm, W // 2, H // 2)ship.anchor = (0.5, 0.5)rocks = picogame_pool.Pool(scene, ROCK_BM[0], 16, anchor=(0.5, 0.5)) # NEWbullets = picogame_pool.Pool(scene, bullet_bm, 6, anchor=(0.5, 0.5))scene.add(ship)
ang = 0vx = vy = 0.0fire_cd = 0wave = 3
def wrap(x, y): return x % W, y % H
def spawn_rock(size, x, y, rvx, rvy): r = rocks.spawn() if r is None: return r.data = {"size": size, "vx": rvx, "vy": rvy} r.bitmap = ROCK_BM[size] # pick the bitmap for this size r.fx, r.fy = float(x), float(y)
def new_wave(n): for i in range(n): a = i * 2 * math.pi / n spawn_rock(0, (W // 2 + int(140 * math.cos(a))) % W, (H // 2 + int(110 * math.sin(a))) % H, math.cos(a) * 1.2, math.sin(a) * 1.2)
new_wave(wave)while True: btn.poll() fire_cd -= 1 if btn.is_pressed(btn.LEFT): ang = (ang - 1) % NF if btn.is_pressed(btn.RIGHT): ang = (ang + 1) % NF dx, dy = DIRS[ang] if btn.is_pressed(btn.UP): vx += dx * 0.25; vy += dy * 0.25 speed = math.sqrt(vx * vx + vy * vy) if speed > 5: vx *= 5 / speed; vy *= 5 / speed vx *= 0.99; vy *= 0.99 ship.fx, ship.fy = wrap(ship.fx + vx, ship.fy + vy) ship.frame = ang
if btn.just_pressed(btn.B) and fire_cd <= 0: b = bullets.spawn() if b: b.data = {"vx": dx * 7, "vy": dy * 7, "life": 30} b.move(ship.x, ship.y) fire_cd = 6 for b in bullets.items: if not b.visible: continue b.data["life"] -= 1 if b.data["life"] <= 0: bullets.free(b) continue b.fx, b.fy = wrap(b.fx + b.data["vx"], b.fy + b.data["vy"])
# drift the rocks for r in rocks.items: if not r.visible: continue r.fx, r.fy = wrap(r.fx + r.data["vx"], r.fy + r.data["vy"])
scene.refresh() clock.tick()step 5 — step5_collide.py · circular hits + splitting
Section titled “step 5 — step5_collide.py · circular hits + splitting”
picogame_collide.is_within(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 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_R or the split velocities.
21 collapsed lines
# Starship -- step 5: shooting rocks (and getting hit).## What you learn: circular collision + spawning on destruction. picogame_collide.is_within# (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: picogame_collide.is_within, 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 mathimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_shapes as shpimport picogame_poolimport picogame_collide as collide
W, H = 320, 240BG = pg.rgb565(0, 0, 8)NF = 16
scene, bufA, bufB = picogame_game.setup(background=BG)btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
ship_bm = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], NF, pg.rgb565(200, 220, 255))DIRS = [(math.sin(f * 2 * math.pi / NF), -math.cos(f * 2 * math.pi / NF)) for f in range(NF)]bullet_bm = shp.circle(4, pg.rgb565(255, 255, 120))ROCK_BM = [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_R = [20, 12, 6] # collision radius per size
ship = pg.Sprite(ship_bm, W // 2, H // 2)ship.anchor = (0.5, 0.5)rocks = picogame_pool.Pool(scene, ROCK_BM[0], 16, anchor=(0.5, 0.5))bullets = picogame_pool.Pool(scene, bullet_bm, 6, anchor=(0.5, 0.5))scene.add(ship)
ang = 0vx = vy = 0.0fire_cd = 0lives = 3inv = 60 # invulnerability frameswave = 3frame = 0
def wrap(x, y): return x % W, y % H
def spawn_rock(size, x, y, rvx, rvy): r = rocks.spawn() if r is None: return r.data = {"size": size, "vx": rvx, "vy": rvy} r.bitmap = ROCK_BM[size] r.fx, r.fy = float(x), float(y)
def new_wave(n): for i in range(n): a = i * 2 * math.pi / n spawn_rock(0, (W // 2 + int(140 * math.cos(a))) % W, (H // 2 + int(110 * math.sin(a))) % H, math.cos(a) * 1.2, math.sin(a) * 1.2)
def respawn(): global vx, vy, inv ship.fx, ship.fy = float(W // 2), float(H // 2) vx = vy = 0.0 inv = 90
new_wave(wave)while True: btn.poll() frame += 1 fire_cd -= 1 if inv > 0: inv -= 1
if btn.is_pressed(btn.LEFT): ang = (ang - 1) % NF if btn.is_pressed(btn.RIGHT): ang = (ang + 1) % NF dx, dy = DIRS[ang] if btn.is_pressed(btn.UP): vx += dx * 0.25; vy += dy * 0.25 speed = math.sqrt(vx * vx + vy * vy) if speed > 5: vx *= 5 / speed; vy *= 5 / speed vx *= 0.99; vy *= 0.99 ship.fx, ship.fy = wrap(ship.fx + vx, ship.fy + vy) ship.frame = ang ship.visible = (inv <= 0) or (frame & 1) # blink while invulnerable
if btn.just_pressed(btn.B) and fire_cd <= 0: b = bullets.spawn() if b: b.data = {"vx": dx * 7, "vy": dy * 7, "life": 30} b.move(ship.x, ship.y) fire_cd = 6 for b in bullets.items: if not b.visible: continue b.data["life"] -= 1 if b.data["life"] <= 0: bullets.free(b) continue b.fx, b.fy = wrap(b.fx + b.data["vx"], b.fy + b.data["vy"])
for r in rocks.items: if not r.visible: continue r.fx, r.fy = wrap(r.fx + r.data["vx"], r.fy + r.data["vy"]) size = r.data["size"] rr = ROCK_R[size] # bullet hits this rock? for b in bullets.items: if not b.visible: continue if collide.is_within(b, r, rr): bullets.free(b) rocks.free(r) if size < 2: # split into two smaller, flying apart for s in (-1, 1): spawn_rock(size + 1, r.fx, r.fy, r.data["vx"] + s * 0.8, r.data["vy"] - s * 0.8) break # rock reaches the ship? if inv <= 0 and r.visible and collide.is_within(ship, r, rr + 6): lives -= 1 respawn() if lives < 0: lives = 3 rocks.free_all() new_wave(wave) break
scene.refresh() clock.tick()step 6 — step6_waves.py · score + progression
Section titled “step 6 — step6_waves.py · score + progression”
Smaller rocks score more. A SceneLabel shows score + ships. When rocks.count() == 0 the
field is clear, so we launch the next, bigger wave — 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 mathimport terminalioimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_shapes as shpimport picogame_poolimport picogame_collide as collideimport picogame_ui as ui
W, H = 320, 240BG = pg.rgb565(0, 0, 8)NF = 16
scene, bufA, bufB = picogame_game.setup(background=BG)btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
ship_bm = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], NF, pg.rgb565(200, 220, 255))DIRS = [(math.sin(f * 2 * math.pi / NF), -math.cos(f * 2 * math.pi / NF)) for f in range(NF)]bullet_bm = shp.circle(4, pg.rgb565(255, 255, 120))ROCK_BM = [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_R = [20, 12, 6]
ship = pg.Sprite(ship_bm, W // 2, H // 2)ship.anchor = (0.5, 0.5)rocks = picogame_pool.Pool(scene, ROCK_BM[0], 16, anchor=(0.5, 0.5))bullets = picogame_pool.Pool(scene, bullet_bm, 6, anchor=(0.5, 0.5))scene.add(ship)hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, pg.rgb565(255, 255, 255), BG)
ang = 0vx = vy = 0.0fire_cd = 0lives = 3inv = 60wave = 3score = 0frame = 0
def wrap(x, y): return x % W, y % H
def spawn_rock(size, x, y, rvx, rvy): r = rocks.spawn() if r is None: return r.data = {"size": size, "vx": rvx, "vy": rvy} r.bitmap = ROCK_BM[size] r.fx, r.fy = float(x), float(y)
def new_wave(n): for i in range(n): a = i * 2 * math.pi / n spawn_rock(0, (W // 2 + int(140 * math.cos(a))) % W, (H // 2 + int(110 * math.sin(a))) % H, math.cos(a) * 1.2, math.sin(a) * 1.2)
def respawn(): global vx, vy, inv ship.fx, ship.fy = float(W // 2), float(H // 2) vx = vy = 0.0 inv = 90
new_wave(wave)while True: btn.poll() frame += 1 fire_cd -= 1 if inv > 0: inv -= 1
if btn.is_pressed(btn.LEFT): ang = (ang - 1) % NF if btn.is_pressed(btn.RIGHT): ang = (ang + 1) % NF dx, dy = DIRS[ang] if btn.is_pressed(btn.UP): vx += dx * 0.25; vy += dy * 0.25 speed = math.sqrt(vx * vx + vy * vy) if speed > 5: vx *= 5 / speed; vy *= 5 / speed vx *= 0.99; vy *= 0.99 ship.fx, ship.fy = wrap(ship.fx + vx, ship.fy + vy) ship.frame = ang ship.visible = (inv <= 0) or (frame & 1)
if btn.just_pressed(btn.B) and fire_cd <= 0: b = bullets.spawn() if b: b.data = {"vx": dx * 7, "vy": dy * 7, "life": 30} b.move(ship.x, ship.y) fire_cd = 6 for b in bullets.items: if not b.visible: continue b.data["life"] -= 1 if b.data["life"] <= 0: bullets.free(b) continue b.fx, b.fy = wrap(b.fx + b.data["vx"], b.fy + b.data["vy"])
for r in rocks.items: if not r.visible: continue r.fx, r.fy = wrap(r.fx + r.data["vx"], r.fy + r.data["vy"]) size = r.data["size"] rr = ROCK_R[size] for b in bullets.items: if not b.visible: continue if collide.is_within(b, r, rr): bullets.free(b) rocks.free(r) score += (3 - size) * 20 # smaller rock -> more points if size < 2: for s in (-1, 1): spawn_rock(size + 1, r.fx, r.fy, r.data["vx"] + s * 0.8, r.data["vy"] - s * 0.8) break if inv <= 0 and r.visible and collide.is_within(ship, r, rr + 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()step 7 — step7_particles.py · explosions, exhaust, sound
Section titled “step 7 — step7_particles.py · explosions, exhaust, sound”
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 mathimport terminalioimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_shapes as shpimport picogame_poolimport picogame_collide as collideimport picogame_ui as ui
W, H = 320, 240BG = pg.rgb565(0, 0, 8)NF = 16
scene, bufA, bufB = picogame_game.setup(background=BG)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_bm = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], NF, pg.rgb565(200, 220, 255))DIRS = [(math.sin(f * 2 * math.pi / NF), -math.cos(f * 2 * math.pi / NF)) for f in range(NF)]bullet_bm = shp.circle(4, pg.rgb565(255, 255, 120))ROCK_BM = [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_R = [20, 12, 6]
ship = pg.Sprite(ship_bm, W // 2, H // 2)ship.anchor = (0.5, 0.5)rocks = picogame_pool.Pool(scene, ROCK_BM[0], 16, anchor=(0.5, 0.5))bullets = picogame_pool.Pool(scene, bullet_bm, 6, anchor=(0.5, 0.5))sparks = pg.Particles(160, size=2, fade=True) # NEWscene.add(sparks)scene.add(ship)hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, pg.rgb565(255, 255, 255), BG)
ang = 0vx = vy = 0.0fire_cd = 0lives = 3inv = 60wave = 3score = 0frame = 0
def wrap(x, y): return x % W, y % H
def spawn_rock(size, x, y, rvx, rvy): r = rocks.spawn() if r is None: return r.data = {"size": size, "vx": rvx, "vy": rvy} r.bitmap = ROCK_BM[size] r.fx, r.fy = float(x), float(y)
def new_wave(n): for i in range(n): a = i * 2 * math.pi / n spawn_rock(0, (W // 2 + int(140 * math.cos(a))) % W, (H // 2 + int(110 * math.sin(a))) % H, math.cos(a) * 1.2, math.sin(a) * 1.2)
def respawn(): global vx, vy, inv ship.fx, ship.fy = float(W // 2), float(H // 2) vx = vy = 0.0 inv = 90
new_wave(wave)while True: btn.poll() frame += 1 fire_cd -= 1 if inv > 0: inv -= 1
if btn.is_pressed(btn.LEFT): ang = (ang - 1) % NF if btn.is_pressed(btn.RIGHT): ang = (ang + 1) % NF dx, dy = DIRS[ang] if btn.is_pressed(btn.UP): vx += dx * 0.25; vy += dy * 0.25 # thrust flame: a couple of orange sparks shot out the back sparks.emit(ship.x - int(dx * 8), ship.y - int(dy * 8), 2, 2, 10, pg.rgb565(255, 150, 40)) speed = math.sqrt(vx * vx + vy * vy) if speed > 5: vx *= 5 / speed; vy *= 5 / speed vx *= 0.99; vy *= 0.99 ship.fx, ship.fy = wrap(ship.fx + vx, ship.fy + vy) ship.frame = ang ship.visible = (inv <= 0) or (frame & 1)
if btn.just_pressed(btn.B) and fire_cd <= 0: b = bullets.spawn() if b: b.data = {"vx": dx * 7, "vy": dy * 7, "life": 30} b.move(ship.x, ship.y) fire_cd = 6 if audio: audio.sfx(snd_fire) for b in bullets.items: if not b.visible: continue b.data["life"] -= 1 if b.data["life"] <= 0: bullets.free(b) continue b.fx, b.fy = wrap(b.fx + b.data["vx"], b.fy + b.data["vy"])
for r in rocks.items: if not r.visible: continue r.fx, r.fy = wrap(r.fx + r.data["vx"], r.fy + r.data["vy"]) size = r.data["size"] rr = ROCK_R[size] for b in bullets.items: if not b.visible: continue if collide.is_within(b, r, rr): bullets.free(b) rocks.free(r) score += (3 - size) * 20 sparks.emit(r.x, r.y, 18, 3, 26, pg.rgb565(255, 200, 120)) # explosion if audio: audio.sfx(snd_boom) if size < 2: for s in (-1, 1): spawn_rock(size + 1, r.fx, r.fy, r.data["vx"] + s * 0.8, r.data["vy"] - s * 0.8) break if inv <= 0 and r.visible and collide.is_within(ship, r, rr + 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()step 8 — step8_states.py · the state machine (capstone)
Section titled “step 8 — step8_states.py · the state machine (capstone)”
A finished game isn’t one endless loop — it has states. We track state: TITLE waits
for a press, PLAY runs the game, GAMEOVER shows the final score and returns to the title.
new_game() resets everything; one centred SceneLabel shows the message for the non-play
states; death ends the run instead of silently restarting. This is what turns a mechanic
into a game. You see: title → play → game over → title. Try it: add a high score
that survives across games (see picogame_save for NVM persistence).
23 collapsed lines
# Starship -- step 8: game states (title / playing / game over) + restart.## What you learn: a state machine, the backbone of a finished game. Instead of one# endless loop, we track a `state`: TITLE waits for a press to start, PLAY runs the# game, GAMEOVER shows the final score and waits to return to the title. new_game()# resets everything. A single SceneLabel shows the centred message for the non-PLAY# states. This is the difference between a mechanic and a game.## New vs step 7: a `state` variable + transitions, new_game() reset, a centred# message label, ending the run on death instead of silently restarting.## Run: python3 sim/run.py tutorials/02-starship/step8_states.py --hold B --shot /tmp/p8.png
import mathimport terminalioimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_shapes as shpimport picogame_poolimport picogame_collide as collideimport picogame_ui as ui
W, H = 320, 240BG = pg.rgb565(0, 0, 8)NF = 16TITLE, PLAY, GAMEOVER = 0, 1, 2
scene, bufA, bufB = picogame_game.setup(background=BG)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_bm = shp.poly_frames(18, [(0, -8), (6, 7), (0, 4), (-6, 7)], NF, pg.rgb565(200, 220, 255))DIRS = [(math.sin(f * 2 * math.pi / NF), -math.cos(f * 2 * math.pi / NF)) for f in range(NF)]bullet_bm = shp.circle(4, pg.rgb565(255, 255, 120))ROCK_BM = [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_R = [20, 12, 6]
ship = pg.Sprite(ship_bm, W // 2, H // 2)ship.anchor = (0.5, 0.5)rocks = picogame_pool.Pool(scene, ROCK_BM[0], 16, anchor=(0.5, 0.5))bullets = picogame_pool.Pool(scene, bullet_bm, 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), BG)msg = ui.SceneLabel(scene, pg, terminalio.FONT, 96, 112, pg.rgb565(255, 255, 255), BG)
state = TITLEang = 0vx = vy = 0.0fire_cd = 0lives = 3inv = 0wave = 3score = 0frame = 0
def wrap(x, y): return x % W, y % H
def spawn_rock(size, x, y, rvx, rvy): r = rocks.spawn() if r is None: return r.data = {"size": size, "vx": rvx, "vy": rvy} r.bitmap = ROCK_BM[size] r.fx, r.fy = float(x), float(y)
def new_wave(n): for i in range(n): a = i * 2 * math.pi / n spawn_rock(0, (W // 2 + int(140 * math.cos(a))) % W, (H // 2 + int(110 * math.sin(a))) % H, math.cos(a) * 1.2, math.sin(a) * 1.2)
def new_game(): global ang, vx, vy, fire_cd, lives, inv, wave, score ang = 0; vx = vy = 0.0; fire_cd = 0; lives = 3; inv = 60; wave = 3; score = 0 ship.fx, ship.fy = float(W // 2), float(H // 2) rocks.free_all() bullets.free_all() sparks.clear() new_wave(wave)
while True: btn.poll() frame += 1
if state == TITLE: ship.visible = False msg.set("STARSHIP PRESS A") if btn.just_pressed(btn.A) or btn.just_pressed(btn.B): new_game() state = PLAY scene.refresh() clock.tick() continue
if state == GAMEOVER: msg.set("GAME OVER %05d A=MENU" % score) if btn.just_pressed(btn.A) or btn.just_pressed(btn.B): state = TITLE sparks.tick() scene.refresh() clock.tick() continue
# ---- state == PLAY ---- msg.set(" ") fire_cd -= 1 if inv > 0: inv -= 1 if btn.is_pressed(btn.LEFT): ang = (ang - 1) % NF if btn.is_pressed(btn.RIGHT): ang = (ang + 1) % NF dx, dy = DIRS[ang] if btn.is_pressed(btn.UP): vx += dx * 0.25; vy += dy * 0.25 sparks.emit(ship.x - int(dx * 8), ship.y - int(dy * 8), 2, 2, 10, pg.rgb565(255, 150, 40)) speed = math.sqrt(vx * vx + vy * vy) if speed > 5: vx *= 5 / speed; vy *= 5 / speed vx *= 0.99; vy *= 0.99 ship.fx, ship.fy = wrap(ship.fx + vx, ship.fy + vy) ship.frame = ang ship.visible = (inv <= 0) or (frame & 1)
if btn.just_pressed(btn.B) and fire_cd <= 0: b = bullets.spawn() if b: b.data = {"vx": dx * 7, "vy": dy * 7, "life": 30} b.move(ship.x, ship.y) fire_cd = 6 if audio: audio.sfx(snd_fire) for b in bullets.items: if not b.visible: continue b.data["life"] -= 1 if b.data["life"] <= 0: bullets.free(b) continue b.fx, b.fy = wrap(b.fx + b.data["vx"], b.fy + b.data["vy"])
for r in rocks.items: if not r.visible: continue r.fx, r.fy = wrap(r.fx + r.data["vx"], r.fy + r.data["vy"]) size = r.data["size"] rr = ROCK_R[size] for b in bullets.items: if not b.visible: continue if collide.is_within(b, r, rr): bullets.free(b) rocks.free(r) score += (3 - size) * 20 sparks.emit(r.x, r.y, 18, 3, 26, pg.rgb565(255, 200, 120)) if audio: audio.sfx(snd_boom) if size < 2: for s in (-1, 1): spawn_rock(size + 1, r.fx, r.fy, r.data["vx"] + s * 0.8, r.data["vy"] - s * 0.8) break if inv <= 0 and r.visible and collide.is_within(ship, r, rr + 6): 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) vx = vy = 0.0 inv = 90 if lives < 0: state = GAMEOVER break
if rocks.count() == 0: wave += 1 new_wave(wave)
sparks.tick() hud.set("SCORE %05d SHIPS %d" % (score, max(0, lives))) scene.refresh() clock.tick()Where to go next: the web editor + picogame_scene — 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.