2.4 Going Beyond the Unity Components Paradigm

When I first started learning Unity, a lot of the tutorials I encountered relied heavily on paradigms and practices that are suitable for prototypes, but made things far more complicated once I tried to grow my project. Once I started working in the industry, I learned a whole lot about other paradigms that made handling a growing code base a lot more easier.

All o your objects go through a cycle of initialization, execution, and termination. While this is very manageable with a few objects, it could quickly become painful to manage and keep track of once your project grows.

Unity and Components

There’s no denying that what led to Unity being a very popular engine is how it’s accessible to newcomers, artists, and game designers.

A big part of that accessibility is depending on a paradigm where objects are composed of components. The way this is taught usually is to have each component control a modular behaviour that can then be reused on other objects.

An example of that is a Ship object with two scripts: ShipMove and ShipFire

This is not a very bad thing in small projects and prototypes, but it’s best to be aware of the tradeoff.

We’ve talked before about the consequences of representing your scripts as beahviours and not as entities in a previous chapter, but I’d like to talk about this in a lifecycle context.

Your lifecycle becomes far more manageable when you separate logic from lifecycle control

Let’s say we’re refactoring the system. To go with the previous example, we’d like to have a modular system that works with features that can be added or removed to our ships.

The two things we don’t want from the component paradigm is letting these features handle their own lifecycles independendtly, and decoupling them from additional responsibility. We’re going to do that by having them managed by a MonoBehaviour that manages their lifecycle.

So now we have a ShipController MonoBehaviour that has a list of ShipFeatures that don’t inherit from MonoBehaviour. This is another route of implementation to consider. Not all of your features need to be represented as MonoBehaviours.

Here’s an example to what that might look like:

public class ShipController : MonoBehaviour
{
    private List<ShipFeature> features = new List<ShipFeature>();
    private bool featuresEnabled = true;

    private void Awake()
    {
        Initialize();
    }
    
    private void Initialize()
    {
        features.Add(new ShipMovementFeature());
    }

    private void Update()
    {
        if (featuresEnabled)
        {
            TickFeatures();
        }
    }

    private void TickFeatures()
    {
        foreach (ShipFeature feature in features)
        {
            if (feature.IsEnabled)
            {
                feature.Tick();
            }
        }
    }
}

Doing things this way might seem useless at first, so what is there to gain from this?

Locked for modificaiton

Let’s say we add this to our base ShipFeature class

public enum ShipEventType
{
    Fire,
    Boost,
    Crashed
}
public event Action<ShipEventType> OnShipEvent;

In order to handle our animation code separately, we’re going to create ShipAnimationContrroler

public class ShipAnimationController : MonoBehaviour
{
    private List<ShipFeature> features;
    private Animator animator;
    private Rigidbody2D rigidbody;

    public void Initialize(List<ShipFeature> features, Animator animator, Rigidbody2D rigidbody)
    {
        this.features = features;
        this.animator = animator;
        this.rigidbody = rigidbody;
        foreach (var feature in features)
        {
            feature.OnShipEvent += HandleShipEvent;
        }
    }

    private void HandleShipEvent(ShipFeature.ShipEventType eventType)
    {
        //TODO: react to animation events here
    }

		private void Update()
		{
			//TODO: Update movement animation based on velocity
		}
		//....
}

Now, let’s add it in our ShipController along with other necessary references:

public class ShipController : MonoBehaviour
{
	[SerializeField]
	private ShipAnimationController shipAnimationController;
	[SerializeField]
	private Animator animator;
	[SerializeField]
  private Rigidbody2D rigidbody;
	//....
	private void Initialize()
  {
      features.Add(new ShipMovementFeature());
			shipAnimationController.Initialize(features, animator, rigidbody);
  }
	//....

Decoupling your code this way by assigning a single responsibility to each class has a lot of benefits. A very common generator of bugs is mixing these responsibilities in the same place. When you have a feature that’s been tested and locked, changing another feature’s logic shouldn’t affect it. Plus, if you have your animation and input logic in the same place, things become really hard to read and debug.

Simplifying essential inspector setups

We’ve all been there. Once you learn that GetComponent and FindObjectOfType are not actually good practices, your setups might look like this.

yikes

By allowing your feature to belong in a hierarchy, and absolving them from mixed responsibilities and the need to fetch the same essential references, you don’t have to keep assigning the same essential references everywhere. Note how we accomplished that in the previous section with ShipAnimationController.

This might seem needless, but imagine this: your ship features have instances in 20 scenes, and you decide to add a new serialized field to one of them, and it’s something that exists beyond the prefab….yea..not cool.

If you don’t want to keep adding parameters to the ShipFeature constructor, you can always pass them as a single parameters object.

public class ShipFeatureParam
{
    public InputController InputController { get; }
    public ShipAvatar ShipAvatar { get; }
    
    public ShipFeatureParam(InputController inputController, ShipAvatar shipAvatar)
    {
        InputController = inputController;
        ShipAvatar = shipAvatar;
    }
}

Then back at the ShipController class..

public class ShipController : MonoBehaviour
{
		//....
    private void Initialize()
    {
				ShipFeatureParam param = new ShipFeatureParam(inputController, shipAvatar);
        features.Add(new ShipMovementFeature(param));
        features.Add(new ShipFireFeature(param));
        features.Add(new ShipBoostFeature(param));
		//....
LifecycleNot SeparatedSeparated Into Outer Layer
Locked for modificationNoYes
Simplifying inspector setupNoYes
Lifecycle controllerUnityArchitecture

Loading

2.4 Going Beyond the Unity Components Paradigm

When I first started learning Unity, a lot of the tutorials I encountered relied heavily on paradigms and practices that are suitable for prototypes, but made things far more complicated once I tried to grow my project. Once I started working in the industry, I learned a whole lot about other paradigms that made handling a growing code base a lot more easier.

All o your objects go through a cycle of initialization, execution, and termination. While this is very manageable with a few objects, it could quickly become painful to manage and keep track of once your project grows.

Unity and Components

There’s no denying that what led to Unity being a very popular engine is how it’s accessible to newcomers, artists, and game designers.

A big part of that accessibility is depending on a paradigm where objects are composed of components. The way this is taught usually is to have each component control a modular behaviour that can then be reused on other objects.

An example of that is a Ship object with two scripts: ShipMove and ShipFire

This is not a very bad thing in small projects and prototypes, but it’s best to be aware of the tradeoff.

We’ve talked before about the consequences of representing your scripts as beahviours and not as entities in a previous chapter, but I’d like to talk about this in a lifecycle context.

Your lifecycle becomes far more manageable when you separate logic from lifecycle control

Let’s say we’re refactoring the system. To go with the previous example, we’d like to have a modular system that works with features that can be added or removed to our ships.

The two things we don’t want from the component paradigm is letting these features handle their own lifecycles independendtly, and decoupling them from additional responsibility. We’re going to do that by having them managed by a MonoBehaviour that manages their lifecycle.

So now we have a ShipController MonoBehaviour that has a list of ShipFeatures that don’t inherit from MonoBehaviour. This is another route of implementation to consider. Not all of your features need to be represented as MonoBehaviours.

Here’s an example to what that might look like:

public class ShipController : MonoBehaviour
{
    private List<ShipFeature> features = new List<ShipFeature>();
    private bool featuresEnabled = true;

    private void Awake()
    {
        Initialize();
    }
    
    private void Initialize()
    {
        features.Add(new ShipMovementFeature());
    }

    private void Update()
    {
        if (featuresEnabled)
        {
            TickFeatures();
        }
    }

    private void TickFeatures()
    {
        foreach (ShipFeature feature in features)
        {
            if (feature.IsEnabled)
            {
                feature.Tick();
            }
        }
    }
}

Doing things this way might seem useless at first, so what is there to gain from this?

Locked for modificaiton

Let’s say we add this to our base ShipFeature class

public enum ShipEventType
{
    Fire,
    Boost,
    Crashed
}
public event Action<ShipEventType> OnShipEvent;

In order to handle our animation code separately, we’re going to create ShipAnimationContrroler

public class ShipAnimationController : MonoBehaviour
{
    private List<ShipFeature> features;
    private Animator animator;
    private Rigidbody2D rigidbody;

    public void Initialize(List<ShipFeature> features, Animator animator, Rigidbody2D rigidbody)
    {
        this.features = features;
        this.animator = animator;
        this.rigidbody = rigidbody;
        foreach (var feature in features)
        {
            feature.OnShipEvent += HandleShipEvent;
        }
    }

    private void HandleShipEvent(ShipFeature.ShipEventType eventType)
    {
        //TODO: react to animation events here
    }

		private void Update()
		{
			//TODO: Update movement animation based on velocity
		}
		//....
}

Now, let’s add it in our ShipController along with other necessary references:

public class ShipController : MonoBehaviour
{
	[SerializeField]
	private ShipAnimationController shipAnimationController;
	[SerializeField]
	private Animator animator;
	[SerializeField]
  private Rigidbody2D rigidbody;
	//....
	private void Initialize()
  {
      features.Add(new ShipMovementFeature());
			shipAnimationController.Initialize(features, animator, rigidbody);
  }
	//....

Decoupling your code this way by assigning a single responsibility to each class has a lot of benefits. A very common generator of bugs is mixing these responsibilities in the same place. When you have a feature that’s been tested and locked, changing another feature’s logic shouldn’t affect it. Plus, if you have your animation and input logic in the same place, things become really hard to read and debug.

Simplifying essential inspector setups

We’ve all been there. Once you learn that GetComponent and FindObjectOfType are not actually good practices, your setups might look like this.

yikes

By allowing your feature to belong in a hierarchy, and absolving them from mixed responsibilities and the need to fetch the same essential references, you don’t have to keep assigning the same essential references everywhere. Note how we accomplished that in the previous section with ShipAnimationController.

This might seem needless, but imagine this: your ship features have instances in 20 scenes, and you decide to add a new serialized field to one of them, and it’s something that exists beyond the prefab….yea..not cool.

If you don’t want to keep adding parameters to the ShipFeature constructor, you can always pass them as a single parameters object.

public class ShipFeatureParam
{
    public InputController InputController { get; }
    public ShipAvatar ShipAvatar { get; }
    
    public ShipFeatureParam(InputController inputController, ShipAvatar shipAvatar)
    {
        InputController = inputController;
        ShipAvatar = shipAvatar;
    }
}

Then back at the ShipController class..

public class ShipController : MonoBehaviour
{
		//....
    private void Initialize()
    {
				ShipFeatureParam param = new ShipFeatureParam(inputController, shipAvatar);
        features.Add(new ShipMovementFeature(param));
        features.Add(new ShipFireFeature(param));
        features.Add(new ShipBoostFeature(param));
		//....
LifecycleNot SeparatedSeparated Into Outer Layer
Locked for modificationNoYes
Simplifying inspector setupNoYes
Lifecycle controllerUnityArchitecture

Loading

PHP Code Snippets Powered By : XYZScripts.com