Tower Defense — Milestone 5
Health Bars & Rising Difficulty
Milestone 5 — Difficulty
# TOWER DEFENSE — MILESTONE 5: Health Bars & Rising Difficulty
# =============================================================================
# TARGET (show me when this works):
# Early enemies die in one hit. As your score climbs, enemies arrive with
# more health (a green bar over their head) and one tower is no longer
# enough — you need two or three covering the same stretch of path.
# Enemies also get faster and spawn more often the longer you survive.
# The HUD shows the current enemy HP, speed and spawn rate.
#
# NEW IN THIS FILE (compared to Milestone 4):
# - Enemy.hp and Enemy.max_hp, plus the health bar
# - enemy_hp(), which grows with your score
# - rescale(), which speeds enemies up and shortens the spawn timer
#
# THE ONE IDEA TO UNDERSTAND — WHY HP STARTS AT 1:
# A tower gets about 2 shots at an enemy walking past it.
# An early version of this game gave every enemy 3 HP from the start.
# That meant your FIRST tower could never finish anything off, so you never
# earned a coin, so you could never build a second tower. The game was
# quietly impossible and nothing on screen told you why.
# Starting at 1 HP and growing means the game can always get going, and it
# gets hard later for a reason the player can see (the health bars).
#
# That is a real lesson about game design: a difficulty setting that is
# wrong at the START can lock a player out of everything that follows.
#
# NEXT (Milestone 6): three more lanes, and the finished game.
# =============================================================================
import pygame
import math
pygame.init()
COLS, ROWS = 20, 15
CELL = 40
HUD_H = 60
WIDTH = COLS * CELL
HEIGHT = ROWS * CELL + HUD_H
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Tower Defense — Milestone 5")
clock = pygame.time.Clock()
font = pygame.font.SysFont("Courier", 18, bold=True)
small_font = pygame.font.SysFont("Courier", 15, bold=True)
big_font = pygame.font.SysFont("Courier", 40, bold=True)
# Colors
BG_COLOR = (20, 28, 40)
GRID_COLOR = (30, 40, 55)
OPEN_COLOR = (35, 50, 70)
PATH_COLOR = (52, 84, 126)
ENTRY_COLOR = (50, 180, 80)
BASE_COLOR = (200, 60, 60)
TOWER_COLOR = (58, 140, 70)
TOWER_EDGE = (95, 210, 110)
BULLET_COLOR = (255, 240, 120)
HUD_COLOR = (185, 220, 255)
COIN_COLOR = (255, 205, 60)
OK_RING = (80, 220, 110)
BAD_RING = (220, 70, 70)
HP_BG = (90, 25, 25)
HP_FG = (70, 205, 90)
# Settings
TOWER_RANGE = 2.5 * CELL
TOWER_RELOAD = 60
BULLET_SPEED = 5
START_LIVES = 25
# Economy
START_COINS = 50
TOWER_COST = 18
COST_STEP = 6
COINS_PER_KILL = 6
# Difficulty — all tuned by simulation
HP_BASE = 1 # enemy health at score 0
HP_EVERY = 18 # +1 health per this many kills
HP_CAP = 8 # never go above this
SPEED_BASE = 1.5 # pixels per frame
SPEED_STEP = 0.15 # + this every 10 kills
SPEED_MAX = 3.5
SPAWN_BASE = 2200 # milliseconds between spawns
SPAWN_STEP = 100 # - this every 10 kills
SPAWN_MIN = 700
BASE = (10, 7)
PATH = [(0, 5), (6, 5), (6, 11), (14, 11), (14, 3), (10, 3), BASE]
grid = [['open'] * ROWS for _ in range(COLS)]
def path_cells(waypoints):
cells = []
for i in range(len(waypoints) - 1):
col0, row0 = waypoints[i]
col1, row1 = waypoints[i + 1]
step_col = 0 if col0 == col1 else (1 if col1 > col0 else -1)
step_row = 0 if row0 == row1 else (1 if row1 > row0 else -1)
col, row = col0, row0
while (col, row) != (col1, row1):
cells.append((col, row))
col += step_col
row += step_row
cells.append(waypoints[-1])
return cells
for cell in path_cells(PATH):
grid[cell[0]][cell[1]] = 'path'
grid[PATH[0][0]][PATH[0][1]] = 'entry'
grid[BASE[0]][BASE[1]] = 'base'
class Enemy:
RADIUS = 11
def __init__(self, waypoints, speed, hp):
self.waypoints = waypoints
self.index = 0
self.speed = speed
self.hp = hp
self.max_hp = hp
col, row = waypoints[0]
self.x = float(col * CELL + CELL / 2)
self.y = float(row * CELL + CELL / 2)
self.alive = True
self.reached_base = False
self.targeted = False
def update(self):
if self.index >= len(self.waypoints) - 1:
self.alive = False
self.reached_base = True
return
col, row = self.waypoints[self.index + 1]
target_x = col * CELL + CELL / 2
target_y = row * CELL + CELL / 2
dx = target_x - self.x
dy = target_y - self.y
dist = math.hypot(dx, dy)
if dist < self.speed:
self.x = target_x
self.y = target_y
self.index += 1
else:
self.x += dx / dist * self.speed
self.y += dy / dist * self.speed
def hit(self):
"""Take one point of damage. Only die when health runs out."""
self.hp -= 1
if self.hp <= 0:
self.alive = False
def draw(self, surface):
center_x, center_y = int(self.x), int(self.y)
ratio = self.hp / self.max_hp
# Healthy enemies are yellow; hurt ones turn red
color = (int(210 + 30 * (1 - ratio)), int(180 * ratio + 40), 40)
pygame.draw.circle(surface, color, (center_x, center_y), self.RADIUS)
pygame.draw.circle(surface, (0, 0, 0), (center_x, center_y), self.RADIUS, 2)
# Only draw a health bar if it can survive more than one hit
if self.max_hp > 1:
bar_w = self.RADIUS * 2
bar_x = center_x - self.RADIUS
bar_y = center_y - self.RADIUS - 7
pygame.draw.rect(surface, HP_BG, (bar_x, bar_y, bar_w, 4))
pygame.draw.rect(surface, HP_FG, (bar_x, bar_y, int(bar_w * ratio), 4))
class Tower:
def __init__(self, col, row):
self.col = col
self.row = row
self.x = col * CELL + CELL / 2
self.y = row * CELL + CELL / 2
self.cooldown = 0
def update(self, enemies, bullets):
if self.cooldown > 0:
self.cooldown -= 1
return
target = None
best_dist = float('inf')
for enemy in enemies:
if not enemy.alive or enemy.targeted:
continue
dist = math.hypot(enemy.x - self.x, enemy.y - self.y)
if dist < TOWER_RANGE and dist < best_dist:
target = enemy
best_dist = dist
if target is not None:
bullets.append(Bullet(self.x, self.y, target))
target.targeted = True
self.cooldown = TOWER_RELOAD
def draw(self, surface):
box = pygame.Rect(self.col * CELL + 5, self.row * CELL + 5,
CELL - 10, CELL - 10)
pygame.draw.rect(surface, TOWER_COLOR, box, border_radius=5)
pygame.draw.rect(surface, TOWER_EDGE, box, 2, border_radius=5)
pygame.draw.circle(surface, TOWER_EDGE, (int(self.x), int(self.y)), 4)
class Bullet:
def __init__(self, x, y, target):
self.x = float(x)
self.y = float(y)
self.target = target
self.active = True
def update(self):
if not self.target.alive:
self.active = False
return
dx = self.target.x - self.x
dy = self.target.y - self.y
dist = math.hypot(dx, dy)
if dist < BULLET_SPEED:
self.target.hit()
self.active = False
else:
self.x += dx / dist * BULLET_SPEED
self.y += dy / dist * BULLET_SPEED
def draw(self, surface):
pygame.draw.circle(surface, BULLET_COLOR, (int(self.x), int(self.y)), 4)
def tower_cost():
return TOWER_COST + COST_STEP * len(towers)
def enemy_hp():
"""Health of an enemy spawning RIGHT NOW, based on the score."""
return min(HP_CAP, HP_BASE + score // HP_EVERY)
def can_build(col, row):
if not (0 <= col < COLS and 0 <= row < ROWS):
return False
return grid[col][row] == 'open'
def rescale():
"""Called after every kill: make the game a little harder."""
global enemy_speed, spawn_ms
enemy_speed = min(SPEED_MAX, SPEED_BASE + (score // 10) * SPEED_STEP)
new_spawn = max(SPAWN_MIN, SPAWN_BASE - (score // 10) * SPAWN_STEP)
if new_spawn != spawn_ms:
spawn_ms = new_spawn
pygame.time.set_timer(SPAWN_EVENT, spawn_ms)
def draw_grid():
for row in range(ROWS):
for col in range(COLS):
x = col * CELL
y = row * CELL
cell_type = grid[col][row]
if cell_type == 'base':
color = BASE_COLOR
elif cell_type == 'entry':
color = ENTRY_COLOR
elif cell_type == 'path':
color = PATH_COLOR
else:
color = OPEN_COLOR
pygame.draw.rect(screen, color, (x, y, CELL, CELL))
pygame.draw.rect(screen, GRID_COLOR, (x, y, CELL, CELL), 1)
base_x = BASE[0] * CELL + CELL // 2
base_y = BASE[1] * CELL + CELL // 2
label = font.render("BASE", True, (255, 200, 200))
screen.blit(label, label.get_rect(center=(base_x, base_y)))
def draw_build_preview():
mouse_x, mouse_y = pygame.mouse.get_pos()
if mouse_y >= ROWS * CELL or lives <= 0:
return
col = mouse_x // CELL
row = mouse_y // CELL
legal = can_build(col, row)
affordable = coins >= tower_cost()
color = OK_RING if (legal and affordable) else BAD_RING
center_x = col * CELL + CELL // 2
center_y = row * CELL + CELL // 2
pygame.draw.rect(screen, color, (col * CELL, row * CELL, CELL, CELL), 2)
pygame.draw.circle(screen, color, (center_x, center_y), int(TOWER_RANGE), 1)
if legal and not affordable:
warn = small_font.render(f"need {tower_cost()}", True, BAD_RING)
screen.blit(warn, warn.get_rect(center=(center_x, center_y - CELL)))
def draw_hud():
top = ROWS * CELL
pygame.draw.rect(screen, (12, 16, 26), (0, top, WIDTH, HUD_H))
line1 = f"SCORE {score} LIVES {lives} COINS {coins} NEXT TOWER {tower_cost()}"
screen.blit(font.render(line1, True, HUD_COLOR), (10, top + 10))
line2 = (f"enemy HP {enemy_hp()} speed {enemy_speed:.2f} "
f"spawn {spawn_ms}ms")
screen.blit(small_font.render(line2, True, COIN_COLOR), (10, top + 34))
# Game state
enemies = []
towers = []
bullets = []
score = 0
lives = START_LIVES
coins = START_COINS
enemy_speed = SPEED_BASE
spawn_ms = SPAWN_BASE
SPAWN_EVENT = pygame.USEREVENT + 1
pygame.time.set_timer(SPAWN_EVENT, spawn_ms)
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mouse_x, mouse_y = event.pos
if mouse_y < ROWS * CELL and lives > 0:
col = mouse_x // CELL
row = mouse_y // CELL
if can_build(col, row) and coins >= tower_cost():
coins -= tower_cost()
towers.append(Tower(col, row))
grid[col][row] = 'tower'
if event.type == SPAWN_EVENT and lives > 0:
enemies.append(Enemy(PATH, enemy_speed, enemy_hp()))
for enemy in enemies:
enemy.targeted = False
for tower in towers:
tower.update(enemies, bullets)
for enemy in enemies[:]:
enemy.update()
if not enemy.alive:
enemies.remove(enemy)
if enemy.reached_base:
lives -= 1
if lives < 0:
lives = 0
else:
score += 1
coins += COINS_PER_KILL
rescale()
for bullet in bullets[:]:
bullet.update()
if not bullet.active:
bullets.remove(bullet)
# Draw
screen.fill(BG_COLOR)
draw_grid()
for tower in towers:
tower.draw(screen)
for enemy in enemies:
enemy.draw(screen)
for bullet in bullets:
bullet.draw(screen)
draw_build_preview()
draw_hud()
if lives <= 0:
shade = pygame.Surface((WIDTH, ROWS * CELL), pygame.SRCALPHA)
shade.fill((0, 0, 0, 170))
screen.blit(shade, (0, 0))
msg = big_font.render("GAME OVER", True, (225, 70, 70))
screen.blit(msg, msg.get_rect(center=(WIDTH // 2, ROWS * CELL // 2)))
pygame.display.flip()
clock.tick(60)
pygame.quit()