Events plus Unity plus advanced C#
We’ll do this:
- A UI Button that triggers a custom C# event.
- Multiple different GameObjects listen to that event and react in different ways.
- You can extend it to show delegates,
Action<>,event, and decoupling.
1. Concept and learning goals
Scenario
There is a “Grant Points” button in the UI. When you press it:
- The ScoreManager increases the player score and updates UI text.
- A ConsoleLogger prints a debug line.
- A ColorFlasher object briefly flashes a color.
All three are listening to the same event. The button does not know who is listening. That is the key teaching point.
C# concepts you can highlight
- Delegates vs
Actionvsevent - Publisher / Subscriber pattern
- Subscribing and unsubscribing in
OnEnable/OnDisable - Why events decouple UI from game logic
2. Unity project setup
- Create a new Unity 3D or 2D project. Any Render Pipeline is fine.
- In Hierarchy:
- Right click → UI → Canvas.
- Add:
- A Button: name it
GrantPointsButton. - A Text (UI) or TextMeshProUGUI text element: name it
ScoreText.
- A Button: name it
- In Scene:
- Create an empty GameObject:
EventChannel. - Create another empty GameObject:
ScoreManager. - Create a Cube or Sprite called
FlashObject. - Create an empty GameObject:
ConsoleLogger.
- Create an empty GameObject:
We will attach scripts to these.
3. Create the Event Channel (publisher)
This is your central hub that defines and raises the event.
Script 1: ScoreEventChannel.cs
using System;
using UnityEngine;
/// <summary>
/// Publishes score events to any listeners.
/// This is the "event channel" or "publisher".
/// </summary>
public class ScoreEventChannel : MonoBehaviour
{
// Simple singleton to make it easy to access in a demo.
public static ScoreEventChannel Instance { get; private set; }
/// <summary>
/// Event that fires whenever score should change.
/// int: scoreDelta, string: reason
/// </summary>
public event Action<int, string> OnScoreChanged;
private void Awake()
{
if (Instance != null && Instance != this)
{
Debug.LogWarning("Multiple ScoreEventChannels found. Destroying duplicate.");
Destroy(gameObject);
return;
}
Instance = this;
}
/// <summary>
/// Call this to raise the score event.
/// </summary>
public void RaiseScoreChanged(int delta, string reason)
{
// Null conditional operator safely invokes if there are subscribers
OnScoreChanged?.Invoke(delta, reason);
}
}
Attach ScoreEventChannel to the EventChannel GameObject.
Teaching notes
- Show
public event Action<int, string> OnScoreChanged; - Explain
Action<int, string>as a delegate type: method signaturevoid Something(int, string). - The
eventkeyword restricts who can invoke it (only the publisher).
4. Create the UI Button script (event source)
The button will call into the event channel when clicked.
Script 2: GrantPointsButton.cs
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(Button))]
public class GrantPointsButton : MonoBehaviour
{
[SerializeField] private int scoreDelta = 10;
[SerializeField] private string reason = "Button Press";
private Button _button;
private void Awake()
{
_button = GetComponent<Button>();
}
private void OnEnable()
{
_button.onClick.AddListener(HandleClick);
}
private void OnDisable()
{
_button.onClick.RemoveListener(HandleClick);
}
private void HandleClick()
{
if (ScoreEventChannel.Instance == null)
{
Debug.LogWarning("No ScoreEventChannel found in scene.");
return;
}
ScoreEventChannel.Instance.RaiseScoreChanged(scoreDelta, reason);
}
}
Attach GrantPointsButton to the GrantPointsButton UI Button.
In the inspector you can tweak:
Score Delta(for example 5, 10, etc)Reason(for example “Test Grant”, “Bonus from button”)
Teaching notes
- Point out this button never references
ScoreManager,ConsoleLogger, orFlashObject. - It only knows about the event channel.
5. Create a listener: ScoreManager
This will subscribe to the event and update the score text.
Script 3: ScoreManager.cs
using UnityEngine;
using UnityEngine.UI;
public class ScoreManager : MonoBehaviour
{
[SerializeField] private Text scoreText; // Or TextMeshProUGUI if you use TMP
public int CurrentScore { get; private set; }
private void OnEnable()
{
if (ScoreEventChannel.Instance != null)
{
ScoreEventChannel.Instance.OnScoreChanged += HandleScoreChanged;
}
}
private void OnDisable()
{
if (ScoreEventChannel.Instance != null)
{
ScoreEventChannel.Instance.OnScoreChanged -= HandleScoreChanged;
}
}
private void Start()
{
UpdateScoreText();
}
private void HandleScoreChanged(int delta, string reason)
{
CurrentScore += delta;
UpdateScoreText();
Debug.Log($"[ScoreManager] Score changed by {delta} due to: {reason}. New total: {CurrentScore}");
}
private void UpdateScoreText()
{
if (scoreText != null)
{
scoreText.text = $"Score: {CurrentScore}";
}
}
}
Attach ScoreManager to the ScoreManager GameObject, then drag the ScoreText UI element into the Score Text field in the inspector.
Teaching notes
- Show subscription in
OnEnableand unsubscription inOnDisable. - Explain why unsubscribing matters (memory leaks, stale references, events on destroyed objects).
6. Create another listener: ConsoleLogger
This one only logs messages to show multiple listeners can react.
Script 4: ConsoleLogger.cs
using UnityEngine;
public class ConsoleLogger : MonoBehaviour
{
private void OnEnable()
{
if (ScoreEventChannel.Instance != null)
{
ScoreEventChannel.Instance.OnScoreChanged += LogScoreEvent;
}
}
private void OnDisable()
{
if (ScoreEventChannel.Instance != null)
{
ScoreEventChannel.Instance.OnScoreChanged -= LogScoreEvent;
}
}
private void LogScoreEvent(int delta, string reason)
{
Debug.Log($"[ConsoleLogger] Event received. Delta: {delta}, Reason: {reason}");
}
}
Attach ConsoleLogger to the ConsoleLogger GameObject.
Hit Play, press the button, and show that multiple listeners receive the same event.
7. Create a visual listener: ColorFlasher
Now we use a GameObject (cube, sprite, whatever) that flashes a color when the event fires.
Script 5: ColorFlasher.cs
using UnityEngine;
[RequireComponent(typeof(Renderer))]
public class ColorFlasher : MonoBehaviour
{
[SerializeField] private Color flashColor = Color.yellow;
[SerializeField] private float flashDuration = 0.2f;
private Renderer _renderer;
private Color _originalColor;
private float _flashTimer;
private bool _isFlashing;
private void Awake()
{
_renderer = GetComponent<Renderer>();
_originalColor = _renderer.material.color;
}
private void OnEnable()
{
if (ScoreEventChannel.Instance != null)
{
ScoreEventChannel.Instance.OnScoreChanged += HandleScoreChanged;
}
}
private void OnDisable()
{
if (ScoreEventChannel.Instance != null)
{
ScoreEventChannel.Instance.OnScoreChanged -= HandleScoreChanged;
}
}
private void Update()
{
if (!_isFlashing) return;
_flashTimer -= Time.deltaTime;
if (_flashTimer <= 0f)
{
_renderer.material.color = _originalColor;
_isFlashing = false;
}
}
private void HandleScoreChanged(int delta, string reason)
{
_renderer.material.color = flashColor;
_flashTimer = flashDuration;
_isFlashing = true;
}
}
Attach ColorFlasher to the FlashObject (cube).
Now your pipeline is:
Button → EventChannel → ScoreManager / ConsoleLogger / ColorFlasher
8. Using this in class to explain advanced C#
Here is a suggested teaching flow.
a) Start with the naive approach
Write a version where the button directly references the ScoreManager:
public class NaiveGrantPointsButton : MonoBehaviour
{
public ScoreManager scoreManager;
public void HandleClick()
{
scoreManager.AddScore(10);
}
}
Point out that:
- UI now depends directly on one specific component.
- You cannot easily add more listeners without modifying the button code.
- This causes tight coupling.
b) Introduce the event version
Switch to the event channel version we built.
Emphasize:
- Button only knows about ScoreEventChannel.
- Any number of listeners can subscribe with matching method signatures.
- You can add or remove listeners without touching the UI code.
c) Talk about delegates vs events
Use ScoreEventChannel as your code lab.
- Show how it would look with a raw delegate field:
public Action<int, string> OnScoreChanged; - Explain why
eventis safer:- Outside classes cannot reassign the delegate.
- They can only
+=or-=.
- If you want to go further, show a custom delegate:
public delegate void ScoreChangedHandler(int delta, string reason); public event ScoreChangedHandler OnScoreChanged;Then compare that toAction<int, string>.
9. Bonus: event arguments type for extra clarity
If you want to be fancy and more “advanced C#”, you can introduce an event args class:
Script 6: ScoreChangedEventArgs.cs
public class ScoreChangedEventArgs
{
public int Delta { get; }
public string Reason { get; }
public ScoreChangedEventArgs(int delta, string reason)
{
Delta = delta;
Reason = reason;
}
}
Then in the channel:
public event Action<ScoreChangedEventArgs> OnScoreChanged;
public void RaiseScoreChanged(int delta, string reason)
{
var args = new ScoreChangedEventArgs(delta, reason);
OnScoreChanged?.Invoke(args);
}
Listeners:
private void HandleScoreChanged(ScoreChangedEventArgs args)
{
CurrentScore += args.Delta;
// use args.Reason etc...
}
This mirrors the classic .NET pattern (EventHandler<TEventArgs>) and is a nice bridge from Unity to general C#.
10. Ideas to extend the demo
Once students grasp this, you can branch into:
- A “QuestCompletedEventChannel” or “ItemPickedEventChannel”.
- A ScriptableObject event channel (common Unity architecture pattern).
- A mini achievement system where different listeners unlock achievements based on events.
The key lesson is that events turn a messy web of direct references into a clean signal system: one thing shouts, whoever cares can listen.
