Skip to content

Scriptable Architecture

yenmoc edited this page Jan 16, 2024 · 23 revisions

What

Scriptable Objects are an immensely powerful yet often underutilized feature of Unity. Learn how to get the most out of this versatile data structure and build more extensible systems and data patterns. In this talk, Schell Games shares specific examples of how they have used the Scriptable Object for everything from a hierarchical state machine, to an event system, to a method of cross scene communication.

Leverages the power of scriptable objects, which are native objects from the Unity Engine. Scriptable objects are usually used for data storage. However, because they are assets, they can be referenced by other assets and are accessible at runtime and in editor. See GDC talk of Ryan Hipple

It was useful mostly for and while making Casual and Hyper casual game. Because the nature of these games is simple and the power of ScriptableObject is flexibility. For more complex games it can be hindered by the complexity spike due to the amount of scriptables generated and easy to lose control, but this can still be avoided by simply applying apply it to the part of the game where you want to reduce dependencies.

Characteristic

Solve dependencies

Avoid coupling your classes by using a reference to a common Scriptable object as a bridge or by using Scriptable Events. This is particularly true in the Hypercasual market for several reasons:

  • Many features are enabled/disabled through independent AB Tests. Therefore, we want to avoid noisy code and hardcoding our features into the core of our game. Having them “hooked” with an independent architecture makes it easy to add or remove them.
  • Because you make a lot of games quickly, you want to reuse features as much as you can. Therefore, having features as “drag and drop” can be a huge time saver over the long run for things that generally don’t change too much across games

Efficiency

  • Subscribe to what you need and have each class handle itself.
  • Reduce code complexity.
  • Avoid useless managers. Avoid creating new classes for simple behaviors
  • Be able to debug visually and have the game react in real time

Member

Scriptable Variable

A scriptable variable is a scriptable object of a certain type containing a value. Example string variable look like:

image

  • Category: works similar to GameObject's Tag, You can create categories inside the scriptable wizard window

  • Show In Wizard: Open Scriptable Wizard and select this scriptable

  • Value: the current value of the variable. Can be changed in inspector, at runtime, by code or in unity events. Changing the value will trigger an event “OnValueChanged” that can be registered to by code.

  • Debug Log Enabled: if true, will log in the console whenever this value is changed.

  • Saved: If true, the value of the variable will be saved to Data when it changes.

image

  • Default Value: If "Saved" is true, then you can set a default value. This is used the first time you load from Data if there is no save yet.

  • Guid Create Mode: If "Saved" is true, then you can choose Mode create guid Auto or Manual, If set to Auto then the guid value will be automatically generated using System.Guid.NewGuid()

  • Reset On: When is this variable reset (or loaded, if saved)?

    • Scene Loaded: Whenever a scene is loaded. Ignores Additive scene loading. Use this if you want the variable to be reset if it is only used in a single scene.
    • Application Start: Reset once when the game starts. Useful if you want changes made to the variable to persist across scenes.
    • AdditiveSceneLoaded: Whenever a scene loaded by LoadSceneMode.Additive. Use this option for compatibility with the use of LoadSceneMode.Additive instead of LoadSingle scene introduced in scene flow, to keep the variable's value reset behavior similar to SceneLoaded. If you are not using a flow load scene like in scene flow or you are not sure how to reset the value when the load scene is adaptive, do not use this option.
  • Is Clamped: Specific to FloatVariable and IntVariable, gives you the ability to clamp it if you need.

  • Reset to initial value which lets you quickly reset the value of the variable to the initial value.

  • Notes: In the Editor, ScriptableVariables automatically reset to their initial value (the value in the inspector before entering play mode) when exiting play mode.

To create a new variable, access via menu Create > Pancake > Scriptable > ScriptableVariables

image

Or via short-cut Alt + 1

image

Or use button create in property to create a new instance of that scriptable variable in the folder (_Root/Scripts/Generated).

image

When you are in play mode, the objects (and their component) that have registered to the OnValueChanged Event of a scriptable variable are display in the inspector.

image

As you see 1 object registered to this variable. Object name is Main Camera and script is Sample

Scriptable List

Lists are useful to avoid need a manager hold that list and we must access to manager to get list (high-coupling).

Screenshot_2 Screenshot_3

  • Reset On : When is this list cleared?
    • Scene Loaded: whenever a scene is loaded. Ignores additive scene loading
    • Application Start: Reset once when the game starts
    • AdditiveSceneLoaded: Whenever a scene loaded by LoadSceneMode.Additive. Use this option for compatibility with the use of LoadSceneMode.Additive instead of LoadSingle scene introduced in scene flow, to keep the variable's value reset behavior similar to SceneLoaded. If you are not using a flow load scene like in scene flow or you are not sure how to reset the value when the load scene is adaptive, do not use this option.

In play mode, you can see all the elements that populate the list.

In the Editor, ScriptableLists automatically clear themselves when exiting play mode

You can create Scriptable List via menu Create > Pancake > Scriptable > ScriptableLists

Screenshot_1

Scriptable Event

image

This ScriptableObject-based event system is our solution to create a solid game architecture and make objects communicate with each other, at the same time avoiding the use of the Singleton pattern.

The reasons why we want to avoid using Singletons are multiple. They create rigid connections between different systems in the game, and that makes it so they can't exist separately and they will always depend on each other (i.e. SystemA always requires SystemB in the scene to work, and so on). This is of course hard to maintain and reuse, and makes testing individual systems harder without testing the whole game.

How it works At the base of the system we have a series of ScriptableObjects that we call "Event Channels". They act as channels (like a radio) on which scripts can "broadcast" events. Other scripts can in turn listen to a specific channel, and they would pick that event up, and implement a reaction (callback) to it. The graph at the top of this page visualises this structure.

Both the script that fires the event and the event listener are Monobehaviours. Since we are using the ScriptableObject Event Channel (which is an asset) to connect the 2 systems, those Monobehaviours can live in 2 different scenes in a completely independent way.

For instance, an event can be raised when pressing a button, and broadcasted on a "Button_X_Pressed" Event Channel ScriptableObject. On the other hand, we can have one or more objects listening to this event and react with different functionality: one of them spawns some particles, one plays a sound, one starts a cutscene.

You can create Scriptable Event via menu Create > Pancake > Scriptable > Scriptable Event

image

  • There are two types of scriptable events:

    • Event with out param

    image

    • Event with param (int, bool, float, string, vector2 ...)

    image

Events can be triggered by code, unity actions or the inspector (via the raise button).

Raising events in the inspector can be useful to quickly debug your game.

When you in play mode, you can see all object that registered to that event:

image

Event Listener

To listen to these events when they are fired, you need to attach an Event Listener component (of the same type) to your GameObjects

image

  • Binding

    • UNTIL_DESTROY: Will register in Awake() and unsubscribe OnDestroy()
    • UNTIL_DISABLE: Will register in OnEnable() and unsubscribe OnDisable()
  • Disable after subscribing: If true, will deactivate the GameObject after registering to the event. Useful for UI elements

  • Event Respones: here you can add multiple events and trigger things with unity events when they are fired. You can also register to events directly from code.

  • Delay: Time in seconds delay to call response

Issues

As the saying goes No Silver Bullet. ScriptableObject architecture will solve coupling problems but it is not perfect in itself and has its own weaknesses.

1.It's very difficult to find a reference

Because ScriptableObject on Editor is also an asset like other assets: prefab, texture, material .. so I will also see the same "diseases" of assets.

  • Reference count : oh, does anyone use this file, I've been here for a long time but I'm too scared to delete it
  • Dependency : well do these ScriptableObject have any objects on the next scene ref? then is there any prefab that uses it, and then the ScriptableObject itself refs to other assets?

To solve this problem we can use the reference search tools in unity.

Inside heart also has a built-in tool called Finder you can open it with the keyboard shortcut ctrl + shift + k.

The strength of UnityEngine is its Serialization, ie the abstraction between script and data. Binding data to the script's behavior is done by unity at runtime.

Ex: You write scripts, reference to prefab, in runtime spawn prefab to use

  • From the script, you only know that it will spawn, but who "injects" it, you don't know, just drag and drop the correct reference into the editor and use it.
  • From the Editor you won't know who the prefab is used by.

2.Data redundancy

Over time the number of scriptables created is increasing. features update more, there will be many Events, old data is no longer used but new data still needs to be created for new feature.

And sometimes the project is developed by more than one developer, each person will take care of a feature, they need event, data.. but didn't know it was already there, he continued to create a new one.

3.Stateization

Actually this is not a scriptable object's own problem

Programs can be severely stateful when the number of variables is too large and perform conditional forking for these variables. Looks a bit like the state machine in animator.

image

image

Reference

In essence, at runtime, ScriptableObject is also a C# object. It is created in a late-binding style (only if anyone uses it to create it, if not), and it will still be collected by GC if no one else refs to it.

Suppose we have 3 scenes A, B and C

  • SceneA : In SceneA we have a GameObjectA that references the ScriptableObject and changes its value, there is a button to switch to SceneB
  • SceneB : There is nothing in SceneB but only a button to switch to SceneC
  • SceneC : In SceneC we have a GameObjectC that references the ScriptableObject in SceneA and logs out its current value.

A strange behavior happens here, to be more specific we have a ScriptableObject declared as follows

[CreateAssetMenu]
public class DemoScriptable : ScriptableObject
{
    public int value;
}

We create the scriptable from DemoScriptable and name it Coin.asset and change value default is 100. In SceneA We reference to Coin.asset and decrease 10 value

The problem is that when going to SceneC the log output value is reset to default 100 instead of 90 after 10 is subtracted in SceneA (Value of your scriptable is reset to the original state, it no longer contains the update state from sceneA). And more terrible is that it is not on the Editor, but only on the build in device.

Actually it happens quite randomly sometimes it's 100, sometimes it's 90

image

What this happens is is the transition to SceneB where the ScriptableObject coin is not being used by any Objects in this scene (no longer referencing the scriptable coin) resulting in the GC releasing the state of the scriptableObject which is no longer in use.

image

quote from roboryantron

The built-in implementation of the scriptable architecture in heart already provides a ResetOn option to define this behavior more explicitly to help you avoid unexpected behavior that occurs on a real device differently than on the editor.

image

Clone this wiki locally