Build a Top-Down Arena Survivor
Build a playable arcade game with Pygame using sprites, collision, and state machines.
Introduction
30 Second Summary
Every game you have ever played runs on the same core loop: read input, update the world, draw the result, repeat. The enemies that chase you, the bullets that fly, and the score that ticks up are all powered by patterns that professional game developers use daily.
In this project, you will build a top-down arena survivor game from scratch using Python and Pygame. You will create a player that moves smoothly, enemies that spawn and chase you, bullets that destroy them on impact, and a wave-based difficulty system that keeps the pressure rising.
What You'll Build
Run your finished game, dodge red enemies pouring in from the screen edges, click to fire bullets toward your cursor, and watch your score climb as each new wave spawns faster and meaner enemies.
By the end of this project, you'll have:
- A player character you control with WASD keys, dodging red enemies that spawn from the edges and chase you with increasing speed each wave.
- A shooting system where clicking fires yellow bullets toward your mouse cursor, destroying enemies on impact and racking up points.
- A complete game state machine that transitions from a start menu to live gameplay (with a HUD showing score, wave, and lives) to a game over screen displaying your final score.
- Secret Mission: Add power-ups that drop from defeated enemies, giving you speed boosts or multi-shot abilities.
Are there any prerequisites?
You should be comfortable with basic Python concepts: variables, functions, loops, and classes. No prior game development experience is needed.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Taking a moment to commit to a clear goal keeps you focused throughout the project and gives you something to measure progress against.
Get Your Player Moving on Screen
Your Pygame installation is ready and your project folder is set up. Now it is time to bring the game to life.
Every game runs on a game loop. This is a continuous cycle that checks for input, updates the world, and redraws the screen dozens of times per second. Without it, nothing moves or responds. In this step, you will build that loop and immediately get a visible, controllable character on screen.
In this step, get ready to:
- Initialize Pygame and define game constants for window size, speed, and colors.
- Create a Player class with WASD movement and frame-independent speed.
- Write the game loop that draws and updates the player every frame.
Set up the game window and game loop
Before anything can appear on screen, Pygame needs to be initialized, and your game needs to know its dimensions, speeds, and colors. This foundation code sets all of that up in one go.
- Switch back to main.py in VS Code and paste the following initialization code:
import pygame
import random
import math
# Initialize Pygame
pygame.init()
# Constants
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
PLAYER_SPEED = 300
BULLET_SPEED = 500
ENEMY_BASE_SPEED = 120
FPS = 60
# Colors
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
BLUE = (50, 100, 255)
RED = (255, 50, 50)
YELLOW = (255, 255, 0)
GRAY = (100, 100, 100)
# Set up display
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Arena Survivor")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 36)
title_font = pygame.font.Font(None, 72)
What does this code do?
- pygame.init() starts Pygame's internal systems (display, sound, fonts).
- The constants define your game's rules in one place. PLAYER_SPEED = 300 means the player moves 300 pixels per second.
- pygame.display.set_mode() creates the actual window the player will see. clock controls how fast the loop runs.
- The fonts are created now so they are ready when you need to draw text on screen later.
- Save main.py.
- Run your script to confirm everything initializes without errors:
python main.py
You should see a greeting from the Pygame community printed in your terminal, then the script exits. The window appears briefly and closes because there is no loop to keep it open yet.
Seeing a ModuleNotFoundError?
- Make sure you installed Pygame in the same Python environment you are using to run the script. Run pip install pygame==2.6.1 again if needed.
- Check that your terminal is open inside the arena_survivor folder so Python can find main.py.
Help me debug a ModuleNotFoundError for pygame
Create the Player class and add movement
Pygame uses a Sprite system for game objects. Each sprite needs an image (what it looks like) and a rect (where it is on screen).
- Below the title_font line in main.py, add a blank line and then paste the Player class constructor:
class Player(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
self.image = pygame.Surface([40, 40])
self.image.fill(BLUE)
self.rect = self.image.get_rect()
self.rect.center = (SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)
self.speed = PLAYER_SPEED
What does __init__ set up?
- super().__init__() registers this object with Pygame's sprite system.
- self.image is a 40x40 pixel blue square. No image files needed.
- self.rect.center places the player in the middle of the 800x600 window.
- self.speed stores how fast the player moves (300 pixels per second).
- Directly below the __init__ method (still inside the Player class), add the update method that handles movement:
def update(self, dt):
keys = pygame.key.get_pressed()
dx = 0
dy = 0
if keys[pygame.K_w]:
dy -= 1
if keys[pygame.K_s]:
dy += 1
if keys[pygame.K_a]:
dx -= 1
if keys[pygame.K_d]:
dx += 1
# Normalize diagonal movement
if dx != 0 and dy != 0:
dx *= 0.7071
dy *= 0.7071
self.rect.x += dx * self.speed * dt
self.rect.y += dy * self.speed * dt
# Clamp to screen bounds
self.rect.clamp_ip(screen.get_rect())
What does the update method do?
- pygame.key.get_pressed() checks which keys are currently held down, not just pressed once.
- dx and dy build a direction vector from WASD input. Multiplying by dt (delta time) makes movement smooth regardless of frame rate.
- The 0.7071 normalization prevents diagonal movement from being faster than horizontal or vertical movement.
- clamp_ip() keeps the player inside the window edges so they cannot escape the arena.
What is delta time?
Delta time (dt) is the number of seconds that passed since the last frame. If your game runs at 60 FPS, dt is about 0.016 seconds.
Multiplying speed by dt means the player moves 300 pixels per second no matter the frame rate. Without it, the game would run at different speeds on different computers.
- Save main.py.
- Run the script again to confirm there are no syntax errors in your class:
python main.py
You should see the same Pygame greeting as before. The Player class is defined but nothing uses it yet.
Seeing an IndentationError?
- Make sure the def update line is indented with exactly 4 spaces to sit inside the Player class, and all lines inside update use 8 spaces of indentation.
- Check that you did not accidentally place the update method outside the class (at the same indent level as class Player).
Help me fix an IndentationError in my Player class
Draw the player and display the window
The Player class exists, but nothing creates an instance of it or tells Pygame to draw it. Now you will wire everything together with a sprite Group and the game loop.
- Below the Player class in main.py, add a blank line and paste the game loop code:
# Create sprite groups
all_sprites = pygame.sprite.Group()
# Create player
player = Player()
all_sprites.add(player)
running = True
while running:
dt = clock.tick(FPS) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
screen.fill(BLACK)
all_sprites.update(dt)
all_sprites.draw(screen)
pygame.display.flip()
pygame.quit()
What does the game loop do?
- all_sprites is a Group that manages every sprite in the game. You add the player to it so Pygame can update and draw it automatically.
- clock.tick(FPS) / 1000 limits the loop to 60 frames per second and returns delta time in seconds.
- The event loop checks for the window close button. Without it, the window would be impossible to close.
- screen.fill(BLACK) clears the previous frame. all_sprites.draw(screen) paints every sprite. pygame.display.flip() shows the finished frame.
- Save main.py.
Before you run this, what do you expect to see when the window opens?
- Run the game:
python main.py
A dark window titled "Arena Survivor" opens with a blue square in the center. Use the W, A, S, and D keys to move it around. The square stays inside the window edges and moves at the same speed in all directions.
Window opens but no blue square?
- Check that you added all_sprites.add(player) after creating the player instance. Without it, the Group is empty and nothing draws.
- Make sure all_sprites.draw(screen) appears after screen.fill(BLACK) and before pygame.display.flip(). If draw comes before fill, the black background covers the player.
Why is my blue square not showing up in the Pygame window?
- Close the game window by clicking the X button or pressing Ctrl+C in the terminal.
✔️ Awesome, I've got everything!
Great. Double check that you have saved main.py and your blue square moves smoothly with WASD.
ⓧ I'd like to double check the full code
Your main.py should contain these sections in order. Scroll up and confirm each is present:
- Imports (pygame, random, math) and pygame.init().
- Constants block (SCREEN_WIDTH, SCREEN_HEIGHT, PLAYER_SPEED, BULLET_SPEED, ENEMY_BASE_SPEED, FPS).
- Colors block (BLACK, WHITE, BLUE, RED, YELLOW, GRAY).
- Display setup (screen, clock, font, title_font).
- Player class with __init__ and update methods.
- Sprite group creation, player instance, and the while running loop ending with pygame.quit().
Your player is alive and moving. Next up, you will spawn enemies that chase your player across the arena. Things are about to get dangerous.
Spawn Enemies That Chase You
In Step 2 you built the foundation. You can move a blue square around an 800x600 window smoothly using WASD keys.
A player that can move around an empty screen is not a game. Enemies create the core tension. In this step you will spawn red enemies from the screen edges that chase your player relentlessly. But here is the catch: you have no way to fight back yet, which will become a problem fast.
In this step, get ready to:
- Create an Enemy class that spawns from random screen edges.
- Set up a timer that spawns a new enemy every 2 seconds.
- Add collision detection so touching an enemy registers a hit.
Create the Enemy class
Each enemy needs to know where it starts and how to chase the player. You will build the Enemy class in two parts: first the constructor that handles spawning, then the update method that handles chasing.
- In main.py, find the closing line of the Player class. Below it, add the Enemy class constructor by pasting this code:
class Enemy(pygame.sprite.Sprite):
def __init__(self, player, speed):
super().__init__()
self.image = pygame.Surface([30, 30])
self.image.fill(RED)
self.rect = self.image.get_rect()
self.player = player
self.speed = speed
# Spawn from a random screen edge
edge = random.choice(["top", "bottom", "left", "right"])
if edge == "top":
self.rect.x = random.randint(0, SCREEN_WIDTH)
self.rect.y = -30
elif edge == "bottom":
self.rect.x = random.randint(0, SCREEN_WIDTH)
self.rect.y = SCREEN_HEIGHT + 30
elif edge == "left":
self.rect.x = -30
self.rect.y = random.randint(0, SCREEN_HEIGHT)
else:
self.rect.x = SCREEN_WIDTH + 30
self.rect.y = random.randint(0, SCREEN_HEIGHT)
What does this code do?
The Enemy stores a reference to the player so it knows where to chase. It also stores a speed value so enemies can get faster in later waves.
The spawning logic picks one of the four screen edges at random, then places the enemy just outside the visible area. This means enemies slide in from off-screen rather than popping into view.
- Still inside the Enemy class, add the update method directly below the __init__ method:
def update(self, dt):
# Calculate direction toward player
dx = self.player.rect.centerx - self.rect.centerx
dy = self.player.rect.centery - self.rect.centery
distance = math.sqrt(dx * dx + dy * dy)
if distance > 0:
dx = dx / distance
dy = dy / distance
self.rect.x += dx * self.speed * dt
self.rect.y += dy * self.speed * dt
How does the chasing work?
Each frame, the enemy calculates the difference between its position and the player's position. It then normalizes that direction (divides by the total distance) so the enemy moves at a consistent speed regardless of how far away the player is.
Multiplying by dt ensures movement is frame-independent, just like the player's movement.
- Save main.py.
Set up enemy spawning with a timer
The Enemy class exists, but nothing creates Enemy instances yet. You need a spawn timer that creates a new enemy every 2 seconds and adds it to the game.
- Find the line all_sprites = pygame.sprite.Group() and add a new enemies group directly below it:
enemies = pygame.sprite.Group()
- Find the line running = True and add these two variables directly above it:
# Track when to spawn the next enemy
last_spawn_time = 0
spawn_interval = 2000
- Now you need to restructure the game loop. Find these four lines inside the while running loop (after the event handling):
screen.fill(BLACK)
all_sprites.update(dt)
all_sprites.draw(screen)
pygame.display.flip()
- Replace those four lines with this code that adds spawning, updates entities separately, then draws everything:
# Spawn enemies on a timer
current_time = pygame.time.get_ticks()
if current_time - last_spawn_time > spawn_interval:
enemy = Enemy(player, ENEMY_BASE_SPEED)
enemies.add(enemy)
all_sprites.add(enemy)
last_spawn_time = current_time
# Update all entities
player.update(dt)
enemies.update(dt)
# Draw everything
screen.fill(BLACK)
all_sprites.draw(screen)
pygame.display.flip()
What does this code do?
The spawning timer uses pygame.time.get_ticks() to check how many milliseconds have passed since the last spawn. Every 2000 milliseconds (2 seconds), it creates a new Enemy and adds it to both the enemies group and all_sprites group.
Updating player and enemies separately (instead of all_sprites.update()) gives you finer control over the update order. This matters when you add collision detection next.
Before you run this, what do you expect to see happen in the game window?
- Save main.py and run the game by entering this in your terminal:
python main.py
You should see red squares slide in from the screen edges every 2 seconds and chase your blue player. Try dodging them with WASD.
Enemies not appearing?
- Make sure enemies = pygame.sprite.Group() is defined before the game loop starts.
- Check that new enemies are added to BOTH enemies and all_sprites. The all_sprites.draw(screen) call only draws sprites in that group.
- Verify you replaced the old all_sprites.update(dt) line. Having both the old line and the new separate updates would cause double movement.
Why aren't my enemies spawning or moving toward me?
Add collision detection between enemies and player
Enemies spawn and chase, but nothing happens when they touch you. You need collision detection to register when an enemy reaches the player.
- In main.py, find the enemies.update(dt) line inside the game loop. Add this collision check directly below it (before the screen.fill(BLACK) line):
# Check if any enemy touches the player
player_hits = pygame.sprite.spritecollide(player, enemies, True)
if player_hits:
print("Hit!")
What does spritecollide do?
The function pygame.sprite.spritecollide(player, enemies, True) checks whether the player's rect overlaps with any sprite in the enemies group. The third argument (True) tells Pygame to automatically remove (kill) any enemy that collides.
What do you think will happen when you run the game and let an enemy reach you?
- Save main.py and run the game:
python main.py
- Move your player around and let an enemy touch you.
- Check your terminal. You should see Hit! printed each time an enemy reaches you, and that enemy disappears.
Try surviving for 10 seconds. You will notice the problem: you can dodge, but you cannot fight back. The enemies will overwhelm you. That gap is exactly what the next step fixes.
Not seeing Hit! in the terminal?
- Make sure the collision code sits AFTER enemies.update(dt) and BEFORE screen.fill(BLACK). Order matters in the game loop.
- Check your indentation. The collision code must be inside the while running loop (one level of indentation), not inside the for event loop.
Help me debug why my collision detection is not printing Hit! when enemies touch my player.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and that enemies spawn, chase, and print Hit! on collision.
ⓧ I'd like to double check the full code
Scroll through your main.py and confirm these sections are present in order:
- Imports, constants, and color definitions at the top.
- Display setup (screen, clock, font, title_font).
- Player class with __init__ and update methods.
- Enemy class with __init__ (random edge spawning) and update (chase toward player).
- Sprite groups: all_sprites and enemies.
- Player instance created and added to all_sprites.
- Spawn tracking variables: last_spawn_time = 0 and spawn_interval = 2000.
- Game loop with: event handling, spawning timer, player.update(dt), enemies.update(dt), collision check with print("Hit!"), then draw and flip.
Your arena now has real danger. Enemies pour in from the edges and hunt you down. But getting overwhelmed with no counterplay is frustrating. Next up, you will give the player a weapon to fight back.
Fight Back with Bullets
In the last step, you saw the problem firsthand. Enemies chase you relentlessly, and all you can do is run.
Shooting gives you agency. In this step, you will create a Bullet class that flies toward your mouse cursor, wire it to left-click, and add collision so bullets destroy enemies on contact. This is the same projectile pattern you will build in nearly every Roblox game.
In this step, get ready to:
- Create a Bullet class that moves in a straight line toward a target position.
- Fire bullets on left-click toward the mouse cursor.
- Add bullet-enemy collision so enemies are destroyed on hit.
Create the Bullet class
A bullet needs to know where it starts and where it is heading. On creation, it calculates a direction vector toward the target, normalizes it, and multiplies by a speed constant so it travels at the same rate regardless of distance.
- In main.py, find the end of the Enemy class (after the last line of its update method).
- Add the following Bullet class below the Enemy class:
class Bullet(pygame.sprite.Sprite):
def __init__(self, start_pos, target_pos):
super().__init__()
self.image = pygame.Surface([16, 16], pygame.SRCALPHA)
pygame.draw.circle(self.image, YELLOW, (8, 8), 8)
self.rect = self.image.get_rect()
self.rect.center = start_pos
# Calculate direction toward target
dx = target_pos[0] - start_pos[0]
dy = target_pos[1] - start_pos[1]
distance = math.sqrt(dx * dx + dy * dy)
if distance > 0:
self.dx = (dx / distance) * BULLET_SPEED
self.dy = (dy / distance) * BULLET_SPEED
else:
self.dx = 0
self.dy = -BULLET_SPEED
def update(self, dt):
self.rect.x += self.dx * dt
self.rect.y += self.dy * dt
# Kill if off screen
if (self.rect.right < 0 or self.rect.left > SCREEN_WIDTH or
self.rect.bottom < 0 or self.rect.top > SCREEN_HEIGHT):
self.kill()
What does this code do?
- pygame.SRCALPHA creates a transparent surface so you see a circle, not a yellow square on a black background.
- The direction math subtracts the bullet's start from the target, then divides by the distance. This produces a unit vector (length 1) pointing at the mouse.
- Multiplying the unit vector by BULLET_SPEED (500) means every bullet flies at the same rate, no matter how far the cursor was when you clicked.
- self.kill() removes the bullet from all Groups once it leaves the screen, preventing memory buildup.
- Save main.py.
You will not see bullets yet because nothing creates a Bullet instance. The next chunk wires the class to your mouse.
Fire bullets on mouse click
Three small wiring changes connect your Bullet class to the game. You need a Group to hold bullets, an event listener for left-click, and an update call to move them each frame.
- Find the line enemies = pygame.sprite.Group() and add the bullets Group directly below it:
bullets = pygame.sprite.Group()
- Inside the event loop, find the line if event.type == pygame.QUIT: and its running = False body.
- Add this block directly after the QUIT handler (still inside the for event loop):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mouse_pos = pygame.mouse.get_pos()
bullet = Bullet(player.rect.center, mouse_pos)
bullets.add(bullet)
all_sprites.add(bullet)
- Find the line enemies.update(dt) and add the bullets update directly below it:
bullets.update(dt)
What's happening here?
- event.button == 1 checks specifically for a left-click so right-click does nothing.
- pygame.mouse.get_pos() grabs the cursor's screen coordinates at the exact moment you click.
- Adding the bullet to both bullets and all_sprites means it gets drawn automatically by all_sprites.draw(screen) while staying queryable for collision checks.
- Save main.py.
- Run your game by running:
python main.py
- Left-click anywhere in the window.
You should see yellow circles shoot from your blue player toward where you clicked. They fly in a straight line and vanish when they leave the window.
Try clicking on a red enemy. The bullet passes right through it. There is no collision logic yet.
Bullets not appearing?
- Make sure the MOUSEBUTTONDOWN block is indented inside the for event in pygame.event.get(): loop (two levels of indentation: one for the while, one for the for).
- Check that you added bullets.update(dt) below enemies.update(dt). Without it, bullets are created but never move.
- Confirm bullets = pygame.sprite.Group() exists above the game loop. A missing Group causes a NameError.
Why are my bullets not showing up when I click?
- Close the game window to continue.
Add bullet-enemy collision
Bullets fly, but they do nothing. One line of collision detection closes the combat loop.
- In main.py, find the line player_hits = pygame.sprite.spritecollide(player, enemies, True).
- Add this line directly above it:
pygame.sprite.groupcollide(bullets, enemies, True, True)
What does groupcollide do?
pygame.sprite.groupcollide() checks every sprite in the first Group against every sprite in the second Group. The two True arguments mean "destroy the bullet on hit" and "destroy the enemy on hit." Both sprites are removed from all their Groups in one call.
- Save main.py.
Before you run this, what do you think will happen when a bullet reaches an enemy?
- Run the game by running:
python main.py
- Click on approaching enemies to fire at them.
Both the bullet and the enemy vanish the instant they overlap. The game now has a complete combat loop: enemies chase, you shoot, enemies die.
Bullets pass through enemies?
- Make sure the groupcollide line sits inside the while running: loop (one level of indentation), not inside the event loop.
- Check that you wrote bullets (the Group), not bullet (a single instance).
My bullets are not destroying enemies on contact in Pygame.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and that bullets destroy enemies on contact when you run the game.
ⓧ I'd like to double check the full code
The file is long, so confirm these pieces are present and in order:
- Imports (pygame, random, math) and constants at the top.
- Player class with WASD movement.
- Enemy class with edge spawning and chase logic.
- Bullet class with direction math, update, and self.kill() when off screen.
- Three Groups: all_sprites, enemies, bullets.
- Event loop with QUIT and MOUSEBUTTONDOWN handlers.
- Enemy spawning timer.
- player.update(dt), enemies.update(dt), bullets.update(dt) in sequence.
- pygame.sprite.groupcollide(bullets, enemies, True, True) before the player collision check.
- screen.fill(BLACK), all_sprites.draw(screen), pygame.display.flip() at the end of the loop.
You have a fully working combat loop. Enemies chase, you aim and click, bullets fly, enemies die. But there is no score, no feedback, and no end state. Next up, you will add scoring, wave progression, and game states to turn this prototype into a complete game.
Add Score, Waves, and Game States
You have a playable combat loop where enemies chase you and bullets destroy them. But there is no feedback telling you how well you are doing, no escalating challenge, and no beginning or end to the experience.
In this step, you will add scoring, wave-based difficulty that ramps up as you play, a lives system, and a complete game state machine that gives your game a title screen, gameplay phase, and game over screen. This is the pattern every professional game engine uses to manage scenes.
In this step, get ready to:
- Track score, waves, and lives with a game data dictionary.
- Render a heads-up display showing your score, wave, and remaining lives.
- Build a state machine that transitions between menu, playing, and game over screens.
Add scoring and wave progression
Right now your game has no memory. It does not track kills, does not get harder, and has no concept of "lives" that run out. You need a central place to store all game progress, plus a function to reset everything when a new round starts.
- In main.py, find the blank line after the Bullet class (just before # Create sprite groups).
- Add this reset_game() function between the Bullet class and the sprite groups section:
def reset_game():
"""Reset all game variables for a new game."""
player.rect.center = (SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)
player.speed = PLAYER_SPEED
enemies.empty()
bullets.empty()
game_data["score"] = 0
game_data["wave"] = 1
game_data["lives"] = 3
game_data["kills_this_wave"] = 0
game_data["spawn_interval"] = 2000
game_data["last_spawn_time"] = pygame.time.get_ticks()
What does this function do?
Every time a new game starts (or restarts after game over), this function snaps everything back to its starting state. It centers the player, clears all enemies and bullets from the screen, and resets all tracking numbers to zero.
- Now find the two standalone variables below the player creation:
last_spawn_time = 0
spawn_interval = 2000
running = True
- Replace those lines with the game_data dictionary and game_state variable:
# Game data
game_data = {
"score": 0,
"wave": 1,
"lives": 3,
"kills_this_wave": 0,
"spawn_interval": 2000,
"last_spawn_time": 0,
}
game_state = "menu"
running = True
Why a dictionary instead of separate variables?
Grouping all game progress into one game_data dictionary makes it easy to reset everything at once. The game_state variable acts as a simple state machine that controls which part of the game loop runs each frame.
Now you need to replace the entire main game loop. The new loop will check which state the game is in and respond differently for each one.
- Delete everything from while running: down to the end of the file (including pygame.quit()).
- Replace it with this new loop structure that handles events based on the current game state:
while running:
dt = clock.tick(FPS) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if game_state == "menu":
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
game_state = "playing"
reset_game()
elif game_state == "playing":
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mouse_pos = pygame.mouse.get_pos()
bullet = Bullet(player.rect.center, mouse_pos)
bullets.add(bullet)
all_sprites.add(bullet)
elif game_state == "game_over":
if event.type == pygame.KEYDOWN and event.key == pygame.K_r:
game_state = "playing"
reset_game()
screen.fill(BLACK)
What does this event handling do?
Each state only listens for the inputs it cares about. In the menu, only SPACE matters. During gameplay, only mouse clicks fire bullets. On game over, only R triggers a restart. This prevents the player from shooting during the menu or restarting mid-game.
- Directly below screen.fill(BLACK), add the playing state block with enemy spawning, updates, collision detection, and scoring logic:
if game_state == "menu":
pass
elif game_state == "playing":
# Spawn enemies
current_time = pygame.time.get_ticks()
if current_time - game_data["last_spawn_time"] > game_data["spawn_interval"]:
enemy_speed = ENEMY_BASE_SPEED + (game_data["wave"] - 1) * 20
enemy = Enemy(player, enemy_speed)
enemies.add(enemy)
all_sprites.add(enemy)
game_data["last_spawn_time"] = current_time
# Update
player.update(dt)
enemies.update(dt)
bullets.update(dt)
How does wave difficulty scaling work?
The enemy speed formula ENEMY_BASE_SPEED + (wave - 1) * 20 means Wave 1 enemies move at 120 pixels per second, Wave 2 at 140, Wave 3 at 160, and so on. Each wave also spawns enemies faster by reducing the spawn interval.
- Continue directly below the update section by adding the collision detection with scoring, wave progression, and lives tracking:
# Bullet-enemy collision
hits = pygame.sprite.groupcollide(bullets, enemies, True, True)
for bullet_hit in hits:
game_data["score"] += 10
game_data["kills_this_wave"] += 1
if game_data["kills_this_wave"] >= 5:
game_data["wave"] += 1
game_data["kills_this_wave"] = 0
game_data["spawn_interval"] = max(500, game_data["spawn_interval"] - 200)
# Enemy-player collision
player_hits = pygame.sprite.spritecollide(player, enemies, True)
if player_hits:
game_data["lives"] -= 1
if game_data["lives"] <= 0:
game_state = "game_over"
# Draw
screen.fill(BLACK)
all_sprites.draw(screen)
What does this collision code do?
Every bullet-enemy hit adds 10 points and increments the kill counter. After 5 kills, the wave advances and spawn_interval decreases by 200ms (down to a minimum of 500ms). Enemy-player hits now subtract a life instead of just printing. When lives reach zero, the game transitions to the game_over state.
- Finally, close the loop with the game over placeholder, display flip, and quit. Add this at the bottom, still inside the while running: loop (except pygame.quit() which goes outside):
elif game_state == "game_over":
pass
pygame.display.flip()
pygame.quit()
- Save main.py.
Before you run this, what do you expect to see when the game starts? The game state begins as "menu" and the menu block just says pass for now.
- Run the game by running this command:
python main.py
You see a black window with nothing on it. Press SPACE to start playing. Enemies begin spawning and chasing you. As you shoot them, you will notice enemies gradually getting faster (the wave system is working behind the scenes). If three enemies hit you, the window goes black again (the game over state has no text yet).
Game crashes or nothing happens when you press SPACE?
Check that reset_game() is defined BEFORE the # Create sprite groups section. Python must see the function definition before the loop calls it.
Make sure you deleted the old last_spawn_time and spawn_interval standalone variables. The new code reads these from game_data instead.
help me debug my game loop restructuring
Add a HUD (heads-up display)
The scoring system works, but you cannot see any of the numbers on screen. A HUD renders live game stats so the player always knows their score, current wave, and remaining lives.
- In main.py, find the # Draw section inside the playing state (the two lines with screen.fill(BLACK) and all_sprites.draw(screen)).
- Add this HUD rendering code directly below all_sprites.draw(screen):
# HUD
score_text = font.render(f"Score: {game_data['score']}", True, WHITE)
wave_text = font.render(f"Wave: {game_data['wave']}", True, WHITE)
lives_text = font.render(f"Lives: {game_data['lives']}", True, WHITE)
screen.blit(score_text, (10, 10))
screen.blit(wave_text, (10, 45))
screen.blit(lives_text, (10, 80))
What does this code do?
font.render() converts text into a Surface (an image of the text). screen.blit() stamps that Surface onto the game window at the specified (x, y) position. The three lines stack vertically at the top-left corner (x=10, y=10/45/80).
- Save main.py and run the game again:
python main.py
- Press SPACE to start. You should now see white text in the top-left corner showing your score, wave number, and lives. Shoot some enemies and watch the score climb by 10 for each kill. After 5 kills, the wave number ticks up and enemies start spawning faster.
HUD text not appearing?
Make sure the HUD code is indented inside the elif game_state == "playing": block (8 spaces of indentation). If it sits outside, it will only render during the wrong state.
Check that the HUD code comes AFTER all_sprites.draw(screen). If it comes before, the sprite draw call will paint over your text.
help me fix my HUD rendering
Implement game states (menu, playing, game over)
The gameplay works, but the game launches to a black screen with no instructions, and dying leaves you staring at nothing. A proper game has a title screen that tells the player what to do, and a game over screen that shows their final score and lets them try again.
- In main.py, find the if game_state == "menu": block that currently says pass.
- Replace pass with this menu rendering code:
if game_state == "menu":
title_text = title_font.render("Arena Survivor", True, WHITE)
start_text = font.render("Press SPACE to start", True, GRAY)
controls_text = font.render("WASD to move, Click to shoot", True, GRAY)
screen.blit(title_text, title_text.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 3)))
screen.blit(start_text, start_text.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)))
screen.blit(controls_text, controls_text.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 + 50)))
What does this code do?
The menu renders three lines of text centered on screen. The title_font (size 72) draws the large game title, while the smaller font (size 36) shows start instructions and controls. The get_rect(center=(...)) trick positions each text surface by its center point rather than its top-left corner.
- Now find the elif game_state == "game_over": block that currently says pass.
- Replace pass with this game over screen rendering:
elif game_state == "game_over":
over_text = title_font.render("GAME OVER", True, RED)
score_text = font.render(f"Final Score: {game_data['score']}", True, WHITE)
wave_text = font.render(f"Reached Wave: {game_data['wave']}", True, WHITE)
restart_text = font.render("Press R to restart", True, GRAY)
screen.blit(over_text, over_text.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 3)))
screen.blit(score_text, score_text.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)))
screen.blit(wave_text, wave_text.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 + 40)))
screen.blit(restart_text, restart_text.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 + 100)))
What does this code do?
The game over screen displays a large red "GAME OVER" title, then your final score and which wave you reached. It also shows "Press R to restart" so the player knows how to try again. The event handler you wrote earlier already listens for R and calls reset_game().
- Save main.py.
Before you run the game this time, predict what you will see immediately when the window opens. Will it be a black screen, the player character, or something else?
- Run the game:
python main.py
You should see a title screen with "Arena Survivor" in large white text, "Press SPACE to start" below it, and control instructions at the bottom. Press SPACE to begin playing. Your score increases as you shoot enemies. Every 5 kills the wave number jumps and enemies spawn faster. After losing all 3 lives, a red "GAME OVER" screen appears with your final score and wave reached. Press R to restart immediately.
Menu text or game over screen not showing?
Verify that screen.fill(BLACK) sits BEFORE the if game_state == "menu": block (directly after the event loop ends). If it is placed after the state blocks, it paints over everything you just rendered.
Make sure pygame.display.flip() is the very last line inside the while running: loop, after all three state blocks. Without it, nothing gets pushed to the screen.
help me debug my game state rendering
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and confirmed all three states (menu, playing, game over) work correctly.
ⓧ I'd like to double check the full code
Here is the complete main.py file. Because it exceeds 30 lines, scroll through and confirm each section is present in this order:
- Imports and constants (including colors) at the top.
- Display setup with screen, clock, font, and title_font.
- Player, Enemy, and Bullet classes (unchanged from the previous step).
- reset_game() function.
- Sprite groups, player creation, game_data dict, and game_state = "menu".
- Main loop with state-based event handling, menu rendering, playing state (spawn + update + collision + draw + HUD), and game over rendering.
- pygame.display.flip() as the last line of the loop, and pygame.quit() after the loop ends.
You have turned a bare prototype into a complete game with progression, feedback, and a full lifecycle. The state machine pattern you just built is exactly how professional game engines like Roblox manage scenes. Next up, the Secret Mission challenges you to add power-ups that drop from defeated enemies.
Secret mission
Add Power-Ups
Your game has shooting and waves, but every run feels the same. What if defeated enemies occasionally dropped power-ups that gave you temporary speed boosts or triple-shot abilities? Build a timed buff system with randomized drops.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine with no cloud services or ongoing costs. There is nothing that will charge you money if left running.
Resources you used:
- The arena_survivor project folder on your Desktop.
- The pygame package installed via pip.
Decide whether to keep your resources to continue experimenting, or delete them entirely.
Keep everything running
No action needed. Choose this if you want to keep playing or modifying your game.
- Your arena_survivor folder stays on your Desktop ready to run anytime.
- Pygame remains installed and available for future projects.
- Run your game anytime with python main.py from inside the project folder.
Pause - I'll come back to this later
Since this is a local project with no running processes, there is nothing to pause. Your files sit idle on disk and consume no memory or CPU when not running.
- Close the game window if it is still open.
- Close VS Code if you are done for now.
- Everything is saved. Come back and run python main.py whenever you are ready.
Delete - I don't want to use this again
Remove all project files and the Pygame package to start fresh.
- Delete the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/arena_survivor
Windows
rmdir /s /q %USERPROFILE%\Desktop\arena_survivor
- Uninstall Pygame by running:
pip uninstall pygame
- Confirm the removal when prompted by typing y and pressing Enter.
Nice Work!
Nice Work!
Nice work! You just built a complete top-down arena survivor game from scratch using Python and Pygame.
You've learned how to:
- Built a complete game loop with delta time for frame-independent movement, and created entity classes (Player, Enemy, Bullet) using Pygame's Sprite system and object-oriented patterns.
- Implemented collision detection between multiple entity groups and designed an enemy spawning system with wave-based difficulty scaling.
- Built a game state machine handling menu, gameplay, and game over transitions with a real-time HUD displaying score, wave, and lives.
- Secret Mission: Added a power-up system with timed buffs and randomized drops from defeated enemies.
Ready to quiz yourself?