DEV Community

Cover image for Unity Foundational Architecture: Loose Coupling, Interfaces & Events

Unity Foundational Architecture: Loose Coupling, Interfaces & Events

Table of Contents:

Introduction

In the last few posts we covered Project Scaffolding, Bootstrapping Logic, and Global State Management. The next problem that we will tackle is how to tie our code and scene objects together without creating a tangled and unmanageable web of dependencies (Spaghetti).

The Power of Interfaces

To decouple our systems, we need to stop relying on concrete classes. If a Turret script needs to deal damage, it shouldn't hold a reference to PlayerHealth, EnemyHealth, or DestructibleCrate. Doing so creates strict dependencies.

Instead, the Turret just needs to know that the thing it's hitting can take damage. As such we can define an generic interface for "things that can take damage". Let's call it IDamageable:

public interface IDamageable 
{
    void TakeDamage(int amount);
}
Enter fullscreen mode Exit fullscreen mode

Now, the Turret only relies on the IDamageable contract. It doesn't care what the object is, as long as it signs that contract. This is textbook loose coupling.

The Unity Serialization Wall

However, if you try to expose this interface in the inspector so that you can inject the dependancy into your monobehaviour scripts connecting the scene objects, you'll hit a wall.

Unity's standard serializer does not serialize interfaces. It can only handle concrete classes that derive from UnityEngine.Object. Now, you can work around this by using GetComponent() in Awake() essentially hooking things up on initialization manually through code, but that forces you to hardcode scene hierarchy paths or rely on slow runtime lookups, and defeating the purpose of a highly configurable architecture that uses dependancy injection.

Building a Serializable Unity Object Reference

To get the best of both world, clean interfaces in our code and drag-&-drop support in the inspector, we can build a generic wrapper.

By utilizing the ISerializationCallbackReceiver interface, we can trick Unity into serializing a standard UnityEngine.Object, while casting it to our interface behind the scenes.

using UnityEngine;

using System;
using System.Collections.Generic;

using UnityObject = UnityEngine.Object;

[Serializable]
public class SerializableUnityRef<T> : ISerializationCallbackReceiver where T : class
{
    // This field will store the serialized reference to the Unity Object that the inspector can serialize (The True Value)
    [SerializeField]
    private UnityObject _reference;

    // This field will store the serialized reference casted to T which may or may not be a serializable type.
    [SerializeField]
    private T _value;

    public SerializableUnityRef() { }

    public SerializableUnityRef(T val)
    {
        Value = val;
    }

    /// <summary>
    /// Gets and Sets the current runtime value of this object. Note that if the value is set at runtime to something that is not a Unity Object,
    /// then the backing reference will be null.
    /// </summary>
    public T Value
    {
        get => _value == null ? _value = _reference as T : _value;
        set
        {
            bool changed = _value != value;

            _value = value;

            if (changed)
            {
                if (_value is UnityObject valueAsUnityObject)
                    _reference = valueAsUnityObject;
                else
                    _reference = null;
            }
        }
    }

    public bool HasValue => Value != null;

    public Type ValueType => typeof(T);

    /// <returns>
    /// The backing reference value of this object (The True Value). If null, then return the stored runtime value of this object without modifying it.
    /// This differs from the <see cref="Value"/> property getter which will adjust the stored value to match the backing reference if the value is null and the reference is not.
    /// </returns>
    public static T ValueOf(SerializableUnityRef<T> serializableUnityRef)
    {
        if (serializableUnityRef == null)
            return null;
        else if (serializableUnityRef._reference == null)
            return serializableUnityRef.Value;
        else
            return serializableUnityRef._reference as T;
    }

    public static implicit operator T(SerializableUnityRef<T> serializableRef) => serializableRef.Value;
    public static implicit operator SerializableUnityRef<T>(T val) => new SerializableUnityRef<T>() { Value = val };

    void ISerializationCallbackReceiver.OnBeforeSerialize()
    {
        ValidateReferenceObject();
    }

    void ISerializationCallbackReceiver.OnAfterDeserialize()
    {
        // do nothing
    }

    internal void ValidateReferenceObject()
    {
        if (_reference == null)
        {
            _value = null;
            return;
        }

        if (ValueType == typeof(GameObject) && _reference.GetType() == typeof(GameObject))
        {
            return;
        }

        if (_reference is GameObject go)
        {
            _reference = null;

            // find first component that inherits from T
            foreach (Component component in go.GetComponents<Component>())
            {
                if (component is T)
                {
                    _reference = component;
                    break;
                }
            }
        }
        else if (!(_reference is T))
        {
            _reference = null;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// This now shows up in the inspector!
[SerializeField]
private SerializableUnityRef<IDamageable> _target;

private void Attack()
{
    target.Value?.TakeDamage(10);
}
Enter fullscreen mode Exit fullscreen mode

While this solution "works" it doesn't look very nice in the inspector and is missing some use cases that still makes it difficult to use properly.

What we have so far:

  • A wrapper to serialize a reference to a Unity Object in the inspector. Works for Monobehaviour and ScriptableObject derived classes.

What we are missing:

  • A better look and feel in the inspector. Requires a custom property drawer.
  • A way to drag and drop a GameObject and reference a specific component on that GameObject. If there are multiple components of the same reference type then we need some sort of dropdown in the inspector to help distinguish between them so we know which one we are injecting. Also requires a custom property drawer.

Let's build a custom property drawer so that we can fix the above problems. You will need to create a SerializableUnityRefPropertyDrawer script inside an Editor folder. We will also have to modify our SerializableUnityRef script and add an interface.

public interface ISerializableUnityRef : ISerializationCallbackReceiver
{
    public List<object> GetSiblingComponentsOfTypeFromReferenceObject();

    public Type ValueType { get; }
}

// change the class so that it inherits from our custom interface
[Serializable]
public class SerializableUnityRef<T> : ISerializableUnityRef where T : class
{
    // all other fields and methods remain the same ...

    // add the GetSiblingComponentsOfTypeFromReferenceObject method to respect our interface:

    /// <summary>
    /// If the referenced object is a Component, then this gets all components attached 
    /// to the same gameObject that are of type <typeparamref name="T"/>. <br />
    /// Otherwise, returns a single element list with just the reference object.
    /// </summary>
    /// <returns>
    /// A non-empty list of all components attached to the same gameObject as the 
    /// referenced object that are of type <typeparamref name="T"/>.
    /// </returns>
    public List<object> GetSiblingComponentsOfTypeFromReferenceObject()
    {
        List<object> siblingComponents = new List<object>();

        if (_reference == null)
            return siblingComponents;

        // we must cast the value and set the reference if value was set via code
        if (_reference == null && _value != null && _value is UnityObject valueAsUnityObject)
        {
            _reference = valueAsUnityObject;
        }

        if (_reference is Component component)
        {
            siblingComponents.AddRange(component.GetComponents<T>());
        }
        else if (!(_reference is T))
        {
            siblingComponents.Add(_reference);
        }

        return siblingComponents;
    }
}
Enter fullscreen mode Exit fullscreen mode

Ok so the next part is just custom PropertyDrawer logic using the EditorGUI API. We will essentially check if the reference is a gameobject and if it is then we will use the function we just created to get all the components attached that inherit from the same type and let the user select the correct component via a dropdown. If it's not a gameobject then we just make it look like a normal single line serialized property field. If you don't understand what is going on here it's ok to just copy paste the following code (this article is not going to cover Unity Editor Custom Tool programming). Just be sure to include this next bit of code inside an Editor folder so that your build will not crash.

[CustomPropertyDrawer(typeof(SerializableUnityRef<>))]
public class SerializableUnityRefPropertyDrawer : PropertyDrawer
{
    private const string REFERENCE_OBJECT_FIELD_NAME = "_reference";

    private int _selectedReferenceIndex;

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        return EditorGUIUtility.singleLineHeight;
    }

    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        EditorGUI.BeginProperty(position, GUIContent.none, property);
        DrawReferenceField(ref position, property, label);
        EditorGUI.EndProperty();
    }

    private void DrawReferenceField(ref Rect position, SerializedProperty property, GUIContent label)
    {
        ISerializableUnityRef serializableRef = PropertyDrawerUtility.GetFieldValue<ISerializableUnityRef>(property);

        if (serializableRef == null)
            return;

        List<object> references = serializableRef.GetSiblingComponentsOfTypeFromReferenceObject();

        float propertyFieldWidth = position.width;

        if (references.Count > 1)
        {
            propertyFieldWidth = Mathf.Min(propertyFieldWidth * 0.7f, 400);
            EditorGUIUtility.labelWidth = Mathf.Clamp(EditorStyles.label.CalcSize(label).x, 100, _originalLabelWidth);
        }

        SerializedProperty spReferenceObject = property.FindPropertyRelative(REFERENCE_OBJECT_FIELD_NAME);

        Rect drawRect = new Rect(position.x, position.y, propertyFieldWidth, EditorGUIUtility.singleLineHeight);

        float horizontalSpacing = 2f;
        float horizontalSpaceUsed = drawRect.width + horizontalSpacing;
        position.x += horizontalSpaceUsed;
        position.width -= horizontalSpaceUsed;

        EditorGUI.PropertyField(drawRect, spReferenceObject, label);

        EditorGUIUtility.labelWidth = _originalLabelWidth;

        if (references.Count > 1)
        {
            string[] displayedOptions = new string[references.Count];
            for (int i = 0; i < references.Count; ++i)
            {
                displayedOptions[i] = $"{i} - {references[i].GetType().Name}";

                if (references[i].Equals(spReferenceObject.objectReferenceValue))
                    _selectedReferenceIndex = i;
            }

            drawRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);

            horizontalSpaceUsed = drawRect.width + horizontalSpacing;
            position.x += horizontalSpaceUsed;
            position.width -= horizontalSpaceUsed;

            EditorGUI.BeginChangeCheck();
            {
                _selectedReferenceIndex = EditorGUI.Popup(drawRect, _selectedReferenceIndex, displayedOptions);
            }
            if (EditorGUI.EndChangeCheck())
            {
                spReferenceObject.objectReferenceValue = references[_selectedReferenceIndex] as UnityObject;
            }
        }
    }
}

// Helper functions contained inside a utility class:
public static class PropertyDrawerUtility
{
    /// <returns>The actual value of the given <paramref name="property"/>. Handles arrays/lists as well.</returns>
    public static T GetFieldValue<T>(SerializedProperty property) where T : class
    {
        object obj = GetTargetObjectOfProperty(property);

        if (obj == null)
            return null;

        Type objType = obj.GetType();
        bool isList = objType.IsGenericType && objType.GetGenericTypeDefinition() == typeof(List<>);

        T actualObject = null;
        if (objType.IsArray || isList)
        {
            int index = Convert.ToInt32(new string(property.propertyPath.Where(c => char.IsDigit(c)).ToArray()));

            if (isList)
                actualObject = (T)((IList)obj)[index];
            else
                actualObject = ((T[])obj)[index];
        }
        else
        {
            actualObject = obj as T;
        }

        return actualObject;
    }

    /// <returns>the object the property represents.</returns>
    public static object GetTargetObjectOfProperty(SerializedProperty property)
    {
        if (property == null) 
            return null;

        string path = property.propertyPath.Replace(".Array.data[", "[");
        object targetObj = property.serializedObject.targetObject;
        string[] elements = path.Split('.');
        foreach (string element in elements)
        {
            if (element.Contains("["))
            {
                string elementName = element.Substring(0, element.IndexOf("["));
                int index = Convert.ToInt32(element.Substring(element.IndexOf("[")).Replace("[", "").Replace("]", ""));
                targetObj = GetEnumerableObjectValue(targetObj, elementName, index);
            }
            else
            {
                targetObj = GetObjectValue(targetObj, element);
            }
        }

        return targetObj;
    }

    private static object GetObjectValue(object source, string name)
    {
        if (source == null)
            return null;

        Type type = source.GetType();

        while (type != null)
        {
            FieldInfo fieldInfo = type.GetField(name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);

            if (fieldInfo != null)
                return fieldInfo.GetValue(source);

            PropertyInfo propertyInfo = type.GetProperty(name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);

            if (propertyInfo != null)
                return propertyInfo.GetValue(source, null);

            type = type.BaseType;
        }

        return null;
    }

    private static object GetEnumerableObjectValue(object source, string name, int index)
    {
        IEnumerable enumerable = GetObjectValue(source, name) as IEnumerable;

        if (enumerable == null) 
            return null;

        IEnumerator enumerator = enumerable.GetEnumerator();

        for (int i = 0; i <= index; i++)
        {
            if (!enumerator.MoveNext()) 
                return null;
        }

        return enumerator.Current;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we have the following Inspector GUI. If there is only one possible reference then there will be no dropdown and the singular reference will be serialized. If there is more than one reference to choose from because we dragged a gameobject into the field, it automatically serialize the first reference it finds and will show the dropdown to let the user switch.

SerializableUnityRef with reference dropdown

We now have the ability to create serializable unity object references but through an interface type. This is a very powerful tool and a big step towards helping us build cleaner software architecture and reduce spaghetti in our game.

Serializing Plain C# Object References

While we may be able to now serialize references in which the object inherits from UnityEngine.Object, what about plain C# object references? Our SerializableUnityRef will not work for those.

You may already know about this, but Unity will serialize plain C# objects and struct types using [SerializeField] as long as you mark the non-abstract class or struct with [System.Serializable].

[System.Serializable] // <-- This is needed for Serializing this type in the inspector
public class Ability
{
    // Quick tip: you can target the backing field of C# properties using "field:"
    [field: SerializeField]
    public string Name { get; private set; }

    [field: SerializeField]
    public int Cost { get; set; }
}

public class Test : MonoBehaviour
{
    [field: SerializeField]
    public Ability InherentAbility { get; private set; }

    [SerializeField]
    public List<Ability> Abilities;
}
Enter fullscreen mode Exit fullscreen mode

This gives us the following in the inspector:

This is nice but we are not utilizing the power of interfaces here. Let's modify our Ability class and the references in our component so that we use interfaces/abstraction:

// I used an abstract class but this could also be an interface
public abstract class Ability
{
    [field: SerializeField]
    public string Name { get; private set; }

    [field: SerializeField]
    public int Cost { get; set; }
}

[System.Serializable]
public class FlyAbility : Ability
{
    public float MaxHeight = 1000;
}

[System.Serializable]
public class ExtraJumpsAbility : Ability
{
    public int ExtraJumps = 1;
}

public class Test : MonoBehaviour
{
    [field: SerializeField]
    public Ability InherentAbility { get; private set; }

    [SerializeField]
    public List<Ability> Abilities;
}
Enter fullscreen mode Exit fullscreen mode

Not so great anymore, we lost our serialization:

In order to serialize plain C# objects by reference, we need to use Unity's [SerializeReference] attribute. It even works on List types allowing for serializing lists of different concrete element types. However, with this new attribute alone we still have a problem, the editor does not know how to serialize unset values because it obviously does not know what the concrete types we want.

Short of using [ContextMenu] to add options to the "three dots" component menu to add specific instances of abilities to the list or set the InherentAbility field, Unity expects you to use the [SerializeReference] attribute in conjunction with a custom editor script. Well, luckily for us someone has already implemented this for us so we don't need to do it ourselves. Simply grab the [SubclassSelector] attribute from this github user's repo : https://github.com/mackysoft/Unity-SerializeReferenceExtensions (see url for installation steps).

With the package installed, the [SubclassSelector] attribute allows us to use the same code we wrote above and easily add support for a search menu to assign (and even swap out) the concrete instances for [SerializeReference] fields directly in the inspector.

public class Test : MonoBehaviour
{
    [field: SerializeField, SubclassSelector]
    public Ability InherentAbility { get; private set; }

    [SerializeField, SubclassSelector]
    public List<Ability> Abilities;
}
Enter fullscreen mode Exit fullscreen mode

Now we have successfully serialized our plain C# objects via interface/abstract class reference and we have a neat little tool to assign them in the inspector! The only thing we still cannot serialize via interface is structs but that's ok.

Decoupling Communication (Events)

Smart references solve the dependency problem when you strictly need to interact with a specific object (like a Turret shooting an IDamageable). But what about events?

When the player dies, the UIManager needs to show the game over screen, the AudioManager needs to play sad_trombone.mp3, and the AchievementManager needs to log a death. If your PlayerHealth script holds references to all three of those systems, it's now coupled to all of them. The player shouldn't know or care that these other systems exist.

This is where an Event Bus comes in. An Event Bus is a centralized hub for communication. Instead of objects talking directly to each other, they speak to the bus and the bus broadcasts those events.

Building a Simple Generic Global Event Bus

We can create a lightweight, type-safe Event Bus using C# delegates and a static dictionary. This allows any system to subscribe to a specific type of event without knowing who triggers it.

// an empty interface so we can constrain what qualifies as event data
public interface IEventData { }
Enter fullscreen mode Exit fullscreen mode
public struct PlayerDamagedEventData : IEventData 
{
    public int DamageAmount;
    public int CurrentHealth;
}
Enter fullscreen mode Exit fullscreen mode

Now, we build the Event Bus itself. We'll use a static generic class. The compiler will generate a unique static instance for every distinct type of EventBus<T>, meaning we don't need to manage a massive dictionary of types.

public static class EventBus<T> where T : IEventData
{
    public static event Action<T> OnEventRaised;

    public static void Subscribe(Action<T> handler)
    {
        OnEventRaised += handler;
    }

    public static void Unsubscribe(Action<T> handler)
    {
        OnEventRaised -= handler;
    }

    public static void Raise(T eventData)
    {
        OnEventRaised?.Invoke(eventData);
    }
}
Enter fullscreen mode Exit fullscreen mode

With this architecture, the PlayerHealth script just raises the event on the EventBus<PlayerDamagedEventData> channel when it takes damage. Over in the other systems that need to wait for a damage event, you simply subscribe to the event. Each system only knows about the EventBus<PlayerDamagedEventData> event, but they don't have any direct dependencies on each other.

public class HealthUI : MonoBehaviour
{
    private void OnEnable()
    {
        EventBus<PlayerDamagedEventData>.Subscribe(EventBus_PlayerDamagedEvent);
    }

    private void OnDisable()
    {
        EventBus<PlayerDamagedEventData>.Unsubscribe(EventBus_PlayerDamagedEvent);
    }

    private void EventBus_PlayerDamagedEvent(PlayerDamagedEventData eventData)
    {
        Debug.Log($"Player took {eventData.DamageAmount} damage! Health is now {eventData.CurrentHealth}.");
    }
}
Enter fullscreen mode Exit fullscreen mode

The Instance Specific Event Problem

This is a great solution for a event bus system that deals with global events however, as you may have already surmised, it falls apart completely if you have 50 Goblins in a scene and the UI only needs to track the health of the specific one you currently have targeted. If you use a global CharacterDamagedEvent, every CharacterHealthBarUI element in the game will update when any goblin takes damage.

As we can see, our event bus is better suited for global events. What we actually want is instance specific events. Fortunately, because of the work we did in the first half of the article, we have an incredibly clean way to handle this.

public interface IDamageable
{
    // The event is now part of the contract
    event Action<float> OnDamageTaken;
    event Action OnDeath;

    void TakeDamage(int amount);
}
Enter fullscreen mode Exit fullscreen mode

Now, your concrete implementation (like CharacterHealth) implements the event:

public class CharacterHealth : MonoBehaviour, IDamageable
{
    public event Action<float> OnDamageTaken;
    public event Action OnDeath;

    private int _health = 100;

    public void TakeDamage(int amount)
    {
        _health -= amount;
        OnDamageTaken?.Invoke(_health);

        if (_health <= 0) 
            OnDeath?.Invoke();
    }
}
Enter fullscreen mode Exit fullscreen mode

Then, over in the CharacterHealthBarUI script, you use the SerializableUnityRef we built earlier to inject the specific instance via the inspector, and subscribe directly to its interface event(s).

public class CharacterHealthBarUI : MonoBehaviour
{
    // Drag the CharacterHealth, BossHealth, CarHealth or a any other IDamageable here!
    [SerializeField]
    private SerializableUnityRef<IDamageable> _damageable;

    private void OnEnable()
    {
        if (_damageable.HasValue)
            _damageable.Value.OnDamageTaken += UpdateFillBar;
    }

    private void OnDisable()
    {
        if (_damageable.HasValue)
            _damageable.Value.OnDamageTaken -= UpdateFillBar;
    }

    private void Damageable_OnDamageTaken(float newHealth)
    {
        // update health bar fill amount ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this is powerful: The UI is listening to a specific instance, but it is still completely decoupled from the concrete class. It doesn't know exactly what it's referencing only that it's an IDamageable and adheres to that contract.

Instance Specific EventBus

We know that things like CharacterAnimator or CharacterAudio need to subscribe to the IDamageable instance OnDamageTaken event (maybe to play a sound on damage or death). However, CharacterAudio might also need to listen to CharacterMovement events or other events specific to the character instance and in a complex gameobject hierarchy it might be annoying to hook up all the references in the inspector. You might find yourself saying "I need a centralized hub (a bus) that is specific to the gameobject instance so that I don't have to manage many different dependencies on a complex prefab hierarchy. I'll just use the event bus pattern I learned earlier but localize it to my gameobject instance." and implementing a solution like so:

public class CharacterEventBus : MonoBehaviour
{
    public event Action<int> OnDamageTaken;
    public void RaiseDamageTaken(int amount) => OnDamageTaken?.Invoke(amount);

    public event Action OnDeath;
    public void RaiseDeath() => OnDeath?.Invoke();

    // keep adding events here as they come up in development ...
}
Enter fullscreen mode Exit fullscreen mode

Now, your character's components just require the CharacterEventBus reference and they just need to ask the CharacterEventBus to fire their events out into the void and whoever is listening will hear them.

The hidden problem with this approach: While it may seem that we cleaned up our dependencies even further, all we did is move them all into a single class CharacterEventBus. In my professional opinion, this is the equivalent of a teenager "cleaning" his room by shoving everything in the closet.

For instance based events, using the interface design from earlier and letting the classes responsible for those events fire their own events is the better approach. It may be annoying to hook up all the dependencies on the gameobject/prefab but this is one time setup work and will save you time in the long run by increasing dependency visibility and allow you to easily see dependencies for a given object at a glance.

Instead of the teenager shoving everything inside the closet, as he gets new stuff he will keep things logically together (video games and film near the TV, books near the bed or desk where they are read, shoes near the door, etc).

A complex hierarchy with lots of component dependencies is like a room with a lot of stuff, it's not necessarily messy because there's lots of stuff everywhere, it's messy because of a lack of architecture and logic in where the stuff is placed (clothes on the floor, video games far from the TV, etc).

Serializing Events

While C# Action delegates are incredibly fast and type-safe, they exist entirely in code. If a level designer wants a door to open when a boss dies, they would have to ask a programmer to write a custom script to subscribe to the boss's OnDeath event. This slows down development. If only the designer could subscribe and tie the event to the specific Door's Open() function without needing the developer to step in. Well luckily for us you can do that in Unity by using the UnityEvent type.

Unlike standard C# events, UnityEvent can be serialized and displayed in the Inspector. This allows designers to drag and drop object references and select methods from a dropdown menu, wiring up complex interactions visually.

Let's look at an example:

public class BigBadBoss : MonoBehaviour
{
    [SerializeField] 
    private UnityEvent<int> _onDamageTaken;

    [SerializeField] 
    private UnityEvent _onDeath;

    private int _health = 1000;

    public void TakeDamage(int amount)
    {
        _health -= amount;
        _onDamageTaken?.Invoke(_health);

        if (_health <= 0) 
            _onDeath?.Invoke();
    }
}
Enter fullscreen mode Exit fullscreen mode

At first glance you might think that this is great and that you can just change all your events to use UnityEvent instead of Action. Don't Do This! While using UnityEvent is incredibly powerful for letting designers and programmers to quickly hook up event logic without any code changes, they come with a few trade-offs that you should keep in mind:

  • Performance: UnityEvent is slightly slower than a standard C# Action because it relies on reflection under the hood. For events that fire every frame (like in the Update() loop), you should stick to C# Action. For infrequent events (like OnDeath), UnityEvent is perfectly fine.
  • Traceability: If you use C# Action, you can easily use "Find All References" in your IDE to see exactly what is listening to that event. With UnityEvent, the connections are hidden inside scene or prefab files, which reduces code visibility and can make debugging a little harder if you overuse them.

On a separate note, you might have noticed that using UnityEvent means that you would have to replace the C# Action members in your interfaces. This is not true, instead you should be using UnityAction instead of C# Action. Under the hood, UnityAction is just a thin wrapper or alias for a standard C# delegate, meaning they compile down to the same underlying code and perform identically during invocation.

What if you want to use UnityAction for code subscriptions and still leverage UnityEvent for serializing the same events. You can do this using the following pattern:

public interface IDamageable
{
    event UnityAction<float> OnDamageTaken;
    event UnityAction OnDeath;

    void TakeDamage(int amount);
}

public class CharacterHealth : MonoBehaviour, IDamageable
{
    public event UnityAction<float> OnDamageTaken;

    // we subscribe using the UnityAction which adds it to the UnityEvent and we always Invoke the UnityEvent.
    [SerializeField]
    protected UnityEvent<IInteractor, bool> _onDeath;
    public event UnityAction OnDeath { add => _onDeath?.AddListener(value); remove => _onDeath?.RemoveListener(value); }

    private int _health = 100;

    public void TakeDamage(int amount)
    {
        _health -= amount;
        OnDamageTaken?.Invoke(_health);

        if (_health <= 0) 
            _onDeath?.Invoke();
    }
}
Enter fullscreen mode Exit fullscreen mode

This way we have the best of both worlds. We keep the event UnityAction OnDeath which follows our contract and we can subscribe through code. We also get the ability to add serialized event callbacks to the same event in the inspector!

The Golden Rule: Use UnityAction (not Action) for when systems need to talk to each other through code. Use UnityEvent for exposing design-specific logic to your design team and avoid using it for event callbacks that are called from performance critical code paths (like Update()). Use both if you need both but avoid using serialized UnityEvent for performance critical code paths.

Conclusion

By using interfaces for dependencies, we have went from tightly coupled architecture to a more loosely coupled one. We can listen to events fired from concrete implementations without needing a reference to each concrete type and what's even better is that we can do this and also take advantage of Unity's built in inspector serialization for dependency injection.

This strict isolation is the exact prerequisite needed to break your game down into independent modules. In the next post, we will discuss Assembly Definitions (asmdefs) and how you can "break off" a part of your game and reuse it as it's own separate module.

Top comments (0)