Skip to main content

Lesson 24: Real-time Strategy

  • Module 12: Genre Studio
  • Lesson 24 of 27
  • ⏱️ About 2 h (instruction + lab)

A real-time strategy game is a dozen small systems running at once: an economy, a map, pathfinding, production, combat and an opponent that plays by the same rules. In this lesson you build a compact, complete RTS and learn how to keep those systems honest as they grow.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Build a faction model in which every player and AI owns its own gold, supply and research.
  • Explain why the octile heuristic is admissible for 8-way movement and Manhattan distance is not, and implement octile A* without corner cutting.
  • Drive units, construction, production and research with timers in seconds, and stop blocked units from searching every frame.
  • Compare "snapshot" stats with stats derived at use time, and choose the one that makes research reach every unit.
  • Build a minimal command interface: box select, context-sensitive right-click orders and train/build buttons.

Project: Tiny RTS, a two-faction game with workers, soldiers, farms, a barracks, one research upgrade and an AI opponent.

In This Lesson

🗺️ The RTS Loop

Most real-time strategy games, from the classics to modern ones, run on a flywheel: gather resources, turn them into units and buildings, use those to control more of the map, and turn the map into more resources. "Real-time" means the world never waits for you; every system updates every frame, whether or not the player is looking at it.

A four-step loop: 1 gather resources, 2 build units and bases, 3 command and fight, 4 control more map, which feeds back into more income. Beside it, a small map shows a blue player territory expanding toward a red enemy and a resource node.
The strategy flywheel: resources become units, units become territory, territory becomes more resources.

This lesson's game has exactly one of each system, small enough to read in one sitting:

SystemIn Tiny RTSBuilds on
Map26 × 20 tiles of grass, water, rock and goldTile Maps
EconomyGold from workers, supply from bases and farmsnew here
PathfindingOctile A* with several goal tilesA* Pathfinding, Advanced Pathfinding
UnitsTask state machine: idle, move, gather, return, attackNPC State Machines
MessagesA tiny event bus for "Worker ready", "Not enough gold"Event Systems
OpponentAn AI that decides every 1.5 s with its own pursenew here

💡 Why this matters

Most RTS bugs are not in any one system; they are in the seams between them: the AI reaching into the player's wallet, a unit trained after an upgrade missing it, a pathfinder hammered every frame by a unit that can never arrive. This lesson is about those seams.

💰 Factions Own Their Economy

Picture two players sharing one wallet: whoever clicks first spends the other's money. That is exactly what happens in code when the game keeps one global resources object and the AI calls the same build() function the player does. The fix is structural: every faction is an object that owns its gold, its research and a pay() method, and every purchase names the faction that pays.

class Faction:
    def __init__(self, name, color, gold=200, is_ai=False):
        self.name = name
        self.color = color
        self.gold = gold
        self.is_ai = is_ai
        self.research = set()             # finished research names
        self.researching = None           # (name, seconds left) or None

    def pay(self, cost):
        """Spend this faction's own gold. Returns False (and spends nothing) if it can't afford it."""
        if self.gold < cost:
            return False
        self.gold -= cost
        return True

Units and buildings carry a faction reference, never a faction name string, so "who owns this?" is a single identity check: unit.faction is game.player. Training uses the building's own faction, so the AI can only ever train from its own base with its own gold:

def train(self, building, kind):
    if building is None or not building.complete or kind not in building.spec.trains:
        return False
    f = building.faction
    if self.supply_used(f) >= self.supply_cap(f):
        self.notice(f, "Need more farms")
        return False
    if not f.pay(UNIT_SPECS[kind].cost):
        self.notice(f, "Not enough gold")
        return False
    building.queue.append(kind)
    return True

The second limit is supply: each unit (trained or queued) uses one, and only finished bases and farms provide it. That building.complete check is the construction gate: a half-built farm occupies its tiles but gives nothing until its build timer runs out.

A three-column pipeline: resources (a mine, a farm and a forest) flow into a barracks, which trains soldier, archer, cavalry and siege units, each labeled with an example gold, food and wood cost.
A typical RTS economy: resource buildings feed production buildings, which turn resources into units. The numbers are an example; Tiny RTS uses one resource (gold) plus supply.

📋 Data-Driven Units, Defense and Research

Unit and building stats live in tables of frozen dataclasses, so adding a unit type is one new line, and the stats cannot be changed by accident at runtime. Buildings have a defense just like units, which means the damage rule works on anything you can attack:

@dataclass(frozen=True)
class UnitSpec:
    cost: int
    build_time: float    # seconds
    hp: int
    attack: int
    defense: int
    range: float         # px
    speed: float         # px/s
    cooldown: float      # seconds between attacks

UNIT_SPECS = {
    "worker": UnitSpec(cost=50, build_time=3.0, hp=40, attack=4, defense=0, range=22, speed=80, cooldown=1.0),
    "soldier": UnitSpec(cost=80, build_time=4.0, hp=90, attack=12, defense=2, range=26, speed=70, cooldown=0.8),
}

def damage(attacker, target):
    """Defense soaks damage, but every hit does at least 1."""
    return max(1, attacker.attack - target.defense)

The max(1, ...) floor matters: without it, a worker (attack 4) hitting a base (defense 5) would heal it by 1 every swing.

Snapshot stats versus derived stats

There are two ways to give a unit its attack value:

  • Snapshot: copy the number into the unit when it is created (self.attack = spec.attack) and mutate it when research finishes. Every code path that creates units must remember to apply every finished upgrade, and every upgrade must walk every existing unit. Miss one and units trained after the upgrade quietly lack it.
  • Derived: store only the unit's kind and faction, and compute attack whenever it is needed from the spec plus the faction's finished research.
@property
def attack(self):
    """Derived at use time, so research reaches units trained before AND after it."""
    return self.spec.attack + self.faction.attack_bonus(self.kind)

The derived version has one source of truth, so old and new soldiers always agree. This is also how the best-known RTS upgrades behave for players: in Age of Empires II, for example, Blacksmith attack and armor upgrades apply to the units you already have as well as the ones you train afterward. If a design really wants "only new units get it" (say, a veteran trait), make that an explicit per-unit flag rather than an accident of when the unit was built.

✅ Growth Mindset: Systems Bugs Hide in the Seams

If your AI suddenly has infinite gold or your upgraded soldiers hit like rookies, it doesn't mean you can't write strategy games. It means two correct-looking pieces disagree about who owns a number. Ask the ownership question out loud ("which object is allowed to change gold?") and you'll usually find the bug in minutes. Every RTS team asks it; you're learning to ask it early.

🧭 Octile A* on the Tile Map

You met A* in A* Pathfinding and its guarantees in Advanced Pathfinding: A* returns a shortest path only if its heuristic is admissible, meaning it never overestimates the remaining cost. On a grid where units move in 8 directions, with straight steps costing 1 and diagonal steps costing √2, the exact cost across open ground is the octile distance:

def octile(a, b):
    """Exact cost on an open 8-way grid (straight 1, diagonal sqrt 2): an admissible heuristic."""
    dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
    return max(dx, dy) + (SQRT2 - 1) * min(dx, dy)

Manhattan distance (dx + dy) assumes you can't move diagonally, so on an 8-way grid it overestimates: from (0, 0) to (3, 3) it says 6 when the real cost is about 4.24. An overestimating heuristic makes A* greedy. This complete program runs both heuristics around a short wall and prints what they cost:

import heapq
import itertools
import math

SQRT2 = math.sqrt(2)
STEPS = [(1, 0, 1.0), (-1, 0, 1.0), (0, 1, 1.0), (0, -1, 1.0),
         (1, 1, SQRT2), (1, -1, SQRT2), (-1, 1, SQRT2), (-1, -1, SQRT2)]


def manhattan(a, b):
    return abs(a[0] - b[0]) + abs(a[1] - b[1])


def octile(a, b):
    dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
    return max(dx, dy) + (SQRT2 - 1) * min(dx, dy)


def astar(blocked, start, goal, h, size=12):
    tie = itertools.count()
    heap = [(h(start, goal), next(tie), start)]
    g = {start: 0.0}
    closed = set()
    expanded = 0
    while heap:
        _, _, cur = heapq.heappop(heap)
        if cur in closed:
            continue
        if cur == goal:
            return g[cur], expanded
        closed.add(cur)
        expanded += 1
        for dx, dy, step in STEPS:
            nxt = (cur[0] + dx, cur[1] + dy)
            if not (0 <= nxt[0] < size and 0 <= nxt[1] < size) or nxt in blocked:
                continue
            if dx and dy and ((cur[0] + dx, cur[1]) in blocked or (cur[0], cur[1] + dy) in blocked):
                continue
            new_g = g[cur] + step
            if new_g < g.get(nxt, math.inf):
                g[nxt] = new_g
                heapq.heappush(heap, (new_g + h(nxt, goal), next(tie), nxt))
    return None, expanded


wall = {(4, y) for y in range(1, 8)}          # a short vertical wall
for name, h in [("manhattan", manhattan), ("octile", octile)]:
    cost, expanded = astar(wall, (1, 9), (11, 0), h)
    print(f"{name:9s}  path cost {cost:.2f}  tiles expanded {expanded}")

On this map it prints:

manhattan  path cost 17.83  tiles expanded 17
octile     path cost 14.90  tiles expanded 34

Manhattan looked at half as many tiles but returned a path about 20% longer: units would visibly take the long way around. That trade (speed for optimality) is exactly what weighted A* makes on purpose; here it happened by accident. Use octile.

Three details that keep units believable

  • No corner cutting. A diagonal step is only allowed when both side tiles are open; otherwise units squeeze between two blocked tiles that visually touch.
  • Several goals. Gold tiles and buildings are not walkable, so the goal is "any open tile next to it". The heuristic is the minimum octile distance to any goal, which is still admissible.
  • A tie-breaker in the heap. Entries are (f, next(counter), tile), so when two f values are equal Python compares the counter and never has to compare the tiles themselves.
for dx, dy, step in NEIGHBORS:
    nxt = (cur[0] + dx, cur[1] + dy)
    if not passable(nxt):
        continue
    if dx and dy and not (passable((cur[0] + dx, cur[1])) and passable((cur[0], cur[1] + dy))):
        continue                          # no squeezing between two blocked tiles
    new_g = g_cost[cur] + step
    if new_g < g_cost.get(nxt, math.inf):
        g_cost[nxt] = new_g
        came_from[nxt] = cur
        heapq.heappush(open_heap, (new_g + h(nxt), next(tie), nxt))

Unreachable goals: fail once, then wait

When a goal is walled off, A* has to explore every reachable tile before it can say "no path", which is the most expensive search there is. A unit that retries every frame repeats that worst case sixty times a second. Instead, record the failure and wait:

def route(self, unit, goals):
    """Plan a path, but after a failure wait RETRY_DELAY seconds instead of searching every frame."""
    if unit.retry > 0:
        return
    path = astar(self.passable, unit.tile, goals)
    if path is None:
        unit.retry = RETRY_DELAY
    else:
        unit.path = path
        unit.repath = REPATH_DELAY

The map generator also protects the pathfinder: it clears the land around both base sites (so no base ever starts on water) and carves a corridor between them, and a test checks 25 seeds to prove the bases can always reach each other. For hundreds of units heading to the same place, the flow fields from Advanced Pathfinding replace one A* per unit with one search for everyone.

⏱️ Units as State Machines, Timed in Seconds

Each unit runs the kind of state machine you built in NPC State Machines, with an Enum for its current task. A worker loops between gathering and returning; a soldier chases and strikes.

stateDiagram-v2 [*] --> IDLE IDLE --> MOVE: right-click ground IDLE --> GATHER: right-click gold IDLE --> ATTACK: right-click enemy GATHER --> RETURN: carrying 10 gold RETURN --> GATHER: gold deposited ATTACK --> IDLE: target destroyed MOVE --> IDLE: arrived or unreachable

Every duration is in seconds and every timer counts down (or up) by dt: mining takes GATHER_TIME = 2.0 s, a worker takes 3.0 s to train, attacks have a cooldown in seconds, construction progress is seconds of work done, and the AI thinks every 1.5 s. Counting frames instead would make a 144 Hz player train units more than twice as fast as a 60 Hz one.

for b in list(self.buildings):
    if not b.complete:
        b.progress = min(b.spec.build_time, b.progress + dt)
    elif b.queue:
        b.train_time += dt
        if b.train_time >= UNIT_SPECS[b.queue[0]].build_time:
            b.train_time = 0.0
            self.spawn(b.queue.pop(0), b)

The gather branch shows the pattern every task follows: walk if there is a path, work if you are in place, plan a route otherwise.

if u.task is Task.GATHER:           # one branch of update_unit()
    if u.carrying:
        u.task, u.path = Task.RETURN, []
    elif u.path:
        self.follow(u, dt)
    elif u.pos.distance_to(tile_center(u.target)) <= TILE * 1.5:
        u.work += dt
        if u.work >= GATHER_TIME:
            u.work, u.carrying = 0.0, GATHER_AMOUNT
    else:
        self.route(u, self.neighbors_of([u.target]))

Unit positions are Vector2 floats that walk toward the center of the next path tile with move_towards_ip, carrying any leftover step into the following tile, so movement stays smooth at any frame rate.

🖱️ A Minimal Command Interface

An RTS without controls is a screensaver. Tiny RTS uses the three conventions nearly every RTS player expects:

  • Left-drag draws a selection box and selects your units inside it (a short click selects the unit under the cursor, because the box is inflated slightly).
  • Right-click is context-sensitive: on an enemy it means attack, on gold (for workers) it means gather, anywhere else it means move.
  • Buttons in a side panel train units, start research, or enter a placement mode that shows a green or red outline of the building under the cursor.
for u in ui["selected"]:
    if target is not None and target.faction is not game.player:
        game.order_attack(u, target)
    elif game.terrain[tile[1]][tile[0]] == "gold" and u.kind == "worker":
        game.order_gather(u, tile)
    else:
        game.order_move(u, tile)

Feedback such as "Not enough gold" and "Barracks complete" travels over a tiny publish/subscribe bus in the style of Event Systems. The game publishes a notice event with the faction attached, and the HUD's handler only keeps the player's notices, so the AI's failures never show up on your screen.

self.bus.subscribe("notice", self.on_notice)

def on_notice(self, faction, text):
    if faction is self.player:
        self.messages.append(text)          # a deque(maxlen=4) drawn in the side panel

🧠 An AI That Plays Fair

The AI opponent is a short list of priorities checked every AI_THINK = 1.5 seconds, not every frame. It uses only public game actions (train, try_build, order_attack) with its own faction, so it obeys exactly the rules and costs the player does.

for w in workers:
    if w.task is Task.IDLE:
        game.order_gather(w)
farm_building = any(b.faction is f and b.kind == "farm" and not b.complete for b in game.buildings)
if game.supply_used(f) + 1 >= game.supply_cap(f) and not farm_building:
    if f.gold >= BUILDING_SPECS["farm"].cost:
        game.try_build(f, "farm", game.find_site(f, "farm"))
elif len(workers) < 5 and not base.queue:
    game.train(base, "worker")
elif barracks is None:
    if f.gold >= BUILDING_SPECS["barracks"].cost:
        game.try_build(f, "barracks", game.find_site(f, "barracks"))
elif barracks.complete and not barracks.queue:
    game.train(barracks, "soldier")

Once it has five soldiers, it sends each idle one at the nearest enemy unit or building. That is a very simple "build order" AI, but because it plays with the same economy it is a real opponent, and it is easy to make smarter with the Behavior Trees or Utility AI you built earlier in this course.

As the game grows, the units, buildings and their components are a natural fit for the Entity-Component-System from the first lesson of this course: specs become components, and "move", "gather" and "attack" become systems. Tiny RTS keeps plain classes so the whole game fits in one file.

✅ Growth Mindset: Big Programs Are Many Small Ones

A 700-line game can feel like a wall of code. Read it the way you'd scout a map: find the Game.update method first, then follow one worker through one trip. You don't need to hold the whole program in your head, yet; you need to know where each piece lives. That skill is exactly what larger codebases demand.

🏋️ Practice Exercise: Tiny RTS

Objective: fix the economy, pathfinding, combat, research and timing of a small RTS so both factions play by the same, honest rules.

Time: about 55 minutes. Starter file: rts_starter.py (your instructor has it). The map, drawing, input handling and AI already work. Gold is free, paths use Manhattan distance and cut corners, blocked units search every frame, defense does nothing, research is ignored, and training counts frames. The numbered to-do comments in it match these steps.

  1. Run the starter, train a few workers and watch the gold counter: it never goes down. (≈ 3 min)
  2. To-do 1: write Faction.pay() so each faction spends only its own gold. (≈ 5 min)
  3. To-do 2: replace Manhattan distance with the octile distance. (≈ 5 min)
  4. To-do 3: forbid diagonal steps that squeeze between two blocked tiles. (≈ 5 min)
  5. To-do 4: in route(), wait RETRY_DELAY seconds after a failed search. (≈ 7 min)
  6. To-do 5: give buildings a defense property and make damage() subtract defense with a minimum of 1. (≈ 8 min)
  7. To-do 6: make Unit.attack include the faction's research bonus. Research Weapons, then train one more soldier: old and new soldiers must show the same attack. (≈ 7 min)
  8. To-do 7: make training count dt seconds, then play a full game against the AI. (≈ 15 min)

You are done when:

  • training and building subtract gold, the buttons refuse when you can't afford them, and the AI's purchases never change your gold;
  • units walk around walls on sensible shortest paths and never slip diagonally between two blocked tiles;
  • ordering a worker to an unreachable spot does not slow the game down;
  • soldiers damage bases more slowly than open units, and workers still chip them for 1;
  • after Weapons finishes, every soldier you own (old and new) has attack 15;
  • a worker takes the same 3 seconds to train at any frame rate.
💡 Hint

For to-do 1, think "check first, then spend": return False before touching self.gold. For to-do 3, a diagonal step (dx, dy) passes two side tiles: (cur[0] + dx, cur[1]) and (cur[0], cur[1] + dy). For to-do 6, the bonus comes from the unit's faction, not the unit, so it changes for everyone at once.

✅ Example Solution

If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top and and frame_budget() in the loop. They let the instructor's checker run the program automatically; when you run it yourself they do nothing.

"""Tiny RTS: Advanced Lesson 24 practice exercise (solution).

Two factions, each with its own gold, supply and research, on a generated
tile map. Workers gather gold, bases train workers, barracks train soldiers,
farms raise supply, and an AI opponent plays by the same rules with its own
purse. Units path with octile A* (no corner cutting) and wait before retrying
an unreachable goal. Every timer counts seconds.

Mouse: left-drag to select your units, right-click to move, gather (gold) or
attack (red things). Use the buttons on the right to train, build and
research. Esc cancels placing a building. Close the window to quit.
"""
import heapq
import itertools
import math
import random
from collections import defaultdict, deque
from dataclasses import dataclass
from enum import Enum, auto

import pygame


TILE = 30
COLS, ROWS = 26, 20
MAP_W, MAP_H = COLS * TILE, ROWS * TILE
PANEL_W = 180
WIDTH, HEIGHT = MAP_W + PANEL_W, MAP_H
SQRT2 = math.sqrt(2)
GATHER_TIME = 2.0        # seconds of mining per trip
GATHER_AMOUNT = 10       # gold per trip
RETRY_DELAY = 1.0        # seconds before retrying a path that failed
REPATH_DELAY = 0.5       # seconds between re-plans while chasing a moving target
AI_THINK = 1.5           # seconds between AI decisions
BASE_SITES = [(3, 9), (21, 8)]                       # top-left tiles of the two 2x2 bases
GOLD_SITES = [(3, 6), (4, 6), (21, 5), (22, 5), (12, 10), (13, 10)]

TERRAIN_COLORS = {"grass": (70, 120, 60), "water": (50, 90, 160), "rock": (110, 105, 100),
                  "gold": (220, 185, 60)}
TEXT = (235, 235, 235)
DIM = (130, 130, 130)


@dataclass(frozen=True)
class UnitSpec:
    cost: int
    build_time: float    # seconds
    hp: int
    attack: int
    defense: int
    range: float         # px
    speed: float         # px/s
    cooldown: float      # seconds between attacks


@dataclass(frozen=True)
class BuildingSpec:
    cost: int
    build_time: float    # seconds
    size: int            # tiles per side
    hp: int
    defense: int
    supply: int = 0
    trains: tuple = ()


@dataclass(frozen=True)
class Research:
    cost: int
    time: float
    attack_bonus: int
    applies_to: tuple


UNIT_SPECS = {
    "worker": UnitSpec(cost=50, build_time=3.0, hp=40, attack=4, defense=0, range=22, speed=80, cooldown=1.0),
    "soldier": UnitSpec(cost=80, build_time=4.0, hp=90, attack=12, defense=2, range=26, speed=70, cooldown=0.8),
}
BUILDING_SPECS = {
    "base": BuildingSpec(cost=0, build_time=0.0, size=2, hp=600, defense=5, supply=5, trains=("worker",)),
    "barracks": BuildingSpec(cost=150, build_time=8.0, size=2, hp=400, defense=3, trains=("soldier",)),
    "farm": BuildingSpec(cost=60, build_time=5.0, size=1, hp=150, defense=1, supply=5),
}
RESEARCH = {"weapons": Research(cost=100, time=6.0, attack_bonus=3, applies_to=("soldier",))}


class Task(Enum):
    IDLE = auto()
    MOVE = auto()
    GATHER = auto()
    RETURN = auto()
    ATTACK = auto()


def tile_center(tile):
    return pygame.Vector2(tile[0] * TILE + TILE / 2, tile[1] * TILE + TILE / 2)


def octile(a, b):
    """Exact cost on an open 8-way grid (straight 1, diagonal sqrt 2): an admissible heuristic."""
    dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
    return max(dx, dy) + (SQRT2 - 1) * min(dx, dy)


NEIGHBORS = [(1, 0, 1.0), (-1, 0, 1.0), (0, 1, 1.0), (0, -1, 1.0),
             (1, 1, SQRT2), (1, -1, SQRT2), (-1, 1, SQRT2), (-1, -1, SQRT2)]


def astar(passable, start, goals):
    """Octile A* from start to the nearest of several goal tiles.

    Diagonal steps may not cut a blocked corner. Returns the list of tiles to
    walk (start excluded), [] if start is already a goal, or None if unreachable.
    """
    goals = {g for g in goals if passable(g)}
    if not goals:
        return None
    if start in goals:
        return []

    def h(t):
        return min(octile(t, g) for g in goals)

    tie = itertools.count()                       # tie-breaker: tiles are never compared
    open_heap = [(h(start), next(tie), start)]
    g_cost = {start: 0.0}
    came_from = {}
    closed = set()
    while open_heap:
        _, _, cur = heapq.heappop(open_heap)
        if cur in closed:
            continue
        if cur in goals:
            path = [cur]
            while path[-1] in came_from and came_from[path[-1]] != start:
                path.append(came_from[path[-1]])
            return path[::-1]
        closed.add(cur)
        for dx, dy, step in NEIGHBORS:
            nxt = (cur[0] + dx, cur[1] + dy)
            if not passable(nxt):
                continue
            if dx and dy and not (passable((cur[0] + dx, cur[1])) and passable((cur[0], cur[1] + dy))):
                continue                          # no squeezing between two blocked tiles
            new_g = g_cost[cur] + step
            if new_g < g_cost.get(nxt, math.inf):
                g_cost[nxt] = new_g
                came_from[nxt] = cur
                heapq.heappush(open_heap, (new_g + h(nxt), next(tie), nxt))
    return None


class EventBus:
    """Publish/subscribe, as in the Event Systems lesson (minimal version)."""
    def __init__(self):
        self.handlers = defaultdict(list)

    def subscribe(self, name, handler):
        self.handlers[name].append(handler)

    def publish(self, name, **data):
        for handler in list(self.handlers[name]):
            handler(**data)


class Faction:
    def __init__(self, name, color, gold=200, is_ai=False):
        self.name = name
        self.color = color
        self.gold = gold
        self.is_ai = is_ai
        self.research = set()             # finished research names
        self.researching = None           # (name, seconds left) or None

    def pay(self, cost):
        """Spend this faction's own gold. Returns False (and spends nothing) if it can't afford it."""
        if self.gold < cost:
            return False
        self.gold -= cost
        return True

    def attack_bonus(self, kind):
        return sum(RESEARCH[r].attack_bonus for r in self.research if kind in RESEARCH[r].applies_to)


class Unit:
    def __init__(self, kind, faction, pos):
        self.kind = kind
        self.spec = UNIT_SPECS[kind]
        self.faction = faction
        self.pos = pygame.Vector2(pos)
        self.hp = self.spec.hp
        self.task = Task.IDLE
        self.target = None                # a gold tile, a Unit or a Building
        self.path = []
        self.cooldown = 0.0
        self.work = 0.0
        self.carrying = 0
        self.retry = 0.0                  # >0: a path just failed; wait before searching again
        self.repath = 0.0

    @property
    def attack(self):
        """Derived at use time, so research reaches units trained before AND after it."""
        return self.spec.attack + self.faction.attack_bonus(self.kind)

    @property
    def defense(self):
        return self.spec.defense

    @property
    def tile(self):
        return int(self.pos.x // TILE), int(self.pos.y // TILE)


class Building:
    def __init__(self, kind, faction, tile, built=False):
        self.kind = kind
        self.spec = BUILDING_SPECS[kind]
        self.faction = faction
        self.tile = tile
        self.hp = self.spec.hp
        self.progress = self.spec.build_time if built else 0.0   # seconds of construction done
        self.queue = []                   # unit kinds waiting to be trained
        self.train_time = 0.0

    @property
    def complete(self):
        return self.progress >= self.spec.build_time

    @property
    def defense(self):
        return self.spec.defense

    def tiles(self):
        x, y = self.tile
        return [(x + i, y + j) for j in range(self.spec.size) for i in range(self.spec.size)]

    def rect(self):
        return pygame.FRect(self.tile[0] * TILE, self.tile[1] * TILE,
                            self.spec.size * TILE, self.spec.size * TILE)


def damage(attacker, target):
    """Defense soaks damage, but every hit does at least 1."""
    return max(1, attacker.attack - target.defense)


def distance_to(unit, target):
    if isinstance(target, Building):
        r = target.rect()
        closest = pygame.Vector2(max(r.left, min(unit.pos.x, r.right)), max(r.top, min(unit.pos.y, r.bottom)))
        return unit.pos.distance_to(closest)
    return unit.pos.distance_to(target.pos)


def generate_map(seed):
    """Random lakes and rocks, then clear both base areas so no base ever starts on water."""
    rng = random.Random(seed)
    terrain = [["grass"] * COLS for _ in range(ROWS)]
    for _ in range(7):
        kind = rng.choice(["water", "water", "rock"])
        cx, cy, r = rng.randrange(COLS), rng.randrange(ROWS), rng.randint(1, 3)
        for y in range(cy - r, cy + r + 1):
            for x in range(cx - r, cx + r + 1):
                if 0 <= x < COLS and 0 <= y < ROWS and (x - cx) ** 2 + (y - cy) ** 2 <= r * r + 1:
                    terrain[y][x] = kind
    for bx, by in BASE_SITES:
        for y in range(by - 3, by + 5):
            for x in range(bx - 3, bx + 5):
                if 0 <= x < COLS and 0 <= y < ROWS:
                    terrain[y][x] = "grass"
    (ax, ay), (bx, by) = BASE_SITES
    for i in range(61):                   # carve a corridor so the bases can always reach each other
        x = round(ax + 2 + (bx - 1 - ax - 2) * i / 60)
        y = round(ay + (by - ay) * i / 60)
        terrain[y][x] = "grass"
        terrain[y + 1][x] = "grass"
    for gx, gy in GOLD_SITES:
        terrain[gy][gx] = "gold"
    return terrain


class Game:
    def __init__(self, seed=7):
        self.terrain = generate_map(seed)
        self.occupied = {}                # tile -> Building
        self.units = []
        self.buildings = []
        self.bus = EventBus()
        self.messages = deque(maxlen=4)
        self.winner = None
        self.player = Faction("Blue", (80, 150, 255))
        self.enemy = Faction("Red", (230, 80, 80), is_ai=True)
        self.factions = [self.player, self.enemy]
        self.ai = AIPlayer(self.enemy)
        self.bus.subscribe("notice", self.on_notice)
        for faction, site in zip(self.factions, BASE_SITES):
            base = self.add_building("base", faction, site, built=True)
            for _ in range(2):
                self.spawn("worker", base)

    # --- map queries -------------------------------------------------------
    def in_bounds(self, t):
        return 0 <= t[0] < COLS and 0 <= t[1] < ROWS

    def passable(self, t):
        return self.in_bounds(t) and self.terrain[t[1]][t[0]] == "grass" and t not in self.occupied

    def neighbors_of(self, tiles):
        tiles = set(tiles)
        around = {(x + dx, y + dy) for x, y in tiles for dx in (-1, 0, 1) for dy in (-1, 0, 1)}
        return [t for t in around - tiles if self.passable(t)]

    def can_place(self, kind, tile):
        size = BUILDING_SPECS[kind].size
        return all(self.passable((tile[0] + i, tile[1] + j)) for i in range(size) for j in range(size))

    # --- economy -------------------------------------------------------------
    def supply_cap(self, faction):
        return sum(b.spec.supply for b in self.buildings if b.faction is faction and b.complete)

    def supply_used(self, faction):
        queued = sum(len(b.queue) for b in self.buildings if b.faction is faction)
        return queued + sum(1 for u in self.units if u.faction is faction)

    def base_of(self, faction):
        return next((b for b in self.buildings if b.faction is faction and b.kind == "base"), None)

    def notice(self, faction, text):
        self.bus.publish("notice", faction=faction, text=text)

    def on_notice(self, faction, text):
        if faction is self.player:
            self.messages.append(text)

    def add_building(self, kind, faction, tile, built=False):
        b = Building(kind, faction, tile, built)
        self.buildings.append(b)
        for t in b.tiles():
            self.occupied[t] = b
        return b

    def try_build(self, faction, kind, tile):
        if tile is None or not self.can_place(kind, tile):
            self.notice(faction, "Can't build there")
            return None
        if not faction.pay(BUILDING_SPECS[kind].cost):
            self.notice(faction, "Not enough gold")
            return None
        return self.add_building(kind, faction, tile)

    def train(self, building, kind):
        if building is None or not building.complete or kind not in building.spec.trains:
            return False
        f = building.faction
        if self.supply_used(f) >= self.supply_cap(f):
            self.notice(f, "Need more farms")
            return False
        if not f.pay(UNIT_SPECS[kind].cost):
            self.notice(f, "Not enough gold")
            return False
        building.queue.append(kind)
        return True

    def start_research(self, faction, name):
        barracks = [b for b in self.buildings if b.faction is faction and b.kind == "barracks" and b.complete]
        if not barracks or name in faction.research or faction.researching:
            return False
        if not faction.pay(RESEARCH[name].cost):
            self.notice(faction, "Not enough gold")
            return False
        faction.researching = (name, RESEARCH[name].time)
        return True

    def spawn(self, kind, building):
        spots = self.neighbors_of(building.tiles())
        if not spots:
            return None
        spot = min(spots, key=lambda t: (t[1], t[0]))
        unit = Unit(kind, building.faction, tile_center(spot))
        self.units.append(unit)
        self.notice(building.faction, f"{kind.title()} ready")
        return unit

    # --- orders --------------------------------------------------------------
    def order_move(self, unit, tile):
        unit.task, unit.target, unit.path, unit.retry = Task.MOVE, tile, [], 0.0

    def order_gather(self, unit, tile=None):
        if unit.kind != "worker":
            return
        if tile is None:
            golds = [(x, y) for y in range(ROWS) for x in range(COLS) if self.terrain[y][x] == "gold"]
            tile = min(golds, key=lambda g: unit.pos.distance_to(tile_center(g)))
        unit.task, unit.target, unit.path, unit.retry = Task.GATHER, tile, [], 0.0

    def order_attack(self, unit, target):
        unit.task, unit.target, unit.path, unit.retry = Task.ATTACK, target, [], 0.0

    def route(self, unit, goals):
        """Plan a path, but after a failure wait RETRY_DELAY seconds instead of searching every frame."""
        if unit.retry > 0:
            return
        path = astar(self.passable, unit.tile, goals)
        if path is None:
            unit.retry = RETRY_DELAY
        else:
            unit.path = path
            unit.repath = REPATH_DELAY

    def follow(self, unit, dt):
        """Walk along unit.path. Returns True when there is nowhere left to walk."""
        step = unit.spec.speed * dt
        while unit.path and step > 0:
            goal = tile_center(unit.path[0])
            gap = unit.pos.distance_to(goal)
            if gap <= step:
                unit.pos.update(goal)
                unit.path.pop(0)
                step -= gap
            else:
                unit.pos.move_towards_ip(goal, step)
                step = 0
        return not unit.path

    # --- simulation ------------------------------------------------------------
    def update_unit(self, u, dt):
        u.cooldown = max(0.0, u.cooldown - dt)
        u.retry = max(0.0, u.retry - dt)
        u.repath = max(0.0, u.repath - dt)
        if u.task is Task.MOVE:
            if u.path:
                self.follow(u, dt)
            elif u.tile == u.target:
                u.task = Task.IDLE
            else:
                self.route(u, [u.target] if self.passable(u.target) else self.neighbors_of([u.target]))
                if u.retry > 0:
                    u.task = Task.IDLE            # unreachable: give up the move order
        elif u.task is Task.GATHER:
            if u.carrying:
                u.task, u.path = Task.RETURN, []
            elif u.path:
                self.follow(u, dt)
            elif u.pos.distance_to(tile_center(u.target)) <= TILE * 1.5:
                u.work += dt
                if u.work >= GATHER_TIME:
                    u.work, u.carrying = 0.0, GATHER_AMOUNT
            else:
                self.route(u, self.neighbors_of([u.target]))
        elif u.task is Task.RETURN:
            base = self.base_of(u.faction)
            if base is None:
                u.task = Task.IDLE
            elif distance_to(u, base) <= TILE * 0.9:
                u.faction.gold += u.carrying      # the worker's OWN faction gets the gold
                u.carrying = 0
                u.task, u.path = Task.GATHER, []
            elif u.path:
                self.follow(u, dt)
            else:
                self.route(u, self.neighbors_of(base.tiles()))
        elif u.task is Task.ATTACK:
            t = u.target
            if t.hp <= 0:
                u.task, u.target, u.path = Task.IDLE, None, []
            elif distance_to(u, t) <= u.spec.range + (0 if isinstance(t, Building) else 8):
                u.path = []
                if u.cooldown == 0:
                    t.hp -= damage(u, t)
                    u.cooldown = u.spec.cooldown
            elif u.path and u.repath > 0:
                self.follow(u, dt)
            else:
                tiles = t.tiles() if isinstance(t, Building) else [t.tile]
                goals = self.neighbors_of(tiles) + ([t.tile] if isinstance(t, Unit) else [])
                self.route(u, goals)

    def update(self, dt):
        if self.winner:
            return
        for b in list(self.buildings):
            if not b.complete:
                b.progress = min(b.spec.build_time, b.progress + dt)
                if b.complete:
                    self.notice(b.faction, f"{b.kind.title()} complete")
            elif b.queue:
                b.train_time += dt
                if b.train_time >= UNIT_SPECS[b.queue[0]].build_time:
                    b.train_time = 0.0
                    self.spawn(b.queue.pop(0), b)
        for f in self.factions:
            if f.researching:
                name, left = f.researching
                left -= dt
                if left <= 0:
                    f.research.add(name)
                    f.researching = None
                    self.notice(f, f"Research done: {name}")
                else:
                    f.researching = (name, left)
        for u in self.units:
            self.update_unit(u, dt)
        for u in [u for u in self.units if u.hp <= 0]:
            self.units.remove(u)
        for b in [b for b in self.buildings if b.hp <= 0]:
            self.buildings.remove(b)
            for t in b.tiles():
                self.occupied.pop(t, None)
            if b.kind == "base":
                self.winner = self.enemy if b.faction is self.player else self.player
        self.ai.update(self, dt)

    def entity_at(self, pos):
        for u in self.units:
            if u.pos.distance_to(pos) <= 10:
                return u
        t = (int(pos[0] // TILE), int(pos[1] // TILE))
        return self.occupied.get(t)

    def find_site(self, faction, kind):
        base = self.base_of(faction)
        if base is None:
            return None
        bx, by = base.tile
        for r in range(2, 8):
            for dy in range(-r, r + 1):
                for dx in range(-r, r + 1):
                    t = (bx + dx, by + dy)
                    if max(abs(dx), abs(dy)) == r and self.can_place(kind, t):
                        return t
        return None


class AIPlayer:
    """Decides every AI_THINK seconds, and only ever spends its own faction's gold."""
    def __init__(self, faction):
        self.faction = faction
        self.timer = AI_THINK

    def update(self, game, dt):
        self.timer -= dt
        if self.timer > 0:
            return
        self.timer = AI_THINK
        f = self.faction
        base = game.base_of(f)
        if base is None:
            return
        mine = [u for u in game.units if u.faction is f]
        workers = [u for u in mine if u.kind == "worker"]
        soldiers = [u for u in mine if u.kind == "soldier"]
        barracks = next((b for b in game.buildings if b.faction is f and b.kind == "barracks"), None)
        for w in workers:
            if w.task is Task.IDLE:
                game.order_gather(w)
        farm_building = any(b.faction is f and b.kind == "farm" and not b.complete for b in game.buildings)
        if game.supply_used(f) + 1 >= game.supply_cap(f) and not farm_building:
            if f.gold >= BUILDING_SPECS["farm"].cost:
                game.try_build(f, "farm", game.find_site(f, "farm"))
        elif len(workers) < 5 and not base.queue:
            game.train(base, "worker")
        elif barracks is None:
            if f.gold >= BUILDING_SPECS["barracks"].cost:
                game.try_build(f, "barracks", game.find_site(f, "barracks"))
        elif barracks.complete and not barracks.queue:
            game.train(barracks, "soldier")
        if len(soldiers) >= 5:
            enemies = [e for e in game.units + game.buildings if e.faction is not f]
            for s in soldiers:
                if s.task is not Task.ATTACK and enemies:
                    target = min(enemies, key=lambda e: s.pos.distance_to(
                        e.pos if isinstance(e, Unit) else e.rect().center))
                    game.order_attack(s, target)


BUTTONS = [("train_worker", "Train Worker 50g"), ("train_soldier", "Train Soldier 80g"),
           ("build_farm", "Build Farm 60g"), ("build_barracks", "Barracks 150g"),
           ("research", "Weapons +3 100g")]


def button_rects():
    return {key: pygame.Rect(MAP_W + 10, 150 + i * 44, PANEL_W - 20, 36) for i, (key, _) in enumerate(BUTTONS)}


def press_button(game, key, ui):
    p = game.player
    if key == "train_worker":
        game.train(game.base_of(p), "worker")
    elif key == "train_soldier":
        barracks = next((b for b in game.buildings if b.faction is p and b.kind == "barracks" and b.complete), None)
        if barracks is None:
            game.notice(p, "Build a barracks first")
        else:
            game.train(barracks, "soldier")
    elif key in ("build_farm", "build_barracks"):
        ui["placing"] = key.split("_")[1]
    elif key == "research":
        game.start_research(p, "weapons")


def draw(screen, game, fonts, ui, terrain_surface):
    small, big = fonts
    screen.blit(terrain_surface, (0, 0))
    for b in game.buildings:
        r = b.rect()
        color = b.faction.color if b.complete else tuple(c // 2 for c in b.faction.color)
        pygame.draw.rect(screen, color, r.inflate(-4, -4), border_radius=4)
        label = small.render(b.kind[0].upper(), True, TEXT)
        screen.blit(label, label.get_rect(center=r.center))
        fraction = b.hp / b.spec.hp if b.complete else b.progress / b.spec.build_time
        pygame.draw.rect(screen, (30, 30, 30), (r.x + 2, r.y - 5, r.w - 4, 4))
        pygame.draw.rect(screen, (90, 230, 90) if b.complete else (240, 200, 80),
                         (r.x + 2, r.y - 5, (r.w - 4) * max(0.0, fraction), 4))
    for u in game.units:
        radius = 7 if u.kind == "worker" else 10
        pygame.draw.circle(screen, u.faction.color, u.pos, radius)
        if u.carrying:
            pygame.draw.circle(screen, TERRAIN_COLORS["gold"], u.pos, 3)
        if u in ui["selected"]:
            pygame.draw.circle(screen, (255, 255, 255), u.pos, radius + 3, 1)
        pygame.draw.rect(screen, (30, 30, 30), (u.pos.x - 10, u.pos.y - radius - 6, 20, 3))
        pygame.draw.rect(screen, (90, 230, 90), (u.pos.x - 10, u.pos.y - radius - 6, 20 * u.hp / u.spec.hp, 3))
    if ui["drag"] is not None:
        x0, y0 = ui["drag"]
        x1, y1 = ui["mouse"]
        pygame.draw.rect(screen, (255, 255, 255), (min(x0, x1), min(y0, y1), abs(x1 - x0), abs(y1 - y0)), 1)
    if ui["placing"]:
        mx, my = ui["mouse"]
        tile = (int(mx // TILE), int(my // TILE))
        size = BUILDING_SPECS[ui["placing"]].size
        ok = game.can_place(ui["placing"], tile)
        pygame.draw.rect(screen, (90, 230, 90) if ok else (230, 80, 80),
                         (tile[0] * TILE, tile[1] * TILE, size * TILE, size * TILE), 2)

    pygame.draw.rect(screen, (28, 30, 38), (MAP_W, 0, PANEL_W, HEIGHT))
    p = game.player
    lines = [f"Gold: {p.gold}", f"Supply: {game.supply_used(p)}/{game.supply_cap(p)}",
             "Weapons: done" if "weapons" in p.research else
             (f"Weapons: {p.researching[1]:.1f}s" if p.researching else "Weapons: none"),
             f"Enemy gold: {game.enemy.gold}"]
    for i, text in enumerate(lines):
        screen.blit(big.render(text, True, TEXT), (MAP_W + 10, 10 + i * 26))
    for key, label in BUTTONS:
        rect = button_rects()[key]
        pygame.draw.rect(screen, (60, 64, 80), rect, border_radius=6)
        screen.blit(small.render(label, True, TEXT), (rect.x + 8, rect.y + 10))
    for i, text in enumerate(game.messages):
        screen.blit(small.render(text, True, DIM), (MAP_W + 10, 390 + i * 20))
    if game.winner:
        msg = big.render(f"{game.winner.name} wins!", True, (255, 255, 255))
        screen.blit(msg, msg.get_rect(center=(MAP_W / 2, MAP_H / 2)))


def render_terrain(game):
    surf = pygame.Surface((MAP_W, MAP_H)).convert()
    for y in range(ROWS):
        for x in range(COLS):
            pygame.draw.rect(surf, TERRAIN_COLORS[game.terrain[y][x]], (x * TILE, y * TILE, TILE, TILE))
            pygame.draw.rect(surf, (0, 0, 0), (x * TILE, y * TILE, TILE, TILE), 1)
    return surf


def handle_click(game, ui, event):
    pos = pygame.Vector2(event.pos)
    if event.button == 1 and pos.x >= MAP_W:
        for key, rect in button_rects().items():
            if rect.collidepoint(event.pos):
                press_button(game, key, ui)
    elif event.button == 1 and ui["placing"]:
        tile = (int(pos.x // TILE), int(pos.y // TILE))
        if game.try_build(game.player, ui["placing"], tile):
            ui["placing"] = None
    elif event.button == 1:
        ui["drag"] = tuple(event.pos)
    elif event.button == 3 and ui["placing"]:
        ui["placing"] = None
    elif event.button == 3 and pos.x < MAP_W:
        target = game.entity_at(pos)
        tile = (int(pos.x // TILE), int(pos.y // TILE))
        for u in ui["selected"]:
            if target is not None and target.faction is not game.player:
                game.order_attack(u, target)
            elif game.terrain[tile[1]][tile[0]] == "gold" and u.kind == "worker":
                game.order_gather(u, tile)
            else:
                game.order_move(u, tile)


def finish_drag(game, ui, event):
    x0, y0 = ui["drag"]
    x1, y1 = event.pos
    box = pygame.Rect(min(x0, x1), min(y0, y1), abs(x1 - x0) + 1, abs(y1 - y0) + 1).inflate(12, 12)
    ui["selected"] = [u for u in game.units if u.faction is game.player and box.collidepoint(u.pos)]
    ui["drag"] = None


def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Tiny RTS")
    clock = pygame.time.Clock()
    fonts = (pygame.font.Font(None, 20), pygame.font.Font(None, 26))   # created once
    game = Game(seed=7)
    terrain_surface = render_terrain(game)
    ui = {"selected": [], "drag": None, "placing": None, "mouse": (0, 0)}

    running = True
    while running:
        dt = min(clock.tick(60) / 1000, 0.05)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.MOUSEMOTION:
                ui["mouse"] = event.pos
            elif event.type == pygame.MOUSEBUTTONDOWN:
                ui["mouse"] = event.pos
                handle_click(game, ui, event)
            elif event.type == pygame.MOUSEBUTTONUP and event.button == 1 and ui["drag"] is not None:
                finish_drag(game, ui, event)
            elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
                ui["placing"] = None
        ui["selected"] = [u for u in ui["selected"] if u.hp > 0]
        game.update(dt)
        draw(screen, game, fonts, ui, terrain_surface)
        pygame.display.flip()

    pygame.quit()
    workers = sum(1 for u in game.units if u.faction is game.player and u.kind == "worker")
    print(f"Blue gold: {game.player.gold}  workers: {workers}")
    print("RTS closed cleanly.")


if __name__ == "__main__":
    main()

📓 Learning Journal

Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:

  • Key concepts you learned today
  • Techniques that clicked (and the ones that haven't, yet)
  • Questions or confusion to bring to the next session
  • Ideas to try in your own game
  • Progress and feelings: how did this lesson go for you?

✍️ This lesson's prompts:

  1. List every place in Tiny RTS that changes a faction's gold. Why is a short list a sign of good design?
  2. When would you deliberately choose snapshot stats over derived stats? Describe a game mechanic that needs them.
  3. Which of the seven to-dos took you longest, and what finally made it click?

📝 Summary

You built a complete, if tiny, RTS. Each faction owns its gold, research and supply, and every purchase goes through pay(), so the AI plays with its own purse. Specs live in frozen dataclass tables, buildings have defense, and damage has a floor of 1. Units find their way with octile A* that never cuts corners, and a failed search waits a second before trying again. Units are state machines whose timers all count seconds, research is derived at use time so every unit gets it, and a small interface plus a fair AI turn it into a game.

🎓 Key Takeaways

  • Give each faction its own economy object; route every purchase through its pay().
  • For 8-way movement with √2 diagonals, octile distance is the exact open-grid cost and an admissible A* heuristic; Manhattan overestimates.
  • Forbid corner cutting, allow several goal tiles, and break heap ties with a counter.
  • After a failed path search, wait before retrying instead of searching every frame.
  • Derive stats from spec plus research at use time, so upgrades reach every unit.
  • Construction, production, gathering, cooldowns and AI decisions are all timers in seconds.

🔭 Looking Ahead

Your games are now big enough that "is it too hard?" becomes a real question. Next, Difficulty & DDA shows how to shape challenge with curves, presets and assists, and how to adjust it automatically without ever making a game unwinnable.

❓ Common Questions

Why don't units block each other?

To keep the lab small. Real RTS games add local avoidance (the separation force from Steering & Flocking works well) and sometimes reserve tiles. Pathfinding treats only terrain and buildings as walls.

Isn't computing attack every time slower than storing it?

It is a little more work per call, and for a game this size it doesn't matter. If profiling ever showed it mattered, you could cache the value per faction and clear the cache when research finishes, which keeps one source of truth. Measure first, as in Profiling & Performance.

Why is the AI's think interval 1.5 seconds?

Strategic decisions don't need 60 updates a second, and a slower rhythm makes the AI's behavior readable. Units still move and fight every frame; only the "what next?" decision is spaced out.

How would I add fog of war?

Keep a per-faction grid of "seen" tiles. Each frame, mark tiles within each unit's sight radius as visible, draw unseen tiles dark, and have the AI (to play fair) only target things its own units have seen.

Why does the map use a seeded random.Random?

So a seed always produces the same map. The tests rely on it, and players can share a seed for a rematch on the same terrain. It is the per-system random generator habit from Randomness for Games.

🎯 Quick Quiz

Question 1: Why does every faction get its own Faction object with a pay() method?

Question 2: Units move in 8 directions and diagonal steps cost √2. What goes wrong with a Manhattan-distance heuristic?

Question 3: How does Tiny RTS make the Weapons research reach soldiers trained after it finished?

Question 4: A worker is ordered to gold that is completely walled off by water. What does route() do?

Question 5: A soldier (attack 12) and a worker (attack 4) each hit a base (defense 5). How much damage does each hit do?

🌟 Going Further

  • Archers: add a ranged unit with one new UnitSpec line and a "Train Archer" button. Nothing else should need to change.
  • Towers: give BuildingSpec optional attack and range, and let complete towers shoot the nearest enemy on a cooldown in seconds.
  • Fog of war: track seen tiles per faction and draw unseen tiles dark (see Common Questions).
  • Smarter AI: rebuild AIPlayer as a behavior tree or a utility scorer, and let it defend its base when attacked.
  • Read the docs: heapq (the priority-queue notes explain the counter tie-breaker) and dataclasses (frozen=True).
  • Coming up in Game Dev III: Advanced: Playtesting & Telemetry shows how to log where players' units get stuck, so you can fix maps with data instead of guesses.