The last part of this tutorial set us up for combat, so now it’s time to actually implement it.
In order to make “killable” Entities, we’ll extend our entity system with components. We’ll create a Fighter
component to hold combat information like HP, max HP, attack, and defense. If an Entity can fight, it will have this component attached to it, and if not, it won’t. This way of doing things is called composition, and it’s an alternative to your typical inheritance-based programming model. (This tutorial uses both composition and inheritance).
Create a new Python package (a folder with an empty __init__.py file), called game/components
. In that new directory, add two new files, one called base_component.py
, and another called fighter.py
. The Fighter
class in fighter.py
will inherit from the class we put in base_component.py
, so let’s start with that one:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import game.engine
import game.entity
import game.game_map
class BaseComponent:
parent: game.entity.Entity # Owning entity instance.
@property
def gamemap(self) -> game.game_map.GameMap:
gamemap = self.parent.gamemap
assert gamemap is not None
return gamemap
@property
def engine(self) -> game.engine.Engine:
return self.gamemap.engine
Components will use parent
to reference the entity that owns them. This gives us access to the gamemap and engine through the parent entity.
With that, let’s now open up fighter.py
and put the following into it:
from __future__ import annotations
from typing import TYPE_CHECKING
from game.components.base_component import BaseComponent
from game.render_order import RenderOrder
import game.input_handlers
if TYPE_CHECKING:
import game.entity
class Fighter(BaseComponent):
parent: game.entity.Actor
def __init__(self, hp: int, defense: int, power: int):
self.max_hp = hp
self._hp = hp
self.defense = defense
self.power = power
@property
def hp(self) -> int:
return self._hp
@hp.setter
def hp(self, value: int) -> None:
self._hp = max(0, min(value, self.max_hp))
if self._hp == 0 and self.parent.ai:
self.die()
def die(self) -> None:
if self.engine.player is self.parent:
death_message = "You died!"
else:
death_message = f"{self.parent.name} is dead!"
self.parent.char = "%"
self.parent.color = (191, 0, 0)
self.parent.blocks_movement = False
self.parent.ai = None
self.parent.name = f"remains of {self.parent.name}"
self.parent.render_order = RenderOrder.CORPSE
print(death_message)
We import and inherit from BaseComponent
, which gives us access to the parent entity and the engine.
The __init__
function takes a few arguments. hp
represents the entity’s hit points. defense
is how much taken damage will be reduced. power
is the entity’s raw attack power.
What’s with the hp
property? We define both a getter and setter, which will allow the class to access hp
like a normal variable. The getter doesn’t do anything special: it just returns the HP. The setter is where things get more interesting.
By defining HP this way, we can modify the value as it’s set within the method. This line:
self._hp = max(0, min(value, self.max_hp))
Means that _hp
(which we access through hp
) will never be set to less than 0, but also won’t ever go higher than the max_hp
attribute. Additionally, when HP reaches 0, we call the die()
method.
The die()
method handles what happens when an entity dies - it becomes a corpse, stops blocking movement, and loses its AI.
We need to define the render order for corpses. Create a new file game/render_order.py
:
from enum import auto, Enum
class RenderOrder(Enum):
CORPSE = auto()
ITEM = auto()
ACTOR = auto()
This enum will help us render entities in the correct order - corpses on the bottom, then items, then actors on top.
So that’s our Fighter
component. To give life to our entities and let them move around and fight, we’ll add an AI component.
Create a file in the game/components
directory called ai.py
, and put the following contents into it:
from __future__ import annotations
from typing import TYPE_CHECKING, List, Tuple
import numpy as np
import tcod
from game.actions import Action, MeleeAction, MovementAction
if TYPE_CHECKING:
import game.entity
class BaseAI(Action):
entity: game.entity.Actor
def perform(self) -> None:
raise NotImplementedError()
def get_path_to(self, dest_x: int, dest_y: int) -> List[Tuple[int, int]]:
"""Compute and return a path to the target position.
If there is no valid path then returns an empty list.
"""
# Copy the walkable array.
gamemap = self.entity.gamemap
assert gamemap is not None
cost = np.array(gamemap.tiles["walkable"], dtype=np.int8)
for entity in gamemap.entities:
# Check that an entity blocks movement and the cost isn't zero (blocking.)
if entity.blocks_movement and cost[entity.x, entity.y]:
# Add to the cost of a blocked position.
# A lower number means more enemies will crowd behind each other in
# hallways. A higher number means enemies will take longer paths in
# order to surround the player.
cost[entity.x, entity.y] += 10
# Create a graph from the cost array and pass that graph to a new pathfinder.
graph = tcod.path.SimpleGraph(cost=cost, cardinal=2, diagonal=3)
pathfinder = tcod.path.Pathfinder(graph)
pathfinder.add_root((self.entity.x, self.entity.y)) # Start position.
# Compute the path to the destination and remove the starting point.
path: List[List[int]] = pathfinder.path_to((dest_x, dest_y))[1:].tolist()
# Convert from List[List[int]] to List[Tuple[int, int]].
return [(index[0], index[1]) for index in path]
class HostileEnemy(BaseAI):
def __init__(self, entity: game.entity.Actor):
super().__init__(entity)
self.path: List[Tuple[int, int]] = []
def perform(self) -> None:
target = self.engine.player
dx = target.x - self.entity.x
dy = target.y - self.entity.y
distance = max(abs(dx), abs(dy)) # Chebyshev distance.
if self.engine.game_map.visible[self.entity.x, self.entity.y]:
if distance <= 1:
return MeleeAction(self.entity, dx, dy).perform()
self.path = self.get_path_to(target.x, target.y)
if self.path:
dest_x, dest_y = self.path.pop(0)
return MovementAction(
self.entity,
dest_x - self.entity.x,
dest_y - self.entity.y,
).perform()
BaseAI
inherits from Action
, so AI classes can perform actions. The get_path_to
method uses TCOD’s pathfinding to navigate around obstacles. The pathfinder builds a cost array - areas with entities have higher cost, encouraging enemies to path around each other rather than bunching up.
HostileEnemy
is our concrete AI implementation. It checks if it can see the player (is in the visible area), and if so, either attacks if adjacent or moves toward the player using pathfinding.
Now we need to distinguish between entities that can act and those that can’t. Let’s create an Actor
subclass of Entity
for entities with AI and combat capabilities. Update game/entity.py
:
from future import annotations
-from typing import TYPE_CHECKING, Optional, Tuple +from typing import TYPE_CHECKING, Optional, Tuple, Type
+from game.render_order import RenderOrder
if TYPE_CHECKING:
class Entity: “”” A generic object to represent players, enemies, items, etc. “””
gamemap: Optional[game.game_map.GameMap]
def init(
self,
gamemap: Optional[game.game_map.GameMap] = None,
x: int = 0,
y: int = 0,
char: str = “?”,
color: Tuple[int, int, int] = (255, 255, 255),
name: str = “
def place(self, x: int, y: int, gamemap: Optional[game.game_map.GameMap] = None) -> None:
"""Place this entity at a new location. Handles moving across GameMaps."""
self.x = x
self.y = y
if gamemap: - if hasattr(self, "gamemap"): # Possibly uninitialized. + if hasattr(self, "gamemap") and self.gamemap is not None: # Possibly uninitialized.
self.gamemap.entities.remove(self)
self.gamemap = gamemap
gamemap.entities.add(self)
def move(self, dx: int, dy: int) -> None:
# Move the entity by a given amount
self.x += dx
self.y += dy
+class Actor(Entity):
from __future__ import annotations from typing import TYPE_CHECKING, Optional, Tuple from typing import TYPE_CHECKING, Optional, Tuple, Type from game.render_order import RenderOrder if TYPE_CHECKING: import game.components.ai import game.components.fighter import game.game_map class Entity: """ A generic object to represent players, enemies, items, etc. """ gamemap: game.game_map.GameMap gamemap: Optional[game.game_map.GameMap] def __init__( self, gamemap: Optional[game.game_map.GameMap] = None, x: int = 0, y: int = 0, char: str = "?", color: Tuple[int, int, int] = (255, 255, 255), name: str = "<Unnamed>", blocks_movement: bool = False, ): self.x = x self.y = y self.char = char self.color = color self.name = name self.blocks_movement = blocks_movement self.render_order = RenderOrder.CORPSE if gamemap: # If gamemap isn't provided now then it will be set later. self.gamemap = gamemap gamemap.entities.add(self) def place(self, x: int, y: int, gamemap: Optional[game.game_map.GameMap] = None) -> None: """Place this entity at a new location. Handles moving across GameMaps.""" self.x = x self.y = y if gamemap: if hasattr(self, "gamemap"): # Possibly uninitialized. if hasattr(self, "gamemap") and self.gamemap is not None: # Possibly uninitialized. self.gamemap.entities.remove(self) self.gamemap = gamemap gamemap.entities.add(self) def move(self, dx: int, dy: int) -> None: # Move the entity by a given amount self.x += dx self.y += dy class Actor(Entity): def __init__( self, *, x: int = 0, y: int = 0, char: str = "?", color: Tuple[int, int, int] = (255, 255, 255), name: str = "<Unnamed>", ai_cls: Type[game.components.ai.BaseAI], fighter: game.components.fighter.Fighter, ): super().__init__( gamemap=None, x=x, y=y, char=char, color=color, name=name, blocks_movement=True, ) self.ai: Optional[game.components.ai.BaseAI] = ai_cls(self) self.fighter = fighter self.fighter.parent = self self.render_order = RenderOrder.ACTOR @property def is_alive(self) -> bool: """Returns True as long as this actor can perform actions.""" return bool(self.ai)
The Actor
class extends Entity
with AI and Fighter components. Actors always block movement and have a higher render order than regular entities.
Now let’s update our entity factories to create Actors instead of Entities. Modify game/entity_factories.py
:
from game.components.ai import HostileEnemy
from game.components.fighter import Fighter
from game.entity import Actor
player = Actor(
char="@",
color=(255, 255, 255),
name="Player",
ai_cls=HostileEnemy, # Will be overridden
fighter=Fighter(hp=30, defense=2, power=5),
)
orc = Actor(
char="o",
color=(63, 127, 63),
name="Orc",
ai_cls=HostileEnemy,
fighter=Fighter(hp=10, defense=0, power=3),
)
troll = Actor(
char="T",
color=(0, 127, 0),
name="Troll",
ai_cls=HostileEnemy,
fighter=Fighter(hp=16, defense=1, power=4),
)
We’ve given each entity type appropriate combat stats. The player is tougher than individual enemies but will need to be careful when facing multiple foes.
Now update game/procgen.py
to spawn our new Actor types:
def place_entities( room: RectangularRoom, dungeon: game.game_map.GameMap, maximum_monsters: int, ) -> None: number_of_monsters = random.randint(0, maximum_monsters)
for _ in range(number_of_monsters):
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
if not any(entity.x == x and entity.y == y for entity in dungeon.entities):
if random.random() < 0.8: - Entity(gamemap=dungeon, x=x, y=y, char="o", color=(63, 127, 63), name="Orc", blocks_movement=True) + game.entity_factories.orc.spawn(dungeon, x, y)
else: - Entity(gamemap=dungeon, x=x, y=y, char="T", color=(0, 127, 0), name="Troll", blocks_movement=True) + game.entity_factories.troll.spawn(dungeon, x, y)
def place_entities( room: RectangularRoom, dungeon: game.game_map.GameMap, maximum_monsters: int, ) -> None: number_of_monsters = random.randint(0, maximum_monsters) for _ in range(number_of_monsters): x = random.randint(room.x1 + 1, room.x2 - 1) y = random.randint(room.y1 + 1, room.y2 - 1) if not any(entity.x == x and entity.y == y for entity in dungeon.entities): if random.random() < 0.8: Entity(gamemap=dungeon, x=x, y=y, char="o", color=(63, 127, 63), name="Orc", blocks_movement=True) game.entity_factories.orc.spawn(dungeon, x, y) else: Entity(gamemap=dungeon, x=x, y=y, char="T", color=(0, 127, 0), name="Troll", blocks_movement=True) game.entity_factories.troll.spawn(dungeon, x, y)
We’ll also need to add the import and use the spawn method which creates copies of our entity templates:
from typing import TYPE_CHECKING, Iterator, List, Tuple import random
import tcod
-from game.entity import Entity +import game.entity_factories from game.game_map import GameMap from game.tiles import floor
from typing import TYPE_CHECKING, Iterator, List, Tuple import random import tcod from game.entity import Entity import game.entity_factories from game.game_map import GameMap from game.tiles import floor
Add the spawn method to the Entity class in game/entity.py
if you haven’t already:
def spawn(self: T, gamemap: GameMap, x: int, y: int) -> T:
"""Spawn a copy of this instance at the given location."""
clone = copy.deepcopy(self)
clone.x = x
clone.y = y
clone.place(x, y, gamemap)
return clone
Don’t forget the imports at the top:
import copy
from typing import TYPE_CHECKING, Optional, Tuple, Type, TypeVar
T = TypeVar("T", bound="Entity")
We need to update our GameMap to handle actors properly. Modify game/game_map.py
:
+from typing import TYPE_CHECKING, Iterator, Iterable, Optional, Set -from typing import TYPE_CHECKING, Optional, Set
if TYPE_CHECKING: import game.engine import game.entity
class GameMap: …
def render(self, console: tcod.console.Console) -> None:
...
from typing import TYPE_CHECKING, Iterator, Iterable, Optional, Set from typing import TYPE_CHECKING, Optional, Set if TYPE_CHECKING: import game.engine import game.entity class GameMap: ... @property def actors(self) -> Iterator[game.entity.Actor]: """Iterate over this maps living actors.""" yield from ( entity for entity in self.entities if isinstance(entity, game.entity.Actor) and entity.is_alive ) def render(self, console: tcod.console.Console) -> None: ... for entity in self.entities: entities_sorted_for_rendering = sorted( self.entities, key=lambda x: x.render_order.value ) for entity in entities_sorted_for_rendering: # Only print entities that are in the FOV if self.visible[entity.x, entity.y]: console.print(x=entity.x, y=entity.y, string=entity.char, fg=entity.color)
The actors
property gives us only the living actors on the map, which is what we’ll use for enemy turns. The rendering now sorts entities by their render order, so corpses appear under living actors.
Now let’s update the MeleeAction to actually deal damage. In game/actions.py
:
class MeleeAction(ActionWithDirection): def perform(self) -> None:
target = self.target_actor if not target: return # No entity to attack.
class MeleeAction(ActionWithDirection): def perform(self) -> None: target = self.blocking_entity target = self.target_actor if not target: return # No entity to attack. print(f"You kick the {target.name}, much to its annoyance!") # Type checking to ensure both entities are Actors with fighter components assert isinstance(self.entity, game.entity.Actor), "Attacker must be an Actor" assert isinstance(target, game.entity.Actor), "Target must be an Actor" damage = self.entity.fighter.power - target.fighter.defense attack_desc = f"{self.entity.name.capitalize()} attacks {target.name}" if damage > 0: print(f"{attack_desc} for {damage} hit points.") target.fighter.hp -= damage else: print(f"{attack_desc} but does no damage.")
The damage calculation is simple: attacker’s power minus defender’s defense. When we set a fighter’s HP, it will automatically trigger the death logic if it reaches 0.
Update the Engine to handle enemy turns using their AI. In game/engine.py
:
-from game.entity import Entity +from game.entity import Actor
class Engine: game_map: game.game_map.GameMap
def init(self, player: Actor): self.player = player
def handle_enemy_turns(self) -> None:
from game.entity import Entity from game.entity import Actor class Engine: game_map: game.game_map.GameMap def __init__(self, player: Entity): def __init__(self, player: Actor): self.player = player def handle_enemy_turns(self) -> None: for entity in set(self.game_map.entities) - {self.player}: print(f"The {entity.name} wonders when it will get to take a real turn.") for entity in set(self.game_map.actors) - {self.player}: if entity.ai: entity.ai.perform()
Now enemies will use their AI to take real turns - moving toward and attacking the player!
Finally, we need to ensure the player doesn’t have AI. Update main.py
:
import copy
import tcod
from game.engine import Engine -from game.entity import Entity +import game.entity_factories from game.input_handlers import BaseEventHandler, MainGameEventHandler from game.procgen import generate_dungeon -import game.entity_factories
def main() -> None: …
import copy import tcod from game.engine import Engine from game.entity import Entity import game.entity_factories from game.input_handlers import BaseEventHandler, MainGameEventHandler from game.procgen import generate_dungeon import game.entity_factories def main() -> None: ... player = Entity(x=0, y=0, char="@", color=(255, 255, 255), name="Player", blocks_movement=True) player = copy.deepcopy(game.entity_factories.player) # The player doesn't need AI player.ai = None engine = Engine(player=player)
We deepcopy the player template and remove its AI since the player is controlled by keyboard input, not AI.
Run the project and you should now have working combat! Enemies will chase you when they can see you, attack when adjacent, and die when their HP reaches 0. The damage system is simple but effective - you can take on individual enemies easily but groups can be dangerous.
This completes our basic combat system. We have enemies that can pathfind, attack, take damage, and die. In future parts, we’ll expand on this foundation with items, equipment, and more sophisticated combat mechanics.
If you want to see the code so far in its entirety, click here.