Using many interfaces and events instead of many components on one class? Code inside

I’m trying something a little different with this project with the way classes are structured.

Take this example and so far the only use case I’ve found for it. A character with many working components. Usually, I would have the character class hold the components and take and send information from one to another.

The problem with this is that when something is removed, alot of work needs to be done since alot of logic relies on that class. Testing and structuring it is quite a hassle since things are constantly being changed.

This time I decided to structure it in a way where the main class simply holds the data where everything is public and have dependant classes just get whatever they need from there and perform the logic.

The problem with this is everything is public and exposed, it’s easy to setup and debug and change things but it can run into the same issue as before where removing something can affect many classes.

So now I ended up with something like this, I’m not sure where this is going but my goal is to have everything work independently where theres very little reliance on custom classes and only unity classes.

    public class EnemyCharacter : MonoBehaviour, ITakeDamage, IHaveCharacterStats, IHaveLives
    {
        public System.Action OnDisable;
        public System.Action OnEnable;
        public System.Action<int> OnTakeDamage { get; set; }
        public System.Action OnReachedNoHealth { get; set; }
        public System.Action OnDie { get; set; }
        public System.Action OnSpawn { get; set; }
        public System.Action OnStatChange { get; set; }
    }
    public class CharacterLivesHandler : MonoBehaviour
    {
        IHaveLives iHaveLivesBase;
        private void Awake()
        {
            iHaveLivesBase = GetComponent<IHaveLives>();
            GetComponent<IHaveCharacterStats>().OnReachedNoHealth += Die;
        }
        private void Die()
        {
            iHaveLivesBase.OnDie?.Invoke();
            gameObject.SetActive(false);
        }
        public void Spawn()
        {
            iHaveLivesBase?.OnSpawn?.Invoke();
        }
    }
 public class CharacterStats : MonoBehaviour
    {
        public IHaveCharacterStats Base;
        public int HealthCurrent
        {
            get => _healthCurrent;
            set
            {
                _healthCurrent = Mathf.Clamp(value, 0, _healthMax);
                if (_healthCurrent <= 0)
                {
                    Base?.OnReachedNoHealth?.Invoke();
                }
            }
        }
        [SerializeField] private int _healthCurrent;
        [SerializeField] private int _healthMax;

        public int HealthMax
        {
            get => _healthMax;
            set
            {
                _healthMax = value;
                _healthCurrent = Mathf.Max(_healthCurrent, _healthMax);
            }
        }
        [SerializeField] private int _power;
        public int Power
        {
            get => _power;
            set
            {

                _power = value;
                Base.OnStatChange?.Invoke();
            }
        }
        private void Awake()
        {
            Base = GetComponent<IHaveCharacterStats>();
            GetComponent<ITakeDamage>().OnTakeDamage += (int damage) =>
            {
                //   Debug.Log(damage);
                HealthCurrent -= damage;
            };
            GetComponent<IHaveLives>().OnSpawn += () => HealthCurrent = HealthMax;
        }
    }

The main problem again is everything is exposed and second heavy reliance on events.

The problems I always run into isnt creating the characters but to integrate an actual game into them.

Everything’s fine on testing because it’s all done on awake and the enemies do their things right away but where I get stuck is integrating the actual game where they shift their behaviors on the state of the game.

An example would be during testing, when enemies spawn in they immediately start attacking the player. Now during the game, the enemies will die if a condition like boss death occurs or if the timer runs out they’ll stop attacking.

Maybe I should make a manager class that handles what I just mentioned by having a state that’s for the game and one for testing? That’s just creating another dependency to the project right?

Another example below. this component is attached to the enemy character object.

    public class EnemyFloorObject : MonoBehaviour, IFloorObject
    {
        private static Floor _currentFloor;
        public void SetFloor(Floor floor)
        {
            _currentFloor = floor;
            GetComponent<IHaveLives>().OnDie += () => floor.ProcessObjectComplete(this); // Enemy dies means 1 score for the floor class
        }
        public void ProcessFloorStateChange(Floor.State state)
        {
            switch (state)
            {
                case Floor.State.Starting:
                    break;
                case Floor.State.Active:
                    GetComponent<IHaveLives>().OnSpawn?.Invoke();// The Logic of enemy, set their current health to max...The logic is set by the characterstats component
                    break;
                case Floor.State.ObjectiveComplete:
                    break;
                case Floor.State.Leave:
                    Destroy(gameObject);
                    break;
            }
        }
    }

You are very close to a mediator pattern that has events in Unity, but you are missing a couple of things:

  1. The way you are using the multicast delegates is bad practice, you don’t want any other script to be able to do anything to the except subscribe/unsubscribe. That’s why we have the event keyword. For example the OnDie Action should be written like this:
public event Action EnemyDied;

public void OnEnemyDeath()
{
     EnemyDied?.Invoke();
}

This way other scripts can only sub/unsub to the EnemyDied Action and if they want to invoke it, they have to call the OnEnemyDeath method that could also handle any other common additional logic for the enemy death in one place. For reference see: https://learn.microsoft.com/en-us/dotnet/standard/events/

  1. The EnemyCharacter class doesn’t have to be a Monobehaviour, because you have all those ugly GetComponent calls in awake, plus this only works for the same game object. What if you want another object to do something when an enemy dies? For example the player character to say something? You would need a way to get a reference to the EnemyCharacter object either by using one of the Find methods in Unity, or even worse use a game manager singleton and this would defeat the whole purpose of your decoupling.

Instead you should make the EnemyCharacter a scriptable object that you can add to the inspector to any class that needs it. This allows you to test each game object in isolation as the scriptable object always exists in the game object, you don’t have to deal with logic that finds other game objects. The scriptable objects in Unity act as singletons, they are the same instance, so assigning the same SO to anything that needs those events will act as mediator that connects everything without any dependencies between them.

3 Likes

Could you kindly provide an example of a scriptableobject for the enemy character and what it would look like? From what I understand, scriptableobjects are one instance. I usually use them for object data but never for logic. How would a gameobject containing enemy logic, movement, damage taking utilize this scriptableobject?

You just swap the component out for a scriptable object, and where you need to use the scriptable object, just reference it via the inspector.

Using one to broadcast enemy deaths would look like this:

[CreateAssetMenu]
public class EnemyMediator : ScriptableObject
{
	public event Action<IEnemy> OnEnemyDeath;
	
	public void InvokeOnEnemyDeath(IEnemy enemy)
	{
		OnEnemyDeath?.Invoke(enemy);
	}
}

Then objects that want to listen for enemy deaths can simply subscribe to OnEnemyDeath, and enemies can simply reference the scriptable object and invoke InvokeOnEnemyDeath when required.

Part of an OOP language like C# is that objects can encapsulate both logic and data. Using scriptable objects as pluggable behaviour is really doing standard OOP things. And the same thing can be done using plain C# classes and [SerializeReference].

2 Likes

Ah I get it now. Just before seeing this I wrote this up to handle floors and enemies.

[CreateAssetMenu(fileName = "Data", menuName = "ScriptableObjects/FloorEvents", order = 1)]
public class FloorEvents : ScriptableObject
{
    public Action<int> OnFloorChanged;
    public Action<Enemy> OnEnemyKilled;
    public Action<FloorState> OnFloorStateChanged;

    public void ClearAllEvents() //This has to be called in awake or else events persist
    {
        OnFloorChanged = null;
        OnEnemyKilled = null;
        OnFloorStateChanged = null;
    }
    public void FloorChanged(int floorIndex)
    {
        OnFloorChanged?.Invoke(floorIndex);
    }
    public void FloorStateChanged(FloorState newState) //This signals to all listeners
    {
        OnFloorStateChanged?.Invoke(newState);
    }
    public void EnemyKilled(Enemy enemyKilled) //Floor removes them from list, once reach 0 count it will change state of floor.
    {
        OnEnemyKilled?.Invoke(enemyKilled);
    }
}

My biggest concern for this is events persisting, so I would need to make sure they’re all unsubscribed with every new instance. The second one is the caller also being a listener, the enemies are listening to FloorStateChanged here so on instantiation they’re listening to the event, the problem is on death they need to make sure to unsubscribe to them.

Perhaps my approach is wrong though, what I need to do is store a total list of enemies somewhere in monobehavior inside the game then call a function on the class called HandleFloorStateChange but it can’t be on the enemy class since I don’t want it to even know about anything related to floors. If I create a mono class Called EnemyFloorRelatedScript, I’ve just created another depedency…

They can just unsubscribe themselves in OnDisable/OnDestroy. When dealing with Unity objects and delegates, it’s a common pattern.

2 Likes

I see so I would do something like this?

    private void OnEnable()
    {
        floorEvents.OnFloorStateChanged += ProcessFloorStateChange;
    }
    private void OnDisable()
    {
        floorEvents.OnFloorStateChanged -= ProcessFloorStateChange;
    }

Yeah that’s the general pattern you’ll follow a lot of the time.

1 Like

Spiney199 is absolutely correct, if you want to see some example code with different mediator patterns for Unity, I have a repo in github when I created some sample code for my blog: https://github.com/meredoth/Mediator-In-Unity, the scriptable object with events is the most decoupled and convenient to use.

2 Likes

Thanks, the last question that I have related to this structure is how would you do this?

  1. Floor spawns objectives which it doesnt know about, it only knows that its part of the floor. It could be a key, a boss, or multiple enemies.
  2. When the objective is “complete” it sends this signal but how?
  3. Since it’s an IFloorObject should it just comunnicate with the floor directly and say ProcessObjectComplete(IFloorObject) OR should it send the signal to the floorevents and the floor catches the event and removes it from the total objective pool?

Should I keep it as a rule to never have these two entities communicate with each other? A IFloorObject could be anything a group of enemies,a boss, and a switch. The only thing the floor cares about is whether the IFloorObject is ‘complete’ or not. I.e. you flip a switch and the floor objective is complete.

There are things like teleporters which when interacted with tells the floor that its done. Does the teleporter simple call to floorevents OnTeleportToNextFloor?
It sounds really nice to make everything abstract there has to be a way of keeping track of floor related objects without the classes even knowing that their floor related. This is because I might scrap the floor system all together later on. Let me know what you think.

I don’t know the specifics of your game, but think of it like this: with every abstraction you make your code more decoupled, but also you can make it more complicated because you have more classes.

One of my favorite quotes is that there is no problem that cannot be solved by adding another layer of abstraction except from the problem of having too many layers of abstraction.

You abstract enough, so that you can increase productivity in one of the four areas: Modifying-extending your code, testability, debugging and making parallel programming (more than 1 people working on the same code) easier.

In your case, because as you said you know that you may remove the floor system, you want to make it in a way that removing it, is as painless as it can be. You may loose functionality from the removal but your program still compiles and the floor system can be substituted with something else that communicates via an interface/mediator or through an adapter.

Find out what are the low level policies ( the parts of your code that probably will change) and don’t have anything depend on them. Decoupling everything may sound nice, but you will have a class explosion. Decouple the things you know (or can make an educated guess) that you will need to change/test in isolation/debug.

Specifically for your questions: For 3, I definitely would not want something to communicate with the floor directly if I knew I would remove it in the future. For 2, I’m not sure I understand what exactly sends a signal to what. If we are talking about the floor then there are many ways for the floor to send a signal, it can be directly to objects that are the core of you game, as removing it would not break your code, it could be an event in a mediator that other objects have subscribed to, it could be a call to a method in a mediator that calls the appropriate objects that have been subscribed to it in a collection like a list etc. Just don’t have anything dependent on the floor, if you are going to remove it.

1 Like

Objectives would need to be their own objects, probably pure C# objects, managed by a component. Maybe factory-ed by scriptable objects.

When they get instanced and fed into the component, they can also be initialised in order to give it a chance to hook into anything it cares about, and the reverse when cleaned up.

Then it’s just a matter of incorporating any required functionality for each objective to hook into.

2 Likes

Hmm something like…

  • ObjectiveSpawner.cs (Spawns objective stuff and floor knows about this. Floor feeds it an enum and objective spawner determines what type of things to spawn.

  • Key.cs (Proponent of objective)

  • ObjectiveItem.cs (is attached to key upon instantiation)

  • InteractableAlgorithms.cs (Has a library of logic on what to do with the two components.

  • DetermineEventLogic(this Key, objectiveItem.cs) => Key.OnTriggerEnter += objectiveItem.CompleteEvent() which either tells floor that its completed or floor events I think the best case is the former since objective items belong only to floor anyways;

  • objectiveItem.cs is then returned inside a list to ObjectiveSpawner which then sends that data into the Floor to add to the list of totalobjectives.

The key,enemy,or boss does NOT have access to anything related to floors at all. They are their own entitiy.

Thanks, based on what spiney199 just told me I think I know what to do. I’m still fairly new at this approach since most tutorials use very heavy reliance on a main object but I think this is the step towards the right direction.

From what I’m trying to understand, the best thing to do is that if the component exists solely for its parent then both should know about each other.

Key.cs < FloorObjective.cs < Floor.cs

FloorObjectAlgorithms in this case is the mediator between these 3. It’s sole purpose is to determine how everything interacts. The only issue I can see is customizability since the main logic goes here.

Don’t overthink it, don’t plan ahead with imaginary code, prototype it. The same way prototyping works for game design to find the fun and any rough edges, it works for any system in you program. Prototype a system with throw away code that will give you an idea of any edge cases, problems etc., then when you know these, make it from scratch with good code.

1 Like

I’m thinking in purely abstract terms. The base implementation of an objective doesn’t have to have any specifics, and could even be an interface.

Something like this:

public interface IObjective : IDisposable
{
	event Action OnObjectiveCompleted;
	
	void InitializeObjective(GameObject container);
}

public sealed class ObjectiveHandler : MonoBehaviour
{
	[SerializeReference] // not required can useful for testing
	private IObjective _objective;
	
	private void Awake()
	{
		_objective.OnObjectiveCompleted += HandleObjectiveCompleted;
	}
	
	private void OnDestroy()
	{
		_objective.OnObjectiveCompleted -= HandleObjectiveCompleted;
	}
	
	public void SetObjective(IObjective objective)
	{
		_objective?.Dispose();
		
		_objective = objective;
		_objective.InitializeObjective(this.gameObject);
	}
	
	private void HandleObjectiveCompleted()
	{
		// next objective??
	}
}

This would allow for any kind of objective, so long as you provide the supporting API that an objective can hook into.

Otherwise just go with what’s natural to start with, then you can refactor as needed. Most of the time my first implementation doesn’t meet the mark.

1 Like

Understandable but I don’t know exactly what is ‘good’ code so when I start to refactor to clean things up I don’t really know what to do and the cycle just repeats.

Thank you, I learned some new things from this code. I would like to ask why exactly do you put ‘event’ in front of action and it makes any difference between that and System.Action or Action?

Actually no one does, good code is code that is better than the code you could make a few months ago. There is no objectively good code, as each programmer’s knowledge and experience is different. If you look at code you made six months or so ago and you are thinking it looks bad then what you are making now is good code because it is better from before.

When I say “good” after prototyping I mean with nice names, keeping conventions, testing for null, out of range, edge cases etc. The code in your prototype should be ‘just’ working to get an idea on what you will have to do, what to depend upon, how to make your abstractions/interfaces and so on. After you know these, you throw away all your prototype code and start from scratch. Prototyping should take some minutes, a few hours max.

1 Like