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