Avoid Prefab Hell with ScriptableObject Services in Unity

Avoid Prefab Hell with ScriptableObject Services in Unity

The Gamble

You've been tuning a game object in a scene... one with many components, some child game objects, and those have components... it's time to apply prefab overrides. Your palms sweat. You click the button, like pulling the handle of a slot machine. What unintentional changes have you rippled throughout the game, prefabs, variants and instances???

Sound familiar? If so, then I have a technique for you to try out. You should be familiar with ScriptableObject, a sibling of MonoBehaviour, used to create custom assets for a project, to centralize and organize design time settings, and often to handle runtime state and events.

A Different Approach

In this article, I'll cover using ScriptableObjects to create "service" assets, which will provide reusable game logic alongside settings that reduce dependence on the prefab workflow and introduce several other benefits such as:

  • Pluggable services that can be added to game objects at design time or runtime.
  • Stateless, testable, independently versioned
  • Source control friendly
  • Follows Single Responsibility Principle (SRP)
  • Can fit into state machine, behavior tree and other common patterns

Here are the rules I use for Service Assets.

  • Inherit from ScriptableObject
  • No "settings" related to this logic in the MonoBehaviour, instead they should live with the asset
  • No "state" or external references, everything must be passed in and then returned, like a pure function
  • MonoBehaviour acts as the "controller" or orchestrator of Service Assets, supplies state and references

Tackling Physics

With that, let's get hands on with an example to encapsulate some different physics scenarios with RigidBody. Physics is something I find myself often flipping settings around to get the "perfect" effects in game, which makes this a great candidate for packaging as an asset.

Project Setup

Create a new 3D project, I'm using Unity 2021.3 LTS.

image.png

Let's start with a simple scene, add a cube that represents the ground with a box collider and green material.

image.png

Add another cube that will be what we move around in this example, with a blue material and a RigidBody.

image.png

CubeController

Let's use a simple MonoBehaviour script to be able to move the Cube around with input. Create a CubeController script and attach it to the Cube in the scene.


using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class CubeController : MonoBehaviour
{

    private Rigidbody body;
    private Vector3 direction;

    private void Awake()
    {
        body = GetComponent<Rigidbody>();
        direction = Vector3.zero;
    }

    private void Update()
    {
        direction.x = Input.GetAxis("Horizontal");
        direction.z = Input.GetAxis("Vertical");
        direction.Normalize(); //handle diagonal scale
    }

    private void FixedUpdate()
    {
        if (direction == Vector3.zero) return;

        // Add code to move the RigidBody
    }
}

Simple, we use Update to gather input per frame and we apply physics in FixedUpdate which will come from the new service asset. Input is captured in a Vector3 called "direction" which will be "state" that we pass to our service asset.

Prefab Approach

A typical approach would be to write the physics code directly in this MonoBehaviour and perhaps expose movement related settings, like "Speed" as a serialized field in the Inspector. We could then prefab it, make variants for different speed cubes, etc. But now we're back into the prefab workflow that I'd like to avoid and which will get more complex with additional components.

Service Asset

Create a new script called MovementServiceAsset, change it to inherit from ScriptableObject and then add a method that should be called from the CubeController to move using the RigidBody. Sticking to our rules, CubeController will pass in the RigidBody as a reference and direction as state.

using UnityEngine;

[CreateAssetMenu(fileName = "MovementServiceAsset", menuName = "ServiceAssets/MovementServiceAsset", order = 0)]
public class MovementServiceAsset : ScriptableObject
{
    public void Move(Rigidbody body, Vector3 direction)
    {
        Debug.Log($"Moving {body.name} in direction: {direction}");
    }
}

Assemble Parts

Now let's put this together. Add a field to the CubeController for a MovementServiceAsset that we can supply in the inspector. Then call the service asset Move method in FixedUpdate, passing the reference and state as paramaters.


using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class CubeController : MonoBehaviour
{

    [SerializeField] private MovementServiceAsset movementService;

    private Rigidbody body;
    private Vector3 direction;

    private void Awake()
    {
        body = GetComponent<Rigidbody>();
        direction = Vector3.zero;
    }

    private void Update()
    {
        direction.x = Input.GetAxis("Horizontal");
        direction.z = Input.GetAxis("Vertical");
        direction.Normalize(); //handle diagonal scale
    }

    private void FixedUpdate()
    {
        if (direction == Vector3.zero) return;

        movementService.Move(body, direction);

    }
}

Create a new instance of our service.

image.png

Name it "BasicMovementService".

image.png

Then add the new service asset to the CubeController in the Scene.

image.png

Fire up play mode and use the WASD keys, which should trigger log messages in the Console window.

image.png

Add Settings

Now let's add some settings to the service asset, so we can change behavior of the movement logic from the editor. These settings will be saved independently in each asset instance created, such as BasicMovementService.


using UnityEngine;

[CreateAssetMenu(fileName = "MovementServiceAsset", menuName = "ServiceAssets/MovementServiceAsset")]
public class MovementServiceAsset : ScriptableObject
{
    [Header("Movement Settings")]
    [Space(10)]

    [SerializeField] private float speed;

    [Header("RigidBody Settings")]
    [Space(10)]

    [SerializeField] private ForceMode forceMode;

    public void Move(Rigidbody body, Vector3 direction)
    {
        Debug.Log($"Moving {body.name} in direction: {direction}");

        body.AddForce(direction * speed, forceMode);
    }
}

Select the BasicMovementService asset in the Project window and configure speed. We'll leave ForceMode at the default for now.

image.png

Fire up play mode again and use the WASD keys to move the cube (it should tumble around because we don't have any RigidBody constraints set).

More Settings

To stick to our rules and avoid prefab impact, I don't want to set RigidBody constraints on the component directly. Instead I want to set them via our new service asset. So let's add a method to be called from Awake.


using UnityEngine;

[CreateAssetMenu(fileName = "MovementServiceAsset", menuName = "ServiceAssets/MovementServiceAsset")]
public class MovementServiceAsset : ScriptableObject
{
    [Header("Movement Settings")]
    [Space(10)]

    [SerializeField] private float speed;

    [Header("RigidBody Settings")]
    [Space(10)]

    [SerializeField] private ForceMode forceMode;
    [SerializeField] private bool freezeRotation;

    [Header("Collider Settings")]
    [Space(10)]

    [Tooltip("Optional")]
    [SerializeField] private PhysicMaterial material;


    public void Initialize(Rigidbody body, Collider collider)
    {
        if (freezeRotation) body.constraints |= RigidbodyConstraints.FreezeRotation;
        if (material != null) collider.material = material;
    }

    public void Move(Rigidbody body, Vector3 direction)
    {
        Debug.Log($"Moving {body.name} in direction: {direction}");

        body.AddForce(direction * speed, forceMode);
    }
}

Another setting related to movement would be the PhysicMaterial which governs friction and bounciness of the Collider.

I'll avoid mass, because that might be something changed depending on the characteristics of a single model and not applied service-wide.

Add the call to Initialize in the CubeController Awake event.


using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class CubeController : MonoBehaviour
{

    [SerializeField] private MovementServiceAsset movementService;

    private Rigidbody body;
    private Vector3 direction;

    private void Awake()
    {
        body = GetComponent<Rigidbody>();
        direction = Vector3.zero;
        movementService.Initialize(body, GetComponent<Collider>());
    }

    private void Update()
    {
        direction.x = Input.GetAxis("Horizontal");
        direction.z = Input.GetAxis("Vertical");
        direction.Normalize(); //handle diagonal scale
    }

    private void FixedUpdate()
    {
        if (direction == Vector3.zero) return;

        movementService.Move(body, direction);

    }
}

Create a new PhysicMaterial for the cube with default settings, add it to the BasicMovementService, and check to freeze rotation. Now that we've added friction, bump up speed to 20 (or play with this setting during play mode).

image.png

Hit play and move around, you'll see that the constraints have been set. You can now change these settings, which will be saved even in play mode and applied to all other objects using this service.

Another Service

Ok great, let's prefab our Cube, dragging into the Project. Create a variant called FastCube. Duplicate BasicMovementService asset, call it FastMovementService and set the speed to 25. Assign this to the FastCube variant, and add an instance to the scene.

image.png

Start play mode and move around, you should see both cubes move but at different speeds. But what has that solved? We still have a problem. Let's make each cube a different player with individual input settings. Again typical approach would be fields on the prefab to govern input schemes. Instead we'll use a service asset.

Create a new script called InputServiceAsset which will contain our key mappings.


using UnityEngine;

[CreateAssetMenu(fileName = "InputServiceAsset", menuName = "ServiceAssets/InputServiceAsset")]
public class InputServiceAsset : ScriptableObject
{
    [Header("Key Mappings")]
    [Space(10)]

    [SerializeField] private KeyCode up;
    [SerializeField] private KeyCode left;
    [SerializeField] private KeyCode down;
    [SerializeField] private KeyCode right;

    public Vector3 Poll()
    {
        var direction = Vector3.zero;

        if (Input.GetKey(up)) direction += Vector3.forward;
        if (Input.GetKey(left)) direction += Vector3.left;
        if (Input.GetKey(down)) direction += Vector3.back;
        if (Input.GetKey(right)) direction += Vector3.right;

        return direction.normalized;
    }
}

Add a field to the CubeController to hold an InputServiceAsset instance and change Update to use the Poll method to get direction each frame.


using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class CubeController : MonoBehaviour
{
    [SerializeField] private InputServiceAsset inputService;
    [SerializeField] private MovementServiceAsset movementService;

    private Rigidbody body;
    private Vector3 direction;

    private void Awake()
    {
        body = GetComponent<Rigidbody>();
        direction = Vector3.zero;
        movementService.Initialize(body, GetComponent<Collider>());
    }

    private void Update()
    {
        direction = inputService.Poll();
    }

    private void FixedUpdate()
    {
        if (direction == Vector3.zero) return;

        movementService.Move(body, direction);
    }
}

Now we are better sticking to rules. The MonoBehaviour is coordinating 2 services, storing state and orchestrating different components.

Create an instance of InputServiceAsset called "PlayerOneInputService".

image.png

Assign keys for WASD movement.

image.png

Duplicate the asset, name it "PlayerTwoInputService" and change the keys to handle arrow movement.

image.png

Assign the "PlayerOneInputService" to one of the cubes in the scene, assign "PlayerTwoInputService" to the other.

image.png

Enter play mode and test out each cube with each set of keys.

Summary

Let's organize and look at our project now.

image.png

We have 3 scripts, two of which are ScriptableObjects. The CubeController, which is the single MonoBehaviour for our "Player" game objects. It gets injected with services that contain game logic. The CubeController doesn't need to supply any settings for these services, they are self-contained, it simply passes state around to and from services. You can test different player settings simply by switching out the assets on the game object or by changing the values in the referenced assets directly.

image.png

We have 4 service assets, a couple of variations of movement, and 2 different player input mappings. These can be tested from any script independently and don't require having their own state or references. They can be duplicated and customized easily and have clear responsibilities.

image.png

We have one prefab with 2 variants. The base prefab references a movement service asset, so that all players share the same movement characteristics. You could extend this to have a reference to both basic movement and fast movement, then switch to fast at runtime when a player has a "boost", for example.

image.png

Then each player variant references a custom input service asset to define controls.

image.png

Now the elements of the project play to their strengths and avoid some of their pain points as complexity scales. You could even distribute new configurations of services via Addressables after install.

Project on Github

Please comment, contribute, and let me know what you think about service assets!

Did you find this article valuable?

Support Steve Mcilwain by becoming a sponsor. Any amount is appreciated!