Architecture patterns
Purpose
This guide helps you choose the right Reactive SO tool for each situation. You will learn when to use Event Channels, Variables, Runtime Sets, and Reactive Entity Sets, and when standard C# fields are the better choice.
The four tools
Reactive SO provides four complementary tools:
| Tool | Purpose | Best For |
|---|---|---|
| Event Channels | Global notifications | Fire-and-forget messages, decoupled communication |
| Variables | Shared state | Global values, cross-scene persistence |
| Runtime Sets | Object collections | Tracking active objects, replacing singletons |
| Reactive Entity Sets | Per-entity state | ID-based lookup, entity state management |
The Instance vs Global rule
This is the most important decision when using Reactive SO:
Instance data (unique per object) → Use C# fields Global data (shared across objects) → Use ScriptableObject
Why this matters
ScriptableObjects are shared resources. All scripts referencing the same asset share the same data.
// BAD: All spawned enemies share the same health!
public class Enemy : MonoBehaviour
{
[SerializeField] private IntVariableSO health; // Shared!
void TakeDamage(int damage)
{
health.Value -= damage; // Affects ALL enemies
}
}
// GOOD: Each enemy has independent health
public class Enemy : MonoBehaviour
{
private int health = 100; // Instance-specific
void TakeDamage(int damage)
{
health -= damage; // Only affects this enemy
}
}
Decision tree
Use this flowchart to choose the right tool for your situation:
flowchart LR
A["What type of data?"] --> B{Instance-specific?}
B -->|YES| C["C# fields<br>Independent per object"]
B -->|NO| D{How is it shared?}
D -->|Notifications only| E["Event Channels<br>Decoupled communication"]
D -->|State sharing| F{Multiple objects?}
D -->|Object tracking| I["Runtime Sets<br>Dynamic collections"]
F -->|Same value shared| G["Variables<br>Global state"]
F -->|Per-entity| H["Reactive Entity Sets<br>ID-based management"]
When to use each tool
Event Channels
Use for global notifications that decouple systems:
// Perfect: Game-wide notifications
[SerializeField] VoidEventChannelSO onPlayerDied;
[SerializeField] VoidEventChannelSO onLevelCompleted;
[SerializeField] IntEventChannelSO onScoreChanged;
// Perfect: Cross-system communication
[SerializeField] GameObjectEventChannelSO onEnemySpawned;
[SerializeField] StringEventChannelSO onAudioRequest;
Do NOT use for instance-specific events:
// BAD: All enemies react to the same death event
public class Enemy : MonoBehaviour
{
[SerializeField] private VoidEventChannelSO onDeath;
private void OnEnable() => onDeath.OnEventRaised += Die;
public void TakeDamage(int damage)
{
if (health <= 0)
onDeath.RaiseEvent(); // ALL enemies die!
}
}
Variables
Use for global state that multiple systems read:
// Perfect: Global game state
[SerializeField] IntVariableSO playerScore;
[SerializeField] BoolVariableSO isPaused;
[SerializeField] FloatVariableSO masterVolume;
// Perfect: Designer-configurable values
[SerializeField] FloatVariableSO enemySpawnRate;
[SerializeField] IntVariableSO maxEnemies;
Do NOT use for per-instance state:
// BAD: Individual enemy health
[SerializeField] IntVariableSO health; // All enemies share this!
// GOOD: Use C# field instead
private int health = 100;
Runtime Sets
Use to track active objects without singletons:
// Perfect: Dynamic object management
[SerializeField] GameObjectRuntimeSetSO enemies;
[SerializeField] GameObjectRuntimeSetSO pickups;
[SerializeField] TransformRuntimeSetSO waypoints;
// Objects register themselves
private void OnEnable() => enemies?.Add(gameObject);
private void OnDisable() => enemies?.Remove(gameObject);
Reactive Entity Sets
Use for per-entity state with ID-based lookup:
// Perfect: Per-entity data storage
[SerializeField] EnemyEntitySetSO entitySet;
// Each entity has independent state
entitySet.Register(this, new EnemyState { Health = 100 });
entitySet.UpdateData(this, state => {
state.Health -= damage;
return state;
});
Common patterns
Pattern 1: Dynamic enemy spawning
Combine instance fields with global events:
public class Enemy : MonoBehaviour
{
// Instance-specific state
[SerializeField] private int maxHealth = 100;
private int currentHealth;
// Global events for cross-system notification
[SerializeField] private IntEventChannelSO onAnyEnemyDamaged;
[SerializeField] private VoidEventChannelSO onAnyEnemyDied;
private void Start() => currentHealth = maxHealth;
public void TakeDamage(int damage)
{
currentHealth -= damage;
onAnyEnemyDamaged?.RaiseEvent(currentHealth);
if (currentHealth <= 0)
{
onAnyEnemyDied?.RaiseEvent();
Destroy(gameObject);
}
}
}
Pattern 2: Global score system
Use Variables for truly shared state:
public class ScoreManager : MonoBehaviour
{
[SerializeField] private IntVariableSO score;
private void OnEnable() => score.ResetToInitial();
public void AddPoints(int points)
{
score.Value += points; // Automatically fires OnValueChanged
}
}
public class ScoreDisplay : MonoBehaviour
{
[SerializeField] private IntVariableSO score;
[SerializeField] private Text scoreText;
private void Update()
{
scoreText.text = $"Score: {score.Value}";
}
}
Pattern 3: Enemy management with Runtime Sets
Replace singleton managers:
public class EnemySpawner : MonoBehaviour
{
[SerializeField] private GameObject enemyPrefab;
[SerializeField] private GameObjectRuntimeSetSO activeEnemies;
[SerializeField] private VoidEventChannelSO onWaveComplete;
private void Update()
{
// No FindObjectsOfType needed
if (activeEnemies.Count == 0)
onWaveComplete?.RaiseEvent();
}
}
public class Enemy : MonoBehaviour
{
[SerializeField] private GameObjectRuntimeSetSO activeEnemies;
private void OnEnable() => activeEnemies?.Add(gameObject);
private void OnDisable() => activeEnemies?.Remove(gameObject);
}
Pattern 4: Hybrid approach
Combine multiple patterns:
public class Player : MonoBehaviour
{
// Instance state
private int currentAmmo = 30;
// Global configuration (designer-tweakable)
[SerializeField] private IntVariableSO maxAmmo;
[SerializeField] private FloatVariableSO reloadTime;
// Global events
[SerializeField] private IntEventChannelSO onAmmoChanged;
public void Shoot()
{
if (currentAmmo > 0)
{
currentAmmo--;
onAmmoChanged?.RaiseEvent(currentAmmo);
}
}
public void Reload()
{
currentAmmo = maxAmmo.Value; // Read from global config
}
}
Common mistakes
Mistake 1: Using Event Channels for instance events
// BAD: All enemies subscribe to the same death event
onDeath.OnEventRaised += Die; // When one dies, ALL die!
// GOOD: Use direct method calls for instance logic
private void Die()
{
onAnyEnemyDied?.RaiseEvent(); // Notify globally (optional)
Destroy(gameObject); // Only this enemy
}
Mistake 2: Using Variables for instance state
// BAD: Shared health
[SerializeField] IntVariableSO health;
// GOOD: Instance health
private int health = 100;
Mistake 3: Overusing ScriptableObjects
// BAD: Not everything needs to be a ScriptableObject
[SerializeField] private FloatVariableSO moveSpeed;
[SerializeField] private FloatVariableSO jumpHeight;
[SerializeField] private FloatVariableSO gravity;
// GOOD: Use C# fields for instance-specific config
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private float jumpHeight = 2f;
Decision guide
State management
| Scenario | Use | Reason |
|---|---|---|
| Player score (single value) | VariableSO | Truly global, shared |
| Game settings (volume, etc.) | VariableSO | Designer-tweakable, persistent |
| Enemy health (many instances) | C# field | Each needs independent state |
| Bullet speed (many instances) | C# field | Instance-specific, performance |
| Current level | VariableSO | Global, cross-scene |
| Active enemies list | RuntimeSetSO | Dynamic collection tracking |
| Per-enemy state (health, status) | ReactiveEntitySetSO | ID-based lookup, per-entity events |
Event communication
| Scenario | Use | Reason |
|---|---|---|
| Player died notification | EventChannelSO | Global, multiple listeners |
| Enemy damaged (any enemy) | EventChannelSO | Notify UI, audio globally |
| Individual enemy death logic | C# method | Instance-specific |
| Button click → UI response | EventChannelSO | Decouple UI from game |
| Collision handling | C# method | Instance-specific physics |
Collection management
| Scenario | Use | Reason |
|---|---|---|
| Track all active enemies | RuntimeSetSO | Replace singleton manager |
| Manage spawned projectiles | RuntimeSetSO | Dynamic with auto cleanup |
| Query nearby objects | Physics.OverlapSphere | Performance, spatial |
| Static List | RuntimeSetSO | Better testability |
Summary
| Tool | Use When | Avoid When |
|---|---|---|
| Event Channels | Global notifications, decoupled messaging | Instance-specific events |
| Variables | Global state, designer config | Per-instance state |
| Runtime Sets | Object tracking, replacing singletons | Per-entity data storage |
| Reactive Entity Sets | Per-entity state, ID-based lookup | Simple object tracking |
| C# fields | Instance state, performance-critical | Shared state across systems |