tools-unity-scriptable-objects
SKILL.md
Unity ScriptableObjects
Overview
ScriptableObjects are data containers that exist outside the scene hierarchy, ideal for configuration, shared data, and decoupling systems.
When to Use
- Game configuration and settings
- Item/ability/character definitions
- Shared runtime data
- Event systems
- Dependency injection alternatives
Basic Patterns
Data Definition
[CreateAssetMenu(fileName = "NewItem", menuName = "Game/Item Definition")]
public class ItemDefinition : ScriptableObject
{
[Header("Basic Info")]
public string Id;
public string DisplayName;
[TextArea] public string Description;
public Sprite Icon;
[Header("Stats")]
public ItemRarity Rarity;
public int MaxStackSize = 99;
public int BaseValue;
[Header("Gameplay")]
public bool IsConsumable;
public bool IsEquippable;
public EquipmentSlot EquipSlot;
// Runtime lookup optimization
private static Dictionary<string, ItemDefinition> s_Lookup;
public static ItemDefinition GetById(string id)
{
if (s_Lookup == null)
{
BuildLookup();
}
return s_Lookup.TryGetValue(id, out var item) ? item : null;
}
private static void BuildLookup()
{
s_Lookup = new Dictionary<string, ItemDefinition>();
var items = Resources.LoadAll<ItemDefinition>("Items");
foreach (var item in items)
{
s_Lookup[item.Id] = item;
}
}
}
Database Pattern
[CreateAssetMenu(fileName = "ItemDatabase", menuName = "Game/Item Database")]
public class ItemDatabase : ScriptableObject
{
[SerializeField] private List<ItemDefinition> _items = new();
private Dictionary<string, ItemDefinition> _lookup;
public IReadOnlyList<ItemDefinition> AllItems => _items;
public void Initialize()
{
_lookup = new Dictionary<string, ItemDefinition>();
foreach (var item in _items)
{
if (item != null && !string.IsNullOrEmpty(item.Id))
{
_lookup[item.Id] = item;
}
}
}
public ItemDefinition GetItem(string id)
{
if (_lookup == null) Initialize();
return _lookup.TryGetValue(id, out var item) ? item : null;
}
public IEnumerable<ItemDefinition> GetItemsByRarity(ItemRarity rarity)
{
return _items.Where(i => i.Rarity == rarity);
}
#if UNITY_EDITOR
[ContextMenu("Collect All Items")]
private void CollectItems()
{
_items.Clear();
var guids = UnityEditor.AssetDatabase.FindAssets("t:ItemDefinition");
foreach (var guid in guids)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
var item = UnityEditor.AssetDatabase.LoadAssetAtPath<ItemDefinition>(path);
if (item != null && item != this)
{
_items.Add(item);
}
}
UnityEditor.EditorUtility.SetDirty(this);
}
#endif
}
Runtime Data
Shared Runtime Value
[CreateAssetMenu(fileName = "RuntimeInt", menuName = "Runtime/Int Value")]
public class RuntimeInt : ScriptableObject
{
[SerializeField] private int _initialValue;
[NonSerialized] private int _runtimeValue;
[NonSerialized] private bool _initialized;
public int Value
{
get
{
EnsureInitialized();
return _runtimeValue;
}
set
{
EnsureInitialized();
if (_runtimeValue != value)
{
_runtimeValue = value;
OnValueChanged?.Invoke(value);
}
}
}
public event Action<int> OnValueChanged;
private void EnsureInitialized()
{
if (!_initialized)
{
_runtimeValue = _initialValue;
_initialized = true;
}
}
private void OnEnable()
{
_initialized = false;
}
public void Reset()
{
_runtimeValue = _initialValue;
_initialized = true;
OnValueChanged?.Invoke(_runtimeValue);
}
}
Observable Collection
[CreateAssetMenu(fileName = "RuntimeList", menuName = "Runtime/List")]
public class RuntimeList<T> : ScriptableObject
{
[NonSerialized] private List<T> _items = new();
public IReadOnlyList<T> Items => _items;
public int Count => _items.Count;
public event Action<T> OnItemAdded;
public event Action<T> OnItemRemoved;
public event Action OnCleared;
public void Add(T item)
{
_items.Add(item);
OnItemAdded?.Invoke(item);
}
public bool Remove(T item)
{
if (_items.Remove(item))
{
OnItemRemoved?.Invoke(item);
return true;
}
return false;
}
public void Clear()
{
_items.Clear();
OnCleared?.Invoke();
}
private void OnEnable()
{
_items = new List<T>();
}
}
Event System
Game Event
[CreateAssetMenu(fileName = "GameEvent", menuName = "Events/Game Event")]
public class GameEvent : ScriptableObject
{
private readonly List<GameEventListener> _listeners = new();
public void Raise()
{
// Iterate backwards for safe removal during iteration
for (int i = _listeners.Count - 1; i >= 0; i--)
{
_listeners[i].OnEventRaised();
}
}
public void RegisterListener(GameEventListener listener)
{
if (!_listeners.Contains(listener))
{
_listeners.Add(listener);
}
}
public void UnregisterListener(GameEventListener listener)
{
_listeners.Remove(listener);
}
}
public class GameEventListener : MonoBehaviour
{
[SerializeField] private GameEvent _event;
[SerializeField] private UnityEvent _response;
private void OnEnable()
{
_event?.RegisterListener(this);
}
private void OnDisable()
{
_event?.UnregisterListener(this);
}
public void OnEventRaised()
{
_response?.Invoke();
}
}
Typed Event
public abstract class GameEvent<T> : ScriptableObject
{
private readonly List<IGameEventListener<T>> _listeners = new();
public void Raise(T value)
{
for (int i = _listeners.Count - 1; i >= 0; i--)
{
_listeners[i].OnEventRaised(value);
}
}
public void RegisterListener(IGameEventListener<T> listener)
{
if (!_listeners.Contains(listener))
_listeners.Add(listener);
}
public void UnregisterListener(IGameEventListener<T> listener)
{
_listeners.Remove(listener);
}
}
public interface IGameEventListener<T>
{
void OnEventRaised(T value);
}
[CreateAssetMenu(fileName = "IntEvent", menuName = "Events/Int Event")]
public class IntEvent : GameEvent<int> { }
[CreateAssetMenu(fileName = "StringEvent", menuName = "Events/String Event")]
public class StringEvent : GameEvent<string> { }
Configuration
Game Settings
[CreateAssetMenu(fileName = "GameSettings", menuName = "Config/Game Settings")]
public class GameSettings : ScriptableObject
{
[Header("Gameplay")]
public float PlayerMoveSpeed = 5f;
public float JumpForce = 10f;
public int MaxHealth = 100;
[Header("Audio")]
[Range(0, 1)] public float MasterVolume = 1f;
[Range(0, 1)] public float MusicVolume = 0.8f;
[Range(0, 1)] public float SfxVolume = 1f;
[Header("Graphics")]
public QualityPreset DefaultQuality = QualityPreset.Medium;
public bool VSyncEnabled = true;
public int TargetFrameRate = 60;
// Singleton access
private static GameSettings _instance;
public static GameSettings Instance
{
get
{
if (_instance == null)
{
_instance = Resources.Load<GameSettings>("GameSettings");
}
return _instance;
}
}
}
Platform-Specific Config
[CreateAssetMenu(fileName = "PlatformConfig", menuName = "Config/Platform Config")]
public class PlatformConfig : ScriptableObject
{
[SerializeField] private PlatformSettings _ios;
[SerializeField] private PlatformSettings _android;
[SerializeField] private PlatformSettings _desktop;
public PlatformSettings Current
{
get
{
#if UNITY_IOS
return _ios;
#elif UNITY_ANDROID
return _android;
#else
return _desktop;
#endif
}
}
}
[Serializable]
public class PlatformSettings
{
public int MaxEnemies = 20;
public int MaxParticles = 100;
public int TextureQuality = 2;
public bool EnableShadows = true;
public float LodBias = 1f;
}
Validation
Editor Validation
public abstract class ValidatedScriptableObject : ScriptableObject
{
#if UNITY_EDITOR
private void OnValidate()
{
Validate();
}
protected virtual void Validate()
{
var errors = GetValidationErrors();
foreach (var error in errors)
{
Debug.LogError($"[{name}] {error}", this);
}
}
protected virtual IEnumerable<string> GetValidationErrors()
{
yield break;
}
#endif
}
[CreateAssetMenu(fileName = "NewWeapon", menuName = "Game/Weapon")]
public class WeaponDefinition : ValidatedScriptableObject
{
public string Id;
public string DisplayName;
public int Damage;
public float AttackSpeed;
public Sprite Icon;
#if UNITY_EDITOR
protected override IEnumerable<string> GetValidationErrors()
{
if (string.IsNullOrEmpty(Id))
yield return "Id is required";
if (string.IsNullOrEmpty(DisplayName))
yield return "DisplayName is required";
if (Damage <= 0)
yield return "Damage must be positive";
if (AttackSpeed <= 0)
yield return "AttackSpeed must be positive";
if (Icon == null)
yield return "Icon is required";
}
#endif
}
ID Uniqueness Check
#if UNITY_EDITOR
public static class ScriptableObjectValidator
{
[UnityEditor.Callbacks.DidReloadScripts]
private static void ValidateAllItems()
{
ValidateUniqueIds<ItemDefinition>("Items");
ValidateUniqueIds<WeaponDefinition>("Weapons");
}
private static void ValidateUniqueIds<T>(string folder) where T : ScriptableObject
{
var guids = UnityEditor.AssetDatabase.FindAssets($"t:{typeof(T).Name}");
var idToAsset = new Dictionary<string, string>();
foreach (var guid in guids)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
var asset = UnityEditor.AssetDatabase.LoadAssetAtPath<T>(path);
var idProperty = typeof(T).GetField("Id");
if (idProperty == null) continue;
var id = (string)idProperty.GetValue(asset);
if (string.IsNullOrEmpty(id)) continue;
if (idToAsset.TryGetValue(id, out var existingPath))
{
Debug.LogError($"Duplicate ID '{id}' found in:\n{existingPath}\n{path}");
}
else
{
idToAsset[id] = path;
}
}
}
}
#endif
Best Practices
- Use CreateAssetMenu for easy creation
- Initialize runtime state in OnEnable
- Clear runtime state - SO persists in editor
- Use databases for collections
- Validate in editor with OnValidate
- Keep IDs unique - Validate automatically
- Use SerializeField for private fields
- Avoid scene references in SO
- Reset state between plays in editor
- Use Resources.Load sparingly - Prefer Addressables
Troubleshooting
| Issue | Solution |
|---|---|
| State persists between plays | Reset in OnEnable |
| Null reference to SO | Check asset assignment |
| Duplicate IDs | Add validation check |
| SO not saving changes | Call EditorUtility.SetDirty |
| Large SO slows editor | Split into smaller assets |
| Events not firing | Check listener registration |
Weekly Installs
1
Repository
tjboudreaux/cc-…-gamedevGitHub Stars
1
First Seen
2 days ago
Security Audits
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1