ScriptableObjects in a Game
In this tutorial you'll use ScriptableObjects four ways in a small action game: data definitions for enemy types, shared variables for player health that the UI reads, event channels that let objects in different scenes talk, and runtime sets that keep track of every enemy alive.
You'll need: the basics from the ScriptableObject page. Knowing UnityEvent helps for part 3.
Your scene is an office building and your MonoBehaviours are the employees. ScriptableObjects are the shared filing cabinet in the hallway. Instead of employees running around asking each other for information ("Hey Player, what's your health?"), they all read from and write to the same folder in the cabinet. Nobody needs to know who else is using that folder.
Part 1: Data definitions (enemy types)
Problem: you have slimes, skeletons and bats. Each needs health, speed, damage and a color. If you put those numbers on every enemy prefab, balancing the game means opening dozens of prefabs.
Solution: one asset per enemy type. Every slime points to the same "Slime" asset.
using UnityEngine;
[CreateAssetMenu(fileName = "NewEnemy", menuName = "Game/Enemy Type")]
public class EnemyType : ScriptableObject
{
[Header("Identity")]
public string displayName = "Slime";
public Color tint = Color.green;
[Header("Stats")]
[Min(1)] public float maxHealth = 30f;
[Min(0)] public float moveSpeed = 2f;
[Min(0)] public float contactDamage = 10f;
[Header("Rewards")]
public int scoreValue = 10;
public GameObject dropPrefab; // optional loot
}
Create three assets: right-click in Project > Create > Game > Enemy Type. Name them Slime, Skeleton and Bat, and give them different numbers.
using UnityEngine;
public class Enemy : MonoBehaviour
{
[SerializeField] private EnemyType type;
[SerializeField] private Renderer rend;
private float health; // per-enemy state lives HERE, not in the asset
private Transform player;
public EnemyType Type => type;
void Awake()
{
health = type.maxHealth;
rend.material.color = type.tint;
player = GameObject.FindWithTag("Player").transform; // once, not every frame
}
void Update()
{
transform.position = Vector3.MoveTowards(
transform.position, player.position, type.moveSpeed * Time.deltaTime);
}
public void TakeDamage(float amount)
{
health -= amount;
if (health <= 0f) Die();
}
private void Die()
{
if (type.dropPrefab != null)
Instantiate(type.dropPrefab, transform.position, Quaternion.identity);
Destroy(gameObject);
}
}
type.maxHealth -= damage, every slime in the game would share one health pool, and in the Editor the asset would stay damaged after you stop playing.Now balancing is easy: select the Slime asset, change Move Speed, and every slime updates, even while the game is running.
Part 2: Shared variables (player health → UI)
Problem: the health bar needs the player's health. The usual approach gives the UI a reference to the player, but the UI lives in a separate scene, or the player is spawned at runtime, so there's nothing to drag in.
Solution: a "FloatVariable" asset. The player writes to it; the UI reads from it. Neither knows the other exists.
using System;
using UnityEngine;
[CreateAssetMenu(menuName = "Game/Variables/Float")]
public class FloatVariable : ScriptableObject
{
[SerializeField] private float value;
// C# event: fires whenever the value changes.
public event Action<float> Changed;
public float Value
{
get => value;
set
{
if (Mathf.Approximately(this.value, value)) return;
this.value = value;
Changed?.Invoke(value); // ?. is fine here: Changed is a C# delegate, not a Unity object
}
}
}
Create two assets: PlayerHealth and PlayerMaxHealth.
using UnityEngine;
public class PlayerHealth : MonoBehaviour
{
[SerializeField] private FloatVariable health;
[SerializeField] private FloatVariable maxHealth;
[SerializeField] private float startingMax = 100f;
void Awake()
{
// Reset at the start of each play session (the asset keeps old values in the Editor).
maxHealth.Value = startingMax;
health.Value = startingMax;
}
public void TakeDamage(float amount)
{
health.Value = Mathf.Max(health.Value - amount, 0f);
}
}
using UnityEngine;
using UnityEngine.UI;
public class HealthBar : MonoBehaviour
{
[SerializeField] private FloatVariable health;
[SerializeField] private FloatVariable maxHealth;
[SerializeField] private Image fill; // Image Type: Filled
void OnEnable()
{
health.Changed += Refresh;
Refresh(health.Value);
}
void OnDisable()
{
health.Changed -= Refresh;
}
private void Refresh(float current)
{
fill.fillAmount = maxHealth.Value > 0f ? current / maxHealth.Value : 0f;
}
}
Drag the same PlayerHealth asset into both components. You can now also build a low-health vignette, a heartbeat sound, or an achievement tracker, each just reading the same asset.
What happens at runtime
- Both the player and the health bar hold a reference to the same asset object in memory.
- The player sets
health.Value = 70. The property setter stores 70 and firesChanged. - The health bar's
Refreshruns immediately, in the same frame, and updates the fill. - No
Updatepolling is needed. The UI only does work when health actually changes.
Part 3: Event channels (game over across scenes)
Problem: when the player dies, the music should change, the enemy spawner should stop, the UI should show "Game Over" and the save system should record the score. They're in different scenes and prefabs.
Solution: an event asset. Anyone can raise it; anyone can listen. It's like a UnityEvent that lives in the Project instead of on one object.
A group chat. The event asset is the chat room. The player posts "I died" to the room. Everyone who joined the room gets the message. The player doesn't need anyone's phone number, and people can join or leave the room anytime.
using System;
using UnityEngine;
[CreateAssetMenu(menuName = "Game/Event Channel")]
public class GameEvent : ScriptableObject
{
public event Action Raised;
public void Raise()
{
Raised?.Invoke();
}
}
A reusable listener component so designers can react in the Inspector with a UnityEvent:
using UnityEngine;
using UnityEngine.Events;
public class GameEventListener : MonoBehaviour
{
[SerializeField] private GameEvent gameEvent;
[SerializeField] private UnityEvent response;
void OnEnable() { gameEvent.Raised += OnRaised; }
void OnDisable() { gameEvent.Raised -= OnRaised; }
private void OnRaised() { response.Invoke(); }
}
Create a PlayerDied event asset. Then:
- In
PlayerHealth.TakeDamage, add a[SerializeField] private GameEvent playerDied;field and callplayerDied.Raise();when health reaches 0. - On the Game Over UI: add a GameEventListener, set the event to PlayerDied, and set the response to
GameObject.SetActive(true)on the panel. - On the music object: another listener with the response
AudioSource.Stop(). - On the spawner: a listener with the response
EnemySpawner.enabled = false.
The player script has one line about dying. Everything else is set up in the Inspector.
Events with data
using System;
using UnityEngine;
[CreateAssetMenu(menuName = "Game/Event Channel (int)")]
public class IntGameEvent : ScriptableObject
{
public event Action<int> Raised;
public void Raise(int value) => Raised?.Invoke(value);
}
Use it for "EnemyKilled(scoreValue)", "CoinsCollected(amount)" and so on.
Part 4: Runtime sets (every enemy alive)
Problem: the wave spawner needs to know when all enemies are dead, and the minimap needs all enemy positions. FindObjectsByType<Enemy>() every frame is slow.
Solution: a list asset. Enemies add themselves when enabled and remove themselves when disabled.
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Game/Runtime Set/Enemies")]
public class EnemySet : ScriptableObject
{
private readonly List<Enemy> items = new List<Enemy>();
public IReadOnlyList<Enemy> Items => items;
public int Count => items.Count;
public void Add(Enemy e) { if (!items.Contains(e)) items.Add(e); }
public void Remove(Enemy e) { items.Remove(e); }
}
Add to Enemy:
[SerializeField] private EnemySet allEnemies;
void OnEnable() { allEnemies.Add(this); }
void OnDisable() { allEnemies.Remove(this); }
A wave spawner that uses everything so far:
using System.Collections;
using UnityEngine;
public class WaveSpawner : MonoBehaviour
{
[System.Serializable]
public class Wave
{
public Enemy prefab; // the prefab's Enemy component holds its EnemyType
public int count = 5;
}
[SerializeField] private Wave[] waves;
[SerializeField] private Transform[] spawnPoints;
[SerializeField] private EnemySet allEnemies;
[SerializeField] private GameEvent allWavesCleared;
IEnumerator Start() // Start can be a coroutine
{
foreach (Wave wave in waves)
{
for (int i = 0; i < wave.count; i++)
{
Transform p = spawnPoints[Random.Range(0, spawnPoints.Length)];
Instantiate(wave.prefab, p.position, p.rotation);
yield return new WaitForSeconds(0.4f);
}
// Wait until the runtime set is empty.
yield return new WaitUntil(() => allEnemies.Count == 0);
yield return new WaitForSeconds(2f);
}
allWavesCleared.Raise();
}
}
Putting it together
| Asset | Kind | Written by | Read by |
|---|---|---|---|
| Slime, Skeleton, Bat | Data definition | You, in the Inspector | Enemy |
| PlayerHealth, PlayerMaxHealth | Shared variable | PlayerHealth | HealthBar, vignette, sounds |
| PlayerDied, AllWavesCleared | Event channel | PlayerHealth, WaveSpawner | GameEventListeners (UI, music, spawner) |
| AllEnemies | Runtime set | Enemy (self-registers) | WaveSpawner, minimap |
Notice what's missing: no singletons, no GameObject.Find in Update, no script holding references to five other scripts. Each piece can be tested alone. Drop a HealthBar into an empty scene with the asset, change the value in the Inspector during Play Mode, and watch it respond.
Things to watch out for
- Values stick around in the Editor. Changes made during Play Mode stay in the asset after you stop. That's why
PlayerHealth.Awakeresets the values. Runtime sets fill back up as objects enable, but a list that was never cleared (e.g. after a script error) can contain stale entries. - Builds don't save changes. When the game restarts, assets have their original values again. Don't use this as a save system.
- Don't overdo it. Making every single number a FloatVariable asset gets messy fast. Use these patterns where things really need to be shared or decoupled.
- Find usages. Right-click an asset > Find References In Scene to see who uses it.
See also
This page is a simplified, AI-assisted interpretation. For the complete and authoritative reference, see the official Unity documentation.