Scriptable Objects
Scriptable objects in Unity are massively convenient. You define a data structure with the various fields you need and can even reference other assets in your project, and the references will automatically be updated if the asset is renamed, moved, etc. They’re extremely flexible, and because they are saved as assets, you can easily reference them anywhere you need. However, there are a few caveats:
- Any changes you make to them at runtime are shared between everything referencing them.
- This can be useful in some situations where you intentionally want shared data, but it’s typically better to use a shared behavior or event system.
- Changes made during play mode will persist until the editor is closed (unless explicitly reset), which leads beginners to believe they can use this in a build to persist data.
What They Are Good For
Many, many things. I personally use them in my current project for:
- Registries: Imagine you have 20 enemy types, or 50 weapons, or 200 unique monster drops. Creating a registry to reference all of these assets is extremely useful for fast lookups, especially if you incorporate a dictionary and lookup methods.
- Data-Driven: Using the same enemy logic, you can create multiple variations by simply tweaking some properties: strength, aggression, level, hit points, movement and attack speed, descriptions, even appearance... really depends on your design.
- Runtime Registration: While I heavily utilize
event Action, there are many global events that various systems and objects within the game world care about. For example, when the player dies, there are multiple things that happen, each handled by different systems. All I need to do is define a singleplayer_death_eventGameEventasset which I can registerGameEventListener(s)to. - Core vs Game Code: I have a core
DamageInfoclass which has zero fields for specific types of damage. Instead, all types of damage are defined byDamageAttribute, which is another scriptable object type. This allows me to have a generic, reusable set of core functionality without dirtying it up with game specifics.
Really, there’s no limit to what you could use them for. It’s really just a matter of practicality and preference.
Runtime Behavior + State
A few years back when I really began utilizing scriptable objects, I treated them more akin to MonoBehaviour in that they
not only contained methods, but runtime state as well. This was okay because I could just clone them at runtime, keeping the
runtime values isolated to the cloned instance. However, where this broke down was when I began building scriptable objects
that referenced other scriptable objects. This then required me to also create a clone (and store it) of every referenced SO. This felt dirty and unmaintainable, not to mention, it was easy to accidentally use the non-cloned data by mistake.
The pattern I switched over to was:
- Treat SOs as data-only assets. They could contain getters and methods, but never runtime state or methods that mutate data.
- Make all fields private or protected, only exposing public properties, ensuring the use of
IReadOnlytypes such asIReadOnlyList- this ensures no external mutations are possible. - Create a component that uses the SO vs declaring all of its own fields/properties. Again, you could define a generic set of enemy behaviors that change only based on the data you provide to them.
- Pass SO references at runtime - perhaps as part of a builder or factory pattern.
- Use SOs as parameters - For example,
GetDamageValue(PoisonDamageSO).
Reuse Patterns Where They Make Sense
When I was building out my ability effect system, I used the same pattern; ability effects - in particular, status effects,
are defined as scriptable objects. For example, DamageOverTimeEffect extends the base StatusEffect class to define the custom
logic and fields. I then created a StatusEffectInstance class which uses the StatusEffect for configuration, but performs
the actual behavior of the effect. This is not a MonoBehaviour, but a simple standard C# class. This allows me to create new instances whenever needed, which then get passed to the target’s StatusEffectManager behavior.
However, here’s where I didn’t follow my own pattern...
VisualEffects
When I was designing my visual effects system, I knew that each visual effect would need control logic. They can be particle systems, visual effects graphs, or even just point to a prefab that needs to be dynamically spawned. I created this in two parts:
- VisualEffectData - the base visual effect SO class.
- VisualEffectController - the base visual effect controller class.
Each unique visual effect then has its own vfx data class as well as vfx controller. This gives me a ton of flexibility while keeping the interface standardized for use in the ability effects system or whatever else wants to trigger a visual effect.
But I didn’t follow this pattern for status effects, and it came back earlier this evening to bite me.
Refactoring The StatusEffect System
It’s a bit frustrating when you start off with what you think is a very clean, elegant solution only to later realize that there are blind spots you completely passed over. I began working on a Breakable behavior which implemented IDamageable, but I had done something stupid; I required anything implementing IDamageable to implement ApplyStatusEffect, which makes very little sense in most cases for, let’s say, pottery the player can smash.
I realized that there weren’t many places that were actually calling this anyway, so it was a quick change to remove it and instead, utilize a builder pattern in the DamageInfo class that allows me to simply pass along status effects to a target when dealing damage. That was fine and all, but there was an issue.
class StatusEffectContext : EffectContext {
public DamageInfo damageOverTime { get; }
}
This was a stupid hack I had put in place months ago that I completely forgot about. I wanted the ability to dynamically determine the type of damage that would be applied over time based on the character’s attributes, so instead of actually thinking through the problem, I was anxious to see it working...
This led me to quickly realizing that the only reason I had done this in the first place was because my StatusEffectInstance class was generic. It doesn’t know anything about the actual status effect it’s managing. In fact, the status effects break my rule about data-only containers. While they’re not storing state data, they expose Apply, Tick, and Expire methods... The context object is what allowed the effect instance to pass along runtime information to these methods without them needing to worry about internal state... but this was a terrible design decision.
Solution
This issue is exactly what I was avoiding when I created the visual effect system. I wanted them to be self-contained without objects triggering them having to implement any special cases; they just need a reference to a scriptable object and access to the visual effect manager. Following this pattern:
- Status effects now always come in pairs: a data class and an instance class. For example,
PoisonStatusEffectDataandPoisonStatusEffectInstance. - The instance classes extend
StatusEffectInstanceso that theStatusEffectManagercan manage them generically. StatusEffectContextis still used, but the instance classes are able to build the appropriate runtime data using the damage source (e.g., the player character).- The
StatusEffectDataSOs no longer have any methods - this is entirely implemented via the instance classes.
Conclusion
I’ve used this same pattern over and over now - scriptable objects do not perform actions, they only provide data which is then acted upon by behaviors. However, it didn’t even cross my mind that this was an oversight I had made until earlier today.
Here’s the thing though. I think I may have actually subconsciously avoided it. The reason is, it’s very easy to fall in love with a pattern and then attempt to use it everywhere, even where a different pattern would make more sense. I had fallen into that trap earlier in my career and as a result, I’m always cautious about repeating that same mistake. In this case, however, it makes a ton of sense. In fact, thinking on it - I actually have another refactor to take care of in the same vein.
The original design made sense to me at the time though. If I could make everything generic enough and entirely data-driven, there would be no need to create specialized logic for each effect. The problem is that you either end up creating bloated god-objects that hold all possible data you MAY need, or you end up with hacks such as damageOverTime { get; }.
Another day, another lesson learned.