ScriptableObject Game Architecture
The lab handout uses Super Mario Bros as assets to demonstrate certain Unity features and functionalities. You are free to follow along and submit it for checkoff, OR you can also create an entirely new project to demonstrate the requested feature(s).
The requirement(s) for the lab checkoff can be found here.
This amazing talk inspires the existence of this section. We simply do not have enough time (unfortunately) to go into every single detailed implementation of common concepts such as game inventory, skill tree, etc but we hope that this quick introduction will point you into the right direction in the future.
If you like this architecture, we suggest checking out this Unity Devlogs.
This topic covers an entirely new game architecture which separates data from code to make your game more maintainable and all around pleasant to work with. You can choose to go down the Singleton Pattern Path for some appropriate parts of your project and utilise SO Architecture for others. The SO Architecture is not strictly a replacement for the Singleton Pattern.
For example, Singleton Pattern and static pattern is ideal when you have a cache or repository for frequently accessed data (e.g., a level cache, asset loader, or a global configuration), as they can provide easy access from anywhere in the game without passing references explicitly. Singleton can be helpful to maintain consistency and centralize control.
On the other hand, SO Architecture is superior in promoting modularity and reusability, such as when you are creating different enemy types or power-ups, or creating reusable systems like event systems, dialogue systems, or inventory management systems that can be easily shared between different game elements. The SO Architecture provide for easy tweaking and tuning without code changes.
The decision regarding which approach to embrace is entirely yours. However, we take it upon ourselves to introduce you to another excellent game architecture leveraging Scriptable Objects. You'll need to overhaul your current lab project, but the advantages make it worthwhile:
- Scenes are clean slates
- No dependency between Systems and they are modular
- Prefabs work on their own
- Pluggable custom components
Since you would have to start prototyping your project anyway, it will be good to apply this week's lab for that. Think about small features in your game that you need to implement as proof of concept. However, feel free to still follow along and use the Mario assets if you want.
Preparation
We need at least two Scenes with completely clean slate. That’s right. Clean Slates. We can’t reuse any of these Scripts anymore: GameManager, PowerupController, PlayerController, etc. To get you up to speed, you can:
- Copy your main menu or loading scene if any, and World-1-1 and World-1-2 into a new folder
- Copy all prefabs used in these two worlds into a new folder, name it something else
- Replace the prefabs with the new prefabs (same, just another copy)
- Remove all scripts attached to any GameObject, do the same for the new set of prefabs
Here's a complete recording on what we do to prepare for this lab. Lots of the setup is about step 3 above. If you want to simply copy the entire project and work on the copy directly, you may do so.
If your main menu and loading screen is simple, you may leave it as-is. Some error might pop up because the event called in some animation clip, e.g: mario-jump animation doesn't exist and it's fine. We can fix that later. Also, do not forget to update the build setting to include these new scenes instead. The setting can be found at File >> Build Settings.
The Singleton Architecture
If you've been following the lab faithfully so far, your current game architecture utilising Singletons is somewhat as follows:

It's decent, in a way that there's no cross-referencing between scripts attached to gameObject instances, except to Singletons: GameManager
and PowerupManager
. Most chain of actions are triggered via events. Let's recap the event flow for powerup-related events and score change.
Powerup Collection
Every Powerup box (brick or question box) is controlled by a script inheriting BasePowerupController
.
- Whenever Mario hits a box (brick or question box), the
OnCollisionEnter2D
will be called by Unity, which will trigger an Animation (bouncing box, etc). - From this animation, we call
SpawnPowerup()
on the powerup inside the box. Any powerup (coin, starman, magic mushroom, and one-up mushroom) are spawned viaSpawnPowerup()
method.SpawnPowerup()
invokespowerupCollected
event in PowerupManager Singleton, passing reference to itself in the process
This calls the subscribers of powerupCollected
: FilterAndCastPowerup
which decides whether to invoke powerupAffectsManager
or powerupAffectsPlayer
based on the type of powerup invoking the event.
The subscribers of powerupAffectsPlayer
or powerupAffectsManager
(Mario or Manager) will then be called. Any gameObject subscribing to these two powerupAffectsX
event should conform to IPowerupApplicable
interface containing RequestPowerupEffect
method, which is the method subscribing to powerupAffectsX
event.
In RequestPowerupEffect
, one simply passes itself (this
) to the powerup triggering the chain of events from the start by calling i.ApplyPowerup(this)
. Then, the actual implementation (how this powerup is affecting this
) is implemented in that powerup script itself.
For instance, when Mario hits a question box containing MagicMushroom, it triggers SpawnPowerup()
which will animate the spawning of the MagicMushroom.
- When Mario collides with the MagicMushroom,
OnCollisionEnter2D
on MagicMushroom's BasePowerup will be triggered, which will invokepowerupCollected
event, passing itself in the process. FilterAndCastPowerup
(subscriber ofpowerupCollected
) will examine thePowerupType
triggering this event (which is MagicMushroom) and hence it invokespowerupAffectsPlayer
event, passing MagicMushroom instance as the argument.- This triggers the callback
RequestPowerupEffect
inPlayerMovement.cs
attached on Mario.- In
RequestPowerupEffect
, we passthis
(which is Mario gameobject instance) to MagicMushroom viaApplyPowerup
method.
- In
- Finally, in the MagicMushroomPowerup script we can decide what the effect of this powerup is to Mario: which is to call
MakeSuperMario()
method implemented inPlayerMovement
.
This is just one of the suggested method to prevent cross-referencing of scripts that needed to be done manually via inspector during runtime. The main idea is to modularise the implementation of the powerup effect as much as possible, implementing parts concerning that instances in the instance script and nowhere else. For instance: it is the MagicMushroom's responsibility to call Mario's: MakeSuperMario, and it is Mario's responsibility to decide what "Super Mario" should be.
Score Update
There are two ways currently to increase the current score: by stomping on Goomba from above or spawning a coin. Both EnemyController
and CoinPowerup
calls GameManager.instance.IncreaseScore(int value)
anytime those conditions are valid. This calls the SetScore
method inside GameManager
, which invokes the scoreChange
event and eventually triggers its subscriber: SetScore
in HUDManager
to update the UI. The actual score is stored at GameScore
, an IntVariable
Scriptable Object, which is updated inside IncreaseScore
method.
Thoughts
The Singleton pattern ensures that a class has only one instance and provides a global point of access to that instance. In Unity, it is often used to manage game-wide systems or managers that need to exist throughout the entire game's lifetime. They are commonly used for managing things like GameManager, AudioManager, UIManager, InputManager, and other central systems. These classes need to be accessible from different parts of the game, and a Singleton pattern ensures there is only one instance to coordinate these tasks. It is relatively easy to implement, but can lead to tight coupling between the systems as discussed before. It can also be cumbersome to sharing data between different scenes or across multiple game objects.
We also utilise some Scriptable Objects to manage data assets (like score) that can be shared across different parts of the game, including scenes, game objects, and scripts. They are primarily used for storing and sharing data.
Scriptable Object Game Architecture
In this new game architecture, we take everything one step further to promote a more modular and decoupled architecture. There's no interaction between scripts (well, at least not between scripts of unrelated gameObjects, interaction between scripts in the same perfab is understandable).
We first create various GameEvents
based on Scriptable Objects. Each instance can subscribe to it OnEnable()
, and unsubscribe from it OnDisable()
. As per the previous lab, we also use SO to store persistent runtime data so that new instances in the next scene can load values from there. This way, we do not need to implement any object as a Singleton.
Creating events and storing it in SO as opposed to creating UnityEvents allow us to store references of listeners without having to tie the events onto an existing GameObject on the Scene.
This is actually the main reason the ScriptableObject-based event pattern exists. It decouples the event system from specific scene objects.
A sketch of the architecture is as follows:

Scriptable Object Event System
Create two new scripts called GameEvent.cs
and GameEventListener.cs
. This SO-based event will store a list of GameEventListeners
, and notify them whenever the GameEvent is Raised.
- GameEvent.cs
- GameEventListener.cs
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
public class GameEvent<T> : ScriptableObject
{
private readonly List<GameEventListener<T>> eventListeners =
new List<GameEventListener<T>>();
public void Raise(T data)
{
for (int i = eventListeners.Count - 1; i >= 0; i--)
eventListeners[i].OnEventRaised(data);
}
public void RegisterListener(GameEventListener<T> listener)
{
if (!eventListeners.Contains(listener))
eventListeners.Add(listener);
}
public void UnregisterListener(GameEventListener<T> listener)
{
if (eventListeners.Contains(listener))
eventListeners.Remove(listener);
}
}
using UnityEngine;
using UnityEngine.Events;
// if attached to an object that might be disabled, callback will not work
// attach it on a parent object that wont be disabled
public class GameEventListener<T> : MonoBehaviour
{
public GameEvent<T> Event;
public UnityEvent<T> Response;
private void OnEnable()
{
Event.RegisterListener(this);
}
// This is also called when the object is destroyed and can be used for any cleanup code. When scripts are reloaded after compilation has finished, OnDisable will be called, followed by an OnEnable after the script has been loaded.
private void OnDisable()
{
Event.UnregisterListener(this);
}
public void OnEventRaised(T data)
{
Response.Invoke(data);
}
}
These are currently of a generic
type because UnityEvent
can have any varying signature: zero parameter, one parameter, etc.
Create Subclasses that Pins T
The goal of this architecture is to conveniently add the listener script as components and then drag and drop the SO events via the inspector to link up the callbacks as such:

However, you cannot drag a GameEvent<T>
into the Inspector unless T
is already known (Unity can’t serialize open generic fields).
- By subclassing, you “fix” the generic parameter at design time
- Unity then treats IntGameEvent or
SimpleGameEvent
like any other ScriptableObject asset
Unity won’t show open generics in the Inspector. You always need to give it a concrete type. Therefore you need a subclass that pins T
.
For the sake of the lab, we need at least three different types: no argument, a single int
type argument, and a single IPowerup
type argument. For each type, we need a pair of scripts: the GameEvent and the GameEventListener variant.
The following creates the "no argument" (custom Void
) variant:
- SimpleGameEvent.cs
- SimpleGameEventListener.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Void { } // dummy class
// no arguments
[CreateAssetMenu(fileName = "SimpleGameEvent", menuName = "ScriptableObjects/SimpleGameEvent", order = 3)]
public class SimpleGameEvent : GameEvent<Void>
{
// create new method that doesn't accept any argument
// calls base' Raise with Void arg
public void Raise() => Raise(new Void()); // automatically create new Void data instead
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SimpleGameEventListener : GameEventListener<Void>
{
}
Do the same for the other two types (int
and IPowerup
).
Create Game Events
When done, create some SO Game Events as follows (your actual number of events may vary, but if we follow the suggested diagram then you shall make 11 events). Rename them properly based on their type.
Subscribe/Register to Game Events with GameEventListener
Once created, each GameEvent
serves as a "container" that store a list of GameEventListeners. Whenever any script calls their Raise
method, it will call the OnEventsRaised
method on all of its GameEventListener (subscribers), which will then call a list of methods registered under Response
in GameEventListener
. For instance, we want to reset our Camera to its starting location whenever OnGameRestart
event is Raised. When we were using the Singleton Method, we first register some callback GameRestart()
in CameraController.cs
:
void Start()
{
GameManager.instance.gameRestart.AddListener(GameRestart);
}
void GameRestart()
{
// reset camera position
transform.position = startPosition;
}
Delete line 3 above, and we register GameRestart
as a callback to OnGameRestart
Game Event using SimpleGameEventListener
Script component as follows:

The Event
field of the SimpleGameEventListener script is linked to OnGameRestart
SO GameEvent, and as its Response
, we register CameraController
's GameRestart()
method. When another script calls OnGameRestart.Raise()
, this will automatically cause OnGameRestart
to loop through its SimpleEventGameListeners
and call OnEventRaised()
on it. This will then trigger Response.Invoke()
where Response
contains GameRestart()
method from CameraController
. Finally, the method GameRestart()
is performed on the CameraController
's instance.
Raising the GameEvent
Method 1: Using UnityEvent (No Params)
What is this other script who can call OnGameRestart.Raise()
? One possible candidate is the RestartButtonController
. We can have a following script that leverages on UnityEvent:
public UnityEvent gameRestart;
void ButtonClick()
{
gameRestart.Invoke();
}
Attach this to the restart Button, and set ButtonClick()
as the callback of the button component. Then link OnGameRestart
SimpleGameEvent SO as gameRestart
UnityEvent that is invoked by clicking the restart button.

Basically in the Inspector, you drag in your SimpleGameEvent
ScriptableObject instance (e.g. OnGameRestart
):
- Then you hook up
SimpleGameEvent.Raise
to be called. - Since SimpleGameEvent inherits from
GameEvent<Object>
, itsRaise(Object data)
method needs an argument. - UnityEvents can’t supply null automatically, so you usually either:
- Set the argument in the inspector (often just
None
if you don’t care), or - Make a
Raise()
overload inSimpleGameEvent
that ignores data and just raises with null.
- Set the argument in the inspector (often just
Now you can make Mario and Goomba GameObject to do the same: attach a SimpleGameEvent script to both gameObjects, with events
field referring to SO OnGameRestart
and a GameRestart()
callback in each of its controller as follows. The video below also shows that each gameObject (e.g: Mario) can contain multiple GameEventListeners
so that you can register various callbacks from any script in that gameObject.
Method 2: Direct Reference Approach
public GameEvent<Object> onDamagePlayer;
void OnTriggerEnter2D(Collider2D other)
{
if (other.gameObject.CompareTag("Player"))
{
onDamagePlayer.Raise();
}
}
Here you hold a direct reference to the GameEvent<Object>
asset (in the inspector you assign the OnDamagePlayer
SO).
- You can now
raise
it in code directly, passing arguments as the payload: in this case sinceonDamagePlayer
is an event ofVoid
type then we don't pass any arguments. - Listeners get that reference in their callback.
Key Distinction
Using UnityEvent
decouples code from the event system. The Button just says “when clicked, call whatever I wired up in the inspector.” You can assign SimpleGameEvent.Raise
with or without arguments. More designer-friendly.
Using GameEvent
field in code results in tighter coupling. You’re explicitly saying “this script raises this event.” You can pass contextual data (this, some ScriptableObject, etc.) at runtime.
Regardless of which method you choose, it is important for you to be able to trace properly the chain of events that make this works. The following diagram illustrates what actually happened from the moment restart button is clicked to the moment all GameRestart()
functions in the scripts attached to Mario, Camera, and all Goombas are called:

Migrate
Now that you know how ScriptableObject Event System work, carefully migrate your entire project (all scenes) to adopt this new event system.
- Delete each old
GameManager.instance.[event].AddListener(CallbackMethod)
line, and replace it by attaching the correspondingGameEventListener
script to the GameObject - Ensure that you select the correct
GameEventListener
type (no argumentVoid
,int
, orIPowerup
type argument) - Link up the right
GameEvent
in the Inspector to match that[event]
you are replacing - Link up
CallbackMethod
at theGameEventListener
Inspector. Make sure that this method is public
After a few tries, the procedure should be quite standard. Firstly, create the GameEvent
s.
Secondly, figure out which callback methods should be run for each event. Create a public
callback method where you will handle a particular event in a script. Then, on the same gameObject where that script is attached to, add a GameEventListener
script with that public
method as the Response
.
Thirdly, figure out which scripts shall Raise
the events. To Raise
a GameEvent
, attach that GameEvent
Raise
method as a listener to public UnityEvent event
member in that script that wants to cast (raise) it, for as written in the RestartButtonController
above.
Here's a quick video for your reference:
Your actual implementation might differ and that's alright, so don't be alarmed. As long as the game works as intended with this new architecture, that's fine.
Summary
A UnityEvent
field on a MonoBehaviour
is basically a container of listeners stored in that component instance. When the component is destroyed or disabled, that “container” (and all the registered listeners) goes away with it.
On the other hand, the GameEvent<T>
ScriptableObject is a shared event channel asset.
- Listeners live inside the channel asset that all scenes can share
- The event channel asset persists across scenes and can be reused anywhere
UnityEvent: listeners live inside the scene instance ScriptableObject GameEvent: listeners live inside the channel asset that all scenes can share