Decoupled Communication in Unity: Building a Flexible Event System with C# and ScriptableObjects
The biggest challenge in game development? Spaghetti code. Intertwined systems, tightly coupled components – a nightmare to maintain and scale. But there’s a powerful solution lurking in the depths of C# and Unity: a flexible event system, built on delegates and ScriptableObjects, that allows for decoupled communication and a far more maintainable architecture. Let’s dive in and build one.
Why Bother with an Event System?
The most-downloaded assets on Wayline this month — sorted by what real developers actually use.
Think about it. Without an event system, triggering an action in one script often requires directly referencing another. That’s fine for small projects, but it quickly spirals out of control. An event system lets scripts communicate without knowing about each other.
Imagine a health system needing to update the UI and trigger a visual effect when the player takes damage. With direct references, the health script would need references to the UI element and the visual effects script. With an event system, it simply raises a “PlayerDamaged” event. The UI and visual effects scripts subscribe to that event and react accordingly, completely decoupled from the health script.
Delegates: The Core of the Matter
Delegates are essentially type-safe function pointers. They allow you to treat methods as variables, passing them around and executing them dynamically. This is the foundation of our event system.
// Define a delegate type for our event
public delegate void DamageEventHandler(int damageAmount);
This defines a delegate type named DamageEventHandler that takes an integer damageAmount as a parameter. Any method with the same signature can be assigned to this delegate.
ScriptableObjects: Persistent Event Definitions
ScriptableObjects are Unity’s data containers that exist independently of scene objects. They are perfect for defining event types because they persist across scenes and can be easily referenced from any script.
- Create a new C# script named
GameEvent.
using UnityEngine;
using System;
using System.Collections.Generic;
[CreateAssetMenu(menuName = "Events/Game Event")]
public class GameEvent : ScriptableObject
{
private List<Action> listeners = new List<Action>();
public void RegisterListener(Action listener)
{
listeners.Add(listener);
}
public void UnregisterListener(Action listener)
{
listeners.Remove(listener);
}
public void Raise()
{
for(int i = listeners.Count -1; i >= 0; i--)
{
listeners[i]?.Invoke();
}
}
}
Create a folder in your Project window called "Resources".
Right-click in the “Resources” folder and select "Create > Events > Game Event". Name this ScriptableObject "OnPlayerDeath".
Now you have a GameEvent ScriptableObject named “OnPlayerDeath” that you can reference from any script.
Raising and Subscribing to Events
Here’s the beauty of it all. To raise the event, you simply call the Raise() method on the GameEvent ScriptableObject.
public class PlayerHealth : MonoBehaviour
{
public GameEvent onPlayerDeath;
private int health = 100;
public void TakeDamage(int damage)
{
health -= damage;
if (health <= 0)
{
onPlayerDeath.Raise(); // Raise the event when the player dies
}
}
}
Any script can now subscribe to the OnPlayerDeath event and perform actions when it’s raised.
public class UIManager : MonoBehaviour
{
public GameEvent onPlayerDeath;
private void OnEnable()
{
onPlayerDeath.RegisterListener(UpdateDeathUI);
}
private void OnDisable()
{
onPlayerDeath.UnregisterListener(UpdateDeathUI);
}
private void UpdateDeathUI()
{
//Update UI here
Debug.Log("Player has died: UPDATE UI");
}
}
Key Takeaway: Notice how PlayerHealth and UIManager don’t directly reference each other. They communicate solely through the OnPlayerDeath event.
Practical Examples: Beyond the Basics
- Spawning Enemies: When a player enters a new area, raise an event like "AreaEntered". Enemy spawner scripts can subscribe to this event and spawn enemies accordingly.
- Updating UI: As mentioned earlier, UI updates are a perfect use case. Raise events when the player’s health changes, when they collect items, or when they complete objectives.
- Triggering Cutscenes: A “LevelCompleted” event can trigger a cutscene manager to start playing a cinematic.
Common Pitfalls and How to Avoid Them
- Forgetting to Unsubscribe: Always unsubscribe from events in
OnDisable()orOnDestroy()to prevent memory leaks and unexpected behavior. Failing to do so results in orphaned listeners that continue to be notified even after the subscribing object is destroyed. - Too Many Events: Don’t create an event for every tiny action. Overusing events can make your code harder to follow. Consider whether a direct function call might be more appropriate in some cases.
- Hard-Coded Event Names: Using ScriptableObjects is the recommended solution to avoid having to use hardcoded event names and instead use references.
The Verdict: Embrace Decoupling
Building a flexible event system using C# delegates and Unity’s ScriptableObjects is a game-changer. It promotes decoupled communication, improves code maintainability, and empowers you to create more complex and scalable games. Ditch the spaghetti code, embrace the power of events, and watch your projects thrive. It requires a bit of initial setup, but the long-term benefits are undeniable.
Remember to always unsubscribe from events when you’re done with them and avoid creating too many events. With these simple guidelines, you’ll be well on your way to building a robust and flexible event system that will serve you well in all your future Unity projects.