Scriptable Objects!
If you’re not using them in your project. You probably should.
If you are using them, there might be even more ways to use them to make your project easier to maintain or make it easier to add features.
SOs and Odin are a great combination which is what we’ll be looking at in the next tutorial.
In this tutorial we're going take a look at the basics of SOs, how to create them, a few examples of how to use them plus some pros and cons for using them in your projects.
What is a SO?
A SO is just a script. It inherits from UnityEngine.Object just like a MonoBehaviour does, but it’s not a monobehaviour.
This means that they won’t receive most callbacks such as Start or Update, but they can contain variables and functions.
This also means that they can’t be attached to a gameObject in the same way that a MB/Component can be. They are objects, but they aren’t gameObjects and they don’t have a transform. Rather they live in your project folders - and this is were a lot of their power and usefulness comes from.
What's the big deal?
SOs allow the separation of design data from runtime data. What does that mean?
Let’s say you want to add a new enemy to your game. If each enemy has their own script, then you’ll need to create a new script with a lot of the same functionality as other enemies.
Or if you have one large “ENEMY” script this can lead to long chains of “if statements” or a bulky switch statement checking to see what type of enemy and then running code specific to that enemy.
And that’s okay, unless you want to add 2 new enemies or 10, or 100!
It can get out of control in a hurry - making debugging hard and testing nearly impossible.
This can be even more complicated if the designer is not the coder…
Instead if all the design data for the enemy, such as the visuals, speed, damage, health, etc are all contained in a single SO then all that needs to be created for a new enemy is a copy of that SO. The values of the fields can be adjusted for the new enemy…
That data can then be loaded into a generic enemy object that then reads the data and adjusts settings as needed.
A Simple Example
To create an SO simply create a new C# script, open it in Visual Studio and then change it’s inheritance from MB to SO.
So let's create a simple example. To create an SO simply create a new C# script, open it in Visual Studio and then change it’s inheritance from MB to SO.
public class EnemyData : ScriptableObject
That’s it! You’ve got a SO. But it’s not terribly useful.
Instances can be created through code using the command ScriptableObject.CreateInstance and then specifying the type.
ScriptableObject.CreateInstance<EnemyData>();
While this has it’s uses, we’ve found that creating a project asset is the more general use case and can offer the most benefits.
Asset Menu
Adding the “Create Asset Menu” attribute at the top of the script, will allow a copy of the SO to be created from the asset menu or by right clicking in a project folder.
The filename of the newly created asset as well as where in the asset menu it should show up can be added as arguments to the attribute. Like so.
[CreateAssetMenu(fileName = "EnemyData", menuName = "Enemy Data")]
public class EnemyData : ScriptableObject
Subgroups can also be created by adding a forward slash after a group name.
Like so.
[CreateAssetMenu(fileName = "EnemyData", menuName = "My Game/Enemy Data")]
public class EnemyData : ScriptableObject
With a copy created it’s now time to add some functionality!
Adding some basic fields will create an SO that acts like a data container.
We can delete the Start and Update functions, as they won’t get called in Unity and we won’t be needing them.
For this example, we’ll then add a field for an enemy name, a description, a GameObject for the enemy model as well as a few generic stats for each enemy.
[CreateAssetMenu(fileName = "EnemyData", menuName = "Enemy Data")]
public class EnemyData : ScriptableObject
{
public new string name;
public string description'
public GameObject enemyModel;
public int health = 20;
public float speed = 2f;
public float detectRange = 10f;
public int damage = 1;
}
With that done, more copies of the SO can be created in Unity and the data populated for different enemy types.
Now this is all well and good, but the data is just sitting in a project folder…
We need something to “handle the data.” We need a script that will do something with the data.
Now trying to keep things simple but still showing the usefulness of this pattern, we’ve created a “enemy controller” script that has a field for the enemy data. This will allow the SO to be dropped into the inspector so the data can be used by this script.
We’ve also created a “Load Enemy” function that gets called from inside the Start function.
[RequireComponent(typeof(NavMeshAgent))]
public class EnemyControl : MonoBehaviour
{
private NavMeshAgent navAgent;
private float wanderDistance = 3;
[OnValueChanged("LoadEnemy")]
[InlineEditor]
public EnemyData data;
private void Start()
{
if (navAgent == null)
navAgent = this.GetComponent<NavMeshAgent>();
if (data != null)
LoadEnemy(data);
}
private void LoadEnemy(EnemyData _data)
{
//remove children objects i.e. visuals
foreach (Transform child in this.transform)
{
if (Application.isEditor)
DestroyImmediate(child.gameObject);
else
Destroy(child.gameObject);
}
//load current enemy visuals
GameObject visuals = Instantiate(data.enemyModel);
visuals.transform.SetParent(this.transform);
visuals.transform.localPosition = Vector3.zero;
visuals.transform.rotation = Quaternion.identity;
//use stats data to set up enemy
if (navAgent == null)
navAgent = this.GetComponent<NavMeshAgent>();
this.navAgent.speed = data.speed;
}
private void Update()
{
if (data == null)
return;
if (navAgent.remainingDistance < 1f)
GetNewDestination();
}
private void GetNewDestination()
{
Vector3 nextDestination = this.transform.position;
nextDestination += wanderDistance * new Vector3(Random.Range(-1f, 1f), 0f, Random.Range(-1f, 1f)).normalized;
NavMeshHit hit;
if (NavMesh.SamplePosition(nextDestination, out hit, 3f, NavMesh.AllAreas))
navAgent.SetDestination(hit.position);
}
}
This function can do any number of things with the data, but in this case we’ll delete any and all children and replace them with the an instance of the enemy model.
This approach, can allow designers and developers to have one generic enemy prefab and simply load in different SOs with different data and the player will see different enemies.
We can demonstrate this by creating two empty objects, attaching the “enemy controller” script to both and then dragging in the different enemy data.
When we push play we can see that the visuals for each enemy are different and matches the data.
And of course, other data can be used to differentiate the behaviors of the enemies as well.
So what this has done, is separate all the design data such as the model, speed, and health from the runtime data or code that uses that data.
It also means that all the data for a given enemy is in one place. It’s not stored in a component that has a copy on each instance of that enemy…
You change the properties on the SO and it changes for all enemies in the scene and in every scene in your game.
This is a huge step in keeping your code clean and making your project easy to maintain AND expand!
Lets Dive a bit Deeper
Now imagine that you are creating a turn based game and each enemy in your game has different actions for their turn.
You could create an “enemy manager” that loops through all the enemies in the game. That’s all well and good, but if each enemy has its own behavior then the “enemy manager” might quickly get clogged with a chain of if statements or if you’re using an enum you might have a complicated switch statement that calls different code based on different enemy types…
public List<EnemyData> enemyList = new List<EnemyData>();
private void DoEnemyTurns()
{
foreach (EnemyData enemy in enemyList)
{
if(enemy.name == "Bob")
{
//do bob's turn
}
else if (enemy.name == "Suzy")
{
//do Suzy's turn
}
else if (enemy.name == "Hank")
{
//do Hank's turn
}
else if (enemy.name == "WhyDidIDoItThisWay")
{
//I'm still typing more stuff...
}
}
}
This gets messy and can quickly become very hard to manage or debug.
Abstract SOs
And this is where the keyword “abstract” comes in… Since SOs are just normal classes we can define an abstract SO for a base enemy and then all other enemy data scripts can inherit from this base.
public abstract class EnemyBase : ScriptableObject
{
public abstract void DoTurn();
public abstract void DoAttack();
}
What does this get us?
Well, we can define abstract functions such as “Do Turn.” This function will be implemented on each and EVERY enemy SO so this function can be called from the “enemy manager” regardless of what type of enemy it is and what type of behavior the enemy may have.
public List<EnemyData> enemyList = new List<EnemyData>();
private void DoEnemyTurns()
{
foreach (EnemyData enemy in enemyList)
{
enemy.DoTurn();
}
}
Then each different enemy can define its own version of “Do Turn” by overriding the base function.
public class EnemyData : EnemyBase
{
public new string name;
public string description'
public GameObject enemyModel;
public int health = 20;
public float speed = 2f;
public float detectRange = 10f;
public int damage = 1;
public override void DoAttack()
{
throw new System.NotImplementedException();
}
public override void DoTurn()
{
throw new System.NotImplementedException();
}
}
The “enemy manager” doesn’t have to know or care about the enemy’s behavior.
The enemy takes care of it’s turn all by itself. This separation can make your code far easier to debug AND it allows the creation of a new enemy types WITHOUT having to change or modify the enemy manager.
Once again. That’s a game changer.
SO as a simple Data Container
Lets go one step further. But this time, lets go a bit more abstract but a bit simpler.
It can be argued that this next approach can go a bit too far if used too liberally. BUT! In some cases it could be very useful!
As we’ve seen SOs can be used as very simple data containers. These data containers may contain as little as one field.
For example a color...
public class ColorData : ScriptableObject
{
public Color colorValue;
}
Storing Colors in SOs
Now why would someone bother to do this? Doesn’t this just add more files to a project?
Yes it does, but, it also means that a given chunk of data is stored in just one place. So if you want to store a color for your UI text and you want to access that color from many different elements or many different scenes then all that needs to be done is to add a reference to the SO and drop it in.
No more copying HEX codes from object to object or scene to scene. Change the color in one place in your project folders and all your scripts can have access to the new value.
This approach can be made even more versatile using Odin to create a custom inspector. This can allow a designer to switch back forth between using the SO or a typed in value*.
*Idea stolen from Unity video but made easier with Odin vs. custom drawer script
[RequiredComponent(typeof(Image))]
public class PanelBackgroundColor : MonoBehaviour
{
private Image panel;
[OnValueChanged("AdjustColor")]
public ColorReference color;
private void AdjustColor(ColorReference color)
{
if (panel == null)
panel = this.GetComponent<Image>();
panel.color = color.Value;
}
}
Pros and Cons of SOs
Pros
The SO can be an asset and accessible by any object in any scene. Easier design workflow.
Decouple the data from scripts - multiple objects/scripts can access the data AND those objects/scripts don’t need to be aware of each other.
Less data to serialize so scene load times can be shorter.
Values don’t get reset after runtime
Cons
Values don’t get reset after runtime in the editor. An example might be using a SO for values such as max or min values rather than “current” values.
Unity Object ? Changing SO can result in loss of data. Rename a field and poof!
Can “appear” like a save solution… And it can be, but not a great idea. Changes in SO script could easily cause loss of data. There are far better save solutions that SOs. Changes made to a SO on a standalone build will not save whereas in the editor they will save - once again giving the “appearance” of a save system.
In Conclusion
We hope these examples have peaked your interest and maybe given you some ideas of how to use SOs in your project.
If you want to dive even deeper check out these great videos on the subject:
Also be sure to check out the tutorial on using scriptable objects with Odin and this tutorial where we go over value reference toggling - scriptable objects as value assets.