Content:

↑ Back to top

Diablo 3 Inspired Enemy AI System

Summary

A lot of the time in games, enemies will work independently of one another, not taking into account what's happening with the others when deciding what they themselves want to do. In Diablo 3 that is often not the case. They have shamans that raise their dead allies to fight again, big bulky mobs that buff their allies with an aura and various enemies making joint attacks with one another using tethers and other means to perform stronger attacks together than they could alone. Recreating that type of behavior, making elite versions of enemies and generally making polished (atleast from the player's PoV) enemy behavior that works similarly to that in Diablo were some of the requirements in one of our group projects. I was tasked with single handedly programming the enemies for our game, so from those requirements, and from the large task I had ahead of me, the system I highlight on this page was born.

A Low Scope for a Short Timeframe

From the very beginning, our group recognized the immense workload required to meet the project's requirement specification. This meant making sacrifices in both quality and the time we could dedicate to each component in order to meet the deadline. Us programmers all understood that trade-offs were inevitable and that the code we produced wouldn't necessarily reflect our full capabilities. The others knew they would have to spend less time than they would have liked on aspects such as the player controller and particle systems. Similarly, I knew I wouldn't have much opportunity to iterate on the enemy system. It had to function reasonably well in the first iteration, with any subsequent refinements focusing on polishing and optimizing what was already in place rather than making major changes.

In an ideal scenario I would have had the time to implement a full fledged behavior tree for each enemy type. Or at the very least I could have made a more proper implementation of a decision tree. I could have polished their attacking behavior to be less simplistic. Used more pushing logic using our Observer pattern (a form of Event-Driven Architecture) instead of polling logic as I ended up doing in most places. Spent more time optimizing the code and making it easier to read. But regardless of what could have been, I'm still very proud of the end result and what I managed to produce in only a couple of weeks, managing to meet the deadline and produce a set of enemies interacting with one another in a very similar fashion as in Diablo.

Examples of the Cooperative Behavior

Zombie

A small melee enemy, weak in both health and damage, meant to swarm the player in great numbers to make up for their frailty. Zombies receive a buff to their movement speed when combat starts if they're grouped together with a Crusader enemy.

Caster

The only enemy type with ranged capability. The Casters launch magical projectiles at the player from a distance, making use of the other enemy types as barriers between the Caster and the player. If all the other enemies in their group are defeated, they start attacking more frantically and try to run away from the player when not attacking.

Crusader

A bulky melee enemy with a lot of damage and health. Crusaders are the leaders of the enemy types, buffing the Zombies and charging in to stand in the way of the player when they try to chase after Casters.

The Algorithms, Models and Patterns

Decision Tree Model

Each enemy type operates based on a loosely defined variation of the decision tree behavioral model. My implementation structures these decision trees as cascading if statements, with specialized conditions tailored to each enemy type's intended behavior. The image below showcases a portion of the easiest-to-read of the three decision trees, that of the Caster enemy type. Every frame, the Caster evaluates its state, first determining high-level conditions such as whether it has been alerted. Nested within that evaluation, it then decides the precise action to take, such as fleeing, turning toward the player, chasing after the player, or recalculating its pathfinding.

Go to the snippet on GitHub
Object Pooling Pattern

Ranged enemies leverage an object pooling system for their projectiles, maintaining a pool of pre-instantiated objects to optimize performance. This approach minimizes runtime memory allocations and garbage collection overhead by reusing projectiles instead of dynamically creating and destroying them during gameplay. This object pool implementation was co-written with another programmer on my team, Hugo Nyberg.

Factory Pattern

Our game employs the factory design pattern to encapsulate enemy creation, ensuring the level loading logic interacts only with an abstract interface. This promotes loose coupling, allowing subclasses to initialize specialized data independently.

Go to the snippet on GitHub

The enemies all navigate the game world using an A-Star pathfinding algorithm that I wrote, along with an accompanying funneling algorithm to smooth out the found path. I wrote my initial implementation for pathfinding in 2D, so for this project I had to convert it to work in 3D. Essentially all that entailed was exchanging the Y axis for the Z axis in the actual pathfinding logic, and only using the Y axis to sample the height of the elevation that's being traversed.

Go to the snippet on GitHub