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:
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 (
lifegoes 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 rockMake 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
breakImportant: 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.