Scriptable Instances

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:

What They Are Good For

Many, many things. I personally use them in my current project for:

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:

  1. Treat SOs as data-only assets. They could contain getters and methods, but never runtime state or methods that mutate data.
  2. Make all fields private or protected, only exposing public properties, ensuring the use of IReadOnly types such as IReadOnlyList - this ensures no external mutations are possible.
  3. 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.
  4. Pass SO references at runtime - perhaps as part of a builder or factory pattern.
  5. 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:

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.

Visual effects prefabs are pooled, storing their `VisualEffectController`, which is managed by the `VisualEffectManager` so that whenever you trigger a visual effect, all you need is the SO in order to get a specific instance from the pool. This is then executed by passing in the SO into the controller.

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:

  1. Status effects now always come in pairs: a data class and an instance class. For example, PoisonStatusEffectData and PoisonStatusEffectInstance.
  2. The instance classes extend StatusEffectInstance so that the StatusEffectManager can manage them generically.
  3. StatusEffectContext is still used, but the instance classes are able to build the appropriate runtime data using the damage source (e.g., the player character).
  4. The StatusEffectData SOs 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.