Strategy Game Mechanics - Python Implementation
Complete Strategy Game Implementation in Python
This is the complete Python/Pygame implementation of a strategy game with all core mechanics including resource management, unit control, pathfinding, base building, and AI opponents.
Full Strategy Game Code
import pygame
import random
import math
import heapq
from enum import Enum
from typing import List, Tuple, Optional, Dict
from dataclasses import dataclass
class UnitType(Enum):
WORKER = "worker"
SOLDIER = "soldier"
ARCHER = "archer"
CAVALRY = "cavalry"
SIEGE = "siege"
class BuildingType(Enum):
BASE = "base"
BARRACKS = "barracks"
MINE = "mine"
FARM = "farm"
TOWER = "tower"
WALL = "wall"
@dataclass
class Resources:
gold: int = 0
food: int = 0
wood: int = 0
def can_afford(self, cost: 'Resources') -> bool:
return (self.gold >= cost.gold and
self.food >= cost.food and
self.wood >= cost.wood)
def subtract(self, cost: 'Resources'):
self.gold -= cost.gold
self.food -= cost.food
self.wood -= cost.wood
def add(self, income: 'Resources'):
self.gold += income.gold
self.food += income.food
self.wood += income.wood
class Unit:
"""Strategy game unit"""
def __init__(self, unit_type: UnitType, x: int, y: int, faction: str):
self.type = unit_type
self.x = x
self.y = y
self.faction = faction
self.selected = False
# Stats based on type
stats = {
UnitType.WORKER: {
'hp': 50, 'attack': 5, 'defense': 0,
'speed': 1.5, 'range': 1, 'sight': 5
},
UnitType.SOLDIER: {
'hp': 100, 'attack': 15, 'defense': 5,
'speed': 1, 'range': 1, 'sight': 6
},
UnitType.ARCHER: {
'hp': 60, 'attack': 12, 'defense': 2,
'speed': 1.2, 'range': 5, 'sight': 8
},
UnitType.CAVALRY: {
'hp': 120, 'attack': 20, 'defense': 3,
'speed': 2, 'range': 1, 'sight': 7
},
UnitType.SIEGE: {
'hp': 200, 'attack': 50, 'defense': 0,
'speed': 0.5, 'range': 6, 'sight': 5
}
}
unit_stats = stats[unit_type]
self.max_hp = unit_stats['hp']
self.hp = self.max_hp
self.attack = unit_stats['attack']
self.defense = unit_stats['defense']
self.speed = unit_stats['speed']
self.range = unit_stats['range']
self.sight = unit_stats['sight']
# Movement
self.target_x = x
self.target_y = y
self.path = []
self.moving = False
# Combat
self.target = None
self.cooldown = 0
# Worker specific
self.carrying = None
self.gathering_from = None
class Building:
"""Strategy game building"""
def __init__(self, building_type: BuildingType, x: int, y: int, faction: str):
self.type = building_type
self.x = x
self.y = y
self.faction = faction
self.selected = False
# Stats based on type
stats = {
BuildingType.BASE: {
'hp': 1000, 'size': 3, 'sight': 10,
'produces': [UnitType.WORKER]
},
BuildingType.BARRACKS: {
'hp': 600, 'size': 2, 'sight': 5,
'produces': [UnitType.SOLDIER, UnitType.ARCHER, UnitType.CAVALRY]
},
BuildingType.MINE: {
'hp': 500, 'size': 2, 'sight': 3,
'generates': {'gold': 10}
},
BuildingType.FARM: {
'hp': 400, 'size': 2, 'sight': 3,
'generates': {'food': 8}
},
BuildingType.TOWER: {
'hp': 800, 'size': 1, 'sight': 8,
'attack': 30, 'range': 8
},
BuildingType.WALL: {
'hp': 300, 'size': 1, 'sight': 1
}
}
building_stats = stats[building_type]
self.max_hp = building_stats['hp']
self.hp = self.max_hp
self.size = building_stats['size']
self.sight = building_stats['sight']
self.produces = building_stats.get('produces', [])
self.generates = building_stats.get('generates', {})
self.attack = building_stats.get('attack', 0)
self.range = building_stats.get('range', 0)
# Production
self.production_queue = []
self.production_progress = 0
# Combat
self.target = None
self.cooldown = 0
# Construction
self.construction_progress = 100 # Starts complete
self.is_complete = True
class Map:
"""Game map with terrain and fog of war"""
def __init__(self, width: int, height: int):
self.width = width
self.height = height
self.tiles = []
self.fog = []
self.generate()
def generate(self):
"""Generate random terrain"""
for y in range(self.height):
row = []
fog_row = []
for x in range(self.width):
terrain = 'grass'
# Add terrain variety
if random.random() < 0.15:
terrain = 'forest'
elif random.random() < 0.05:
terrain = 'mountain'
elif random.random() < 0.03:
terrain = 'water'
# Resources
resource = None
if terrain == 'grass' and random.random() < 0.05:
resource = random.choice(['gold', 'food', 'wood'])
row.append({
'terrain': terrain,
'resource': resource,
'occupied': False
})
fog_row.append(False) # Not explored
self.tiles.append(row)
self.fog.append(fog_row)
def is_passable(self, x: int, y: int) -> bool:
"""Check if tile is passable"""
if not self.in_bounds(x, y):
return False
tile = self.tiles[y][x]
return (tile['terrain'] != 'water' and
tile['terrain'] != 'mountain' and
not tile['occupied'])
def in_bounds(self, x: int, y: int) -> bool:
"""Check if coordinates are in map bounds"""
return 0 <= x < self.width and 0 <= y < self.height
def reveal(self, x: int, y: int, radius: int):
"""Reveal fog of war around position"""
for dy in range(-radius, radius + 1):
for dx in range(-radius, radius + 1):
nx, ny = x + dx, y + dy
if self.in_bounds(nx, ny):
dist = math.sqrt(dx*dx + dy*dy)
if dist <= radius:
self.fog[ny][nx] = True
class Pathfinding:
"""A* pathfinding for units"""
@staticmethod
def find_path(start: Tuple[int, int], goal: Tuple[int, int],
game_map: Map) -> List[Tuple[int, int]]:
"""Find path using A* algorithm"""
def heuristic(a, b):
return abs(a[0] - b[0]) + abs(a[1] - b[1])
open_set = [(0, start)]
came_from = {}
g_score = {start: 0}
f_score = {start: heuristic(start, goal)}
while open_set:
current = heapq.heappop(open_set)[1]
if current == goal:
# Reconstruct path
path = []
while current in came_from:
path.append(current)
current = came_from[current]
return list(reversed(path))
for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0),
(1, 1), (-1, 1), (1, -1), (-1, -1)]:
neighbor = (current[0] + dx, current[1] + dy)
if not game_map.is_passable(neighbor[0], neighbor[1]):
continue
tentative_g = g_score[current] + 1
if neighbor not in g_score or tentative_g < g_score[neighbor]:
came_from[neighbor] = current
g_score[neighbor] = tentative_g
f_score[neighbor] = tentative_g + heuristic(neighbor, goal)
heapq.heappush(open_set, (f_score[neighbor], neighbor))
return [] # No path found
class AIController:
"""AI opponent controller"""
def __init__(self, faction: str, difficulty: str = 'normal'):
self.faction = faction
self.difficulty = difficulty
self.action_timer = 0
self.strategy = 'balanced' # balanced, aggressive, defensive, economic
self.build_order = ['worker', 'barracks', 'soldier', 'mine', 'worker', 'farm']
self.current_build_index = 0
def update(self, game_state: 'StrategyGame', dt: float):
"""Update AI decisions"""
self.action_timer += dt
# Make decisions at different rates based on difficulty
decision_rate = {'easy': 5, 'normal': 3, 'hard': 2}[self.difficulty]
if self.action_timer > decision_rate:
self.action_timer = 0
self.make_strategic_decision(game_state)
def make_strategic_decision(self, game_state: 'StrategyGame'):
"""Make high-level strategic decisions"""
my_units = [u for u in game_state.units if u.faction == self.faction]
my_buildings = [b for b in game_state.buildings if b.faction == self.faction]
enemy_units = [u for u in game_state.units if u.faction != self.faction]
enemy_buildings = [b for b in game_state.buildings if b.faction != self.faction]
# Assess situation
military_strength = sum(1 for u in my_units if u.type != UnitType.WORKER)
worker_count = sum(1 for u in my_units if u.type == UnitType.WORKER)
# Decide strategy
if military_strength < 3:
self.strategy = 'defensive'
elif worker_count < 5:
self.strategy = 'economic'
elif military_strength > 10:
self.strategy = 'aggressive'
else:
self.strategy = 'balanced'
# Execute strategy
if self.strategy == 'aggressive':
self.execute_attack(my_units, enemy_buildings + enemy_units)
elif self.strategy == 'defensive':
self.build_defenses(game_state, my_buildings)
elif self.strategy == 'economic':
self.expand_economy(game_state, my_buildings, my_units)
else:
self.balanced_approach(game_state, my_units, my_buildings)
def execute_attack(self, my_units: List[Unit], targets: List):
"""Command units to attack"""
if not targets:
return
# Find closest target
for unit in my_units:
if unit.type != UnitType.WORKER and not unit.target:
closest_target = min(targets,
key=lambda t: math.sqrt((t.x - unit.x)**2 + (t.y - unit.y)**2))
unit.target = closest_target
def build_defenses(self, game_state: 'StrategyGame', my_buildings: List[Building]):
"""Build defensive structures"""
for building in my_buildings:
if building.type == BuildingType.BASE:
# Build towers around base
positions = [(building.x - 2, building.y),
(building.x + building.size + 1, building.y),
(building.x, building.y - 2),
(building.x, building.y + building.size + 1)]
for pos in positions:
if game_state.build_structure(BuildingType.TOWER, pos[0], pos[1]):
break
def expand_economy(self, game_state: 'StrategyGame', my_buildings: List[Building],
my_units: List[Unit]):
"""Focus on economic growth"""
# Train more workers
for building in my_buildings:
if building.type == BuildingType.BASE and not building.production_queue:
game_state.train_unit(UnitType.WORKER, building)
# Build resource buildings
if len([b for b in my_buildings if b.type == BuildingType.MINE]) < 2:
# Find good location for mine
for building in my_buildings:
if building.type == BuildingType.BASE:
x, y = building.x + building.size + 2, building.y
game_state.build_structure(BuildingType.MINE, x, y)
break
def balanced_approach(self, game_state: 'StrategyGame', my_units: List[Unit],
my_buildings: List[Building]):
"""Balanced strategy"""
# Train units
for building in my_buildings:
if building.type == BuildingType.BARRACKS and not building.production_queue:
unit_types = [UnitType.SOLDIER, UnitType.ARCHER, UnitType.CAVALRY]
game_state.train_unit(random.choice(unit_types), building)
# Build structures
if len(my_buildings) < 5:
building_types = [BuildingType.BARRACKS, BuildingType.MINE, BuildingType.FARM]
for building in my_buildings:
if building.type == BuildingType.BASE:
x = building.x + random.randint(-5, 5)
y = building.y + random.randint(-5, 5)
game_state.build_structure(random.choice(building_types), x, y)
break
class StrategyGame:
"""Main strategy game class"""
def __init__(self, screen_width: int = 800, screen_height: int = 600):
pygame.init()
self.screen = pygame.display.set_mode((screen_width, screen_height))
pygame.display.set_caption("Strategy Game")
self.clock = pygame.time.Clock()
# Game state
self.resources = Resources(gold=500, food=200, wood=100)
self.map = Map(50, 40)
self.camera_x = 0
self.camera_y = 0
# Entities
self.units = []
self.buildings = []
self.selected = []
# AI
self.ai_controller = AIController('enemy', 'normal')
# Costs
self.unit_costs = {
UnitType.WORKER: Resources(gold=50),
UnitType.SOLDIER: Resources(gold=100, food=20),
UnitType.ARCHER: Resources(gold=120, food=20),
UnitType.CAVALRY: Resources(gold=200, food=50),
UnitType.SIEGE: Resources(gold=300, wood=100)
}
self.building_costs = {
BuildingType.BASE: Resources(gold=500, wood=200),
BuildingType.BARRACKS: Resources(gold=300, wood=100),
BuildingType.MINE: Resources(gold=200),
BuildingType.FARM: Resources(gold=150, wood=50),
BuildingType.TOWER: Resources(gold=250, wood=50),
BuildingType.WALL: Resources(gold=50, wood=20)
}
# Tech tree
self.researched_techs = set()
self.tech_tree = {
'improved_mining': {'cost': 200, 'effect': 'gold_bonus'},
'advanced_farming': {'cost': 200, 'effect': 'food_bonus'},
'military_training': {'cost': 300, 'effect': 'unit_attack'},
'fortification': {'cost': 300, 'effect': 'building_hp'}
}
# Initialize game
self.initialize_game()
def initialize_game(self):
"""Set up initial game state"""
# Player starting position
start_x, start_y = 5, 5
# Create player base
base = Building(BuildingType.BASE, start_x, start_y, 'player')
self.buildings.append(base)
self.map.reveal(start_x, start_y, base.sight)
# Create player starting units
for i in range(3):
worker = Unit(UnitType.WORKER, start_x + 3 + i, start_y + 3, 'player')
self.units.append(worker)
self.map.reveal(worker.x, worker.y, worker.sight)
# Create enemy base
enemy_x, enemy_y = 40, 30
enemy_base = Building(BuildingType.BASE, enemy_x, enemy_y, 'enemy')
self.buildings.append(enemy_base)
# Create enemy starting units
for i in range(3):
worker = Unit(UnitType.WORKER, enemy_x + 3 + i, enemy_y + 3, 'enemy')
self.units.append(worker)
def handle_click(self, x: int, y: int, button: int):
"""Handle mouse click"""
# Convert screen to world coordinates
world_x = (x + self.camera_x) // 20
world_y = (y + self.camera_y) // 20
if button == 1: # Left click - selection
self.select_at(world_x, world_y)
elif button == 3: # Right click - action
self.issue_command(world_x, world_y)
def select_at(self, x: int, y: int):
"""Select units/buildings at position"""
# Clear previous selection
for unit in self.units:
unit.selected = False
for building in self.buildings:
building.selected = False
self.selected = []
# Select unit at position
for unit in self.units:
if unit.x == x and unit.y == y and unit.faction == 'player':
unit.selected = True
self.selected.append(unit)
return
# Select building at position
for building in self.buildings:
if (building.x <= x < building.x + building.size and
building.y <= y < building.y + building.size and
building.faction == 'player'):
building.selected = True
self.selected.append(building)
return
def issue_command(self, x: int, y: int):
"""Issue command to selected units"""
for entity in self.selected:
if isinstance(entity, Unit):
# Check for enemy at target
enemy = None
for unit in self.units:
if unit.faction != entity.faction and unit.x == x and unit.y == y:
enemy = unit
break
if not enemy:
for building in self.buildings:
if (building.faction != entity.faction and
building.x <= x < building.x + building.size and
building.y <= y < building.y + building.size):
enemy = building
break
if enemy:
# Attack command
entity.target = enemy
else:
# Move command
entity.target_x = x
entity.target_y = y
entity.path = Pathfinding.find_path(
(entity.x, entity.y),
(x, y),
self.map
)
entity.moving = bool(entity.path)
def train_unit(self, unit_type: UnitType, building: Building):
"""Train a unit from building"""
if unit_type not in building.produces:
return False
cost = self.unit_costs[unit_type]
if not self.resources.can_afford(cost):
return False
self.resources.subtract(cost)
building.production_queue.append({
'type': unit_type,
'time': 180 # 3 seconds at 60 FPS
})
return True
def build_structure(self, building_type: BuildingType, x: int, y: int):
"""Build a structure"""
cost = self.building_costs[building_type]
if not self.resources.can_afford(cost):
return False
# Check if area is clear
size = Building(building_type, 0, 0, 'player').size
for dy in range(size):
for dx in range(size):
if not self.map.is_passable(x + dx, y + dy):
return False
self.resources.subtract(cost)
# Create building
building = Building(building_type, x, y, 'player')
building.construction_progress = 0
building.is_complete = False
self.buildings.append(building)
# Mark tiles as occupied
for dy in range(size):
for dx in range(size):
self.map.tiles[y + dy][x + dx]['occupied'] = True
return True
def research_tech(self, tech_name: str):
"""Research a technology"""
if tech_name in self.researched_techs:
return False
tech = self.tech_tree.get(tech_name)
if not tech:
return False
if self.resources.gold < tech['cost']:
return False
self.resources.gold -= tech['cost']
self.researched_techs.add(tech_name)
# Apply tech effects
if tech['effect'] == 'gold_bonus':
for building in self.buildings:
if building.type == BuildingType.MINE:
building.generates['gold'] = int(building.generates['gold'] * 1.25)
elif tech['effect'] == 'food_bonus':
for building in self.buildings:
if building.type == BuildingType.FARM:
building.generates['food'] = int(building.generates['food'] * 1.25)
elif tech['effect'] == 'unit_attack':
for unit in self.units:
unit.attack = int(unit.attack * 1.2)
elif tech['effect'] == 'building_hp':
for building in self.buildings:
building.max_hp = int(building.max_hp * 1.25)
building.hp = building.max_hp
return True
def update(self, dt: float):
"""Update game state"""
# Update units
for unit in self.units[:]:
# Movement
if unit.path:
next_pos = unit.path[0]
unit.x, unit.y = next_pos
unit.path.pop(0)
if unit.faction == 'player':
self.map.reveal(unit.x, unit.y, unit.sight)
if not unit.path:
unit.moving = False
# Combat
if unit.target:
dx = abs(unit.x - unit.target.x)
dy = abs(unit.y - unit.target.y)
dist = max(dx, dy)
if dist <= unit.range:
if unit.cooldown <= 0:
# Attack
damage = max(1, unit.attack - unit.target.defense)
unit.target.hp -= damage
unit.cooldown = 60 # 1 second
if unit.target.hp <= 0:
unit.target = None
else:
# Move toward target
unit.target_x = unit.target.x
unit.target_y = unit.target.y
unit.path = Pathfinding.find_path(
(unit.x, unit.y),
(unit.target_x, unit.target_y),
self.map
)
# Cooldown
if unit.cooldown > 0:
unit.cooldown -= 1
# Death
if unit.hp <= 0:
self.units.remove(unit)
# Update buildings
for building in self.buildings[:]:
# Construction
if not building.is_complete:
building.construction_progress += 1
if building.construction_progress >= 100:
building.is_complete = True
if building.faction == 'player':
self.map.reveal(building.x, building.y, building.sight)
# Production
if building.production_queue and building.is_complete:
production = building.production_queue[0]
building.production_progress += 1
if building.production_progress >= production['time']:
# Spawn unit
unit = Unit(
production['type'],
building.x + building.size,
building.y + building.size,
building.faction
)
self.units.append(unit)
building.production_queue.pop(0)
building.production_progress = 0
# Resource generation
if building.generates and building.is_complete:
for resource, amount in building.generates.items():
if resource == 'gold':
self.resources.gold += amount / 60 # Per second
elif resource == 'food':
self.resources.food += amount / 60
elif resource == 'wood':
self.resources.wood += amount / 60
# Tower attacks
if building.type == BuildingType.TOWER and building.is_complete:
if building.cooldown <= 0:
# Find nearest enemy
nearest_enemy = None
nearest_dist = float('inf')
for unit in self.units:
if unit.faction != building.faction:
dist = max(abs(unit.x - building.x),
abs(unit.y - building.y))
if dist <= building.range and dist < nearest_dist:
nearest_enemy = unit
nearest_dist = dist
if nearest_enemy:
damage = building.attack
nearest_enemy.hp -= damage
building.cooldown = 60
if building.cooldown > 0:
building.cooldown -= 1
# Death
if building.hp <= 0:
# Clear occupied tiles
for dy in range(building.size):
for dx in range(building.size):
self.map.tiles[building.y + dy][building.x + dx]['occupied'] = False
self.buildings.remove(building)
# Update AI
self.ai_controller.update(self, dt)
def render(self):
"""Render game"""
self.screen.fill((34, 139, 34))
# Calculate visible tiles
tile_size = 20
start_x = self.camera_x // tile_size
start_y = self.camera_y // tile_size
end_x = min(self.map.width, start_x + self.screen.get_width() // tile_size + 2)
end_y = min(self.map.height, start_y + self.screen.get_height() // tile_size + 2)
# Draw terrain
terrain_colors = {
'grass': (34, 139, 34),
'forest': (0, 100, 0),
'mountain': (139, 137, 137),
'water': (64, 164, 223)
}
for y in range(start_y, end_y):
for x in range(start_x, end_x):
if not self.map.fog[y][x]:
# Fog of war
color = (20, 20, 20)
else:
tile = self.map.tiles[y][x]
color = terrain_colors[tile['terrain']]
screen_x = x * tile_size - self.camera_x
screen_y = y * tile_size - self.camera_y
pygame.draw.rect(self.screen, color,
(screen_x, screen_y, tile_size, tile_size))
# Draw resources
if self.map.fog[y][x] and tile['resource']:
resource_colors = {
'gold': (255, 215, 0),
'food': (139, 69, 19),
'wood': (101, 67, 33)
}
pygame.draw.circle(self.screen,
resource_colors[tile['resource']],
(screen_x + tile_size//2, screen_y + tile_size//2),
5)
# Draw buildings
for building in self.buildings:
screen_x = building.x * tile_size - self.camera_x
screen_y = building.y * tile_size - self.camera_y
size = building.size * tile_size
color = (100, 100, 200) if building.faction == 'player' else (200, 100, 100)
if building.is_complete:
pygame.draw.rect(self.screen, color,
(screen_x, screen_y, size, size))
else:
# Under construction
pygame.draw.rect(self.screen, color,
(screen_x, screen_y, size, size), 2)
# Progress bar
progress_width = size * (building.construction_progress / 100)
pygame.draw.rect(self.screen, (0, 255, 0),
(screen_x, screen_y - 5, progress_width, 3))
# Selection indicator
if building.selected:
pygame.draw.rect(self.screen, (255, 255, 0),
(screen_x - 2, screen_y - 2, size + 4, size + 4), 2)
# Health bar
if building.hp < building.max_hp:
health_width = size * (building.hp / building.max_hp)
pygame.draw.rect(self.screen, (255, 0, 0),
(screen_x, screen_y - 10, size, 3))
pygame.draw.rect(self.screen, (0, 255, 0),
(screen_x, screen_y - 10, health_width, 3))
# Draw units
for unit in self.units:
screen_x = unit.x * tile_size - self.camera_x + tile_size//2
screen_y = unit.y * tile_size - self.camera_y + tile_size//2
color = (50, 50, 200) if unit.faction == 'player' else (200, 50, 50)
pygame.draw.circle(self.screen, color, (screen_x, screen_y), 8)
# Selection indicator
if unit.selected:
pygame.draw.circle(self.screen, (255, 255, 0),
(screen_x, screen_y), 12, 2)
# Health bar
if unit.hp < unit.max_hp:
bar_width = 20
health_width = bar_width * (unit.hp / unit.max_hp)
pygame.draw.rect(self.screen, (255, 0, 0),
(screen_x - bar_width//2, screen_y - 15, bar_width, 3))
pygame.draw.rect(self.screen, (0, 255, 0),
(screen_x - bar_width//2, screen_y - 15, health_width, 3))
# Draw UI
self.render_ui()
pygame.display.flip()
def render_ui(self):
"""Render user interface"""
# Resource bar
ui_height = 40
pygame.draw.rect(self.screen, (50, 50, 50),
(0, 0, self.screen.get_width(), ui_height))
font = pygame.font.Font(None, 24)
# Resources
resources_text = f"Gold: {int(self.resources.gold)} " \
f"Food: {int(self.resources.food)} " \
f"Wood: {int(self.resources.wood)}"
text_surface = font.render(resources_text, True, (255, 255, 255))
self.screen.blit(text_surface, (10, 10))
# Selection info
if self.selected:
entity = self.selected[0]
if isinstance(entity, Unit):
info = f"Unit: {entity.type.value} HP: {entity.hp}/{entity.max_hp}"
else:
info = f"Building: {entity.type.value} HP: {entity.hp}/{entity.max_hp}"
info_surface = font.render(info, True, (255, 255, 255))
self.screen.blit(info_surface, (10, self.screen.get_height() - 30))
def run(self):
"""Main game loop"""
running = True
while running:
dt = self.clock.tick(60) / 1000.0
# Handle events
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN:
self.handle_click(event.pos[0], event.pos[1], event.button)
elif event.type == pygame.KEYDOWN:
# Camera movement
speed = 10
if event.key == pygame.K_LEFT:
self.camera_x = max(0, self.camera_x - speed)
elif event.key == pygame.K_RIGHT:
self.camera_x = min(self.map.width * 20 - self.screen.get_width(),
self.camera_x + speed)
elif event.key == pygame.K_UP:
self.camera_y = max(0, self.camera_y - speed)
elif event.key == pygame.K_DOWN:
self.camera_y = min(self.map.height * 20 - self.screen.get_height(),
self.camera_y + speed)
# Update
self.update(dt)
# Render
self.render()
pygame.quit()
if __name__ == "__main__":
game = StrategyGame()
game.run()
Key Features Implemented
â Complete Systems
- Resource Management: Gold, food, and wood economy with income generation
- Unit System: Workers, soldiers, archers, cavalry, and siege units with unique stats
- Building System: Base, barracks, mines, farms, towers, and walls
- Pathfinding: A* algorithm for intelligent unit movement
- Fog of War: Vision-based map exploration
- Combat: Range-based attacks with cooldowns and damage calculations
- AI Opponent: Strategic decision-making with multiple difficulty levels
- Tech Tree: Research system with economic and military upgrades
- Production Queues: Unit training with time-based completion
- Construction: Building placement and construction progress
Usage Instructions
đŽ How to Play
- Install Pygame:
pip install pygame - Run the game:
python strategy_game.py - Controls:
- Left-click to select units/buildings
- Right-click to move units or attack
- Arrow keys to move camera
- Build structures by selecting a builder unit
- Train units from barracks
Customization Options
đ§ Easy Modifications
- Map Size: Change the Map constructor parameters
- Starting Resources: Modify Resources initialization in StrategyGame
- Unit Stats: Edit the stats dictionary in Unit class
- Building Costs: Adjust unit_costs and building_costs dictionaries
- AI Difficulty: Change AIController difficulty parameter
- Tech Effects: Modify research_tech method effects
đď¸ââď¸ Practice Exercise
đď¸ââď¸ Exercise 1: Three Pillars of Python RTS Architecture in One Pygame Window
Objective: Build a runnable pygame program (~85 lines) that distills the lesson's UnitType/BuildingTypeâEnum + perâtype stats dict + Building.is_complete gate + research_tech retroactiveâmutation architecture into one cohesive pygame demo where three orthogonal PythonâRTSâarchitecture disciplines are visible per frame on a 1088Ă540 window split into three vertical panels (LEFT = Buildingâlifecycle gate at is_complete + global income tally / MIDDLE = Enumâkeyed UNIT_STATS dict + spawn cohort + runtime mutation / RIGHT = retroactive research_tech walk over extant soldier cohort). (a) Buildingâlifecycle gate at is_complete boolean checked at every consumer site at BUILDINGâLIFECYCLE scope: each Building ramps progress per frame until >= 100 then flips is_complete = True; downstream consumers ALL guard via if b.is_complete: checks before adding income / running production / firing tower attacks â so during construction (progress < 100) the building exists on the map but contributes ZERO income to the global tally. Pythonâidiomatic delta from chatâ81's onComplete() method: chatâ81 (JS) centralized the sideâeffects into one onComplete() method called exactly once at the moment progress crossed 100; chatâ82 (Python) distributes the sideâeffectâgating across N consumerâsite checks. Both are valid; the JSâvsâPython implementations differ at the callâsiteâdistributionâvsâcallbackâcentralization level even though the architectural intent is identical (gate side effects on lifecycle completion). The twoâlayer separation (data: income_per_sec in the building record; gate: is_complete flag check) is the same defaultâtoâdenyâatâtheâboundary shape as platformer_tilemap's is_solidâTrueâOOB invariant + architecture_state_machines' on_exitâreassignâon_enter strict ordering + architecture_event_systems' queuedâemitâvsâimmediateâemit timing modes + chatâ81 genres_strategy's onCompleteâasâgate, applied here at BUILDINGâLIFECYCLE scope with the consumerâcheck Pythonâidiomatic delta. (b) Enumâkeyed stats dict at UNITâTYPEâDEFINITION scope: a moduleâlevel UNIT_STATS: dict[UnitType, dict] holds perâtype stat records keyed by UnitType Enum MEMBERS (not string literals); Unit.__init__ reads UNIT_STATS[t]['hp'] and UNIT_STATS[t]['attack'] exactly once per spawn. Pythonâidiomatic delta from chatâ81's JS objectâliteral UNIT_STATS = { worker: {...} }: chatâ82 uses Enum members as keys, which means a typo at the spawn site (Unit(UnitType.WROKER)) raises AttributeError at IMPORT time before the constructor even runs; the equivalent JS typo (new Unit('wroker')) silently produces a unit with allâundefined stats that crashes much later or runs as a glitched zeroâstat unit. Adding a new unit type is one new UnitType enum member + one new UNIT_STATS entry rather than scattered if/elif edits across __init__ / update() / render(). SEVENTH designâtimeâvalidationâvsâruntimeâdiscovery lesson reinforcing the pattern across Phase 8, FIRST applied at STATSâDICTâKEYâIDENTITY scope where Enumâmemberâasâkey replaces stringâasâkey. The H key mutates UNIT_STATS[UnitType.SOLDIER]['hp'] += 20 at runtime so subsequent spawned soldiers show the new HP while extant soldiers retain their old HP â a property of constructorâsnapshot semantics and moduleâlevel dict identity rather than a property of the Enumâkey shape itself. (c) Retroactive research bonus applied to all extant units once at research moment, NOT snapshotted at creation time at RESEARCHâEFFECTâTIMING scope: pressing R triggers for u in soldiers: u.attack = int(u.attack * 1.2) mutating every extant soldier's attack stat IN PLACE at the moment research completes, with NO corresponding hook in Unit.__init__ to consult a researchedâtechs set for future spawns. The HUD shows PreâR: [15, 15, 15] static and PostâR: [18, 18, 18] live so the retroactive walk is visible as a singleâframe transition across the existing cohort. OPPOSITE design choice from chatâ81's snapshotâatâcreation: chatâ81 (JS) had this.attack *= 1.25 inside Unit's constructor guarded by gameState.researched.has('military') so future spawns get the bonus and extant units retain baseline; chatâ82 (Python) does liveâretroactive at the research call site so extant units get the bonus and future spawns get baseline. Both are legitimate design choices producing different gameplay rhythms â snapshot encourages 'techâup THEN rebuild' armyâreplacement pacing (AoE2âstyle lateâgame cycle); retroactive encourages 'techâup empowers your existing army immediately' instantâeffect pacing (Civâstyle instantâtech). The chatâ82 lesson picking the OPPOSITE side from chatâ81 is a deliberate pedagogical contrast that lets the JSâthenâpygame paired companion lessons demonstrate the SAME architectural axis (RESEARCHâEFFECTâTIMING) from BOTH sides of the designâspectrum, making the contextâdeterminesâcorrectâchoice rhetoric visible as the pair. SECOND lesson at RESEARCHâEFFECTâTIMING scope (after chatâ81) and FIRST to demonstrate the retroactive side at this scope. HUD shows time, gold income tally, current UNIT_STATS[UnitType.SOLDIER]['hp'] value, researchâon/off flag, and the legend â three orthogonal PythonâRTSâarchitecture disciplines visible per frame as concrete numbers and panel deltas. ADVANCES the genres module 6/9 â 7/9 partial at chatâ82 M1; moduleâcompleteness stays 12/13 since genres doesn't close in one chat (2 genres lessons remain after chatâ82: tower_defense / tower_defense_python).
Instructions:
- Define
class UnitType(Enum)with membersWORKER,SOLDIER,ARCHERmirroring the lesson's pattern (string values are fine but the Enum members themselves are what matter for keying). - Define a moduleâlevel
UNIT_STATSdict whose keys are theUnitTypeEnum members and whose values are{'hp': int, 'attack': int}records. - Define
class Unitwhose__init__(self, t)readsUNIT_STATS[t]once and assignsself.hpandself.attackfrom the record. - Define a
@dataclass Buildingwith fieldsname: str,rate: float,income_per_sec: float,progress: float = 0.0,is_complete: bool = False; create three instances (Mine / Farm / Barracks) at staggered rates so they finish at visibly different times. - In the perâframe loop, ramp each building's
progress += rate * dtand flipis_complete = Trueatprogress >= 100; gate the income tally withif b.is_complete: gold += b.income_per_sec * dtso only complete buildings contribute to gold. - Bind keys 1/2/3 to spawn worker/soldier/archer via
Unit(UnitType.WORKER)/ etc; bind H toUNIT_STATS[UnitType.SOLDIER]['hp'] += 20at runtime so the next soldier spawned shows the new HP while extant soldiers retain old HP. - Preâspawn 3 soldiers and snapshot their attack values as
pre_attack_snapshot; bind R to walk the cohort withfor u in soldiers: u.attack = int(u.attack * 1.2)exactly once (gate on aresearch_doneflag so subsequent R presses do nothing) so the HUD shows the visible singleâframe transition across the existing cohort. - Render three vertical panels with thin separator lines at x=362 and x=724; perâpanel HUD lines + perâbuilding progress bars + perâspawn unit list + perâsoldier circles colored green after research; verify the demo runs at 60 FPS with all three axes responding correctly to keyboard input before submitting.
đĄ Hint
The is_complete consumerâcheck pattern means EVERY consumer of the building (income, production, tower attacks in the full lesson code) does if b.is_complete: at its own call site â do NOT try to centralize the sideâeffects into a single on_complete() method (that would be the chatâ81 JS pattern; the Pythonâidiomatic shape is distributed checks). The UNIT_STATS dict MUST be moduleâlevel (not local to Unit.__init__) for the Hâkey mutation to propagate to subsequent spawns; the lesson's actual code keeps stats local to the constructor which means runtime mutation isn't visible â hoist it to module level for the demo. The retroactive research walk is a SINGLE pass over the extant soldiers list at the moment R is pressed; do NOT add any check inside Unit.__init__ for a researchedâtechs flag (that would be chatâ81's snapshot pattern; the Pythonâidiomatic shape here is the OPPOSITE choice). Use int(u.attack * 1.2) not u.attack * 1.2 to match the lesson's integerâfloored arithmetic so the HUD shows clean integer values.
â Example Solution
import pygame
from enum import Enum
from dataclasses import dataclass
pygame.init()
W, H = 1088, 540
screen = pygame.display.set_mode((W, H))
font = pygame.font.SysFont(None, 18)
clock = pygame.time.Clock()
class UnitType(Enum):
WORKER = 'worker'
SOLDIER = 'soldier'
ARCHER = 'archer'
# Axis B: module-level Enum-keyed stats dict
UNIT_STATS = {
UnitType.WORKER: {'hp': 50, 'attack': 5},
UnitType.SOLDIER: {'hp': 100, 'attack': 15},
UnitType.ARCHER: {'hp': 60, 'attack': 12},
}
class Unit:
def __init__(self, t):
self.type = t
self.hp = UNIT_STATS[t]['hp'] # KeyError if t isn't a UnitType member
self.attack = UNIT_STATS[t]['attack']
@dataclass
class Building:
name: str
rate: float # progress per second
income_per_sec: float
progress: float = 0.0
is_complete: bool = False
# Axis A: 3 buildings under construction, gate via is_complete
buildings = [
Building('Mine', rate=33.0, income_per_sec=2.0), # ~3s to complete
Building('Farm', rate=20.0, income_per_sec=1.5), # ~5s to complete
Building('Barracks', rate=14.0, income_per_sec=0.0), # ~7s, produces units only
]
gold = 0.0
# Axis C: 3 pre-spawned soldiers, retroactive research bonus
soldiers = [Unit(UnitType.SOLDIER) for _ in range(3)]
research_done = False
pre_attack_snapshot = [u.attack for u in soldiers] # [15, 15, 15]
spawned = [] # middle-panel spawn list
WHITE=(240,240,240); GREY=(90,90,100); GREEN=(80,180,90)
BLUE=(80,130,220); YELLOW=(220,200,80)
running = True
while running:
dt = clock.tick(60) / 1000.0
for e in pygame.event.get():
if e.type == pygame.QUIT: running = False
elif e.type == pygame.KEYDOWN:
if e.key == pygame.K_1: spawned.append(Unit(UnitType.WORKER))
elif e.key == pygame.K_2: spawned.append(Unit(UnitType.SOLDIER))
elif e.key == pygame.K_3: spawned.append(Unit(UnitType.ARCHER))
elif e.key == pygame.K_h:
UNIT_STATS[UnitType.SOLDIER]['hp'] += 20 # propagates only to subsequent spawns
elif e.key == pygame.K_r and not research_done:
for u in soldiers: u.attack = int(u.attack * 1.2) # retroactive walk
research_done = True
# Axis A: building update + income consumer-check
for b in buildings:
if not b.is_complete:
b.progress += b.rate * dt
if b.progress >= 100:
b.progress = 100; b.is_complete = True
if b.is_complete: # consumer-check pattern
gold += b.income_per_sec * dt
# Render
screen.fill((22, 26, 32))
pygame.draw.line(screen, GREY, (362, 0), (362, H))
pygame.draw.line(screen, GREY, (724, 0), (724, H))
# LEFT: Axis A
screen.blit(font.render('A: is_complete gate', True, YELLOW), (12, 8))
screen.blit(font.render(f'gold = {gold:.1f}', True, WHITE), (12, 28))
for i, b in enumerate(buildings):
y = 70 + i * 90
screen.blit(font.render(f'{b.name} income/s={b.income_per_sec}', True, WHITE), (12, y))
bar_w = int(330 * (b.progress / 100))
col = GREEN if b.is_complete else BLUE
pygame.draw.rect(screen, GREY, (12, y + 22, 330, 18), 1)
pygame.draw.rect(screen, col, (12, y + 22, bar_w, 18))
screen.blit(font.render(f'progress={b.progress:.0f}/100 is_complete={b.is_complete}', True, WHITE), (12, y + 46))
# MIDDLE: Axis B
screen.blit(font.render('B: Enum-keyed UNIT_STATS', True, YELLOW), (374, 8))
cur_hp = UNIT_STATS[UnitType.SOLDIER]['hp']
screen.blit(font.render(f"UNIT_STATS[UnitType.SOLDIER]['hp'] = {cur_hp}", True, WHITE), (374, 28))
screen.blit(font.render('keys: 1=W 2=S 3=A H=hp+=20', True, WHITE), (374, 50))
for i, u in enumerate(spawned[-12:]):
y = 90 + i * 28
screen.blit(font.render(f'{u.type.name:8s} hp={u.hp} atk={u.attack}', True, WHITE), (374, y))
# RIGHT: Axis C
screen.blit(font.render('C: Retroactive research', True, YELLOW), (734, 8))
screen.blit(font.render('R = research_tech (extant only)', True, WHITE), (734, 28))
screen.blit(font.render(f'Pre-R: {pre_attack_snapshot}', True, WHITE), (734, 60))
cur_attack = [u.attack for u in soldiers]
label = 'Post-R' if research_done else 'Now '
screen.blit(font.render(f'{label}: {cur_attack}', True, GREEN if research_done else WHITE), (734, 84))
for i, u in enumerate(soldiers):
col = GREEN if research_done else BLUE
pygame.draw.circle(screen, col, (754 + i * 70, 150), 14)
screen.blit(font.render(f'atk={u.attack}', True, WHITE), (734 + i * 70, 175))
pygame.display.flip()
pygame.quit()
đŻ Quick Quiz
Question 1: Why does the demo gate the global income tally with if b.is_complete: gold += b.income_per_sec * dt rather than letting every building contribute income from the moment it's placed on the map?
Question 2: The lesson uses UNIT_STATS = {UnitType.WORKER: {...}, UnitType.SOLDIER: {...}, ...} with UnitType Enum members as dict keys, then reads UNIT_STATS[unit_type] in the Unit constructor. What architectural property does the Enum-member-as-key shape provide that string-as-key (UNIT_STATS = {"worker": {...}, ...}) does not?
Question 3: chat-81's genres_strategy.html (JS canvas) implements research bonuses as a SNAPSHOT taken in Unit's constructor (future spawns get the bonus, extant units retain baseline). chat-82's genres_strategy_python.html (pygame) implements research bonuses as a RETROACTIVE walk over the extant cohort at the moment research_tech() is called (extant units get the bonus, future spawns retain baseline). Which framing best captures the relationship between the two design choices?