Tips for writing cleaner code that scales in Unity, Part 5: Using interfaces to build extendable systems in your game

Hi everyone,

This is the fifth article in our series “Tips for writing cleaner code that scales in Unity”. Please be sure to read the four previous posts listed here:

Article 1: Adding an AI-driven NPC
Article 2: Adding a jump mechanic using the state pattern
Article 3: Adding a modular interaction system using interfaces
Article 4: Composition and inheritance

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.

image10
In this fifth article of our cleaner code series, we’ll step through adding a damage system.

This article delves further into the power of interfaces, and builds on top of our previous articles – namely article 3 – by exploring how interfaces enable polymorphism, allowing us to create systems that are easily extendable with new behaviors.

Interfaces can sometimes be intimidating for beginners who don’t have a good understanding of object-oriented programming (OOP). In Unity, interfaces can be tricky to work with because dependencies are usually assigned through the Inspector, which doesn’t natively support interfaces.

In this article we will try to demystify the concept by using them to create a damage system for our game that leverages the existing Attack mechanic to affect objects in the world.

image8
The Enemy NPC attacks the Player, triggering hit effects using the new Damage System. The Player retaliates, initiating the EnemyDeathState.

Let’s dive in and see how we can implement this into our project!

You can follow along with this article by downloading a starter project from Github. In the Project tab go to the _Scripts > Article 5 > Start folder and open the scene there.

Interfaces in object-oriented programming

In object-oriented programming (OOP), objects are often best understood by their behavior (methods) rather than just the data (fields) they hold. That is because methods define how an object interacts with other objects, making behavior the primary focus. The principle of encapsulation emphasizes bundling data with the methods that operate on it. By clearly defining the behavior an object exposes, such as through an interface, we ensure consistent and effective interactions between objects.

Example: Connecting AttackState with a Health component

Consider the following simplified example of how our AttackState can interact with an example Health component:

public class Health : MonoBehaviour
{
    private int m_currentHealth = 2;

    …

    public void GetHit(int damageAmount)
    {
        m_currentHealth -= damageAmount;
        if(m_currentHealth <= 0)
           Debug.Log(“Health dropped to 0!”);
    }
}

public class AttackState : State
{
    private GameObject m_target;
    private int m_damageAmount = 1;

    …

    public override void Enter()
    {
        m_target.GetComponent<Health>().GetHit(m_damageAmount)
    }
}

In the AttackState class, it searches for a Health component on the target and calls the GetHit() method on it. The AttackState is solely interested in the interface that the Health component exposes to interact with it, without needing to know how the Health object operates internally.

Extending functionality with an interface

Now, suppose we want to introduce a WoodenCrate into our game that should break when hit. We could modify the Health object to trigger an event for this new functionality. However, a more flexible approach is to expose the GetHit() method as an interface.


AttackState depends on an IDamagable interface, represented as a dashed line with an open arrow in the diagram. Health and WoodenCrate classes implement the interface, allowing AttackState to use polymorphism to call GetHit() on any object that implements the interface.

We have introduced an interface-based solution where we define a common interface IDamagable that exposes a GetHit(int damageAmount) method that takes in an int parameter (we will create a similar interface in our example project). Any class implementing this interface must define the GetHit() method. This approach allows us to treat all implementing objects as IDamageable types, enabling consistent and reusable interaction with different objects.

public interface IDamagable
{
    void GetHit(int damageAmount);
}

public class Health : MonoBehaviour, IDamagable
{
    …
    public void GetHit(int damageAmount) {...}
    Public void AddHealth(int healthAmount) {...}
}

public class WoodenCrate : MonoBehaviour, IDamagable
{
    …
    public void GetHit(int damageAmount) {...}
}

public class AttackState : State
{
    private GameObject m_target;
    private int m_damageAmount = 1;

    …

    public override void Enter()
    {
        m_target.GetComponent<IDamagable>().GetHit(m_damageAmount)
    }
}

With this setup, the AttackState script searches for IDamagable components on the m_target GameObject. Since both Health and WoodenCrate implement this interface, they can be accessed by AttackState without requiring any modifications to the AttackState class. The AttackState class only depends on the IDamagable interface, which means it can interact with any object that implements the GetHit() method, whether it’s a WoodenCrate, a Health component, or any new object we introduce to the game.

The Benefits of interfaces and polymorphism

While C# does not allow a class to inherit from more than one base class, you can implement multiple interfaces. In that way you can achieve a similar effect for defining shared contracts or behaviors.

For example, the Health and WoodenCrate objects can both inherit from MonoBehaviour and implement any number of interfaces. This capability is valuable for keeping our classes focused on a single responsibility, which in turn promotes the composition of more complex behaviors. We explored this in greater detail in Article 4.

One key aspect of using interfaces is the flexibility they provide in designing your systems. However, deciding when to implement interfaces might be difficult in the beginning. Here are a couple of scenarios where you could consider using interfaces:

  • Limited access: In the AttackState, we can’t access the AddHealth() method of the Health class because it’s not part of the IDamagable interface. Is this a limitation that will get in the way later in the project?
  • Design flexibility: There’s nothing to prevent preventing us from defining a new interface, say IHealable, that includes the AddHealth() method.
  • Practical considerations: In some cases, it might be unlikely that other objects would need to reuse the AddHealth() method. If so, we might continue accessing the object as a Health type when we need this specific functionality. This lowers the complexity of our project.

The key takeaway here is that interface design doesn’t always have a clear right or wrong way to approach it. As you gain experience, you’ll develop an intuition for when and how to use interfaces effectively.

Let’s implement this solution into our project to give you a hands-on understanding of how interfaces are used in practice.

Starter Project – Player AttackState

This article builds upon the concepts introduced in Article 4, where an AttackState was added to the EnemyNPC. The same principles have been applied to add attack behavior to the Player character. To follow along, refer to the starter project for Article 5 on GitHub: _Scripts > Article 5 > Start.

image2
The Player is now able to draw a weapon and perform an attack animation.

The following changes have been made to implement the Player’s attack behavior:

These modifications enable the Player to draw weapons and perform attack animations, with hit effects and animations already implemented.

Damageable system

The new damage system introduces an IDamagable interface, detected using a new HitDetector component. This system allows the AttackState to find all IDamagable objects within attack range and apply damage.


AttackState uses HitDetector, a MonoBehaviour with a lifespan that isn’t tied to the AttackSate. That’s why it isn’t shown as composition, even though it works this way. AttackState depends on DamageData and the IDamagable interface, shown as a dashed line with an arrow. IDamagable depends on the DamageData. Lastly HitDetector uses the SphereDetector but again, it isn’t a true composition as both of those are MonoBehaviours.

The AttackState will use HitDetector to find all the GameObjects with an IDamagable component on them which are within attacking range. It will pass to it the DamageData for it to act on this data. Since we already have an InteractionDetector script that uses a Physics.OverlapSphere() we will just refactor this code by placing it inside the SphereDetector script.

Let’s start by creating the IDamagable interface:

public interface IDamagable
{
    void TakeDamage(DamageData damageData);
}

public class DamageData
{
    public int DamageAmount { get; set; }
}

Using a DamageData object instead of passing damage values directly allows us to easily expand the damage-related information, such as adding knockback effects later.

public class DamageData
{
    public GameObject Sender { get; set; }
    public int DamageAmount { get; set; }
}

By passing a DamageData object we can easily add new parameters. Otherwise we would have to modify the definition of our interface and every class that implements it.


Health, PlayAudioGetHitEffect, and PlayerGetHitEffect all implement the IDamagable interface. This way, all of those can be interfaced with by our AttackState. PlayerGetHitEffect uses PlayerTakeDamageEffect to add a screen overlay effect to provide Hit feedback to our player.

Implementing IDamagable

The three main damage effects we’ll create that will implement the IDamagable interface are:

  1. Health: Manages the object’s health value and reacts to damage inflicted
  2. PlayAudioGetHitEffect: Plays a sound effect when hit
  3. PlayerGetHitEffect: Triggers a visual effect to indicate damage to the player

Here is an example implementation of the Health script:

public class Health : MonoBehaviour, IDamagable
{
    [field: SerializeField]
    public int CurrentHealth { get; private set; }

    [SerializeField]
    private int m_maxHealth = 2;
    [SerializeField]
    private bool m_isInvincible = false;

    public event Action OnHit;

    private void Awake()
    {
        CurrentHealth = m_maxHealth;
    }

    public void SetInvincible(bool isInvincible)
        => m_isInvincible = isInvincible;

    public void TakeDamage(DamageData damageData)
    {
        if (m_isInvincible)
            return;
        CurrentHealth -= damageData.DamageAmount;
        OnHit?.Invoke();
    }
}

public class PlayAudioGetHitEffect : MonoBehaviour, IDamagable
{
    [SerializeField]
    private AudioSource m_audioSource;

    public void TakeDamage(DamageData damageData)
    {
        m_audioSource.Play();
    }
}

Inside the Health script we define a CurrentHealth value and an OnHit event Action. Note that we inherit from MonoBehaviour and implement the IDamagable interface. In the TakeDamage() implementation we affect the CurrentHealth value and call the OnHit event (unless the object is Invincible). We can see how we can utilize the DamageData values here. The PlayAudioGetHitEffect makes the audioSource play in its TakeDamage() method.

The remaining scripts PlayerGetHitEffect and PlayerTakeDamageScreenEffect can be found on Github in the Scripts for Articles > Article 5 folder.


The player character (named Ellen_5_End) now has a Health component, PlayAudioGetHitEffect and PlayerGetHitEffect scripts, while the Enemy character has only Health and PlayAudioGetHitEffect.

Next we need to focus on detecting the IDamagable objects when the Player or Enemy performs its attack.

Detecting when we hit something

To detect collisions with IDamageable objects, we’ll implement a hit detection system by refactoring the existing interaction detector. This involves extracting the OverlapSphere casting logic into a reusable detector - SphereDetector:

public class SphereDetector : MonoBehaviour
    {
        [SerializeField] private float m_detectionRange = 1.0f;
        [SerializeField] private float m_detectionRadius = 0.5f;
        [SerializeField] private float m_height = 1.0f;
        [SerializeField] private LayerMask m_detectionLayer;

        public Color GizmoColor { get; set; } = Color.blue;

        public Collider[] DetectObjects()
        {
            Collider[] results = Physics.OverlapSphere(GetDetectionPosition(), m_detectionRadius, m_detectionLayer);
            return results;
        }

        public Vector3 GetDetectionPosition()
        {
            return transform.position + Vector3.up * m_height + transform.forward * m_detectionRange;
        }

        private void OnDrawGizmosSelected()
        {
            Gizmos.color = GizmoColor;
            Gizmos.DrawWireSphere(GetDetectionPosition(), m_detectionRadius);
        }
    }

public class InteractionDetector : MonoBehaviour
{
    [SerializeField]
    private SphereDetector m_sphereDetector;

    public IInteractable CurrentInteractable { get; private set; }
    private Highlight m_currentHighlight;

    public void DetectInteractable()
    {
        Collider[] result = m_sphereDetector.DetectObjects();
        //rest of the code the same code as before
        ...
    }

    private void ClearCurrentInteractable()
    {
        ...
    }
}

We moved the logic for detecting objects from InteractionDetector to SphereDetector, allowing reuse of the detection functionality for IInteractable objects.


The SphereDetector component is added as a child of the Player GameObject (Ellen_5_End), and its parameters are configured in the Inspector.

Make sure to check if the new changes work correctly before proceeding. Next, create a HitDetector:

public class HitDetector : MonoBehaviour
    {
        [SerializeField]
        private SphereDetector m_sphereDetector;

        public Dictionary<Collider, List<IDamagable>> PerformDetection()
        {
            Dictionary<Collider, List<IDamagable>> hitObjects = new Dictionary<Collider, List<IDamagable>>();
            Collider[] result = m_sphereDetector.DetectObjects();
            if (result.Length > 0)
            {
                foreach (var collider in result)
                {
                    List<IDamagable> damagables = new(collider.GetComponents<IDamagable>());
                    if (damagables.Count > 0)
                        hitObjects.Add(collider, damagables);
                }
            }
            return hitObjects;
        }
    }

In this script, SphereDetector is reused to detect colliders, and the IDamagable components attached to each detected collider are identified and stored in a Dictionary<Collider, List<IDamagable>>. This allows efficient access to damageable components associated with specific colliders.

Dictionary data structure

A Dictionary is a collection that associates unique keys with specific values, making it ideal for tasks such as linking game objects or components to unique IDs, or storing configuration settings and lookup tables.

Dictionaries are part of the System.Collections.Generic namespace and can be resized as elements are added similar to Lists.

Dictionaries provide efficient value lookups by their keys, ensuring fast access. For example, in a tile-based strategy game, you might use a Dictionary<Vector3Int, List> to map each tile position (represented by a Vector3Int) to a list of units (List) occupying that tile. The uniqueness of keys ensures no duplicate entries, simplifying data management and enhancing performance when retrieving specific values.

Read more about Dictionary<TKey,TValue> data structure.

In our example context, we’re using a Dictionary<Collider, List<IDamagable>> to store and retrieve all the IDamagable components attached to each detected Collider GameObject. This allows us to efficiently manage and access the damageable components associated with specific colliders.

Add the HitDetector component to Enemy and Player GameObjects, and create a new SphereDetector as a child object. Next add it as a reference to the HitDetector, setting its Detection Layer to Player, NPC, and Environment.

Ensure the Ellen_5_End and Enemy_NPC GameObjects have trigger colliders assigned to one of the detectable layers.


HitDetector was added to Player and Enemy GameObjects. It now references the SphereDetector instance.

To perform detection, reference the HitDetector in the PlayerAgent and EnemyAgent, passing it to the AttackState which will initiate detection and apply damage to each detected damageable object:

public class AttackState : State
{
    ...
    private HitDetector m_hitDetector;
    private float m_detectionDelay;
    private float m_currentTime = 0;

    public AttackState(AgentAnimations agentAnimations, IAgentMover mover, AgentStats agentStats, GameObject agent, HitDetector hitDetector, float detectionDelay)
    {
        ...
        m_hitDetector = hitDetector;
        m_detectionDelay = detectionDelay;
        m_agent = agent;
    }

    public override void Enter()
    {
        ...
    }

    public override void Exit()
    {
        return;
    }

    protected override void StateUpdate(float deltaTime)
    {
        if (m_currentTime < 0)
            return;
        m_currentTime += Time.deltaTime;
        if (m_currentTime >= m_detectionDelay)
        {
            m_currentTime = -1;
            Dictionary<Collider, List<IDamagable>> result = m_hitDetector.PerformDetection();
            foreach (var collider in result.Keys)
            {
                foreach (var damageable in result[collider])
                {
                    damageable.TakeDamage(new DamageData() { Sender = m_agent, DamageAmount = 1 });
                }
            }
        }
    }
}

In AttackState, the HitDetector is used within the StateUpdate() method to perform detection. The Agent GameObject is included in the DamageData object. The detectionDelay parameter ensures synchronization between attack animations and damage effects. Each detected damageable object is processed to apply damage. Here is our PlayerAgent script:

public class PlayerAgent : Agent
    {
        ...

        [SerializeField]
        private HitDetector m_hitDetector;

        protected override void Awake()
        {
            base.Awake();
            ...
            m_hitDetector = GetComponent<HitDetector>();
        }

        protected override State StateFactory(Type stateType)
        {
            State newState = null;
            if (stateType == typeof(JumpState))
            {
                ...
            }
            else if (stateType == typeof(AttackState))
            {
                newState = new AttackState(m_agentAnimations, m_mover, m_agentStats, gameObject, m_hitDetector, 0.03f);
                newState.AddTransition(new DelayedTransition(0.35f, typeof(MovementState)));
            }
            else
            {
                ...
            }
            return newState;
        }

        protected override void Update()
        {
            ...
        }
    }

PlayerAgent needs to have a reference to the HitDetector and pass it and other parameters to the AttackState for our system to work. The EnemyAgent class requires the same changes with a difference of passing 0.2f as the detectionDelay parameter. The fact that the data is duplicated is a code smell and indicates a need to refactor our code. You can browse changes made to the EnemyAgent on Github.

Let’s press Play to test our setup:

image1
Our screen overlay visual effect is now being triggered as a result of us implementing our Damage System.

We can see that the enemy performing the attack results in a screen overlay effect with the screen tinting red/purple for a brief moment. If you are running the project in the Editor you should also be able to hear the sound effect when the Player hits the Enemy.

We will improve the hit feedback in future iterations but first, let’s explore how to implement a damageable object using the IDamagable interface.

Reusing IDamagable interface

image3
As the player hits the tree object, it will trigger an effect with a shake animation and spawn particles.

To expand the versatility of our IDamagable interface, we want to allow interactions with a variety of objects in the game world, such as trees, which should provide visual feedback when hit. To achieve this, we’ll use the provided ShakeTransformEffect and SpawnParticleEffect scripts along with simple wood chip particles. These components are already attached to the tree object in our scene.


The Tree GameObject has ShakeTransformEffect, SpawnParticleEffect and PlayAudioGetHitEffect components attached to it.

Additionally, the PlayAudioGetHitEffect component is attached to the tree object leveraging Unity’s component system for composition. By using this approach we are able to create reusable “hit effect” scripts that can be applied to different game objects.

The tree object already has a Collider and is assigned to the Environment layer. To reuse our damage system, we’ll need to modify these scripts to implement the IDamagable interface.

Let’s start by modifying the ShakeTransformEffect script:

public class ShakeTransformEffect : MonoBehaviour, IDamagable
{
    ...

    private Vector3 originalPosition;
    private Coroutine shakeCoroutine;

    private void Awake()
    {
        originalPosition = transform.position;
    }

    public void Shake()
    {
        ...
    }

    private IEnumerator ShakeCoroutine()
    {
        ...
    }

    public void TakeDamage(DamageData damageData)
    {
        Shake();
    }
}

In this implementation, the TakeDamage() method simply calls the Shake() method, causing the object to shake when it takes damage. You can view the complete version of this script on GitHub.

Next, let’s implement the IDamagable interface in the SpawnParticleEffect script:

public class SpawnParticleEffect : MonoBehaviour, IDamagable
{
    ...

    private void Awake()
    {
        InitializeParticleSystem();
    }

    private void InitializeParticleSystem()
    {
        ...
    }

    public void SpawnParticles(Vector3 position, Vector3 normal)
    {
        ...
    }

    public void TakeDamage(DamageData damageData)
    {
        Vector3 position = transform.position;
        position.y = damageData.Sender.transform.position.y;
        Vector3 normal = (damageData.Sender.transform.position - transform.position).normalized;
        SpawnParticles(position, normal);
    }
}

The SpawnParticleEffect uses the Sender parameter from DamageData to calculate the normal vector from the tree object to the detection sphere. This allows the wood chip particles to fly in the direction of the Player, rather than dispersing randomly around the tree. This technique could be extended to implement a knockback effect for characters as well.

Integrating the Damage System with the state pattern

In Article 2, we introduced the state design pattern, and demonstrated how it’s easy to add more behaviors to the existing codebase. Now, we will expand our character behaviors with DeathState and GetHitState and integrate them with our Damage System.

The first step, is adding a death animation using the ragdoll wizard

Our project already includes a GetHit animation for characters. To add a death animation, we can use Unity’s ragdoll wizard. Here’s how to do it:

  1. In the Hierarchy, right-click on the Enemy GameObject.
  2. Select Create > 3D Object > Ragdoll.
  3. Assign the appropriate bones to their respective slots and click the Create button.


Accessing the ragdoll wizard in Unity


All the bones have now been assigned.

To enable ragdoll physics on your character, simply disable its Animator component. This will stop the Animator from driving the bone movements, causing the character to collapse onto the floor – an effective simple death animation.

image6
Enable ragdoll physics by disabling the Animator component.

Next, we need to add NavMeshNPCDeathState, GetHitState, NPCDeathTransition, and GetHitTransition to our project. These scripts are already present in the project, so our task is to integrate them with the PlayerAgent and EnemyAgent scripts.

Here are the transition scripts:

public class NPCDeathTransition : ITransitionRule
{
    public Type NextState => typeof(NavMeshNPCDeathState);
    private Health m_health;

    public NPCDeathTransition(Health health)
    {
        m_health = health;
    }

    public bool ShouldTransition(float deltaTime)
    {
        return m_health.CurrentHealth <= 0;
    }
}

public class GetHitTransition : IEventTransitionRule
{
    public Type NextState => typeof(GetHitState);
    private Health m_health;
    private bool m_shouldTransition = false;
    public GetHitTransition(Health health)
    {
        m_health = health;
    }

    public bool ShouldTransition(float deltaTime)
    {
        return m_shouldTransition;
    }

    public void Subscribe()
    {
        m_health.OnHit += TriggerTransition;
    }

    private void TriggerTransition()
    {
        m_shouldTransition = true;
    }

    public void Unsubscribe()
    {
        m_health.OnHit -= TriggerTransition;
    }
}

As shown, both transitions utilize the Health component, which implements the IDamagable interface. When the Damage System calls the TakeDamage() method on the Health object, the transitions either check the CurrentHealth value or wait for the OnHit event to trigger the transition to their respective states.

image10
The Player is now affected by the Enemy attack and vice versa. Our Player is also able to hit the Tree GameObject causing it to play its own hit effect.

To see the complete code used for integrating these with the PlayerAgent and EnemyAgent scripts, visit the GitHub repository and open the scene inside the _Scripts > Article 5 Result folder.

Refactoring StateFactory method to separate objects

In Article 2, we implemented the state design pattern in our project. This allowed us to add new states without having to change the Agent each time. However, the Agent implementations still depend on all the specific state classes they use. Ideally, an Agent should only be aware of the abstract State, not the concrete State implementations.


Agent implementation depends on all the State classes that it uses.

This high coupling between the Agent and its states means that any modification to any State implementation may require changes across multiple Agent implementations. This scenario contradicts our goal of making the Agent implementations responsible for only directing the flow of control in our character setup. Currently, the Agent class changes for multiple reasons – when we add or modify a state, and when we need to pass a new reference object to our states.

Fortunately, we can refactor our code by extracting the StateFactory() method into a separate class:


The Agent class now uses a non-abstract StateFactory class that contains StateFactoryData. Each StateFactory implementation expects to receive an instance of a respective StateFactoryData implementation. Note that this diagram might be oversimplified compared to the actual code to maintain readability.

This refactoring restores the Agent's responsibility to only pass specific references around, while the StateFactory implementations handle the creation of new State instances. As a result, the Agent class only needs to know about the abstract State class, rather than being tightly coupled to all the individual state implementations. Here is the StateFactory script:

public class StateFactory
{
    protected StateFactoryData m_stateFactoryData;
    public StateFactory(StateFactoryData stateFactoryData)
    {
        m_stateFactoryData = stateFactoryData;
    }

    public virtual State CreateState(Type stateType)
    {
        State newState = null;
        if (stateType == typeof(MovementState))
        {
            newState = new MovementState(m_stateFactoryData.AgentMover, m_stateFactoryData.GroundDetector, m_stateFactoryData.AgentAnimations, m_stateFactoryData.MovementInput, m_stateFactoryData.AgentStats);
            newState.AddTransition(new GroundedFallTransition(m_stateFactoryData.GroundDetector));
        }
        else if (stateType == typeof(FallState))
        {
            newState = new FallState(m_stateFactoryData.AgentMover, m_stateFactoryData.AgentAnimations, m_stateFactoryData.MovementInput, m_stateFactoryData.AgentStats);
            newState.AddTransition(new FallLandTransition(m_stateFactoryData.GroundDetector));
        }
        else if (stateType == typeof(LandState))
        {
            newState = new LandState(m_stateFactoryData.AgentAnimations, m_stateFactoryData.AgentStats);
            newState.AddTransition(new LandMovementTransition());
        }
        else
        {
            throw new Exception($"Type not handled {stateType}");
        }
        return newState;
    }
}

In this refactoring, the StateFactory class now contains the logic that was previously embedded in the StateFactory() method. To facilitate this, we use the StateFactoryData object to pass the necessary references into the StateFactory constructor. C# properties are used to easily assign these fields, and you may consider making the setters private and adding a constructor to prevent other objects from modifying the references.

Here’s the StateFactoryData class:

public class StateFactoryData
{
    public IAgentMover AgentMover { get; set; }
    public IAgentMovementInput MovementInput { get; set; }
    public GroundedDetector GroundDetector { get; set; }
    public AgentAnimations AgentAnimations { get; set; }
    public AgentStats AgentStats { get; set; }

}

With this structure, the Agent class is simplified, focusing on passing information and directing control flow between the components:

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 StateFactory _stateFactory;

    protected virtual void Awake()
    {
        m_input = GetComponent<IAgentMovementInput>();

        m_groundDetector = GetComponent<GroundedDetector>();
        m_agentAnimations = GetComponent<AgentAnimations>();
        m_mover = GetComponent<IAgentMover>();
        m_agentStats = GetComponent<AgentStats>();

        _stateFactory = new StateFactory(
            new StateFactoryData
            {
                AgentStats = m_agentStats,
                MovementInput = m_input,
                GroundDetector = m_groundDetector,
                AgentAnimations = m_agentAnimations,
                AgentMover = m_mover
            });
    }

    protected virtual void Start()
    {
        TransitionToState(typeof(MovementState));
    }

    private void TransitionToState(Type stateType)
    {
        State newState = _stateFactory.CreateState(stateType);
        ...
    }

    protected virtual void Update()
    {
        ...
    }

    protected virtual void FixedUpdate()
    {
        ...
    }

}

The Agent class has become shorter and with a focused single responsibility. It now primarily manages the flow of information and control between its components, ensuring that the Agent operates as expected.

For more details on the StateFactory implementation for both the Player and NPCs, you can explore the GitHub repository by opening a scene inside the Scripts for Articles > Article 5 > 3 Agent StateFactory refactoring folder.

Conclusion

Throughout this article series, we’ve made significant strides in refactoring and improving our game project’s architecture. In this article our focus was to create a reusable and flexible Damage System. We did that by leveraging interfaces, successfully decoupling damage-related logic from specific classes, and enabling different objects to interact with the damage system seamlessly. This modular design not only enhances code reusability but also simplifies the addition of new features, as components can now handle damage in a consistent and extensible manner.

We also refactored our Agent class significantly. We started with a massive monolithic AgentMonolithic class in Article 1 that had too many responsibilities, making it cumbersome to reuse for both our Player and NPCs. Through refactoring, we broke down this class into smaller, more manageable components. As a result, none of our classes now exceed 100 lines of code, and each script has a clearly defined purpose. While no software is ever perfect and one could argue for different ways to refactor it further, the project is now more extendable than the original, even after the addition of multiple new game mechanics.

Our hope is that by applying the tips and techniques discussed in this series to your own projects, you can keep your codebase flexible and maintainable, allowing you to continue developing your games with greater ease and confidence.

2 Likes