12. NPC and monster AI¶
Not every entity in the game are controlled by a player. NPCs and enemies need to be controlled by the computer - that is, we need to give them artificial intelligence (AI).
For our game we will implement a type of AI called a ‘state machine’. It means that the entity (like an NPC or mob) is always in a given ‘state’. An example of a state could be ‘idle’, ‘roaming’ or ‘attacking’. At regular intervals, the AI entity will be ‘ticked’ by Evennia. This ‘tick’ starts with an evaluation which determines if the entity should switch to another state, or stay and perform one (or more) actions inside the current state.
For example, if a mob in a ‘roaming’ state comes upon a player character, it may switch into the ‘attack’ state. In combat it could move between different combat actions, and if it survives combat it would go back to its ‘roaming’ state.
The AI can be ‘ticked’ on different time scales depending on how your game works. For example, while a mob is moving, they might automatically move from room to room every 20 seconds. But once it enters turn-based combat (if you use that), the AI will ‘tick’ only on every turn.
12.1. Our requirements¶
For this tutorial game, we’ll need AI entities to be able to be in the following states:
Idle - don’t do anything, just stand around.
Roam - move from room to room. It’s important that we add the ability to limit where the AI can roam to. For example, if we have non-combat areas we want to be able to lock all exits leading into those areas so aggressive mods doesn’t walk into them.
Combat - initiate and perform combat with PCs. This state will make use of the Combat Tutorial to randomly select combat actions (turn-based or tick-based as appropriately).
Flee - this is like Roam except the AI will move so as to avoid entering rooms with PCs, if possible.
We will organize the AI code like this:
AIHandler
this will be a handler stored as.ai
on the AI entity. It is responsible for storing the AI’s state. To ‘tick’ the AI, we run.ai.run()
. How often we crank the wheels of the AI this way we leave up to other game systems..ai_<state_name>
methods on the NPC/Mob class - when theai.run()
method is called, it is responsible for finding a method named like its current state (e.g..ai_combat
if we are in the combat state). Having methods like this makes it easy to add new states - just add a new method named appropriately and the AI now knows how to handle that state!
12.2. The AIHandler¶
This is the core logic for managing AI states. Create a new file evadventure/ai.py
.
Create a new file
evadventure/ai.py
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | # in evadventure/ai.py from evennia.logger import log_trace class AIHandler: attribute_name = "ai_state" attribute_category = "ai_state" def __init__(self, obj): self.obj = obj self.ai_state = obj.attributes.get(self.attribute_name, category=self.attribute_category, default="idle") def set_state(self, state): self.ai_state = state self.obj.attributes.add(self.attribute_name, state, category=self.attribute_category) def get_state(self): return self.ai_state def run(self): try: state = self.get_state() getattr(self.obj, f"ai_{state}")() except Exception: log_trace(f"AI error in {self.obj.name} (running state: {state})") |
The AIHandler is an example of an Object Handler. This is a design style that groups all functionality together. To look-ahead a little, this handler will be added to the object like this:
# just an example, don't put this anywhere yet
from evennia.utils import lazy_property
from evadventure.ai import AIHandler
class MyMob(SomeParent):
@lazy_property
class ai(self):
return AIHandler(self)
So in short, accessing the .ai
property will initialize an instance of AIHandler
, to which we pass self
(the current object). In the AIHandler.__init__
we take this input and store it as self.obj
(lines 10-13). This way the handler can always operate on the entity it’s “sitting on” by accessing self.obj
. The lazy_property
makes sure that this initialization only happens once per server reload.
More key functionality:
Line 11: We (re)load the AI state by accessing
self.obj.attributes.get()
. This loads a database Attribute with a given name and category. If one is not (yet) saved, return “idle”. Note that we must accessself.obj
(the NPC/mob) since that is the only thing with access to the database.Line 16: In the
set_state
method we force the handler to switch to a given state. When we do, we make sure to save it to the database as well, so its state survives a reload. But we also store it inself.ai_state
so we don’t need to hit the database on every fetch.line 23: The
getattr
function is an in-built Python function for getting a named property on an object. This allows us to, based on the current state, call a methodai_<statename>
defined on the NPC/mob. We must wrap this call in atry...except
block to properly handle errors in the AI method. Evennia’slog_trace
will make sure to log the error, including its traceback for debugging.
12.2.1. More helpers on the AI handler¶
It’s also convenient to put a few helpers on the AIHandler. This makes them easily available from inside the ai_<state>
methods, callable as e.g. self.ai.get_targets()
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | # in evadventure/ai.py # ... import random class AIHandler: # ... def get_targets(self): """ Get a list of potential targets for the NPC to combat. """ return [obj for obj in self.obj.location.contents if hasattr(obj, "is_pc") and obj.is_pc] def get_traversable_exits(self, exclude_destination=None): """ Get a list of exits that the NPC can traverse. Optionally exclude a destination. Args: exclude_destination (Object, optional): Exclude exits with this destination. """ return [ exi for exi in self.obj.location.exits if exi.destination != exclude_destination and exi.access(self, "traverse") ] def random_probability(self, probabilities): """ Given a dictionary of probabilities, return the key of the chosen probability. Args: probabilities (dict): A dictionary of probabilities, where the key is the action and the value is the probability of that action. """ # sort probabilities from higheest to lowest, making sure to normalize them 0..1 prob_total = sum(probabilities.values()) sorted_probs = sorted( ((key, prob / prob_total) for key, prob in probabilities.items()), key=lambda x: x[1], reverse=True, ) rand = random.random() total = 0 for key, prob in sorted_probs: total += prob if rand <= total: return key |
get_targets
checks if any of the other objects in the same location as theis_pc
property set on their typeclass. For simplicity we assume Mobs will only ever attack PCs (no monster in-fighting!).get_traversable_exits
fetches all valid exits from the current location, excluding those with a provided destination or those which doesn’t pass the “traverse” access check.get_random_probability
takes a dict{action: probability, ...}
. This will randomly select an action, but the higher the probability, the more likely it is that it will be picked. We will use this for the combat state later, to allow different combatants to more or less likely to perform different combat actions. This algorithm uses a few useful Python tools:Line 41: Remember
probabilities
is adict
{key: value, ...}
, where the values are the probabilities. Soprobabilities.values()
gets us a list of only the probabilities. Runningsum()
on them gets us the total sum of those probabilities. We need that to normalize all probabilities between 0 and 1.0 on the line below.Lines 42-46: Here we create a new iterable of tuples
(key, prob/prob_total)
. We sort them using the Pythonsorted
helper. Thekey=lambda x: x[1]
means that we sort on the second element of each tuple (the probability). Thereverse=True
means that we’ll sort from highest probability to lowest.Line 47:The
random.random()
call generates a random value between 0 and 1.Line 49: Since the probabilities are sorted from highest to lowest, we loop over them until we find the first one fitting in the random value - this is the action/key we are looking for.
To give an example, if you have a
probability
input of{"attack": 0.5, "defend": 0.1, "idle": 0.4}
, this would become a sorted iterable(("attack", 0.5), ("idle", 0.4), ("defend": 0.1))
, and ifrandom.random()
returned 0.65, the outcome would be “idle”. Ifrandom.random()
returned0.90
, it would be “defend”. That is, this AI entity would attack 50% of the time, idle 40% and defend 10% of the time.
12.3. Adding AI to an entity¶
All we need to add AI-support to a game entity is to add the AI handler and a bunch of .ai_statename()
methods onto that object’s typeclass.
We already sketched out NPCs and Mob typeclasses back in the NPC tutorial. Open evadventure/npcs.py
and expand the so-far empty EvAdventureMob
class.
# in evadventure/npcs.py
# ...
from evennia.utils import lazy_property
from .ai import AIHandler
# ...
class EvAdventureMob(EvAdventureNPC):
@lazy_property
def ai(self):
return AIHandler(self)
def ai_idle(self):
pass
def ai_roam(self):
pass
def ai_roam(self):
pass
def ai_combat(self):
pass
def ai_flee(self):
pass
All the remaining logic will go into each state-method.
12.3.1. Idle state¶
In the idle state the mob does nothing, so we just leave the ai_idle
method as it is - with just an empty pass
in it. This means that it will also not attack PCs in the same room - but if a PC attacks it, we must make sure to force it into a combat state (otherwise it will be defenseless).
12.3.2. Roam state¶
In this state the mob should move around from room to room until it finds PCs to attack.
# in evadventure/npcs.py
# ...
import random
class EvAdventureMob(EvAdventureNPC):
# ...
def ai_roam(self):
"""
roam, moving randomly to a new room. If a target is found, switch to combat state.
"""
if targets := self.ai.get_targets():
self.ai.set_state("combat")
self.execute_cmd(f"attack {random.choice(targets).key}")
else:
exits = self.ai.get_traversable_exits()
if exits:
exi = random.choice(exits)
self.execute_cmd(f"{exi.key}")
Every time the AI is ticked, this method will be called. It will first check if there are any valid targets in the room (using the get_targets()
helper we made on the AIHandler
). If so, we switch to the combat
state and immediately call the attack
command to initiate/join combat (see the Combat tutorial).
If no target is found, we get a list of traversible exits (exits that fail the traverse
lock check is already excluded from this list). Using Python’s in-bult random.choice
function we grab a random exit from that list and moves through it by its name.
12.3.3. Flee state¶
Flee is similar to Roam except the the AI never tries to attack anything and will make sure to not return the way it came.
# in evadventure/npcs.py
# ...
class EvAdventureMob(EvAdventureNPC):
# ...
def ai_flee(self):
"""
Flee from the current room, avoiding going back to the room from which we came. If no exits
are found, switch to roam state.
"""
current_room = self.location
past_room = self.attributes.get("past_room", category="ai_state", default=None)
exits = self.ai.get_traversable_exits(exclude_destination=past_room)
if exits:
self.attributes.set("past_room", current_room, category="ai_state")
exi = random.choice(exits)
self.execute_cmd(f"{exi.key}")
else:
# if in a dead end, roam will allow for backing out
self.ai.set_state("roam")
We store the past_room
in an Attribute “past_room” on ourselves and make sure to exclude it when trying to find random exits to traverse to.
If we end up in a dead end we switch to Roam mode so that it can get back out (and also start attacking things again). So the effect of this is that the mob will flee in terror as far as it can before ‘calming down’.
12.3.4. Combat state¶
While in the combat state, the mob will use one of the combat systems we’ve designed (either twitch-based combat or turn-based combat). This means that every time the AI ticks, and we are in the combat state, the entity needs to perform one of the available combat actions, hold, attack, do a stunt, use an item or flee.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | # in evadventure/npcs.py # ... class EvAdventureMob(EvAdventureNPC): combat_probabilities = { "hold": 0.0, "attack": 0.85, "stunt": 0.05, "item": 0.0, "flee": 0.05, } # ... def ai_combat(self): """ Manage the combat/combat state of the mob. """ if combathandler := self.nbd.combathandler: # already in combat allies, enemies = combathandler.get_sides(self) action = self.ai.random_probability(self.combat_probabilities) match action: case "hold": combathandler.queue_action({"key": "hold"}) case "combat": combathandler.queue_action({"key": "attack", "target": random.choice(enemies)}) case "stunt": # choose a random ally to help combathandler.queue_action( { "key": "stunt", "recipient": random.choice(allies), "advantage": True, "stunt": Ability.STR, "defense": Ability.DEX, } ) case "item": # use a random item on a random ally target = random.choice(allies) valid_items = [item for item in self.contents if item.at_pre_use(self, target)] combathandler.queue_action( {"key": "item", "item": random.choice(valid_items), "target": target} ) case "flee": self.ai.set_state("flee") elif not (targets := self.ai.get_targets()): self.ai.set_state("roam") else: target = random.choice(targets) self.execute_cmd(f"attack {target.key}") |
Lines 7-13: This dict describe how likely the mob is to perform a given combat action. By just modifying this dictionary we can easily creating mobs that behave very differently, like using items more or being more prone to fleeing. You can also turn off certain action entirely - by default his mob never “holds” or “uses items”.
Line 22: If we are in combat, a
CombadHandler
should be initialized on us, available as asself.ndb.combathandler
(see the base combat tutorial).Line 24: The
combathandler.get_sides()
produces the allies and enemies for the one passed to it.Line 25: Now that
random_probability
method we created earlier in this lesson becomes handy!
The rest of this method just takes the randomly chosen action and performs the required operations to queue it as a new action with the CombatHandler
. For simplicity, we only use stunts to boost our allies, not to hamper our enemies.
Finally, if we are not currently in combat and there are no enemies nearby, we switch to roaming - otherwise we start another fight!
12.4. Unit Testing¶
Create a new file
evadventure/tests/test_ai.py
.
Testing the AI handler and mob is straightforward if you have followed along with previous lessons. Create an EvAdventureMob
and test that calling the various ai-related methods and handlers on it works as expected. A complexity is to mock the output from random
so that you always get the same random result to compare against. We leave the implementation of AI tests as an extra exercise for the reader.
12.5. Conclusions¶
You can easily expand this simple system to make Mobs more ‘clever’. For example, instead of just randomly decide which action to take in combat, the mob could consider more factors - maybe some support mobs could use stunts to pave the way for their heavy hitters or use health potions when badly hurt.
It’s also simple to add a ‘hunt’ state, where mobs check adjoining rooms for targets before moving there.
And while implementing a functional game AI system requires no advanced math or machine learning techniques, there’s of course no limit to what kind of advanced things you could add if you really wanted to!