Hi everyone,
This is the fourth article in our series “Tips for writing cleaner code that scales in Unity”.
The other posts in the series are here:
Article 1: Adding an AI-controlled NPC to a game
Article 2: Adding a jump mechanic using the state pattern
Article 3: Adding a modular interaction system using interfaces
Article 5: Using interfaces to build extendable systems in your game
We teamed up with Peter from Sunny Valley Studio, who has created a long list of great YouTube tutorials and courses on the topic. Peter is the main author of the articles and created a project you can follow along with.
The goal with this series is to help you write clean, scalable, and maintainable code in Unity by applying object-oriented programming principles and design patterns. Through a practical example project, where we expand a simple third-person controller, you’ll learn how to implement new features in a way that keeps your codebase organized and easy to extend.
In this article we will create an NPC Agent which is levering the AI Navigation pathfinding package to move around the map.
In this post, we implement an enemy NPC that will use the AI Navigation system (also referred to as NavMesh) for pathfinding. The NPC will be able to detect and follow the player and perform an attack when within a specified attacking range to the Player.
To implement this, we will use inheritance and composition to refactor our code and we’ll explore how each concept contributes to building a more maintainable and scalable codebase.
You can follow along with this article by downloading a starter project from Github. In the Project tab go to the _Scripts > Article 4 > Start folder and open the scene there.
Enemy NPC detecting the player, approaching using the AI Navigation system and performing an attack animation as the player comes within attacking range
Let’s start by breaking it down into smaller manageable tasks:
- Implementing the NavMesh-based movement
- Creating a new Enemy NPC
- Implementing the attack logic of the NPC
Since we are focusing on inheritance and composition in this article, let’s first review these two concepts and how they can help us structure our code more effectively.
What is composition?
Composition is a key concept in object-oriented programming (OOP) where a class is made up of objects from other classes. This allows developers to build complex objects by combining simpler, reusable components, leading to more flexible and modular code design.
Composition is often described as a “has-a” relationship, meaning that one object contains or uses another object as part of its functionality. For instance, consider a Weapon object with a CalculateDamage()
method. Both a Goblin enemy and a Trap object might use an instance of the Weapon
class to calculate damage, thus reusing the code. In this scenario, both the Goblin and Trap “have” a Weapon object and integrate it into their logic:
In object-oriented programming (OOP), composition is represented in Unified Modeling Language (UML) with a filled diamond, indicating that Goblin and Trap objects are composed of a Weapon object.
Typically, composition means that one class is responsible for creating and managing the lifecycle of its component objects. For example, if a Goblin or Trap object is removed from the game, its associated Weapon is also removed. This establishes a tightly coupled lifecycle between the composite object and its components.
Composition in Unity
Unity implements composition through its component-based architecture, offering a unique twist on the concept. In Unity, GameObjects serve as containers, and components (such as MonoBehaviour scripts) define behavior and properties. This allows developers to build complex behaviors by combining various components. For example:
public class Goblin : MonoBehaviour
{
private Weapon m_weapon;
void Start()
{
m_weapon = GetComponent<Weapon>();
}
}
In this code snippet, the Goblin
class has a Weapon component. The Goblin retrieves this component using the GetComponent<>()
method, showcasing Unity’s approach to composition.
Composition in Unity
Composition represents a whole-part relationship between two objects, where the part’s lifecycle is tightly bound to the whole. In true composition, if the whole object is destroyed, its part objects are also destroyed.
Unity’s component architecture: Unity employs a component-based architecture (not to be confused with ECS), where GameObjects serve as entities and MonoBehaviours (other Components) function as modular building blocks defining behavior and properties. This is what makes composition work differently in Unity:
Components as parts: In Unity, components are added to GameObjects to provide specific functionality. A GameObject can be seen as the whole, and its components as the parts.
Different lifecycle: Unlike traditional composition Unity allows components to be individually added or removed from GameObjects without necessarily destroying the GameObject itself.
Marking composition in UML for Unity: In UML diagrams, composition is typically represented by a solid line with a filled diamond, indicating a strict whole-part relationship. However, Unity’s flexible component-based architecture often favors a solid line with an open arrow to represent an association relationship. This reflects the fact that while components interact closely, they don’t always follow traditional whole-part dependencies.
Read more about entity component systems and object composition.
In Unity, most scripts inherit from MonoBehaviour, enabling component-based architecture. The relationship between objects like Trap and Goblin with their Weapon components is depicted using lines with an arrow, indicating association – there is no lifecycle dependency that traditional composition uses. We still preserve all the benefits of the composition structure here.
A downside of composition: Code duplication
A potential drawback of using composition is code duplication. For example, both Goblin
and Trap
classes might contain similar code when composed of the same objects like Weapon, Mover, and AnimationController. If we add a Dragon
class that shares many components with Goblin
class, we may end up duplicating code across these two classes. This is where inheritance can offer a more elegant solution by reducing redundancy and organizing common behavior in a more structured way.
What is inheritance?
Inheritance is a core concept in OOP, where a derived class inherits properties and methods from a base class. This establishes a hierarchical relationship, enabling code reuse by centralizing shared functionality in the base class while allowing derived classes to extend or customize that functionality.
In game development, inheritance is often used to create specialized versions of GameObjects or components. For instance, you might have a base Enemy
class, with more specific enemy types, such as Goblin
and Dragon
, inheriting from it:
Goblin
and Dragon
are specialized types of Enemy
. They inherit the m_health
and m_weapon
properties (# means “protected” access modifier in C#) and both TakeDamage()
and DealDamager()
methods. Dragon defines its own Fly()
method and Goblin
has the Steal()
method defined. We use both inheritance and composition concepts to create this structure.
In the example above, we reduced code duplication by introducing an abstract Enemy
class that contains the code shared by both Dragon
and Goblin
, while still allowing these classes to define their unique methods and data. This eliminates the code duplication caused by composition.
Another more simplified way to think of the inheritance is like a family tree. A child class automatically inherits traits (properties and methods) from its parent class. If a Bird class inherits from an Animal class, it gets all the basic characteristics of an animal (e.g., Eat() or Sleep() methods). And so inheritance creates what is referred to as a “tight relationship” between the parent and child. If the parent changes, the child is affected too.
Potential downsides of inheritance
While inheritance is a powerful tool, it can also introduce challenges if not used carefully:
-
Unintended ripple effects: Changes to a base class can unintentionally impact all its derived classes, potentially introducing bugs. For example, consider making a
Trap
class inherit from anEnemy
class under the assumption that a trap is a type of enemy. Initially, this might seem logical. However, if you later decide to add a Movement component to the baseEnemy
class, problems can arise. While it makes sense for dynamic entities like Dragon and Goblin to use the Movement component, a static Trap object would also inherit this dependency, despite not needing or using it. -
Complex inheritance hierarchies: Deep inheritance hierarchies can lead to complex and fragile code, making it difficult to understand and maintain. For example, if we introduced a
MovingEnemy
class that inherits fromEnemy
, this would allow us to distinguish between static and moving enemies.Goblin
andDragon
could then inherit fromMovingEnemy
, whileTrap
could inherit directly fromEnemy
. This might seem like a solution to the previous problem, but it introduces new complications. Suppose we create aSwimmingTrap
class later on. Now, bothGoblin
andSwimmingTrap
can swim, butDragon
and the originalTrap
cannot. This highlights a common limitation: a class in C# cannot inherit from multiple branches of the inheritance tree. In other words: you can only inherit from one class in C#. Fixing this would likely require restructuring the hierarchy, which could break existing logic. There are ways to fix it but we could create deeper and more complex inheritance trees when a composition-based solution will be simpler and easier to use.
To build reusable and maintainable code, it’s often best to combine inheritance and composition, taking advantage of their respective strengths. However, be aware of the potential drawbacks of each approach and carefully select the right strategy depending on the specific needs of the project.
Using composition to add NavMesh-based movement
Currently, our reusable Agent
script is tightly coupled with the BasicCharacterControllerMover
. To introduce a new enemy NPC that uses the NavMesh system for movement, we can create a new NavMeshMover
class. This will allow us to reuse the Agent
script for both player and NPC agents. But first, we need to decouple the movement logic from the Agent
class.
Agent
depends on BasicCharacterControllerMover
.
To achieve this decoupling, we’ll refactor the existing BasicCharacterControllerMover
class by applying the dependency inversion principle. The dependency inversion principle states that high-level classes (like Agent
) should not depend on low-level classes (like BasicCharacterControllerMover
) but instead on abstractions, such as interfaces, allowing both to rely on the same abstraction rather than each other directly.
So we’ll create an IAgentMover
interface, which will serve as an abstraction layer between the Agent
class and its movement behavior. This design allows us to plug in different implementations of IAgentMover
for different movement behaviors (e.g., flying, walking, or swimming).
By introducing
IAgentMover
, we make the Agent
class depend on an interface rather than a specific implementation. This allows us to reuse Agent
to create agents with different movement behaviors by providing different IAgentMover
implementations, along with corresponding IAgentMovementInput
implementations.
In other words, introducing the IAgentMover
interface enables polymorphism, allowing us to easily swap between different movement behaviors. This approach also aligns with the open-closed principle (OCP) discussed in Article 1, as it allows us to add new movement types without modifying the existing Agent code.
This is also an example of composition in action. We have a single Agent
class that can be reused by various entities in our game, such as players, NPCs, and enemies. There is a relationship between the Agent
class and IAgentMover
object, enabling us to compose different agents by assigning each one a different implementation of the IAgentMover
interface. Note that here we leverage inheritance (or more precisely, interface implementation) to improve our composition-based solution. Here is our IAgentMover
script:
public interface IAgentMover
{
void Move(Vector3 input, float speed);
Vector3 CurrentVelocity { get; }
}
public class BasicCharacterControllerMover : MonoBehaviour, IAgentMover
{
…
}
public abstract class Agent : MonoBehaviour
{
protected IAgentMover _mover;
…
private void Awake()
{
m_mover = GetComponent<IAgentMover>();
…
}
}
We will have an error inside the MovementState
, JumpState
, and FallState
as those rely on the BasicCharacterControllerMover
class as well.
Different states rely on the concrete
BasicCharacterControllerMover
– breaking the dependency inversion principle.
This is an example of how delaying the refactoring process before going forward with the project (adding the State pattern in Article 2) can add more work later on. Still, this refactoring will happen in the code and no change inside the Editor needs to be made. It shouldn’t cause any issues with other systems in our project. Here is our new version of the FallState
script:
public class FallState : State
{
private IAgentMover m_mover;
…
public FallState(IAgentMover mover, AgentAnimations agentAnimations, IAgentMovementInput movementInput, AgentStats agentStats)
{
m_mover = mover;
…
}
…
}
public class MovementState : State
{
private IAgentMover m_mover;
…
public MovementState(IAgentMover mover, GroundedDetector groundedDetector, AgentAnimations agentAnimations, IAgentMovementInput movementInput, AgentStats agentStats)
{
m_mover = mover;
…
}
…
}
public class JumpState : State
{
private IAgentMover m_mover;
…
public JumpState(IAgentMover mover, AgentAnimations agentAnimations, IAgentMovementInput input, AgentStats agentStats)
{
m_mover = mover;
…
}
…
}
Lastly, we need to make the JumpFallTransition
and the MovementHelper
scripts also depend on the IAgentMover
references:
public class MovementHelper
{
public float PerformMovement(IAgentMovementInput input, AgentStats agentStats, IAgentMover mover, float verticalVelocity)
{
…
}
}
public class JumpFallTransition : ITransitionRule
{
public Type NextState => typeof(FallState);
private IAgentMover m_mover;
…
public JumpFallTransition(IAgentMover mover)
{
m_mover = mover;
}
…
}
With code refactored, let’s make sure everything works as before.
The gif shows the expected behavior. Make sure to check that you don’t break anything and that the Player can complete the jumps as before.
Setting up NavMesh and making Enemy NPC use it
With the IAgentMover
interface in place, we can now compose different NPC agents by reusing the Agent
class and providing it with any IAgentMover
implementation. To implement our new Enemy NPC, we’ll start by creating a NavMeshMover
. You can find the full version of this code on Github:
public class NavMeshMover : MonoBehaviour, IAgentMover
{
private NavMeshAgent m_navMeshAgent;
public Vector3 CurrentVelocity => m_navMeshAgent.velocity;
private void Awake()
{
m_navMeshAgent = GetComponent<NavMeshAgent>();
}
public void Move(Vector3 input, float speed)
{
m_navMeshAgent.speed = speed;
Vector3 targetPosition =
transform.position + new Vector3(input.x, 0, input.z);
m_navMeshAgent.SetDestination(targetPosition);
}
}
To use the NavMesh functionality we need to import the AI Navigation package using the Package Manager. If you are not familiar with the package, check out our Youtube video tutorial for a quick introduction to all the key concepts you need to know in order to follow along here.
We’ll use the NavMeshSurface component to bake our NavMesh and the NavMeshAgent component to enable our Enemy NPC to navigate through the environment.
The NavMeshSurface component is used to generate the NavMesh that our Enemy agent will use to move around the level.
To create our Enemy NPC, we’ll start by duplicating the existing NPC agent. Then, we’ll modify it by removing the NPCAIInput, CharacterController, and BasicCharacterControllerMover components. In their place, we’ll add the NavMeshAgent, NavMeshMover, and a new NavMeshMovementInput component, which we’ll create next.
The Enemy NPC object created from our existing NPC. We have removed NPCAIInput, CharacterController and BasicCharacterControllerMover components and added NavMeshAgent, NavMeshMovementInput and NavMeshMover.
The NavMeshAgent
component allows the NPC to receive a target position and automatically move towards it. Given how our code is structured, we need to create a custom IAgentMovementInput
implementation called NavMeshMovementInput
. This class will calculate the path on the NavMesh and send the resulting Vector3 input
to the NavMeshMover
, which will then set the destination on the NavMeshAgent. You can find the complete code on GitHub.
Finally, we need to code the behavior for our Enemy NPC. We want the NPC to chase the player when detected and then stop when it gets close enough, as you can see in the gif animation below.
The Enemy NPC walks towards the Player using the NavMesh system and stops within the stopping distance specified in the NavMeshAgent component.
To achieve this, we’ll create a NavMeshEnemyAI
script to handle the enemy’s behavior:
public class NavMeshEnemyAI : MonoBehaviour
{
private NavMeshMovementInput m_navMeshMovementInput;
[field: SerializeField]
public Transform Target { get; set; }
[SerializeField]
private float m_stoppingDistance = 0.3f;
private void Awake()
{
m_navMeshMovementInput = GetComponent<NavMeshMovementInput>();
}
private void HandleTargetReached()
{
m_navMeshMovementInput.SetTarget(null);
}
private void Update()
{
if (m_navMeshMovementInput == null)
{
return;
}
if (Target == null)
{
m_navMeshMovementInput.SetTarget(Target);
return;
}
if (Vector3.Distance(transform.position, Target.position) < m_stoppingDistance)
{
m_navMeshMovementInput.SetTarget(null);
return;
}
m_navMeshMovementInput.SetTarget(Target);
}
}
The NavMeshMovementInput
script is crucial for directing the Agent to move towards a target based on the path calculated by the NavMesh system. The SetTarget()
method is key here as it sets the destination that the enemy will pursue. Attach this script, along with NavMeshMovementInput
, to the EnemyNPC GameObject. Once we assign the Ellen GameObject as the Target, the Enemy NPC will start moving toward the player.
While the Enemy NPC does get quite close to the player, this can be easily adjusted by tweaking the stopping distance in the NavMeshAgent component. Through composition, we successfully added a new NPC that uses NavMesh for movement by reusing the Agent
class and making it depend on the IAgentMover
abstraction instead of a specific mover implementation. This setup allows us to easily create additional NPCs or entities that leverage IAgentMover objects for movement.
Introducing attack behavior using inheritance
Next, let’s address the “Enemy Attack Player” feature request. Adding attack behavior directly to the Agent
script would increase its dependencies and reduce its flexibility. Instead, a better approach is to refactor the existing code by introducing an inheritance hierarchy.
Here, Agent
will serve as an abstract base class that encapsulates all common behaviors shared by specialized classes like PlayerAgent
, NPCAgent
, and EnemyAgent
. The abstract class will manage essential functionalities such as input handling, movement, animation, state management, and ground detection, ensuring these shared elements are centrally maintained while allowing each subclass to define unique behaviors.
Abstract Agent
class with protected fields and methods (denoted by # symbol) and the subclasses that inherit it: PlayerAgent
, NPCAgent
and EnemyAgent
The advantage of this approach is that it allows us to reuse the entire state factory setup that’s common to all agents, making it easier to extend with new states as needed. Additionally, this refactor adheres to the interface segregation principle (ISP), as each agent class now only depends on the inputs and behaviors relevant to it. For example, the NPC GameObject that previously depended on jump input because of the Agent
script will no longer need to do so with the introduction of the NPCAgent
subclass.
However, this design comes with some downsides. If the Agent
script has already been used to create multiple distinct agent objects, these will no longer function correctly. You’ll need to replace the old Agent components on these prefabs with the new subclass components, as Agent is now abstract.
Despite this, the benefits of being able to easily introduce new agents like EnemyAgent
or any other agent type outweigh the drawbacks. Here is our new version of the Agent
script:
public abstract class Agent : MonoBehaviour
{
protected IAgentMover m_mover;
protected IAgentMovementInput m_input;
protected GroundedDetector m_groundDetector;
protected AgentAnimations m_agentAnimations;
protected State m_currentState;
protected AgentStats m_agentStats;
protected virtual void Awake()
{
…
}
protected virtual void Start()
{
…
}
protected virtual State StateFactory(Type stateType)
{
State newState = null;
if (stateType == typeof(MovementState))
{
newState = new MovementState(m_mover, m_groundDetector, m_agentAnimations, m_input, m_agentStats);
newState.AddTransition(new GroundedFallTransition(m_groundDetector));
}
else if (stateType == typeof(FallState))
{
newState = new FallState(m_mover, m_agentAnimations, m_input, m_agentStats);
newState.AddTransition(new FallLandTransition(m_groundDetector));
}
else if (stateType == typeof(LandState))
{
newState = new LandState(m_agentAnimations);
newState.AddTransition(new LandMovementTransition());
}
else
{
throw new Exception($"Type not handled {stateType}");
}
return newState;
}
private void TransitionToState(Type stateType)
{
…
}
protected virtual void Update()
{
if (m_currentState != null)
m_currentState.Update(Time.deltaTime);
}
protected virtual void FixedUpdate()
{
…
}
}
The Agent
class becomes abstract and is composed of objects common to all the agents in our game. We will make all the fields and most methods to be protected and all but the TransitionToState()
methods as virtual. Thanks to this the StateFactory()
method can be overridden in the subclasses so we can easily add more states to specific agents. Let’s take a look at the NPCAgent
subclass:
public class NPCAgent : Agent
{
private IAgentWaveInput m_waveInput;
protected override void Awake()
{
base.Awake();
m_waveInput = GetComponent<IAgentWaveInput>();
}
protected override State StateFactory(Type stateType)
{
State newState = null;
if (stateType == typeof(WaveState))
{
newState = new WaveState(m_agentAnimations);
newState.AddTransition(new WaveMoveTransition());
}
else
{
newState = base.StateFactory(stateType);
if (stateType == typeof(MovementState))
{
newState.AddTransition(new MoveWaveTransition(m_waveInput));
}
}
return newState;
}
}
NPCAgent
is a subclass that depends on IAgentWaveInput
and modifies the StateFactory()
method to include the WaveState
and the transition between it and the MovementState
. Now we also need to define a PlayerAgent
:
public class PlayerAgent : Agent
{
private IAgentJumpInput m_jumpInput;
private IAgentInteractInput m_interactInput;
private InteractionDetector m_interactDetector;
[SerializeField]
private WeaponHelper m_weaponHelper;
protected override void Awake()
{
base.Awake();
m_jumpInput = GetComponent<IAgentJumpInput>();
m_interactInput = GetComponent<IAgentInteractInput>();
m_interactDetector = GetComponent<InteractionDetector>();
m_weaponHelper = GetComponent<WeaponHelper>();
}
protected override State StateFactory(Type stateType)
{
State newState = null;
if (stateType == typeof(JumpState))
{
newState = new JumpState(m_mover, m_agentAnimations, m_input, m_agentStats);
newState.AddTransition(new JumpFallTransition(m_mover));
}
else if (stateType == typeof(InteractState))
{
newState = new InteractState(m_agentAnimations, m_interactDetector);
newState.AddTransition(new InteractMoveTransition());
}
else
{
newState = base.StateFactory(stateType);
if (stateType == typeof(MovementState))
{
newState.AddTransition(new JumpTransition(m_jumpInput));
newState.AddTransition(new MoveInteractTransition(m_interactInput, m_interactDetector));
}
}
return newState;
}
protected override void Update()
{
base.Update();
m_interactDetector.DetectInteractable();
}
}
The PlayerAgent
subclass on the other hand allows us to add the JumpState
and InteractState
and uses Update()
method to call the m_interactDetector
to detect interactive objects.
Now all we need is to modify the GameObjects representing the Player (Ellen_4) and NPC (NPC_4) and for now we will disable the Enemy GameObject because we don’t yet have a script to attach to it (we could assign to it the NPCAgent
script).
Inspector view of Ellen (player GameObject) and NPC (NPC GameObject) and the changes that we make to those; we have removed the Agent component and added PlayerAgent and NPCAgent to the respective GameObjects
Make sure to test the game to confirm that everything works exactly as it did previously before you move on to the next step.
Now it is time to create our EnemyAgent
class that will allow the enemy agent to perform attack behavior when it’s close to the Player:
public class EnemyAgent : Agent
{
[SerializeField]
private IAgentAttackInput m_attackInput;
protected override void Awake()
{
base.Awake();
m_attackInput = GetComponent<IAgentAttackInput>();
}
protected override State StateFactory(Type stateType)
{
State newState = null;
if (stateType == typeof(AttackState))
{
newState = new AttackState(m_agentAnimations, m_mover);
newState.AddTransition(new DelayedTransition(2f, typeof(MovementState)));
}
else
{
newState = base.StateFactory(stateType);
if (stateType == typeof(MovementState))
{
newState.AddTransition(new MoveAttackTransition(m_attackInput));
}
}
return newState;
}
}
This script will utilize the new input defined in the IAgentAttackInput
interface, implemented within the NavMeshEnemyAI
script. Additionally, we need to introduce a new AttackState
, which, for now, will simply trigger the attack animation. To transition to this state, we will define a MoveAttackTransition
that activates upon receiving the attack input. We’ll also implement a more generic DelayedTransition
, for states like InteractState
, where the associated animation lasts a certain time and then transitions to the next state. We have introduced the State pattern in Article 2 so there are no new concepts included in those scripts.
Feel free to explore them on our GitHub repository.
Take note of how each new Agent
implementation customizes its own fields and adds specific states (behaviors), all while reusing the underlying structure provided by inheritance. This approach significantly reduces code duplication, resulting in concise scripts for PlayerAgent
, NPCAgent
, and now EnemyAgent
. However, this efficiency comes with a trade-off: added complexity.
To fully understand how the EnemyAgent
script operates, one must analyze both the subclass and the abstract Agent
class. Moreover, any changes made to the Agent
class will directly impact all subclasses, as they are tightly coupled with the base class.
Attack animation generated using Unity Muse AI model
To complete the implementation of our attack functionality, we’ll use Muse Animations to generate a simple punch animation. This animation can be added to the Animator, where it will be defined as an Attack trigger within the AgentAnimations
class.
To create a more realistic example for this article, let’s implement a SimplePlayerDetector
script. This script will reference a Transform object (such as Ellen’s Transform) and update the NavMeshEnemyAI
based on the distance between the enemy and the player. Using UnityEvent, the script will instruct the enemy to either stop or move toward the player, depending on the proximity. Here is the SimplePlayerDetector
script:
public class SimplePlayerDetector : MonoBehaviour
{
[SerializeField]
private Transform m_playerObject;
[SerializeField]
private bool m_isOn = true;
[SerializeField]
private float m_detectionRadius = 5.0f;
private bool m_playerDetected = false;
public UnityEvent<Transform> OnDetectionUpdate;
private void Update()
{
if (m_playerObject == null || !m_isOn)
{
if (m_playerDetected)
{
OnDetectionUpdate?.Invoke(null);
m_playerDetected = false;
}
return;
}
if (Vector3.Distance(m_playerObject.position, transform.position) < m_detectionRadius)
{
Vector3 directionToPlayer = (m_playerObject.position - transform.position).normalized;
if (m_playerDetected)
return;
m_playerDetected = true;
OnDetectionUpdate?.Invoke(m_playerObject);
}
else if (m_playerDetected)
{
OnDetectionUpdate?.Invoke(null);
m_playerDetected = false;
}
}
private void OnDrawGizmosSelected()
{
…
}
}
Here is how our Enemy_NPC GameObject looks in the Hierarchy:
The Enemy_NPC object has the following components: NavMeshMover, NavMeshMovementInput, NavMeshAgent, NavMeshEnemyAI, EnemyAgent and the SimplePlayerDetector. We also assign the
OnDetectionUpdate
to the NavMeshEnemyAI Target as the listener. We have also removed the CharacterController, NPCInteractable, NPCAIInput and the BasicCharacterControllerMover components.
It’s important to note that by removing the NPCInteractable
, the Enemy NPC is no longer highlighted when targeted, and you won’t be able to trigger interaction behaviors as you could with the NPC agent. Additionally, we’ve set the stopping distance in NavMeshEnemyAI
to 0.6 to prevent the enemy from getting too close to the player. Now, let’s press play and see if everything works as expected:
The Enemy has detected the Player and walks towards it, then transitions to the attack state.
The enemy NPC should successfully detect the player, approach them, and play the attack animation when within range. While the attack currently has no functional impact, the primary goal of this article was to demonstrate the principles of composition and inheritance and how they can be applied to introduce new features into our project. We’ll continue expanding on this game mechanic in Article 5.
Conclusion
In this article, we demonstrated how to leverage composition and inheritance to implement a new Enemy NPC that uses the NavMesh system for movement, detects and follows the Player, and performs an attack when close.
By refactoring the existing BasicCharacterControllerMover
class into the IAgentMover
interface, we enabled the creation of flexible movement behaviors and adhered to the open-closed principle (OCP). This composition-based approach allowed us to add new movement types, like the NavMeshMover, without modifying the existing Agent code.
We also utilized inheritance to create an abstract Agent
class. This gave us a base structure for shared behaviors from which we could derive the specialized classes (PlayerAgent
, NPCAgent
, EnemyAgent
). This hierarchical setup reduced code duplication and enabled us to extend the functionality of each agent type individually.
By integrating the SimplePlayerDetector and NavMeshEnemyAI components, we demonstrated how composition can help decouple our code. Using UnityEvents to dynamically link these components we were able to use the flexibility of Unity’s component-based architecture.
In summary, by combining composition and inheritance, we created a scalable and maintainable codebase capable of handling new features efficiently. These object-oriented principles ensure our code remains clean, modular, and extensible. You can get the full project on Github.
In our final article in the series, we’ll delve into building a reusable and flexible damage system. We’ll also focus on the reusability of components across different GameObjects and explore dependency injection for further flexibility.