Subsections of Dojo Defender
Dojo Defender: Session 1
Your spaceship has landed in dark space. Time to bring it to life. In this session you add a ship you can steer and you make asteroids fall down. Dodge them or lose a life. How long do you survive?
What you’ll learn today
- Using lists to keep track of multiple asteroids
- Spawning and removing objects
- Detecting collisions with
colliderect - Keeping track of lives and showing an explosion effect
- Forcing game over when your lives run out
Step 0: Setup
- Open Thonny.
- Download the Dojo Defender starter, unpack the ZIP and open
main.py. - Click Run. You see a dark blue screen with your spaceship at the bottom. You can already move it with the arrow keys.
- Problems? Take a look at Installing Thonny.
Step 1: Let asteroids spawn
A game without danger is boring. Add a list in which you keep track of all the asteroids, and a function that creates new asteroids:
# at the top, after WIDTH and HEIGHT:
asteroids = []
def spawn_asteroid():
rock = Actor('asteroid_big')
rock.x = random.randint(30, WIDTH - 30)
rock.y = -30
rock.speed = random.uniform(3, 5)
asteroids.append(rock)
You also need import random at the top of the file. Add that line.
Call the function in update() with a counter so that an asteroid does not appear every single frame:
# at the top, outside the functions:
timer = 0
def update():
global timer
timer += 1
if timer % 60 == 0: # every second (60 frames)
spawn_asteroid()
Put this before the movement code of the ship.
Step 2: β
Basic: dropping and drawing asteroids
Asteroids have to fall down and disappear when they are off the screen:
def update():
global timer
timer += 1
if timer % 60 == 0:
spawn_asteroid()
for rock in asteroids[:]: # [:] = copy so you can remove safely
rock.y += rock.speed
if rock.y > HEIGHT + 30:
asteroids.remove(rock)
Don’t forget to draw the asteroids in draw():
def draw():
screen.fill((10, 10, 30))
for rock in asteroids:
rock.draw()
ship.draw()
Step 3: β Stretch: collision + lives
When an asteroid hits your ship, you lose a life. Add lives and game_over at the top:
lives = 3
game_over = False
Add collision detection in the for loop:
for rock in asteroids[:]:
rock.y += rock.speed
if rock.y > HEIGHT + 30:
asteroids.remove(rock)
elif rock.colliderect(ship):
asteroids.remove(rock)
global lives, game_over
lives -= 1
if lives <= 0:
game_over = True
Show the lives and the score in draw():
screen.draw.text(f"Score: {score}", (10, 10), color="white", fontsize=24)
screen.draw.text(f"Lives: {lives}", (10, 40), color="red", fontsize=24)
Stop the game when game_over is True:
def update():
if game_over:
return
...
Show a game-over screen in draw():
if game_over:
screen.draw.text(
"GAME OVER",
center=(WIDTH / 2, HEIGHT / 2),
color="red",
fontsize=60,
)
screen.draw.text(
f"Final Score: {score}",
center=(WIDTH / 2, HEIGHT / 2 + 60),
color="white",
fontsize=30,
)
Step 4: π₯ Expert: explosions + invisibility
When an asteroid hits you, you want to see an explosion instead of the ship just disappearing. Use the four explosion frames (explosion_1 to explosion_4):
explosion = Actor('explosion_1')
explosion_active = False
explosion_frame = 0
explosion_timer = 0
On a collision: hide the ship, start the explosion:
elif rock.colliderect(ship):
asteroids.remove(rock)
global lives, game_over, explosion_active, explosion_frame, explosion_timer
lives -= 1
explosion.pos = (ship.x, ship.y)
explosion.image = 'explosion_1'
explosion_frame = 1
explosion_timer = 5
explosion_active = True
ship.visible = False
if lives <= 0:
game_over = True
Play the explosion in update():
if explosion_active:
explosion_timer -= 1
if explosion_timer <= 0:
explosion_frame += 1
if explosion_frame > 4:
explosion_active = False
ship.visible = True
ship.pos = (WIDTH / 2, HEIGHT - 50)
else:
explosion.image = f'explosion_{explosion_frame}'
explosion_timer = 5
Then add an invisibility period so that you don’t get hit again straight away:
invincible = False
invincible_timer = 0
In update():
if invincible:
invincible_timer -= 1
if invincible_timer <= 0:
invincible = False
ship.visible = True
Adjust the collision check:
elif rock.colliderect(ship) and not invincible and ship.visible:
Draw the explosion in draw():
if explosion_active:
explosion.draw()
Showcase
Show your Dojo Defender to a coach and a buddy. How long do you survive? Do you have explosions? Does it look cool?
See you next time!
“Next time: your spaceship SHOOTS BACK. Enemies in formation, and you blast them away with lasers. This is going to be epic.”
Take it home
Try one of these extensions at home:
- Sound β Add
sounds/explosion.wav when an asteroid explodes (sounds.explosion.play()). - Harder β Make asteroids fall faster as the score rises:
rock.speed = random.uniform(3 + score // 10, 5 + score // 10). - Power-up β Add a rare green power-up that gives an extra life.
- Smaller β Use
asteroid_med or asteroid_small for variation in size and speed.
Download Dojo Defender
Dojo Defender: Session 2
Your ship has a weapon now. Enemies are coming. Shoot them down before they reach the bottom. Every 10 enemies means a new wave β and it gets harder and harder.
What you’ll learn today
- Shooting with the space bar and a cooldown
- Lists for bullets and enemies
- Collision between bullets and enemies
- Waves that get harder and harder
- Enemies that make sine movements
Step 0: Open the starter
- Open Thonny.
- Download the Dojo Defender starter for today, unpack the ZIP and open
main.py. - Were you not there last time? Download the solution from session 1 and start with that.
- Click Run. You see the same game as last time: ship, asteroids, explosions.
- Problems? Take a look at Installing Thonny.
Step 1: Shooting with SPACE
Your ship has a laser cannon. Add a list for bullets at the top:
Use on_key_down β it is called only once when you press a key (unlike update, which keeps repeating):
def on_key_down(key):
if key == keys.SPACE:
bullet = Actor('bullet')
bullet.x = ship.x
bullet.y = ship.y - 20
bullets.append(bullet)
Make the bullets move up in update() and remove them when they are off screen:
for bullet in bullets[:]:
bullet.y -= 8
if bullet.y < -10:
bullets.remove(bullet)
Don’t forget to draw the bullets in draw():
for bullet in bullets:
bullet.draw()
Step 2: β
Basic: shooting cooldown
Without a cooldown you fire dozens of bullets per second. Add a timer:
In update():
if shoot_cooldown > 0:
shoot_cooldown -= 1
Adjust on_key_down so that you can only shoot when the cooldown is 0:
def on_key_down(key):
global shoot_cooldown
if key == keys.SPACE and shoot_cooldown == 0:
bullet = Actor('bullet')
bullet.x = ship.x
bullet.y = ship.y - 20
bullets.append(bullet)
shoot_cooldown = 15
Step 3: β Stretch: adding enemies
Enemies spawn at the top and fall down. Add a list:
A spawn function:
def spawn_enemy():
enemy = Actor('enemy_drone')
enemy.x = random.randint(30, WIDTH - 30)
enemy.y = -30
enemy.speed = random.uniform(1, 3)
enemies.append(enemy)
Call spawn_enemy() in update() with a spawn interval:
# at the top:
spawn_counter = 0
spawn_interval = 120
# in update():
spawn_counter += 1
if spawn_counter >= spawn_interval:
spawn_counter = 0
spawn_enemy()
Draw enemies in draw():
for enemy in enemies:
enemy.draw()
Step 4: π₯ Expert: collision + waves
Bullet hits enemy: remove both, +10 points:
for bullet in bullets[:]:
bullet.y -= 8
if bullet.y < -10:
bullets.remove(bullet)
continue
for enemy in enemies[:]:
if bullet.colliderect(enemy):
bullets.remove(bullet)
enemies.remove(enemy)
global score
score += 10
break
Enemy hits the bottom or the ship: lose a life (same as an asteroid).
Waves: a new wave every 10 enemies. Enemies spawn faster:
# at the top:
wave = 1
enemies_destroyed = 0
# on destroy:
enemies_destroyed += 1
if enemies_destroyed % 10 == 0:
wave += 1
spawn_interval = max(60, spawn_interval - 10)
Show the current wave in draw():
screen.draw.text(f"Wave: {wave}", (10, 70), color="yellow", fontsize=24)
Show “WAVE N!” in big letters on a new wave (use a timer).
Sine movement: let enemies veer left and right:
import math # at the top
enemy.x = enemy.start_x + math.sin(timer * 0.03 + enemy.strafe_offset) * 50
Showcase
Show your Dojo Defender to a coach. How many waves do you reach? Do you have the sine movement working? Compare scores with a buddy.
See you next time!
“Next time: POWER-UPS. Shields, speed boosts and spread shots. Your ship gets stronger the longer you survive.”
Take it home
Try one of these extensions at home:
- Enemy variation β Use
enemy_charger.png for a faster enemy that doesn’t do a sine, but comes straight at you. - Sound β Add
sounds/laser.wav and sounds/explosion.wav. - Hard mode β Enemies spawn faster and faster, up to every 30 frames at most.
- Score multiplier β The higher the wave, the more points per kill:
score += 10 * wave.
Download Dojo Defender
Dojo Defender: Session 3
Today Dojo Defender moves from pgzero to real pygame. No more pgzrun.go() β you write the game loop yourself. The game stays the same, but the engine underneath is different. And at the end you add SOUND.
What you’ll learn today
- The difference between pgzero and raw pygame
- Writing the game loop yourself:
while running: clock.tick(60) - Converting actors to
Surface + Rect - Loading and playing sound with
pygame.mixer - Looping background music
Step 0: Download the starter
Today’s starter is a full port of Dojo Defender Session 2 to raw pygame. No more pgzero, but it plays the same.
- Download the Dojo Defender starter for today, unpack it and open
main.py. - Were you not there last time? Download the solution from session 2 and try to port it.
- Click Run. The game works the way you’re used to: ship, asteroids, bullets, enemies, waves, explosions.
What is different?
In pgzero you wrote draw() and update() and pgzrun did the rest. In raw pygame you do everything yourself:
pgzero (before):
import pgzrun
def draw():
screen.fill((10, 10, 30))
ship.draw()
def update():
if keyboard.left:
ship.x -= 5
pgzrun.go()
raw pygame (now):
import pygame, sys
pygame.init()
screen = pygame.display.set_mode((800, 600))
clock = pygame.time.Clock()
running = True
while running:
clock.tick(60)
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit(); sys.exit()
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
ship.rect.centerx -= 5
screen.fill((10, 10, 30))
ship.draw(screen)
pygame.display.flip()
The most important differences:
| pgzero | raw pygame |
|---|
Actor('plaatje') | GameObject('plaatje.png', x, y) |
keyboard.left | keys[pygame.K_LEFT] |
on_key_down(key) | event.type == pygame.KEYDOWN |
screen.draw.text(...) | font.render() + screen.blit() |
rock.colliderect(ship) | rock.rect.colliderect(ship.rect) |
pgzrun.go() | while running: ... clock.tick(60) |
Step 1: Explore the code
Open main.py and compare it with the old pgzero code of session 2. Look for these parts:
- Game loop β Look for the
while running: loop. Do you see clock.tick(60), pygame.display.flip()? - GameObject β Look for the
class GameObject. How is an image loaded? What is rect? - Input β Look for
pygame.key.get_pressed() and pygame.KEYDOWN. How does the space bar work?
Change the WIDTH to 1024. What happens? (Everything gets wider β the game still works.)
Step 2: β
Basic β Add restart with the R key
In the pgzero version you could not restart after game over. Add that now:
# in the event loop, on KEYDOWN:
if event.key == pygame.K_r and game_over:
# reset everything
ship.rect.center = (WIDTH / 2, HEIGHT - 50)
ship.visible = True
asteroids.clear()
bullets.clear()
enemies.clear()
lives = 3
score = 0
wave = 1
enemies_destroyed = 0
spawn_counter = 0
spawn_interval = 120
wave_display_timer = 0
game_over = False
invincible = False
ship.visible = True
Also show a hint on the game-over screen: press R to play again.
Step 3: β Stretch β Add sound
The starter has no sound yet. Add sounds!
Make a sounds/ folder next to images/ and copy the sound files from dojo-defender-assets/sounds/ (ask the coach). Or download the solution, which already contains them.
Add at the top:
pygame.mixer.init() # after pygame.init()
shoot_sound = pygame.mixer.Sound('sounds/shoot.wav')
explode_sound = pygame.mixer.Sound('sounds/explode.wav')
hit_sound = pygame.mixer.Sound('sounds/hit.wav')
pygame.mixer.music.load('sounds/bg_music.wav')
pygame.mixer.music.play(-1) # -1 = keep repeating
Watch out: the path has to be right. Use os.path.join(_DIR, 'sounds/shoot.wav') like the starter also does with images.
Play the sounds at the right moments:
shoot_sound.play() β when you shootexplode_sound.play() β when an enemy or asteroid explodeshit_sound.play() β when your ship gets hit
Add an M key that turns the sound on and off:
muted = False
# in KEYDOWN:
if event.key == pygame.K_m:
muted = not muted
pygame.mixer.music.set_volume(0 if muted else 1)
Every sound then has to be checked:
if not muted:
shoot_sound.play()
Volume per sound: Make the shooting sound quieter than the explosion:
shoot_sound.set_volume(0.3)
explode_sound.set_volume(0.7)
Showcase
Show a coach that:
- Your game works in real pygame without pgzero
- You added sound (shoot, explode, hit, bg music)
- Bonus: restart with R or mute with M
See you next time!
“Next time: POWER-UPS. Shields, speed boosts and spread shots. And your ship gets stronger the longer you survive.”
Take it home
Try one of these extensions at home:
- Health bar β Replace the text “Lives: 3” with a visual bar: draw three small rectangles at the top.
- Pause β Add a P key that pauses the game. Setting
running = False is not enough β use a separate paused variable. - Particle effect β Let small white dots splatter around on an explosion (use a list of particles that get smaller and smaller).
- Your own skin β Replace
player_ship.png with your own design.
Download Dojo Defender
Dojo Defender: Session 4
Today Dojo Defender becomes a real game with a menu. No more black start β you get a title screen, a game-over screen, and three different enemy types that each move in their own way.
What you’ll learn today
- State machines: your game has states (MENU, PLAYING, GAME OVER)
- Enemy AI: every enemy moves differently (straight, zigzag, charger)
- Wave system: increasingly hard waves with a mix of enemies
- A reset function: putting everything back neatly without restarting
Step 0: Download the starter
The starter is the solution of session 3 β Dojo Defender in raw pygame with sound.
- Download the starter for today, unpack it and open
main.py. - Weren’t you there? Download the solution of session 3.
- Click Run. The game works like before: ship, asteroids, bullets, drones, sound.
Step 1: Add a state machine
A state machine keeps track of which “state” the game is in:
state = "MENU" # can be "MENU", "PLAYING", "GAME_OVER"
Put this variable at the top, with the other global variables.
Drawing the menu screen: Replace the screen.fill and all the drawing with a check on state:
if state == "MENU":
# Draw title and instructions
screen.fill((10, 10, 30))
titel = title_font.render("DOJO DEFENDER", True, (0, 200, 255))
screen.blit(titel, (WIDTH // 2 - titel.get_width() // 2, HEIGHT // 2 - 60))
sub = big_font.render("Press SPACE to start", True, (200, 200, 200))
screen.blit(sub, (WIDTH // 2 - sub.get_width() // 2, HEIGHT // 2))
elif state == "PLAYING":
# = all your existing game code here
elif state == "GAME_OVER":
# Draw "GAME OVER" + score
Make title_font and big_font at the top:
title_font = pygame.font.SysFont(None, 80)
big_font = pygame.font.SysFont(None, 60)
Now SPACE has to do the following:
- If
state == "MENU": reset the game and go to PLAYING - If
state == "GAME_OVER": reset the game and go to PLAYING - If
state == "PLAYING" and game_over == False: shoot like normal
Change the KEYDOWN logic:
if event.key == pygame.K_SPACE:
if state == "MENU":
reset_game()
state = "PLAYING"
elif state == "GAME_OVER":
reset_game()
state = "PLAYING"
elif state == "PLAYING" and shoot_cooldown == 0 and not game_over:
# shoot (existing code)
Step 2: β
Basic β Write the reset function
Make a function that puts everything back to the starting values:
def reset_game():
global ship, asteroids, bullets, enemies
global lives, score, game_over, timer
global explosion_active, explosion_frame, explosion_timer
global invincible, invincible_timer, shoot_cooldown
global wave, wave_display_timer, enemies_destroyed
global spawn_counter, spawn_interval
ship.rect.center = (WIDTH / 2, HEIGHT - 50)
ship.visible = True
asteroids.clear()
bullets.clear()
enemies.clear()
lives = 3
score = 0
game_over = False
timer = 0
explosion_active = False
explosion_frame = 0
explosion_timer = 0
invincible = False
invincible_timer = 0
shoot_cooldown = 0
wave = 1
wave_display_timer = 0
enemies_destroyed = 0
spawn_counter = 0
spawn_interval = 120
Set state = "GAME_OVER" when the player has 0 lives (at lives <= 0):
if lives <= 0:
game_over = True
state = "GAME_OVER"
Check: The game now starts with a blue title screen. SPACE starts the game. On game over you see GAME OVER and you can restart with SPACE.
Step 3: β Stretch β Three enemy types
Replace the single spawn_enemy() with three functions:
def spawn_enemy_drone():
enemy = GameObject('enemy_drone.png', random.randint(30, WIDTH - 30), -30)
enemy.type = 'drone'
enemy.speed = random.uniform(1, 3)
enemies.append(enemy)
def spawn_enemy_zigzag():
enemy = GameObject('enemy_zigzag.png', random.randint(30, WIDTH - 30), -30)
enemy.type = 'zigzag'
enemy.speed = random.uniform(2, 4)
enemy.dx = random.choice([-2, 2])
enemies.append(enemy)
def spawn_enemy_charger():
enemy = GameObject('enemy_charger.png', random.randint(30, WIDTH - 30), -30)
enemy.type = 'charger'
enemy.speed = random.uniform(3, 5)
enemies.append(enemy)
Movement per type
In the game loop, where you did enemy.rect.y += enemy.speed, you now add different movement per type:
for enemy in enemies[:]:
if enemy.type == 'drone':
enemy.rect.y += enemy.speed
elif enemy.type == 'zigzag':
enemy.rect.x += enemy.dx
enemy.rect.y += enemy.speed
if enemy.rect.left <= 0 or enemy.rect.right >= WIDTH:
enemy.dx = -enemy.dx
elif enemy.type == 'charger':
# Steer towards the player's x position
dx = ship.rect.centerx - enemy.rect.centerx
if dx > 0:
enemy.rect.centerx += min(enemy.speed, abs(dx))
elif dx < 0:
enemy.rect.centerx -= min(enemy.speed, abs(dx))
enemy.rect.y += enemy.speed * 0.8
# After that: off-screen check, collision with the ship, etc.
Important: The charger has to descend more slowly (* 0.8) so the player has time to react.
Step 4: π₯ Expert β Enhanced waves
Make a spawn_enemy() that decides which type spawns based on the current wave:
def spawn_enemy():
if wave == 1:
spawn_enemy_drone()
elif wave <= 3:
if random.random() < 0.5:
spawn_enemy_drone()
else:
spawn_enemy_zigzag()
else:
r = random.random()
if r < 0.3:
spawn_enemy_drone()
elif r < 0.65:
spawn_enemy_zigzag()
else:
spawn_enemy_charger()
That way it gets harder and harder:
- Wave 1: only drones (warming up)
- Wave 2-3: drones + zigzag (50/50)
- Wave 4+: all three, with more chargers (30% drone, 35% zigzag, 35% charger)
Showcase
Show a coach that:
- You have a menu screen with a title and “Press SPACE to start”
- You have three enemy types: drone (β), zigzag (ββ), charger (β your ship)
- The waves get harder and harder (more zigzags, then chargers)
- Game over works with a score display and restart
See you next time!
“Next time: POWER-UPS. Shields, speed-boosts, spread-shots. And a real final boss.”
Take it home
Try one of these extensions at home:
- High score β Save the highest score in a file and show it on the menu.
- Difficulty β Let the player choose between “Easy” and “Hard” on the menu.
- Boss wave β Every 5 waves a big boss appears with lots of hits.
- Charger Warning β Draw a red line under a charger before it appears.
Download Dojo Defender
Dojo Defender: Session 5
Today you give Dojo Defender a depth effect with a parallax star background and explosion particles. Asteroids also become destructible: big asteroids split into smaller ones.
What you’ll learn today
- Parallax maths: several layers of stars with their own speed
- Particle lists: a list of dictionaries for particles
- Position-based effects: spawning particles in the right spot
- Recursive splitting: big asteroid β 2 medium β 2 small β gone
Step 0: Download the starter
The starter is the solution of session 4 β Dojo Defender with a menu, three enemy types and waves.
- Download the starter for today, unpack it and open
main.py. - Weren’t you there? Download the solution of session 4.
- Click Run. The game works like before: ship, asteroids, bullets, drones, zigzags, chargers.
Step 1: π Parallax star background
A parallax background has several layers that move at different speeds. Far away = slow, close by = fast. That gives depth.
Add a stars = [] list at the top of the file, with the other global variables:
Make an init_stars() function that creates three layers of stars:
def init_stars():
global stars
stars = []
layers = [
(40, 0.3, 2, 120), # far away: 40 stars, speed 0.3, size 2, darker
(40, 0.7, 3, 180), # middle: 40 stars, speed 0.7, size 3
(30, 1.5, 4, 255), # close by: 30 stars, speed 1.5, size 4, bright
]
for count, speed, size, brightness in layers:
for _ in range(count):
stars.append({
'x': random.randint(0, WIDTH),
'y': random.randint(0, HEIGHT),
'speed': speed,
'size': size,
'brightness': brightness,
})
The maths behind it: every star gets a y speed. Far stars (0.3 px/frame) barely move, nearby stars (1.5 px/frame) move faster. Your brain reads that as depth, exactly like in a car: trees next to the road fly past, mountains far away stay still.
Also add the update function. It makes stars drop and puts them back at the top when they’re below the screen:
def update_stars():
for star in stars:
star['y'] += star['speed']
if star['y'] > HEIGHT + star['size']:
star['y'] = -star['size']
star['x'] = random.randint(0, WIDTH)
And a drawing function. You draw small stars with pygame.draw.rect (2Γ2), bigger ones with pygame.draw.circle:
def draw_stars(surface):
for star in stars:
c = (star['brightness'], star['brightness'], star['brightness'])
if star['size'] <= 2:
pygame.draw.rect(surface, c, (star['x'], star['y'], star['size'], star['size']))
else:
pygame.draw.circle(surface, c, (int(star['x']), int(star['y'])), star['size'] // 2)
Calling them: Call init_stars() once (at start-up, after all the functions), update_stars() every frame (even in the menu!), and draw_stars(screen) after screen.fill() in ALL states.
Check
Do you see three layers of stars scrolling down, in the menu too? The far stars are smaller and move more slowly.
Step 2: β
Basic β Particle system
A particle is a small speck that moves, fades and disappears. We use a list of dictionaries:
particles = [] # {x, y, vx, vy, life, max_life, color, size}
Make a function to spawn particles:
def spawn_particles(x, y, count, colors, speed_range, size_range, life_range):
for _ in range(count):
angle = random.uniform(0, 2 * math.pi)
speed = random.uniform(speed_range[0], speed_range[1])
particles.append({
'x': x,
'y': y,
'vx': math.cos(angle) * speed,
'vy': math.sin(angle) * speed,
'life': random.randint(life_range[0], life_range[1]),
'max_life': life_range[1],
'color': random.choice(colors),
'size': random.uniform(size_range[0], size_range[1]),
})
What happens here? Every particle gets:
- A position (
x, y) - A speed in x and y (
vx, vy), calculated from an angle and a speed - A lifetime (
life goes down by 1 every frame) - A colour and size
Update function (move every particle 1 step every frame and shorten its lifetime):
def update_particles():
for p in particles[:]:
p['x'] += p['vx']
p['y'] += p['vy']
p['life'] -= 1
if p['life'] <= 0:
particles.remove(p)
Why [:]? We change the list while looping over it (we remove particles). Without [:] you get a runtime error. The slice [:] makes a copy of the list so you can loop over it safely.
Drawing function with fading (alpha fade): the less lifetime left, the smaller and darker:
def draw_particles(surface):
for p in particles:
ratio = p['life'] / p['max_life']
color = tuple(int(c * ratio) for c in p['color'])
pygame.draw.circle(surface, color, (int(p['x']), int(p['y'])), int(p['size'] * ratio))
The ratio is a number between 1 (just born) and 0 (almost dead). We multiply the colour and size by it, so the particle slowly disappears.
Emptying the particle list in reset
Add particles.clear() to the reset_game() function, otherwise dead particles keep floating around after a restart.
Call update_particles() in the PLAYING state and draw_particles(screen) after draw_stars(screen) in all states.
Check
Start the game and press SPACE. Nothing visible happens yet β but the system is ready for the next steps.
Step 3: β Stretch β Position-based effects
Now we’re going to spawn particles at the right moments and positions.
Exhaust fumes
When the ship moves, spawn small red/orange particles under the ship. Add this in the if not game_over section, where you check the arrow keys:
keys = pygame.key.get_pressed()
moving = False
if keys[pygame.K_LEFT] and ship.rect.centerx > 30:
ship.rect.centerx -= 5
moving = True
if keys[pygame.K_RIGHT] and ship.rect.centerx < WIDTH - 30:
ship.rect.centerx += 5
moving = True
if moving and ship.visible:
spawn_particles(
ship.rect.centerx, ship.rect.bottom,
1, # or random.randint(1, 2)
[(255, 100, 0), (255, 60, 0), (200, 50, 0)],
(0.5, 1.5), (2, 4), (10, 20),
)
The particles spawn under the ship (ship.rect.bottom) and get a small upward speed (negative vy because of the angle).
Explosion debris
When an enemy is hit by a bullet, spawn 8-12 particles in all directions. Add this where you remove an enemy from the list after a bullet collision:
spawn_particles(
enemy.rect.centerx, enemy.rect.centery,
random.randint(8, 12),
[(255, 140, 0), (255, 0, 0), (255, 255, 0), (255, 255, 255)],
(1, 4), (2, 5), (20, 40),
)
Enemy death sparks
When an enemy reaches the bottom of the screen, spawn 3-5 white sparks:
spawn_particles(
enemy.rect.centerx, HEIGHT - 30,
random.randint(3, 5),
[(255, 255, 255), (200, 200, 255)],
(1, 3), (1, 3), (15, 30),
)
Check
Move the ship β do you see orange exhaust fumes? Shoot an enemy β do you see 8-12 specks flying around? Let an enemy escape β do you see white sparks?
Step 4: π₯ Expert β Destructible asteroids
Asteroids can now split recursively:
asteroid_big.png β gets hit β 2Γ asteroid_med.pngasteroid_med.png β gets hit β 2Γ asteroid_small.pngasteroid_small.png β gets hit β gone + particles
Change spawn_asteroid() so every asteroid gets an asteroid_size:
def spawn_asteroid(size='big'):
rock = GameObject(f'asteroid_{size}.png', random.randint(30, WIDTH - 30), -30)
rock.speed = random.uniform(3, 5)
rock.asteroid_size = size
asteroids.append(rock)
return rock
Make a split_asteroid() function that splits an asteroid into two smaller ones:
def split_asteroid(rock):
# First: exploding particles
spawn_particles(
rock.rect.centerx, rock.rect.centery,
random.randint(8, 12),
[(255, 140, 0), (255, 0, 0), (255, 255, 0), (255, 255, 255)],
(1, 4), (2, 5), (20, 40),
)
if rock.asteroid_size == 'big':
for _ in range(2):
r = spawn_asteroid('med')
r.rect.center = rock.rect.center
r.rect.x += random.randint(-20, 20)
r.speed = random.uniform(2, 4)
elif rock.asteroid_size == 'med':
for _ in range(2):
r = spawn_asteroid('small')
r.rect.center = rock.rect.center
r.rect.x += random.randint(-15, 15)
r.speed = random.uniform(1, 3)
asteroids.remove(rock)
What happens here?
- We spawn explosion particles at the position of the asteroid
- We check the size: ‘big’ β 2 ‘med’, ‘med’ β 2 ‘small’, ‘small’ β no further splitting
- New asteroids spawn close to the old position with a small offset
- The smaller asteroids are slower than the bigger ones
Change the collision logic: where you did astersoids.remove(rock) on a bullet hit, now call split_asteroid(rock):
for rock in asteroids[:]:
if bullet.rect.colliderect(rock.rect):
bullets.remove(bullet)
explode_sound.play()
split_asteroid(rock)
score += 5
break
Important: You play the sound (explode_sound.play()) before split_asteroid() so it doesn’t accidentally play twice.
Check
Shoot a big asteroid. Do you see it split into two medium asteroids? Shoot those β they split into two small ones. Shoot the small ones β they disappear with a nice particle explosion.
Showcase
Show a coach that:
- Parallax star background with three speeds
- Particle system with fading (alpha fade)
- Exhaust fumes under the moving ship
- Explosion debris on destroyed enemies and asteroids
- Asteroids splitting: big β med β small β gone
See you next time!
“Next time: POWER-UPS. Shields, speed-boosts, spread-shots. And a real final boss.”
Take it home
Try one of these extensions at home:
- Easy: change the colour of the stars per layer (e.g. far stars bluish, nearby ones yellow).
- Medium: add a fourth layer of stars, even faster (speed 3.0), with big bright stars.
- Tricky: let the particle colour change during its lifetime (from yellow to red to grey).
- Very tricky: add a “comet” particle type that has a small tail.
Download Dojo Defender
Dojo Defender: Session 6
Today you add a final boss to Dojo Defender. The boss appears every 5 waves and has 3 phases with their own behaviour. You learn to work with a state machine of its own, HP bars and screen effects.
What you’ll learn today
- State machine for a boss: PHASE_1, PHASE_2, PHASE_3 and EXPLODING
- Drawing an HP bar with a colour gradient (green β yellow β red)
- Homing bullets: a bullet that moves towards the player
- Screen shake: a random offset on the drawing coordinates
- Phase transitions with visual effects
Step 0: Download the starter
The starter is the solution of session 5 β Dojo Defender with parallax stars, particles and splitting asteroids.
- Download the starter for today, unpack it and open
main.py. - Weren’t you there? Download the solution of session 5.
- Click Run. The game works like before. Kill 10 enemies to reach wave 5 β then the boss appears!
Step 1: β
Basic β Boss variables
A boss has variables of its own. Add these to the other global variables:
boss = None
boss_active = False
boss_hp = 0
boss_hp_max = 50
boss_phase = "PHASE_1"
boss_timer = 0
boss_phase_transition_timer = 0
boss_defeated_timer = 0
boss_shoot_timer = 0
boss_minion_spawn_timer = 0
boss_bullets = []
boss_minions = []
screen_shake_x = 0
screen_shake_y = 0
boss_direction = 1
Also add emptying boss_bullets and boss_minions in reset_game().
Why so many variables? A boss is more complex than ordinary enemies. It has to move, shoot, spawn minions, have phases and a death animation. Every variable is one aspect of that behaviour.
Check
Start the game. Nothing changes yet β but the variables are ready.
Step 2: β Stretch β Spawning and drawing the boss
Spawn function
Write a function that activates the boss:
def start_boss():
global boss_active, boss, boss_hp, boss_phase, boss_timer
global boss_bullets, boss_minions, boss_shoot_timer
global boss_minion_spawn_timer, boss_direction, screen_shake_x, screen_shake_y
boss_active = True
boss = GameObject('boss.png', WIDTH / 2, -80)
boss_hp = boss_hp_max
boss_phase = "PHASE_1"
boss_timer = 0
boss_shoot_timer = 0
boss_minion_spawn_timer = 0
boss_direction = 1
screen_shake_x = 0
screen_shake_y = 0
boss_bullets.clear()
boss_minions.clear()
roar_path = os.path.join(_DIR, 'sounds/boss_roar.wav')
if os.path.exists(roar_path):
pygame.mixer.Sound(roar_path).play()
Boss wave detection
Every 5th wave (from wave 5 on) a boss appears. Add this where the wave goes up:
def check_boss_wave():
if wave >= 5 and wave % 5 == 0 and not boss_active:
start_boss()
Call check_boss_wave() after wave += 1.
Stopping enemies during the boss
Make sure ordinary enemies don’t spawn during a boss fight. Put the spawn logic in an if:
if not boss_active:
spawn_counter += 1
if spawn_counter >= spawn_interval:
spawn_counter = 0
spawn_enemy()
Drawing the boss
Draw the boss when it’s active, and also draw the HP bar at the top of the screen:
if boss_active and boss is not None:
boss.draw(screen)
# HP bar
bar_width = 300
bar_height = 20
bar_x = (WIDTH - bar_width) // 2
bar_y = 10
bar_bg = pygame.Rect(bar_x, bar_y, bar_width, bar_height)
pygame.draw.rect(screen, (100, 20, 20), bar_bg)
hp_ratio = max(0, boss_hp / boss_hp_max)
fill_width = int(bar_width * hp_ratio)
if hp_ratio > 0.5:
color = (0, 255, 0)
elif hp_ratio > 0.25:
color = (255, 255, 0)
else:
color = (255, 0, 0)
fill_rect = pygame.Rect(bar_x, bar_y, fill_width, bar_height)
pygame.draw.rect(screen, color, fill_rect)
pygame.draw.rect(screen, (255, 255, 255), bar_bg, 2)
label = font.render(f"BOSS {boss_hp}/{boss_hp_max}", True, (255, 255, 255))
screen.blit(label, (bar_x + bar_width // 2 - label.get_width() // 2, bar_y - 5))
Check
Play until wave 5. Do you see the boss come in with an HP bar? Ordinary enemies no longer spawn.
Step 3: π₯ Expert β Boss AI (3 phases)
The boss has a state machine with 3 phases. Every phase is different behaviour.
Phase 1 (HP 34-50, 100-66%)
The boss drifts slowly to the left and right and now and then fires a homing bullet.
def fire_boss_bullet():
dx = ship.rect.centerx - boss.rect.centerx
dy = ship.rect.centery - boss.rect.centery
dist = math.sqrt(dx * dx + dy * dy)
if dist == 0:
dist = 1
speed = 3
bullet = {
'x': boss.rect.centerx,
'y': boss.rect.centery + 30,
'vx': dx / dist * speed,
'vy': dy / dist * speed,
}
boss_bullets.append(bullet)
Maths: By dividing dx and dy by the distance (dist) you get a unit vector β a direction with length 1. Multiply by speed for the speed you want. This works in every direction, not just horizontal/vertical.
Add the AI update in the game loop (if boss_active and boss is not None):
if boss.rect.centery < 80:
boss.rect.centery += 2 # coming in
else:
if boss_phase == "PHASE_1":
boss.rect.centerx += boss_direction * 1.5
if boss.rect.centerx > WIDTH - 60:
boss_direction = -1
if boss.rect.centerx < 60:
boss_direction = 1
boss_shoot_timer += 1
if boss_shoot_timer >= 60:
boss_shoot_timer = 0
fire_boss_bullet()
Phase transition (HP β€ 66%)
Check whether the boss has to change phase. Call this after the boss update:
hp_ratio = boss_hp / boss_hp_max
if hp_ratio <= 0.66 and boss_phase == "PHASE_1":
boss_phase = "PHASE_2"
boss_phase_transition_timer = 20
During a phase transition you make the boss flash:
if boss_phase_transition_timer > 0 and boss_phase_transition_timer % 4 < 2:
flash_surf = pygame.Surface(boss.image.get_size(), pygame.SRCALPHA)
flash_surf.fill((255, 255, 255, 180))
screen.blit(flash_surf, (boss.rect.x, boss.rect.y))
Phase 2 (HP 17-33, 66-33%)
The boss moves faster, fires more often and spawns minions:
elif boss_phase == "PHASE_2":
boss.rect.centerx += boss_direction * 3
...
boss_shoot_timer += 1
if boss_shoot_timer >= 40:
boss_shoot_timer = 0
fire_boss_bullet()
boss_minion_spawn_timer += 1
if boss_minion_spawn_timer >= 180:
boss_minion_spawn_timer = 0
spawn_boss_minion()
Spawning a minion:
def spawn_boss_minion():
minion = GameObject('boss_minion.png', random.randint(30, WIDTH - 30), -30)
minion.speed = random.uniform(1, 2)
boss_minions.append(minion)
Phase 3 (HP 0-16, 33-0%)
The boss is enraged: fires two bullets at once, spawns minions faster, and the screen shakes:
elif boss_phase == "PHASE_3":
boss.rect.centerx += boss_direction * 4
...
boss_shoot_timer += 1
if boss_shoot_timer >= 20:
boss_shoot_timer = 0
fire_boss_bullet()
if boss_hp < boss_hp_max * 0.15:
fire_boss_bullet()
...
Screen shake: add a random offset to ALL the drawing coordinates:
if boss_phase == "PHASE_3":
screen_shake_x = random.randint(-3, 3)
screen_shake_y = random.randint(-3, 3)
else:
screen_shake_x = 0
screen_shake_y = 0
Change all the draw functions so they accept offset_x and offset_y parameters. For example draw_stars(screen, screen_shake_x, screen_shake_y).
Check
Play until wave 5. Do you see the boss go through 3 phases? Phase 1: slow, now and then a bullet. Phase 2: faster + minions. Phase 3: screen shakes, rain of bullets.
Step 4: π Expert β Defeating the boss
When the boss reaches 0 HP, the exploding sequence starts:
if boss_hp <= 0:
boss_phase = "EXPLODING"
boss_defeated_timer = 90
boss_phase_transition_timer = 60
score += 500
explode_sound.play()
During EXPLODING you spawn a particle burst every 10 frames:
if boss_phase == "EXPLODING":
if boss_phase_transition_timer > 0 and boss_phase_transition_timer % 10 == 0:
spawn_particles(
boss.rect.centerx + random.randint(-40, 40),
boss.rect.centery + random.randint(-40, 40),
random.randint(10, 20),
[(255, 255, 0), (255, 100, 0), (255, 0, 0), (255, 255, 255)],
(2, 6), (3, 7), (20, 40),
)
Show “BOSS DEFEATED!” at the top of the screen:
if boss_defeated_timer > 0:
defeat_msg = big_font.render("BOSS DEFEATED!", True, (255, 255, 0))
screen.blit(defeat_msg, (WIDTH // 2 - defeat_msg.get_width() // 2, HEIGHT // 2 - 100))
Watch out: after the boss, switch the ordinary enemy spawning back on. The wave continues to the next one.
Check
Defeat the boss. Do you see the explosion animation, the “BOSS DEFEATED!” text and +500 score? Do the ordinary waves continue after that?
Showcase
Show a coach that:
- Boss appears every 5 waves with an HP bar
- 3 phases with their own behaviour (slow β minions β enraged)
- Homing bullets that fly towards the player
- Screen shake in phase 3
- Minions spawning in phase 2 and 3
- Boss defeated: explosion sequence, “BOSS DEFEATED!”, +500 score
See you next time!
“Next time: the polished version. Sound, menu screen, high scores β and your own twist on the game.”
Take it home
Try one of these extensions at home:
- Easy: change the boss colour or size via the image.
- Medium: add a fourth phase (HP < 10%) in which the boss teleports.
- Tricky: let the boss fire delay-bullets that leave a trail.
- Very tricky: add a second boss type that appears in a different wave.
Download Dojo Defender
Dojo Defender: Session 7
Today you decide what happens in Dojo Defender. No step-by-step instructions β pick a feature, plan it, and build it.
You start from the complete Dojo Defender with everything on it: ship, asteroids, 3 enemy types, waves, particles, parallax stars, and a final boss with 3 phases.
This is your game. Make something of it.
Choose what you want to build
Below are some ideas. Pick one β or come up with your own feature.
Easy
- Power-ups: enemies sometimes (20%) drop a power-up. Red = spread shot (3 bullets), green = shield, yellow = speed boost. Power-ups work for 5 seconds.
- Difficulty: make the game easier (more lives, slower enemies) or harder (fewer lives, faster spawns).
- Sound: add a power-up sound (
powerup.wav), a game-over sound, or background music that speeds up at higher waves.
Medium
- High score: save the highest score in a file (
highscore.txt). Show it on the menu and game-over screen. Only overwrite it if the new score is higher. - Difficulty scaling: make the game a bit harder every wave β faster enemies, smaller asteroids, more frequent spawning.
- Poison enemies: a new enemy type that leaves a trail of poisonous particles.
- Ship animation: the ship can only fire 3 bullets at a time. Load a “heat” indicator or add a cooldown.
Hard
- Second boss: make a new boss that appears in a different wave (e.g. wave 7). Different looks, different behaviour.
- Extending the power-up system: add a 4th power-up (e.g. “time freeze” β enemies freeze for 3 seconds).
- Combo system: the faster you kill enemies, the more points. A “combo multiplier” that goes up.
- Boss has its own power-up drops: the boss drops a rare power-up when it’s defeated.
Own idea
Got an idea of your own? Great. Discuss it briefly with a coach and start building.
Tips for planning your feature
Before you start writing code, answer these questions:
- What should the player see? (a new power-up, a text, an icon?)
- When does it happen? (when you press SPACE, when an enemy dies, when wave 10 starts?)
- How long does it last? (5 seconds? Permanent? Until the next wave?)
- What has to change in the code? (new variables? a new list? a timer?)
- How do I know it works? (what is the “check” β what do I have to see to know it’s finished?)
Write your answers on paper or in comments at the top of the file.
Cheatsheets from previous sessions
Can’t remember something anymore? Use the cheatsheets from previous sessions:
Coach-guided planning
You fill this part in together with a coach before you start coding.
My feature: _________________________________________________
I choose: Easy / Medium / Hard / Own idea
New variables I need:
- __________________________________
- __________________________________
- __________________________________
New images or sounds:
- __________________________________
- __________________________________
Check: how do I know it works?
- __________________________________
Showcase
At the end of the session you show a coach and your buddy what you built. Tell them in 30 seconds:
- What you wanted to make
- Whether it worked
- What was the hardest part
- Open
main.py in Thonny and click Run β does it still work? - Make a copy of the game folder in case you get stuck.
- Start with the smallest possible change (e.g. adding a variable).
- Test. Does it work? β Next step. Doesn’t it work? β Ask a coach.
See you next time!
“Next time: a completely new game. What will it be?”
Progress per session
| DD Session | After curriculum | What is new? | Snapshot |
|---|
| 1 | S2 | Spaceship + asteroids + lives + explosions | (follows) |
| 2 | S3 | Shooting + enemies + score + waves | (follows) |
| 3 | S4 (Pygame) | Rebuilt in Pygame with sprites + sound | (follows) |
| 4 | S5 | Menu + 3 enemy types | (follows) |
| 5 | S6 | Parallax stars + particles + splitting asteroids | (follows) |
| 6 | S7 | Boss fight with 3 phases + HP bar | (follows) |
| 7 | S8 | Your own version (power-ups, high score, …) | (follows) |