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