Dojo Defender

Dojo Defender is our house game: a top-down survival shooter that we build together over several sessions.

Who is it for?

Dojo Defender is for ninjas who come along regularly. The game grows along with the normal lessons over 7 sessions. Only drop by now and then? No problem: do the mini-game of that session. It is always complete on its own.

Don’t count on a complete Dojo Defender if you miss a lot of sessions. But the template and all the snapshots are on this site, so building on at home is always possible.

How do I join in?

  • From lesson 2 you get a Dojo Defender snapshot at the end of every lesson: a ZIP file you can download from the lesson page.
  • Unpack it, open it in Thonny, and the next lesson you continue with it.
  • Missed a lesson? Download the snapshot of that lesson, or skip a lesson and pick it up further on.

Can I make it my own thing?

Yes! Change the name, the graphical style, the story. Dojo Defender is a template, not an obligation.

October 30, 2026, 3:59 pm

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

  1. Open Thonny.
  2. Download the Dojo Defender starter, unpack the ZIP and open main.py.
  3. Click Run. You see a dark blue screen with your spaceship at the bottom. You can already move it with the arrow keys.
  4. 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:

  1. Sound β€” Add sounds/explosion.wav when an asteroid explodes (sounds.explosion.play()).
  2. Harder β€” Make asteroids fall faster as the score rises: rock.speed = random.uniform(3 + score // 10, 5 + score // 10).
  3. Power-up β€” Add a rare green power-up that gives an extra life.
  4. Smaller β€” Use asteroid_med or asteroid_small for variation in size and speed.

Download Dojo Defender

October 30, 2026, 3:59 pm

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

  1. Open Thonny.
  2. Download the Dojo Defender starter for today, unpack the ZIP and open main.py.
  3. Were you not there last time? Download the solution from session 1 and start with that.
  4. Click Run. You see the same game as last time: ship, asteroids, explosions.
  5. 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:

bullets = []

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:

shoot_cooldown = 0

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:

enemies = []

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:

  1. Enemy variation β€” Use enemy_charger.png for a faster enemy that doesn’t do a sine, but comes straight at you.
  2. Sound β€” Add sounds/laser.wav and sounds/explosion.wav.
  3. Hard mode β€” Enemies spawn faster and faster, up to every 30 frames at most.
  4. Score multiplier β€” The higher the wave, the more points per kill: score += 10 * wave.

Download Dojo Defender

October 30, 2026, 3:59 pm

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.

  1. Download the Dojo Defender starter for today, unpack it and open main.py.
  2. Were you not there last time? Download the solution from session 2 and try to port it.
  3. 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:

pgzeroraw pygame
Actor('plaatje')GameObject('plaatje.png', x, y)
keyboard.leftkeys[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:

  1. Game loop β€” Look for the while running: loop. Do you see clock.tick(60), pygame.display.flip()?
  2. GameObject β€” Look for the class GameObject. How is an image loaded? What is rect?
  3. 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 shoot
  • explode_sound.play() β€” when an enemy or asteroid explodes
  • hit_sound.play() β€” when your ship gets hit

Step 4: πŸ”₯ Expert β€” Mute button and volume

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:

  1. Health bar β€” Replace the text “Lives: 3” with a visual bar: draw three small rectangles at the top.
  2. Pause β€” Add a P key that pauses the game. Setting running = False is not enough β€” use a separate paused variable.
  3. Particle effect β€” Let small white dots splatter around on an explosion (use a list of particles that get smaller and smaller).
  4. Your own skin β€” Replace player_ship.png with your own design.

Download Dojo Defender

October 30, 2026, 3:59 pm

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.

  1. Download the starter for today, unpack it and open main.py.
  2. Weren’t you there? Download the solution of session 3.
  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:

  1. High score β€” Save the highest score in a file and show it on the menu.
  2. Difficulty β€” Let the player choose between “Easy” and “Hard” on the menu.
  3. Boss wave β€” Every 5 waves a big boss appears with lots of hits.
  4. Charger Warning β€” Draw a red line under a charger before it appears.

Download Dojo Defender

October 30, 2026, 3:59 pm

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.

  1. Download the starter for today, unpack it and open main.py.
  2. Weren’t you there? Download the solution of session 4.
  3. 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:

stars = []

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.png
  • asteroid_med.png β†’ gets hit β†’ 2Γ— asteroid_small.png
  • asteroid_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?

  1. We spawn explosion particles at the position of the asteroid
  2. We check the size: ‘big’ β†’ 2 ‘med’, ‘med’ β†’ 2 ‘small’, ‘small’ β†’ no further splitting
  3. New asteroids spawn close to the old position with a small offset
  4. 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:

  1. Easy: change the colour of the stars per layer (e.g. far stars bluish, nearby ones yellow).
  2. Medium: add a fourth layer of stars, even faster (speed 3.0), with big bright stars.
  3. Tricky: let the particle colour change during its lifetime (from yellow to red to grey).
  4. Very tricky: add a “comet” particle type that has a small tail.

Download Dojo Defender

October 30, 2026, 3:59 pm

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.

  1. Download the starter for today, unpack it and open main.py.
  2. Weren’t you there? Download the solution of session 5.
  3. 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:

  1. Easy: change the boss colour or size via the image.
  2. Medium: add a fourth phase (HP < 10%) in which the boss teleports.
  3. Tricky: let the boss fire delay-bullets that leave a trail.
  4. Very tricky: add a second boss type that appears in a different wave.

Download Dojo Defender

October 30, 2026, 3:59 pm

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:

  1. What should the player see? (a new power-up, a text, an icon?)
  2. When does it happen? (when you press SPACE, when an enemy dies, when wave 10 starts?)
  3. How long does it last? (5 seconds? Permanent? Until the next wave?)
  4. What has to change in the code? (new variables? a new list? a timer?)
  5. 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

Step-by-step plan (for when you can’t figure it out yourself)

  1. Open main.py in Thonny and click Run β€” does it still work?
  2. Make a copy of the game folder in case you get stuck.
  3. Start with the smallest possible change (e.g. adding a variable).
  4. 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?”

October 30, 2026, 3:59 pm

Progress per session

DD SessionAfter curriculumWhat is new?Snapshot
1S2Spaceship + asteroids + lives + explosions(follows)
2S3Shooting + enemies + score + waves(follows)
3S4 (Pygame)Rebuilt in Pygame with sprites + sound(follows)
4S5Menu + 3 enemy types(follows)
5S6Parallax stars + particles + splitting asteroids(follows)
6S7Boss fight with 3 phases + HP bar(follows)
7S8Your own version (power-ups, high score, …)(follows)