Runtime Sets
Runtime Sets are available since v1.1.0.
Purpose
This guide explains how to use Runtime Sets to track collections of objects without singleton managers. You will learn the registration pattern, how to query active objects, and when to choose Runtime Sets over alternatives.
What are runtime sets?
Runtime Sets are ScriptableObject-based collections that track objects created and destroyed at runtime. Objects register themselves when enabled and unregister when disabled.
// Objects register themselves
private void OnEnable() => activeEnemies?.Add(gameObject);
private void OnDisable() => activeEnemies?.Remove(gameObject);
// Query active objects anywhere
int count = activeEnemies.Count;
foreach (var enemy in activeEnemies.Items) { ... }
This eliminates the need for singleton managers like EnemyManager.Instance.
Lifecycle
The registration and unregistration flow works as follows:
sequenceDiagram
participant E as Enemy
participant RS as RuntimeSet
participant M as Manager
rect rgb(200, 230, 200)
Note over E,M: Registration flow
E->>E: OnEnable()
E->>RS: Add(gameObject)
RS->>RS: items.Add()
RS->>M: OnItemsChanged
RS->>M: OnCountChanged(count)
Note over M: Update UI
end
rect rgb(230, 200, 200)
Note over E,M: Unregistration flow
E->>E: OnDisable()
E->>RS: Remove(gameObject)
RS->>RS: items.Remove()
RS->>M: OnItemsChanged
RS->>M: OnCountChanged(count)
Note over M: Update UI
end
When to use runtime sets
Use runtime sets when
- You need to track dynamically spawned objects (enemies, pickups, projectiles)
- You want to avoid singleton managers
- Multiple systems need to query the same collection
- You need to check “all active X” without
FindObjectsOfType
Use C# List when
- The collection belongs to one GameObject only
- You access it every frame (performance critical)
- You don’t need cross-system visibility
- You don’t need event notifications
Quick decision guide
| Scenario | Use |
|---|---|
| Track all enemies in level | Runtime Set |
| Enemy’s personal waypoint list | C# List |
| Track all pickups for minimap | Runtime Set |
| Inventory items in player | C# List |
Available types
| Type | Tracks | Use Case |
|---|---|---|
| GameObject Runtime Set | GameObject | Most common, spawned entities |
| Transform Runtime Set | Transform | Position-based queries, waypoints |
Basic usage
Step 1: Create a runtime set asset
Right-click in the Project window and select the following menu path.
Create > Reactive SO > Runtime Sets > GameObject Runtime Set
Name it descriptively, such as ActiveEnemies or SpawnedPickups.
Step 2: Create event channels (optional)
If you need notifications when the collection changes, create an event channel.
Create > Reactive SO > Channels > Void Event
Assign it to the runtime set’s On Items Changed field.
Step 3: Objects register themselves
Use the OnEnable/OnDisable pattern.
using Tang3cko.ReactiveSO;
using UnityEngine;
public class Enemy : MonoBehaviour
{
[SerializeField] private GameObjectRuntimeSetSO activeEnemies;
private void OnEnable()
{
activeEnemies?.Add(gameObject);
}
private void OnDisable()
{
activeEnemies?.Remove(gameObject);
}
}
Step 4: Query the collection
Access active objects from anywhere.
public class WaveManager : MonoBehaviour
{
[SerializeField] private GameObjectRuntimeSetSO activeEnemies;
[SerializeField] private VoidEventChannelSO onWaveComplete;
private void Update()
{
if (activeEnemies.Count == 0)
{
onWaveComplete?.RaiseEvent();
}
}
}
API reference
Properties
| Property | Type | Description |
|---|---|---|
Count | int | Number of items in the set |
Items | IReadOnlyList<T> | Read-only access to all items |
Methods
| Method | Description |
|---|---|
Add(T item) | Add an item (prevents duplicates) |
Remove(T item) | Remove an item |
Contains(T item) | Check if item exists |
Clear() | Remove all items |
DestroyItems() | Destroy all GameObjects and clear (GameObject sets only) |
Common patterns
Pattern 1: Wave-based spawning
Track enemies to detect when wave is complete.
public class WaveSpawner : MonoBehaviour
{
[SerializeField] private GameObject enemyPrefab;
[SerializeField] private GameObjectRuntimeSetSO activeEnemies;
[SerializeField] private VoidEventChannelSO onWaveComplete;
public void SpawnWave(int count)
{
for (int i = 0; i < count; i++)
{
// Enemies register themselves in OnEnable
Instantiate(enemyPrefab, GetSpawnPosition(), Quaternion.identity);
}
}
private void Update()
{
if (activeEnemies.Count == 0)
{
onWaveComplete?.RaiseEvent();
}
}
}
Pattern 2: Find nearest object
Query without FindObjectsOfType.
public GameObject GetNearestEnemy(Vector3 position)
{
GameObject nearest = null;
float minDistance = float.MaxValue;
foreach (var enemy in activeEnemies.Items)
{
if (enemy == null) continue;
float distance = Vector3.Distance(position, enemy.transform.position);
if (distance < minDistance)
{
minDistance = distance;
nearest = enemy;
}
}
return nearest;
}
Pattern 3: Pickup collection
Track collectible items.
public class Pickup : MonoBehaviour
{
[SerializeField] private GameObjectRuntimeSetSO activePickups;
private void OnEnable()
{
activePickups?.Add(gameObject);
}
private void OnDisable()
{
activePickups?.Remove(gameObject);
}
public void Collect()
{
// Award points, play sound, etc.
Destroy(gameObject); // OnDisable removes from set
}
}
Pattern 4: Level cleanup
Destroy all spawned objects at once.
public class LevelManager : MonoBehaviour
{
[SerializeField] private GameObjectRuntimeSetSO spawnedEnemies;
[SerializeField] private GameObjectRuntimeSetSO spawnedPickups;
public void EndLevel()
{
// Destroy all tracked objects
spawnedEnemies.DestroyItems();
spawnedPickups.DestroyItems();
}
}
Subscribing to changes
React when items are added or removed.
public class EnemyCounter : MonoBehaviour
{
[SerializeField] private GameObjectRuntimeSetSO activeEnemies;
[SerializeField] private VoidEventChannelSO onEnemiesChanged;
[SerializeField] private Text countText;
private void OnEnable()
{
onEnemiesChanged.OnEventRaised += UpdateDisplay;
UpdateDisplay();
}
private void OnDisable()
{
onEnemiesChanged.OnEventRaised -= UpdateDisplay;
}
private void UpdateDisplay()
{
countText.text = $"Enemies: {activeEnemies.Count}";
}
}
Inspector features
During Play Mode, select a Runtime Set asset to see the following.
- Live item list - All currently registered objects
- Click to ping - Click any item to highlight in Hierarchy
- Count display - Current number of items
Items are automatically cleared when exiting Play Mode.
Best practices
Always use OnEnable/OnDisable
This ensures automatic registration and cleanup.
// ✅ Good: Balanced registration
private void OnEnable()
{
runtimeSet?.Add(gameObject);
}
private void OnDisable()
{
runtimeSet?.Remove(gameObject);
}
// ❌ Bad: Only registers, never unregisters
private void Start()
{
runtimeSet?.Add(gameObject);
}
Null-check when iterating
Items may be destroyed between checks.
foreach (var enemy in activeEnemies.Items)
{
if (enemy == null) continue; // Important!
// Use enemy
}
Clear on scene transitions
Call Clear() or DestroyItems() when changing scenes.
public void LoadNextLevel()
{
activeEnemies.Clear(); // Prevent stale references
SceneManager.LoadScene("NextLevel");
}
Use descriptive names
// ✅ Good: Clear what it tracks
ActiveEnemies
SpawnedPickups
RegisteredWaypoints
// ❌ Bad: Ambiguous
Enemies
Items
Set1
Comparison with alternatives
| Feature | Runtime Sets | Singleton Manager | List in MonoBehaviour |
|---|---|---|---|
| Inspector visibility | Yes | No | Limited |
| Event notifications | Automatic | Manual | Manual |
| Testability | High | Low | Medium |
| Cross-scene persistence | Yes | Yes | No |
| Coupling | Low | High | Medium |
Creating custom runtime sets
For custom types, inherit from RuntimeSetSO<T>:
[CreateAssetMenu(
fileName = "EnemyRuntimeSet",
menuName = "Reactive SO/Runtime Sets/Enemy Runtime Set"
)]
public class EnemyRuntimeSetSO : RuntimeSetSO<Enemy>
{
// Add custom methods if needed
public Enemy GetStrongestEnemy()
{
return Items
.Where(e => e != null)
.OrderByDescending(e => e.Health)
.FirstOrDefault();
}
}
Use in your component:
public class Enemy : MonoBehaviour
{
[SerializeField] private EnemyRuntimeSetSO activeEnemies;
public int Health { get; private set; } = 100;
private void OnEnable() => activeEnemies?.Add(this);
private void OnDisable() => activeEnemies?.Remove(this);
}
Troubleshooting
Items not appearing in Inspector
Runtime Set contents are only visible during Play Mode.
Objects not registering
Ensure you call Add() in OnEnable and the runtime set is assigned in the Inspector.
Null references after scene change
Call Clear() before changing scenes to remove stale references.
Count not updating in UI
Subscribe to the On Items Changed event channel rather than polling every frame.
References
- Event Channels Guide - For notifications
- Variables Guide - For shared state
- Reactive Entity Sets Guide - For entity data with IDs