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.
- Download the starter for today, unpack it and open
main.py. - Weren’t you there? Download the solution of session 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" + scoreMake 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 toPLAYING - If
state == "GAME_OVER": reset the game and go toPLAYING - If
state == "PLAYING"andgame_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 = 120Set 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:
- High score — Save the highest score in a file and show it on the menu.
- Difficulty — Let the player choose between “Easy” and “Hard” on the menu.
- Boss wave — Every 5 waves a big boss appears with lots of hits.
- Charger Warning — Draw a red line under a charger before it appears.