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