Skip to main content

Tower Defense Python Implementation

15 minute read

Complete Tower Defense in Python

A full tower defense implementation featuring multiple tower types, enemy waves, upgrade systems, pathfinding, and special abilities.

Full Implementation

"""
Tower Defense Game Implementation
A complete tower defense game with multiple tower types, enemy waves, and upgrade systems
"""

import pygame
import math
import random
from typing import List, Tuple, Dict, Optional
from dataclasses import dataclass
from enum import Enum
import json

# Initialize Pygame
pygame.init()

# Constants
SCREEN_WIDTH = 1024
SCREEN_HEIGHT = 768
FPS = 60
GRID_SIZE = 32

# Colors
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)
YELLOW = (255, 255, 0)
BROWN = (139, 69, 19)
GRAY = (128, 128, 128)
DARK_GREEN = (0, 100, 0)
GOLD = (255, 215, 0)
PURPLE = (128, 0, 128)
CYAN = (0, 255, 255)
LIGHT_BROWN = (205, 133, 63)

class TowerType(Enum):
    """Tower types enumeration"""
    BASIC = "basic"
    CANNON = "cannon"
    LASER = "laser"
    SLOW = "slow"
    MONEY = "money"

class EnemyType(Enum):
    """Enemy types enumeration"""
    BASIC = "basic"
    FAST = "fast"
    TANK = "tank"
    FLYING = "flying"
    BOSS = "boss"

@dataclass
class TowerStats:
    """Tower statistics"""
    name: str
    cost: int
    damage: int
    range: int
    fire_rate: float
    projectile_speed: int
    color: Tuple[int, int, int]
    splash_radius: int = 0
    slow_effect: float = 0
    slow_duration: float = 0
    gold_generation: int = 0
    can_target_flying: bool = True

@dataclass
class EnemyStats:
    """Enemy statistics"""
    name: str
    health: int
    speed: int
    armor: int
    value: int
    color: Tuple[int, int, int]
    size: int
    flying: bool = False

# Tower definitions
TOWER_STATS = {
    TowerType.BASIC: TowerStats(
        "Basic Tower", 100, 20, 120, 1.5, 300, BROWN
    ),
    TowerType.CANNON: TowerStats(
        "Cannon Tower", 200, 50, 100, 0.5, 200, GRAY, splash_radius=50
    ),
    TowerType.LASER: TowerStats(
        "Laser Tower", 300, 10, 150, 5.0, 1000, CYAN, can_target_flying=True
    ),
    TowerType.SLOW: TowerStats(
        "Slow Tower", 150, 5, 100, 2.0, 250, BLUE, 
        slow_effect=0.5, slow_duration=2.0
    ),
    TowerType.MONEY: TowerStats(
        "Money Tower", 250, 0, 0, 0.1, 0, GOLD, gold_generation=10
    )
}

# Enemy definitions
ENEMY_STATS = {
    EnemyType.BASIC: EnemyStats(
        "Basic", 100, 50, 0, 10, GREEN, 10
    ),
    EnemyType.FAST: EnemyStats(
        "Fast", 75, 100, 0, 15, YELLOW, 8
    ),
    EnemyType.TANK: EnemyStats(
        "Tank", 300, 30, 5, 30, DARK_GREEN, 15
    ),
    EnemyType.FLYING: EnemyStats(
        "Flying", 150, 60, 2, 20, BLUE, 12, flying=True
    ),
    EnemyType.BOSS: EnemyStats(
        "Boss", 1000, 25, 10, 100, PURPLE, 20
    )
}

class Tower:
    """Tower class"""
    def __init__(self, x: int, y: int, tower_type: TowerType):
        self.x = x
        self.y = y
        self.type = tower_type
        self.stats = TOWER_STATS[tower_type]
        self.level = 1
        self.last_fire_time = 0
        self.target = None
        self.angle = 0
        self.kills = 0
        self.damage_dealt = 0
        self.last_generation_time = 0
        
    def update(self, enemies: List['Enemy'], current_time: float) -> Optional[int]:
        """Update tower and return gold generated if any"""
        # Money tower generates gold
        if self.type == TowerType.MONEY:
            if current_time - self.last_generation_time > 1000 / self.stats.fire_rate:
                self.last_generation_time = current_time
                return self.stats.gold_generation * self.level
            return None
            
        # Find and track target
        self.find_target(enemies)
        
        # Fire at target if ready
        if self.target and not self.target.is_dead:
            dx = self.target.x - self.x
            dy = self.target.y - self.y
            self.angle = math.atan2(dy, dx)
            
            if current_time - self.last_fire_time > 1000 / self.stats.fire_rate:
                self.last_fire_time = current_time
                return self.create_projectile()
        
        return None
    
    def find_target(self, enemies: List['Enemy']):
        """Find the best target within range"""
        # Clear dead or out-of-range target
        if self.target:
            if self.target.is_dead or not self.is_in_range(self.target):
                self.target = None
        
        # Find new target if needed
        if not self.target:
            valid_targets = []
            for enemy in enemies:
                if (not enemy.is_dead and 
                    self.is_in_range(enemy) and
                    (not enemy.stats.flying or self.stats.can_target_flying)):
                    valid_targets.append(enemy)
            
            # Target enemy closest to exit (highest progress)
            if valid_targets:
                self.target = max(valid_targets, key=lambda e: e.path_progress)
    
    def is_in_range(self, enemy: 'Enemy') -> bool:
        """Check if enemy is within tower's range"""
        distance = math.sqrt((enemy.x - self.x) ** 2 + (enemy.y - self.y) ** 2)
        return distance <= self.stats.range
    
    def create_projectile(self) -> 'Projectile':
        """Create a projectile targeting current enemy"""
        return Projectile(
            self.x, self.y,
            self.target,
            self.stats.damage * self.level,
            self.stats.projectile_speed,
            self
        )
    
    def upgrade(self) -> int:
        """Upgrade tower and return cost"""
        cost = self.get_upgrade_cost()
        self.level += 1
        return cost
    
    def get_upgrade_cost(self) -> int:
        """Get cost to upgrade tower"""
        return self.stats.cost * self.level
    
    def get_sell_value(self) -> int:
        """Get sell value of tower"""
        total_value = sum(self.stats.cost * i for i in range(1, self.level + 1))
        return int(total_value * 0.7)
    
    def draw(self, screen: pygame.Surface, selected: bool = False):
        """Draw the tower"""
        # Draw range if selected
        if selected:
            pygame.draw.circle(screen, (255, 255, 255, 30), 
                             (self.x, self.y), self.stats.range, 1)
        
        # Draw tower base
        tower_rect = pygame.Rect(self.x - 16, self.y - 16, 32, 32)
        pygame.draw.rect(screen, self.stats.color, tower_rect)
        pygame.draw.rect(screen, BLACK, tower_rect, 2)
        
        # Draw level indicators
        for i in range(self.level):
            level_rect = pygame.Rect(self.x - 16 + i * 6, self.y - 22, 4, 4)
            pygame.draw.rect(screen, GOLD, level_rect)
        
        # Draw turret (except for money tower)
        if self.type != TowerType.MONEY:
            turret_end_x = self.x + math.cos(self.angle) * 20
            turret_end_y = self.y + math.sin(self.angle) * 20
            pygame.draw.line(screen, BLACK, (self.x, self.y), 
                           (turret_end_x, turret_end_y), 4)

class Enemy:
    """Enemy class"""
    def __init__(self, enemy_type: EnemyType, path: List[Tuple[int, int]], wave_number: int):
        self.type = enemy_type
        self.stats = ENEMY_STATS[enemy_type]
        self.path = path
        self.path_index = 0
        self.path_progress = 0.0
        
        # Scale health with wave number
        self.max_health = self.stats.health * (1 + wave_number * 0.1)
        self.health = self.max_health
        
        # Position at start of path
        self.x = path[0][0]
        self.y = path[0][1]
        
        # Movement
        self.speed = self.stats.speed
        self.is_slowed = False
        self.slow_end_time = 0
        
        # Status
        self.is_dead = False
        self.reached_end = False
    
    def update(self, dt: float, current_time: float):
        """Update enemy position"""
        if self.is_dead or self.reached_end:
            return
        
        # Check slow effect
        if self.is_slowed and current_time > self.slow_end_time:
            self.is_slowed = False
            self.speed = self.stats.speed
        
        # Move along path
        if self.path_index < len(self.path) - 1:
            target = self.path[self.path_index + 1]
            dx = target[0] - self.x
            dy = target[1] - self.y
            distance = math.sqrt(dx * dx + dy * dy)
            
            if distance < 5:
                # Reached waypoint
                self.path_index += 1
                if self.path_index >= len(self.path) - 1:
                    self.reached_end = True
            else:
                # Move towards waypoint
                move_distance = self.speed * dt * (0.5 if self.is_slowed else 1)
                self.x += (dx / distance) * move_distance
                self.y += (dy / distance) * move_distance
                
                # Update progress for targeting
                total_segments = len(self.path) - 1
                segment_progress = 1 - (distance / 100)  # Approximate
                self.path_progress = (self.path_index + segment_progress) / total_segments
    
    def take_damage(self, damage: float):
        """Take damage, accounting for armor"""
        actual_damage = max(1, damage - self.stats.armor)
        self.health -= actual_damage
        
        if self.health <= 0:
            self.is_dead = True
            return self.stats.value
        return 0
    
    def apply_slow(self, slow_factor: float, duration: float, current_time: float):
        """Apply slow effect"""
        self.is_slowed = True
        self.speed = self.stats.speed * slow_factor
        self.slow_end_time = current_time + duration * 1000
    
    def draw(self, screen: pygame.Surface):
        """Draw the enemy"""
        if self.is_dead:
            return
        
        # Draw shadow
        shadow_pos = (int(self.x + 2), int(self.y + 2))
        pygame.draw.circle(screen, (0, 0, 0, 50), shadow_pos, self.stats.size)
        
        # Draw enemy
        color = (100, 100, 255) if self.is_slowed else self.stats.color
        pygame.draw.circle(screen, color, (int(self.x), int(self.y)), self.stats.size)
        pygame.draw.circle(screen, BLACK, (int(self.x), int(self.y)), self.stats.size, 2)
        
        # Draw health bar
        if self.health < self.max_health:
            bar_width = self.stats.size * 2
            bar_height = 4
            bar_y = self.y - self.stats.size - 10
            
            # Background
            bar_rect = pygame.Rect(self.x - bar_width // 2, bar_y, bar_width, bar_height)
            pygame.draw.rect(screen, RED, bar_rect)
            
            # Health
            health_width = int(bar_width * (self.health / self.max_health))
            health_rect = pygame.Rect(self.x - bar_width // 2, bar_y, health_width, bar_height)
            pygame.draw.rect(screen, GREEN, health_rect)
        
        # Draw flying indicator
        if self.stats.flying:
            font = pygame.font.Font(None, 16)
            text = font.render("✈", True, WHITE)
            screen.blit(text, (self.x - 8, self.y - 8))

class Projectile:
    """Projectile class"""
    def __init__(self, x: float, y: float, target: Enemy, damage: float, 
                 speed: float, tower: Tower):
        self.x = x
        self.y = y
        self.target = target
        self.damage = damage
        self.speed = speed
        self.tower = tower
        self.is_dead = False
        
        # Calculate lead position for moving target
        if target and not target.is_dead:
            distance = math.sqrt((target.x - x) ** 2 + (target.y - y) ** 2)
            time_to_impact = distance / speed
            
            # Predict where enemy will be
            self.target_x = target.x
            self.target_y = target.y
            
            if target.path_index < len(target.path) - 1:
                next_point = target.path[target.path_index + 1]
                dx = next_point[0] - target.x
                dy = next_point[1] - target.y
                dist = math.sqrt(dx * dx + dy * dy)
                if dist > 0:
                    predict_distance = target.speed * time_to_impact
                    self.target_x += (dx / dist) * predict_distance
                    self.target_y += (dy / dist) * predict_distance
    
    def update(self, dt: float, enemies: List[Enemy]) -> Optional[Tuple[Enemy, float]]:
        """Update projectile, return hit enemy and damage if hit"""
        if self.is_dead:
            return None
        
        # Check if target died
        if self.target and self.target.is_dead:
            self.is_dead = True
            return None
        
        # Move towards predicted position
        dx = self.target_x - self.x
        dy = self.target_y - self.y
        distance = math.sqrt(dx * dx + dy * dy)
        
        if distance < 5:
            # Hit target
            self.is_dead = True
            
            # Handle splash damage
            if self.tower.stats.splash_radius > 0:
                hit_enemies = []
                for enemy in enemies:
                    if not enemy.is_dead:
                        enemy_dist = math.sqrt((enemy.x - self.x) ** 2 + 
                                             (enemy.y - self.y) ** 2)
                        if enemy_dist <= self.tower.stats.splash_radius:
                            splash_damage = self.damage * (1 - enemy_dist / self.tower.stats.splash_radius)
                            hit_enemies.append((enemy, splash_damage))
                return hit_enemies
            else:
                # Single target
                if self.target and not self.target.is_dead:
                    return [(self.target, self.damage)]
        else:
            # Move projectile
            move_distance = self.speed * dt
            self.x += (dx / distance) * move_distance
            self.y += (dy / distance) * move_distance
            
            # Check direct hit on actual target position
            if self.target and not self.target.is_dead:
                actual_dist = math.sqrt((self.target.x - self.x) ** 2 + 
                                      (self.target.y - self.y) ** 2)
                if actual_dist < self.target.stats.size:
                    self.is_dead = True
                    return [(self.target, self.damage)]
        
        return None
    
    def draw(self, screen: pygame.Surface):
        """Draw the projectile"""
        if self.is_dead:
            return
        
        # Different projectile types
        if self.tower.type == TowerType.LASER:
            # Instant laser beam
            if self.target and not self.target.is_dead:
                pygame.draw.line(screen, CYAN, 
                               (self.tower.x, self.tower.y),
                               (self.target.x, self.target.y), 2)
                # Glow effect
                pygame.draw.line(screen, (150, 255, 255, 128),
                               (self.tower.x, self.tower.y),
                               (self.target.x, self.target.y), 6)
        elif self.tower.type == TowerType.CANNON:
            # Cannonball
            pygame.draw.circle(screen, BLACK, (int(self.x), int(self.y)), 5)
            pygame.draw.circle(screen, GRAY, (int(self.x), int(self.y)), 3)
        else:
            # Regular projectile
            pygame.draw.circle(screen, YELLOW, (int(self.x), int(self.y)), 3)

class Wave:
    """Wave manager class"""
    def __init__(self, wave_number: int):
        self.wave_number = wave_number
        self.enemies_to_spawn = []
        self.spawn_timer = 0
        self.spawn_delay = 1000  # milliseconds
        self.is_complete = False
        
        # Generate wave composition
        self.generate_wave()
    
    def generate_wave(self):
        """Generate enemies for this wave"""
        # Base wave patterns
        if self.wave_number <= 3:
            # Early waves - mostly basic enemies
            enemy_count = 5 + self.wave_number * 2
            for _ in range(enemy_count):
                self.enemies_to_spawn.append(EnemyType.BASIC)
        
        elif self.wave_number <= 6:
            # Introduce fast enemies
            basic_count = 10
            fast_count = self.wave_number - 3
            
            for _ in range(basic_count):
                self.enemies_to_spawn.append(EnemyType.BASIC)
            for _ in range(fast_count):
                self.enemies_to_spawn.append(EnemyType.FAST)
        
        elif self.wave_number <= 10:
            # Add tanks
            basic_count = 10
            fast_count = 5
            tank_count = self.wave_number - 6
            
            for _ in range(basic_count):
                self.enemies_to_spawn.append(EnemyType.BASIC)
            for _ in range(fast_count):
                self.enemies_to_spawn.append(EnemyType.FAST)
            for _ in range(tank_count):
                self.enemies_to_spawn.append(EnemyType.TANK)
        
        elif self.wave_number <= 15:
            # Flying enemies appear
            basic_count = 15
            fast_count = 8
            tank_count = 3
            flying_count = self.wave_number - 10
            
            for _ in range(basic_count):
                self.enemies_to_spawn.append(EnemyType.BASIC)
            for _ in range(fast_count):
                self.enemies_to_spawn.append(EnemyType.FAST)
            for _ in range(tank_count):
                self.enemies_to_spawn.append(EnemyType.TANK)
            for _ in range(flying_count):
                self.enemies_to_spawn.append(EnemyType.FLYING)
        
        else:
            # Boss waves every 5 waves after 15
            if self.wave_number % 5 == 0:
                self.enemies_to_spawn.append(EnemyType.BOSS)
            
            # Scale other enemies
            scale = self.wave_number / 10
            basic_count = int(10 * scale)
            fast_count = int(8 * scale)
            tank_count = int(5 * scale)
            flying_count = int(3 * scale)
            
            for _ in range(basic_count):
                self.enemies_to_spawn.append(EnemyType.BASIC)
            for _ in range(fast_count):
                self.enemies_to_spawn.append(EnemyType.FAST)
            for _ in range(tank_count):
                self.enemies_to_spawn.append(EnemyType.TANK)
            for _ in range(flying_count):
                self.enemies_to_spawn.append(EnemyType.FLYING)
        
        # Randomize spawn order
        random.shuffle(self.enemies_to_spawn)
    
    def update(self, current_time: float, path: List[Tuple[int, int]]) -> Optional[Enemy]:
        """Update wave spawning, return new enemy if spawned"""
        if self.is_complete:
            return None
        
        if current_time - self.spawn_timer > self.spawn_delay:
            if self.enemies_to_spawn:
                enemy_type = self.enemies_to_spawn.pop(0)
                self.spawn_timer = current_time
                
                # Decrease spawn delay as wave progresses
                self.spawn_delay = max(500, 1000 - self.wave_number * 20)
                
                return Enemy(enemy_type, path, self.wave_number)
            else:
                self.is_complete = True
        
        return None

class Particle:
    """Visual effects particle"""
    def __init__(self, x: float, y: float, color: Tuple[int, int, int], 
                 lifetime: float = 1000):
        self.x = x
        self.y = y
        self.color = color
        self.lifetime = lifetime
        self.age = 0
        self.vy = -50
        self.vx = random.uniform(-20, 20)
    
    def update(self, dt: float) -> bool:
        """Update particle, return False if dead"""
        self.age += dt * 1000
        self.x += self.vx * dt
        self.y += self.vy * dt
        self.vy += 200 * dt  # Gravity
        
        return self.age < self.lifetime
    
    def draw(self, screen: pygame.Surface):
        """Draw particle"""
        alpha = 1 - (self.age / self.lifetime)
        color = (*self.color, int(255 * alpha))
        pygame.draw.circle(screen, color, (int(self.x), int(self.y)), 2)

class Game:
    """Main game class"""
    def __init__(self):
        self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
        pygame.display.set_caption("Tower Defense")
        self.clock = pygame.time.Clock()
        self.running = True
        
        # Game state
        self.gold = 500
        self.lives = 20
        self.wave_number = 0
        self.score = 0
        self.game_speed = 1
        self.paused = False
        self.game_over = False
        
        # Path waypoints
        self.path = [
            (0, 400),
            (200, 400),
            (200, 200),
            (500, 200),
            (500, 500),
            (700, 500),
            (700, 300),
            (SCREEN_WIDTH, 300)
        ]
        
        # Game objects
        self.towers = []
        self.enemies = []
        self.projectiles = []
        self.particles = []
        self.wave_manager = None
        
        # Selection
        self.selected_tower_type = TowerType.BASIC
        self.selected_tower = None
        self.show_preview = False
        self.preview_pos = (0, 0)
        
        # Fonts
        self.font = pygame.font.Font(None, 36)
        self.small_font = pygame.font.Font(None, 24)
    
    def handle_events(self):
        """Handle input events"""
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                self.running = False
            
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_ESCAPE:
                    self.running = False
                elif event.key == pygame.K_SPACE:
                    self.paused = not self.paused
                elif event.key == pygame.K_w:
                    self.start_wave()
                elif event.key == pygame.K_1:
                    self.selected_tower_type = TowerType.BASIC
                elif event.key == pygame.K_2:
                    self.selected_tower_type = TowerType.CANNON
                elif event.key == pygame.K_3:
                    self.selected_tower_type = TowerType.LASER
                elif event.key == pygame.K_4:
                    self.selected_tower_type = TowerType.SLOW
                elif event.key == pygame.K_5:
                    self.selected_tower_type = TowerType.MONEY
                elif event.key == pygame.K_s:
                    self.game_speed = 2 if self.game_speed == 1 else 1
                elif event.key == pygame.K_u and self.selected_tower:
                    self.upgrade_tower()
                elif event.key == pygame.K_DELETE and self.selected_tower:
                    self.sell_tower()
            
            elif event.type == pygame.MOUSEBUTTONDOWN:
                if event.button == 1:  # Left click
                    mouse_x, mouse_y = pygame.mouse.get_pos()
                    self.handle_click(mouse_x, mouse_y)
            
            elif event.type == pygame.MOUSEMOTION:
                self.preview_pos = pygame.mouse.get_pos()
                self.show_preview = True
    
    def handle_click(self, x: int, y: int):
        """Handle mouse click"""
        # Snap to grid
        grid_x = (x // GRID_SIZE) * GRID_SIZE + GRID_SIZE // 2
        grid_y = (y // GRID_SIZE) * GRID_SIZE + GRID_SIZE // 2
        
        # Check if clicking on existing tower
        for tower in self.towers:
            if abs(tower.x - grid_x) < GRID_SIZE // 2 and abs(tower.y - grid_y) < GRID_SIZE // 2:
                self.selected_tower = tower
                return
        
        # Try to place new tower
        if self.can_place_tower(grid_x, grid_y):
            tower_stats = TOWER_STATS[self.selected_tower_type]
            if self.gold >= tower_stats.cost:
                self.gold -= tower_stats.cost
                new_tower = Tower(grid_x, grid_y, self.selected_tower_type)
                self.towers.append(new_tower)
                self.selected_tower = new_tower
                
                # Placement effect
                for _ in range(10):
                    self.particles.append(Particle(grid_x, grid_y, GOLD))
    
    def can_place_tower(self, x: int, y: int) -> bool:
        """Check if tower can be placed at position"""
        # Check if on path
        for i in range(len(self.path) - 1):
            p1 = self.path[i]
            p2 = self.path[i + 1]
            
            # Simple rectangle check along path
            min_x = min(p1[0], p2[0]) - 40
            max_x = max(p1[0], p2[0]) + 40
            min_y = min(p1[1], p2[1]) - 40
            max_y = max(p1[1], p2[1]) + 40
            
            if min_x <= x <= max_x and min_y <= y <= max_y:
                return False
        
        # Check if tower already exists
        for tower in self.towers:
            if abs(tower.x - x) < GRID_SIZE and abs(tower.y - y) < GRID_SIZE:
                return False
        
        return True
    
    def start_wave(self):
        """Start next wave"""
        if self.wave_manager and not self.wave_manager.is_complete:
            return
        
        self.wave_number += 1
        self.wave_manager = Wave(self.wave_number)
        
        # Wave start bonus
        bonus = 50 + self.wave_number * 5
        self.gold += bonus
    
    def upgrade_tower(self):
        """Upgrade selected tower"""
        if self.selected_tower:
            cost = self.selected_tower.get_upgrade_cost()
            if self.gold >= cost:
                self.gold -= cost
                self.selected_tower.upgrade()
                
                # Upgrade effect
                for _ in range(15):
                    angle = random.uniform(0, math.pi * 2)
                    distance = random.uniform(10, 30)
                    x = self.selected_tower.x + math.cos(angle) * distance
                    y = self.selected_tower.y + math.sin(angle) * distance
                    self.particles.append(Particle(x, y, YELLOW))
    
    def sell_tower(self):
        """Sell selected tower"""
        if self.selected_tower:
            self.gold += self.selected_tower.get_sell_value()
            self.towers.remove(self.selected_tower)
            
            # Sell effect
            for _ in range(10):
                self.particles.append(
                    Particle(self.selected_tower.x, self.selected_tower.y, GOLD)
                )
            
            self.selected_tower = None
    
    def update(self, dt: float):
        """Update game state"""
        if self.paused or self.game_over:
            return
        
        current_time = pygame.time.get_ticks()
        dt = dt * self.game_speed
        
        # Update wave spawning
        if self.wave_manager:
            new_enemy = self.wave_manager.update(current_time, self.path)
            if new_enemy:
                self.enemies.append(new_enemy)
        
        # Update towers
        for tower in self.towers:
            result = tower.update(self.enemies, current_time)
            
            if tower.type == TowerType.MONEY and result:
                # Gold generated
                self.gold += result
                self.particles.append(
                    Particle(tower.x, tower.y - 20, GOLD)
                )
            elif tower.type == TowerType.LASER and tower.target:
                # Instant laser damage
                if not tower.target.is_dead:
                    gold_earned = tower.target.take_damage(
                        tower.stats.damage * tower.level
                    )
                    if gold_earned:
                        self.gold += gold_earned
                        self.score += gold_earned * 10
                        tower.kills += 1
                    
                    # Apply slow effect
                    if tower.stats.slow_effect > 0:
                        tower.target.apply_slow(
                            tower.stats.slow_effect,
                            tower.stats.slow_duration,
                            current_time
                        )
            elif result:
                # Regular projectile created
                self.projectiles.append(result)
        
        # Update projectiles
        new_projectiles = []
        for projectile in self.projectiles:
            hit_result = projectile.update(dt, self.enemies)
            
            if hit_result:
                # Handle hits
                if isinstance(hit_result, list):
                    # Multiple enemies (splash damage)
                    for enemy, damage in hit_result:
                        gold_earned = enemy.take_damage(damage)
                        if gold_earned:
                            self.gold += gold_earned
                            self.score += gold_earned * 10
                            projectile.tower.kills += 1
                        
                        # Apply slow effect
                        if projectile.tower.stats.slow_effect > 0:
                            enemy.apply_slow(
                                projectile.tower.stats.slow_effect,
                                projectile.tower.stats.slow_duration,
                                current_time
                            )
                        
                        # Hit particles
                        for _ in range(5):
                            self.particles.append(
                                Particle(enemy.x, enemy.y, enemy.stats.color)
                            )
            
            if not projectile.is_dead:
                new_projectiles.append(projectile)
        
        self.projectiles = new_projectiles
        
        # Update enemies
        alive_enemies = []
        for enemy in self.enemies:
            enemy.update(dt, current_time)
            
            if enemy.reached_end:
                self.lives -= 1
                if self.lives <= 0:
                    self.game_over = True
            elif not enemy.is_dead:
                alive_enemies.append(enemy)
            else:
                # Death particles
                for _ in range(8):
                    self.particles.append(
                        Particle(enemy.x, enemy.y, enemy.stats.color)
                    )
        
        self.enemies = alive_enemies
        
        # Update particles
        self.particles = [p for p in self.particles if p.update(dt)]
        
        # Check wave completion
        if (self.wave_manager and self.wave_manager.is_complete and 
            len(self.enemies) == 0):
            # Wave complete bonus
            bonus = 100 + self.wave_number * 10
            self.gold += bonus
            self.score += bonus * 5
    
    def draw(self):
        """Draw everything"""
        self.screen.fill(DARK_GREEN)
        
        # Draw grid
        for x in range(0, SCREEN_WIDTH, GRID_SIZE):
            pygame.draw.line(self.screen, (0, 50, 0), (x, 0), (x, SCREEN_HEIGHT))
        for y in range(0, SCREEN_HEIGHT, GRID_SIZE):
            pygame.draw.line(self.screen, (0, 50, 0), (0, y), (SCREEN_WIDTH, y))
        
        # Draw path
        for i in range(len(self.path) - 1):
            pygame.draw.line(self.screen, LIGHT_BROWN, 
                           self.path[i], self.path[i + 1], 40)
        
        # Draw path borders
        for i in range(len(self.path) - 1):
            pygame.draw.line(self.screen, BROWN, 
                           self.path[i], self.path[i + 1], 44)
        
        # Draw placement preview
        if self.show_preview:
            grid_x = (self.preview_pos[0] // GRID_SIZE) * GRID_SIZE + GRID_SIZE // 2
            grid_y = (self.preview_pos[1] // GRID_SIZE) * GRID_SIZE + GRID_SIZE // 2
            
            if self.can_place_tower(grid_x, grid_y):
                color = (0, 255, 0, 50)
            else:
                color = (255, 0, 0, 50)
            
            preview_rect = pygame.Rect(
                grid_x - GRID_SIZE // 2,
                grid_y - GRID_SIZE // 2,
                GRID_SIZE,
                GRID_SIZE
            )
            pygame.draw.rect(self.screen, color, preview_rect)
            
            # Show range
            tower_stats = TOWER_STATS[self.selected_tower_type]
            pygame.draw.circle(self.screen, (255, 255, 255, 30),
                             (grid_x, grid_y), tower_stats.range, 1)
        
        # Draw towers
        for tower in self.towers:
            tower.draw(self.screen, tower == self.selected_tower)
        
        # Draw enemies
        for enemy in self.enemies:
            enemy.draw(self.screen)
        
        # Draw projectiles
        for projectile in self.projectiles:
            projectile.draw(self.screen)
        
        # Draw particles
        for particle in self.particles:
            particle.draw(self.screen)
        
        # Draw UI background
        ui_rect = pygame.Rect(0, SCREEN_HEIGHT - 100, SCREEN_WIDTH, 100)
        pygame.draw.rect(self.screen, BLACK, ui_rect)
        pygame.draw.rect(self.screen, WHITE, ui_rect, 2)
        
        # Draw UI text
        gold_text = self.font.render(f"Gold: {self.gold}", True, GOLD)
        self.screen.blit(gold_text, (20, SCREEN_HEIGHT - 80))
        
        lives_text = self.font.render(f"Lives: {self.lives}", True, RED)
        self.screen.blit(lives_text, (20, SCREEN_HEIGHT - 40))
        
        wave_text = self.font.render(f"Wave: {self.wave_number}", True, WHITE)
        self.screen.blit(wave_text, (200, SCREEN_HEIGHT - 80))
        
        score_text = self.font.render(f"Score: {self.score}", True, WHITE)
        self.screen.blit(score_text, (200, SCREEN_HEIGHT - 40))
        
        # Draw tower buttons
        button_x = 400
        for i, tower_type in enumerate(TowerType):
            tower_stats = TOWER_STATS[tower_type]
            
            button_rect = pygame.Rect(button_x + i * 100, SCREEN_HEIGHT - 90, 80, 80)
            
            if tower_type == self.selected_tower_type:
                pygame.draw.rect(self.screen, GREEN, button_rect, 3)
            else:
                pygame.draw.rect(self.screen, WHITE, button_rect, 1)
            
            # Tower icon
            icon_rect = pygame.Rect(button_x + i * 100 + 25, SCREEN_HEIGHT - 75, 30, 30)
            pygame.draw.rect(self.screen, tower_stats.color, icon_rect)
            
            # Tower name and cost
            name_text = self.small_font.render(tower_stats.name[:6], True, WHITE)
            self.screen.blit(name_text, (button_x + i * 100 + 5, SCREEN_HEIGHT - 35))
            
            cost_text = self.small_font.render(f"${tower_stats.cost}", True, GOLD)
            self.screen.blit(cost_text, (button_x + i * 100 + 20, SCREEN_HEIGHT - 15))
        
        # Draw selected tower info
        if self.selected_tower:
            info_rect = pygame.Rect(SCREEN_WIDTH - 250, 10, 240, 150)
            pygame.draw.rect(self.screen, BLACK, info_rect)
            pygame.draw.rect(self.screen, WHITE, info_rect, 2)
            
            tower = self.selected_tower
            info_lines = [
                f"{tower.stats.name}",
                f"Level: {tower.level}",
                f"Damage: {tower.stats.damage * tower.level}",
                f"Range: {tower.stats.range}",
                f"Kills: {tower.kills}",
                f"Upgrade: ${tower.get_upgrade_cost()}",
                f"Sell: ${tower.get_sell_value()}"
            ]
            
            for i, line in enumerate(info_lines):
                text = self.small_font.render(line, True, WHITE)
                self.screen.blit(text, (SCREEN_WIDTH - 240, 20 + i * 20))
        
        # Draw controls
        controls = [
            "1-5: Select Tower",
            "W: Start Wave",
            "U: Upgrade Tower",
            "Del: Sell Tower",
            "Space: Pause",
            "S: Speed x2"
        ]
        
        for i, control in enumerate(controls):
            text = self.small_font.render(control, True, WHITE)
            self.screen.blit(text, (SCREEN_WIDTH - 150, 200 + i * 25))
        
        # Draw game over screen
        if self.game_over:
            overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
            overlay.set_alpha(200)
            overlay.fill(BLACK)
            self.screen.blit(overlay, (0, 0))
            
            game_over_text = self.font.render("GAME OVER", True, RED)
            text_rect = game_over_text.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 - 50))
            self.screen.blit(game_over_text, text_rect)
            
            score_text = self.font.render(f"Final Score: {self.score}", True, WHITE)
            text_rect = score_text.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 + 20))
            self.screen.blit(score_text, text_rect)
            
            wave_text = self.font.render(f"Waves Survived: {self.wave_number}", True, WHITE)
            text_rect = wave_text.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 + 60))
            self.screen.blit(wave_text, text_rect)
        
        # Draw paused
        if self.paused:
            pause_text = self.font.render("PAUSED", True, WHITE)
            text_rect = pause_text.get_rect(center=(SCREEN_WIDTH // 2, 50))
            self.screen.blit(pause_text, text_rect)
        
        pygame.display.flip()
    
    def run(self):
        """Main game loop"""
        while self.running:
            dt = self.clock.tick(FPS) / 1000.0
            
            self.handle_events()
            self.update(dt)
            self.draw()
        
        pygame.quit()

def main():
    """Main function"""
    game = Game()
    game.run()

if __name__ == "__main__":
    main()

Key Features Implemented

How to Run

  1. Save the code to a file named tower_defense.py
  2. Install Pygame: pip install pygame
  3. Run the game: python tower_defense.py

Game Controls

Enhancement Ideas

🚀 Take It Further

Performance Optimization

⚡ Optimization Tips


# Use sprite groups for batch operations
all_sprites = pygame.sprite.Group()
enemy_sprites = pygame.sprite.Group()
tower_sprites = pygame.sprite.Group()

# Spatial hashing for collision detection
class SpatialHash:
    def __init__(self, cell_size):
        self.cell_size = cell_size
        self.hash = {}
    
    def add(self, obj, x, y):
        cell_x = int(x // self.cell_size)
        cell_y = int(y // self.cell_size)
        key = (cell_x, cell_y)
        
        if key not in self.hash:
            self.hash[key] = []
        self.hash[key].append(obj)
    
    def get_nearby(self, x, y, radius):
        nearby = []
        cells_to_check = int(radius // self.cell_size) + 1
        
        center_x = int(x // self.cell_size)
        center_y = int(y // self.cell_size)
        
        for dx in range(-cells_to_check, cells_to_check + 1):
            for dy in range(-cells_to_check, cells_to_check + 1):
                key = (center_x + dx, center_y + dy)
                if key in self.hash:
                    nearby.extend(self.hash[key])
        
        return nearby

# Object pooling for projectiles
class ProjectilePool:
    def __init__(self, size=100):
        self.pool = [Projectile() for _ in range(size)]
        self.active = []
    
    def get(self):
        if self.pool:
            proj = self.pool.pop()
            self.active.append(proj)
            return proj
        return None
    
    def return_to_pool(self, proj):
        if proj in self.active:
            self.active.remove(proj)
            self.pool.append(proj)
        

🏋️‍♂️ Practice Exercise

🏋️‍♂️ Exercise 1: Three Pillars of Tower Defense Architecture (Python Edition) — Snapshot-vs-Live-vs-AOE Targeting via Consumer-Check-Distributed Dispatch + Enum-Keyed @dataclass TYPE_STATS Double Design-Time Validation + PATH-Waypoint-List List-of-Tuple in One Pygame Window

Objective: Build a runnable pygame program (~95 lines) that distills three orthogonal tower-defense disciplines into one demo on a 1024×540 window split between play area (top 360 px) and HUD bar (bottom 180 px). (a) Snapshot-vs-Live-vs-AOE targeting at TARGETING-EFFECT-TIMING scope via Python-idiomatic consumer-check-distributed dispatch: PROJECTILE tower snapshots target position via leading-shot prediction at fire moment by sampling PATH[tgt.path_idx + 1] and extrapolating tgt.stats.speed * time_to_impact, then projectile flies straight-line toward predicted future point hitting whatever is at the impact location when it arrives; LASER tower bypasses the projectile machinery entirely and applies tgt.health -= s.damage * dt directly to the current live target each frame the target is in range — no flight, no prediction, hits live position; AOE cannon snapshots target at fire moment but on impact damages all live enemies within splash_radius via an enemies walk applying (1 - distance/splash_radius) falloff. The dispatch is consumer-check-distributed via if ttype == TowerType.LASER: / elif ttype == TowerType.PROJECTILE: / elif ttype == TowerType.AOE: branches in the main loop rather than a stats.instant: true flag check inside Tower.fire as in the chat-83 JS-canvas counterpart. SECOND-AT-TARGETING-EFFECT-TIMING-SCOPE after chat-83's first, expressed in Python-idiomatic style. (b) Enum-keyed @dataclass TYPE_STATS dicts at TYPE-CATALOG scope as DOUBLE design-time validation: class TowerType(Enum) with three members + @dataclass class TowerStats with type-hinted fields + TOWER_STATS = {TowerType.PROJECTILE: TowerStats(...), TowerType.LASER: TowerStats(...), TowerType.AOE: TowerStats(...)} Enum-keyed dict-of-dataclass-instances pairs TWO Python-specific design-time gates that the JS plain-string-keyed-object-literal counterpart does NOT have — Enum membership validated at attribute-access (TowerType.LSAER raises AttributeError at parse time vs JS 'lsaer' silently returning undefined at runtime) AND @dataclass fields validated at construction (missing required arg raises TypeError at instance creation; type hints catch typoed field reads). EIGHTH design-time-validation-vs-runtime-discovery lesson across Phase 8 after chat-47 (validate vs is_solid) + chat-70 (hidden_imports static-AST-vs-runtime) + chat-72 (profile-bars vs intuition) + chat-73 (capability detection) + chat-76 (Enum-vs-isinstance dispatch) + chat-79 (Enum-roundtrip-vs-live-comparison) + chat-82 (Enum-keyed UNIT_STATS) = 7 prior cases; chat-84 is the EIGHTH and FIRST applied at TYPE-CATALOG scope where Enum + @dataclass STACK to provide TWO orthogonal validation gates rather than one. (c) PATH list-of-tuple data-driven externalization at PATH-WAYPOINT-LIST scope: PATH: List[Tuple[int, int]] = [(0, 110), (300, 110), (300, 240), (700, 240), (700, 110), (1024, 110)] IS the canonical route definition that ALL consumers iterate over by index — Enemy.update walks PATH[self.path_idx + 1] for next-waypoint movement, the PROJECTILE main-loop branch samples PATH[tgt.path_idx + 1] for leading-shot prediction, the rendering loop walks pairs via pygame.draw.line(screen, (140, 100, 60), PATH[i], PATH[i + 1], 30). Adding/changing the route is one list edit and ALL three consumers pick it up automatically because they iterate or index into the same list rather than branching on hardcoded coordinates. REPRISE of chat-83 axis 2 at PATH-WAYPOINT-LIST scope with Python list-of-tuple idiom delta vs chat-83's JS list-of-object-literal-records. HUD bar shows three-axis legend, live counts (enemies / projectiles / PATH waypoints), rolling dispatch-mode log of recent unique events (P/L/A), and a per-tower-type stats summary printed by iterating TowerType and reading TOWER_STATS[tt] Enum-keyed entries — all three orthogonal disciplines visible per frame as concrete numbers and per-tower behavior. CLOSES the genres module 8/9 → 9/9 = 13TH COMPLETE PHASE-8 MODULE; MODULE-COMPLETENESS 12/13 → 13/13; PHASE 8 COMPLETE.

Instructions:

  1. Open a 1024×540 pygame window split between play area (top 360 px) and HUD bar (bottom 180 px). Declare a 6-waypoint PATH: List[Tuple[int, int]] = [(0, 110), (300, 110), (300, 240), (700, 240), (700, 110), (1024, 110)] at module level — drawn as 30-px-wide brown segments by walking pairs PATH[i] to PATH[i + 1] in the rendering loop.
  2. Declare class TowerType(Enum) with three members PROJECTILE / LASER / AOE and class EnemyType(Enum) with two members BASIC / FAST. Declare @dataclass class TowerStats with type-hinted fields name: str, color: Tuple[int, int, int], damage: int, rng: int, fire_rate: float, projectile_speed: float = 380.0, splash_radius: int = 0; declare @dataclass class EnemyStats similarly. Declare TOWER_STATS and ENEMY_STATS Enum-keyed dicts mapping each Enum member to its dataclass instance — three Enum-keyed entries for towers (PROJECTILE/LASER/AOE), two for enemies (BASIC/FAST).
  3. Place 3 fixed Tower instances on the play area: PROJECTILE at (200, 200), LASER at (500, 50), AOE at (800, 200). Maintain a per-tower cooldown counter cd = [0.0] * len(TOWERS) for fire-rate gating. Auto-spawn an Enemy at PATH[0] every 1.4 seconds alternating BASIC/FAST. Each Enemy's update method reads PATH[self.path_idx + 1] for the next waypoint, computes (dx, dy) toward it, and steps at self.stats.speed * dt; on reaching distance < 5 of the next waypoint, increment self.path_idx.
  4. In the main loop, dispatch tower behavior via consumer-check-distributed branches: if ttype == TowerType.LASER: applies tgt.health -= s.damage * dt directly each frame the target is in range (LIVE timing — no projectile, no prediction); elif ttype == TowerType.PROJECTILE: creates a projectile snapshotted to the predicted future point via PATH[tgt.path_idx + 1] sampling and projectile_speed-based time-to-impact extrapolation (SNAPSHOT-AT-FIRE timing); elif ttype == TowerType.AOE: creates a projectile snapshotted to the current target position then on impact walks the enemies list applying (1 - distance/splash_radius) falloff damage to all enemies within splash radius (SNAPSHOT-AT-FIRE + LIVE-SPATIAL-QUERY-AT-IMPACT timing). The cooldown gates only PROJECTILE and AOE; LASER's continuous live damage uses dt directly.
  5. Update projectiles each frame as straight-line flight to the snapshot point at projectile_speed. On impact (distance < 6 px to snapshot point), apply damage and mark the projectile dead. AOE projectiles apply distance-weighted falloff damage to all enemies within splash_radius; PROJECTILE projectiles apply single-target damage to whoever is within 18 px of the impact location.
  6. Render the path, towers (with range circles and Enum.value labels), enemies (with health bars), and projectiles. Render a LIVE laser beam visualization separately by re-finding the LASER tower's current target each frame and drawing a cyan line from tower to current target position — the live laser visualization is what makes the LIVE-vs-SNAPSHOT timing distinction visible (PROJECTILE projectiles fly to a stored snapshot point; LASER beam tracks the current live position).
  7. Render an HUD bar at the bottom (rows 360–540) showing: a three-axis legend ('3 axes: (1) targeting timing P/L/A | (2) Enum-keyed @dataclass TYPE_STATS | (3) PATH list-of-tuple'), live counts (enemies / projectiles / PATH waypoints), a rolling dispatch-mode log showing the most recent unique events as a string like 'P -> L -> A -> P' (each new dispatch type appends only if different from the last logged), and a per-tower-type stats summary printed by iterating for tt in TowerType: s = TOWER_STATS[tt]; render(...) — Enum-keyed reads make the type-catalog summary one loop with zero hardcoded names.
💡 Hint

The three axes connect: the dispatch in the main loop reads from the Enum-keyed TOWER_STATS dict (Axis 2 feeds Axis 1), and the PROJECTILE branch reads from the shared PATH list (Axis 3 feeds Axis 1). Adding a fourth tower type is the test: it requires (a) one new TowerType Enum member, (b) one new TOWER_STATS dict entry with a TowerStats(...) dataclass instance, and (c) one new elif ttype == TowerType.NEW: branch in the dispatch loop — and the type checker catches typos in the Enum reference at parse time, the dataclass catches missing required fields at construction, and the rendering / HUD summary code picks up the new entry automatically because it iterates TowerType and reads from the Enum-keyed dict. Try also: changing one waypoint in PATH and notice that movement, prediction, and rendering all update simultaneously because all three systems index into the same list. The Python-idiomatic dispatch delta vs chat-83's JS counterpart is the consumer-check-distributed shape — JS centralized type-discrimination INSIDE Tower.fire via stats.instant: true flag, Python distributes the check across the main loop's per-tower branch — both produce equivalent gameplay but with different architectural shapes that fit each language's idioms.

✅ Example Solution
"""
Three pillars of tower defense (Python edition):
  (1) Snapshot/live/AOE targeting via consumer-check-distributed dispatch
  (2) Enum-keyed @dataclass TYPE_STATS for double design-time validation
  (3) PATH list-of-tuple consumed by movement, prediction, and render.
"""
import pygame, math
from enum import Enum
from dataclasses import dataclass
from typing import List, Tuple

class TowerType(Enum):
    PROJECTILE = 'P'; LASER = 'L'; AOE = 'A'

class EnemyType(Enum):
    BASIC = 'B'; FAST = 'F'

@dataclass
class TowerStats:
    name: str; color: Tuple[int, int, int]; damage: int; rng: int
    fire_rate: float; projectile_speed: float = 380.0; splash_radius: int = 0

@dataclass
class EnemyStats:
    name: str; color: Tuple[int, int, int]; speed: int; health: int

# Axis 2: Enum-keyed @dataclass TYPE_STATS dicts (double design-time validation)
TOWER_STATS = {
    TowerType.PROJECTILE: TowerStats('Projectile', (255, 200, 0), 25, 140, 1.0),
    TowerType.LASER:      TowerStats('Laser',      (0, 220, 220), 35, 160, 0.0),
    TowerType.AOE:        TowerStats('AOE Cannon', (255, 90, 90), 18, 130, 0.7, splash_radius=55),
}
ENEMY_STATS = {
    EnemyType.BASIC: EnemyStats('Basic', (40, 220, 40), 70, 80),
    EnemyType.FAST:  EnemyStats('Fast',  (255, 230, 60), 130, 50),
}

# Axis 3: PATH list-of-tuple consumed by movement, prediction, rendering
PATH: List[Tuple[int, int]] = [(0, 110), (300, 110), (300, 240), (700, 240), (700, 110), (1024, 110)]

class Enemy:
    def __init__(self, et):
        self.stats = ENEMY_STATS[et]; self.path_idx = 0
        self.x, self.y = float(PATH[0][0]), float(PATH[0][1])
        self.health = self.stats.health
    def update(self, dt):
        if self.path_idx >= len(PATH) - 1 or self.health <= 0: return
        tx, ty = PATH[self.path_idx + 1]
        dx, dy = tx - self.x, ty - self.y; d = math.hypot(dx, dy)
        if d < 5: self.path_idx += 1
        else:
            mv = self.stats.speed * dt
            self.x += dx / d * mv; self.y += dy / d * mv

pygame.init()
screen = pygame.display.set_mode((1024, 540))
clock = pygame.time.Clock(); font = pygame.font.Font(None, 22)
TOWERS = [(TowerType.PROJECTILE, 200, 200), (TowerType.LASER, 500, 50), (TowerType.AOE, 800, 200)]
enemies, projectiles, log = [], [], []
cd = [0.0] * len(TOWERS); spawn_t = 0.0; running = True
while running:
    dt = clock.tick(60) / 1000.0
    for ev in pygame.event.get():
        if ev.type == pygame.QUIT: running = False
    spawn_t += dt
    if spawn_t > 1.4:
        spawn_t = 0.0
        enemies.append(Enemy(EnemyType.BASIC if len(enemies) % 2 == 0 else EnemyType.FAST))
    for e in enemies: e.update(dt)
    enemies[:] = [e for e in enemies if e.path_idx < len(PATH) - 1 and e.health > 0]
    # Axis 1: consumer-check-distributed targeting dispatch via tower.type comparison
    for i, (ttype, tx, ty) in enumerate(TOWERS):
        s = TOWER_STATS[ttype]
        tgt = next((e for e in enemies if math.hypot(e.x - tx, e.y - ty) <= s.rng), None)
        if not tgt: continue
        if ttype == TowerType.LASER:                     # LIVE: damage current target each frame in range
            tgt.health -= s.damage * dt
            if not log or log[-1] != 'L': log.append('L')
        elif ttype == TowerType.PROJECTILE:              # SNAPSHOT-AT-FIRE with leading-shot prediction
            cd[i] -= dt
            if cd[i] <= 0:
                cd[i] = 1.0 / s.fire_rate
                if tgt.path_idx < len(PATH) - 1:
                    nx, ny = PATH[tgt.path_idx + 1]
                    pdx, pdy = nx - tgt.x, ny - tgt.y; pd = math.hypot(pdx, pdy) or 1
                    tti = math.hypot(tgt.x - tx, tgt.y - ty) / s.projectile_speed
                    px = tgt.x + (pdx / pd) * tgt.stats.speed * tti
                    py = tgt.y + (pdy / pd) * tgt.stats.speed * tti
                else: px, py = tgt.x, tgt.y
                projectiles.append([float(tx), float(ty), px, py, s.damage, s.color, 'P', s.projectile_speed, 0, 0])
                if not log or log[-1] != 'P': log.append('P')
        elif ttype == TowerType.AOE:                     # SNAPSHOT-AT-FIRE then live-spatial-query at impact
            cd[i] -= dt
            if cd[i] <= 0:
                cd[i] = 1.0 / s.fire_rate
                projectiles.append([float(tx), float(ty), tgt.x, tgt.y, s.damage, s.color, 'A', s.projectile_speed, s.splash_radius, 0])
                if not log or log[-1] != 'A': log.append('A')
    for p in projectiles:
        x, y, px, py, dmg, col, mode, spd, splash, dead = p
        dx, dy = px - x, py - y; d = math.hypot(dx, dy)
        if d < 6:
            p[9] = 1
            if mode == 'A':
                for e in enemies:
                    sd = math.hypot(e.x - px, e.y - py)
                    if sd < splash: e.health -= dmg * (1 - sd / splash)
            else:
                for e in enemies:
                    if math.hypot(e.x - px, e.y - py) < 18: e.health -= dmg
        else:
            p[0] += dx / d * spd * dt; p[1] += dy / d * spd * dt
    projectiles[:] = [p for p in projectiles if p[9] == 0]
    screen.fill((22, 36, 22))
    for i in range(len(PATH) - 1): pygame.draw.line(screen, (140, 100, 60), PATH[i], PATH[i + 1], 30)
    for ttype, tx, ty in TOWERS:
        st = TOWER_STATS[ttype]
        pygame.draw.circle(screen, (180, 180, 180), (tx, ty), st.rng, 1)
        pygame.draw.rect(screen, st.color, (tx - 14, ty - 14, 28, 28))
        screen.blit(font.render(ttype.value, True, (0, 0, 0)), (tx - 5, ty - 9))
    for e in enemies:
        pygame.draw.circle(screen, e.stats.color, (int(e.x), int(e.y)), 10)
        pygame.draw.rect(screen, (200, 0, 0), (e.x - 12, e.y - 18, 24, 3))
        pygame.draw.rect(screen, (0, 220, 0), (e.x - 12, e.y - 18, max(0, 24 * e.health / e.stats.health), 3))
    for p in projectiles: pygame.draw.circle(screen, p[5], (int(p[0]), int(p[1])), 4)
    for ttype, tx, ty in TOWERS:                         # LIVE laser beam re-targets each frame
        if ttype != TowerType.LASER: continue
        ls = TOWER_STATS[ttype]
        ltgt = next((e for e in enemies if math.hypot(e.x - tx, e.y - ty) <= ls.rng), None)
        if ltgt: pygame.draw.line(screen, ls.color, (tx, ty), (int(ltgt.x), int(ltgt.y)), 2)
    pygame.draw.rect(screen, (0, 0, 0), (0, 360, 1024, 180))
    pygame.draw.line(screen, (180, 180, 180), (0, 360), (1024, 360))
    hud = [
        '3 axes: (1) targeting timing P/L/A | (2) Enum-keyed @dataclass TYPE_STATS | (3) PATH list-of-tuple',
        f'enemies={len(enemies)} projectiles={len(projectiles)} PATH waypoints={len(PATH)}',
        f"dispatch log: {' -> '.join(log[-14:])}",
        'P=snapshot+lead flies to predicted future; L=live damages current target; A=snapshot+live-AOE splash',
    ]
    for i, line in enumerate(hud): screen.blit(font.render(line, True, (220, 220, 220)), (10, 370 + i * 22))
    for i, tt in enumerate(TowerType):
        s = TOWER_STATS[tt]
        screen.blit(font.render(f'{tt.name}: dmg={s.damage} rng={s.rng} fr={s.fire_rate}', True, s.color), (10, 462 + i * 20))
    pygame.display.flip()
pygame.quit()

🎯 Quick Quiz

Question 1: Demo runs with three fixed towers (PROJECTILE at one position, LASER at another, AOE cannon at a third) on the same path with the same enemies in range simultaneously. The PROJECTILE tower fires once per fire_rate-gated cooldown, with each shot snapshotting the target's predicted future position via PATH[tgt.path_idx + 1] sampling and projectile_speed-based time-to-impact extrapolation, then the projectile flies straight-line to that snapshot point and damages whatever enemy is at the impact location. The LASER tower has fire_rate=0.0 and bypasses the projectile machinery entirely; the main loop's elif ttype == TowerType.LASER: branch applies tgt.health -= s.damage * dt directly to the current live target each frame the target is in range, with no flight time and no prediction. The AOE cannon snapshots the target position at fire moment but on impact damages all live enemies within splash_radius via an enemies walk applying (1 - distance/splash_radius) falloff. Why are these three different timing models present in one demo, rather than picking the single 'best' timing approach for all towers?

Question 2: Demo declares class TowerType(Enum): PROJECTILE = 'P'; LASER = 'L'; AOE = 'A', then @dataclass class TowerStats with type-hinted fields, then TOWER_STATS = {TowerType.PROJECTILE: TowerStats(...), TowerType.LASER: TowerStats(...), TowerType.AOE: TowerStats(...)} as an Enum-keyed dict-of-dataclass-instances. The chat-83 JS-canvas counterpart used plain string keys (TOWER_TYPES = {basic: {...}, cannon: {...}, laser: {...}}) and JS object literals for the inner stats. Why are Python's Enum + @dataclass shapes both used here rather than the equivalent JS-style plain-string-keyed-object-dict?

Question 3: Demo declares PATH: List[Tuple[int, int]] = [(0, 110), (300, 110), (300, 240), (700, 240), (700, 110), (1024, 110)] once at module level. Three different systems consume the same PATH each frame: Enemy.update walks PATH[self.path_idx + 1] to find the next waypoint to move toward; the PROJECTILE tower's main-loop branch samples PATH[tgt.path_idx + 1] to compute the leading-shot prediction toward where the enemy will be by the time the projectile arrives; the rendering loop walks pairs via pygame.draw.line(screen, (140, 100, 60), PATH[i], PATH[i + 1], 30). The chat-83 JS counterpart used PATH = [{x: 0, y: 110}, {x: 300, y: 110}, ...] (list-of-object-literal-records) with the same data-driven shape. Why does the demo externalize the route as one shared PATH structure consumed by all three systems rather than each system having its own copy of the path coordinates?