Different Ways to Determine When an Event Occurred in Code

Posted by : on

Category : C#   Unity

Introduction

In programming, we often encounter situations where we need to check when something happens. This “check” could be for various events, such as when a variable’s value changes, when a variable equals a certain value, when a method is called, or when an expression becomes true or false.

For common scenarios like “while something is true” or “if something is true,” programming languages provide relevant keywords that make these conditions easy to handle. However, detecting “when something happens” is a different challenge. There are two main reasons for this: first, there are multiple ways to implement this functionality, each with its own pros and cons. Second, the interpretation of “when” can vary.

The phrase “When something happens, do something else” can be interpreted in several ways. It might mean doing something the first time an event occurs, doing something whenever the event happens (which is the most common approach in coding), or performing an action the first time an event occurs and continuing to do it as long as the condition remains true.

In this post, I’ll provide several implementations for the second scenario: “Whenever something happens, do something else.” The other cases can easily be handled by slightly modifying these implementations, such as by adding boolean variables to ensure the action occurs only the first time an event happens. Alternatively, you could use two implementations: one that triggers a behavior while a condition is true and another that stops the behavior when the condition becomes false. This can be done using a while statement that runs asynchronously as long as the condition is true.

There are two basic approaches for implementing this “when” functionality: signal-based or polling-based methods.

Signal Vs Polling Based

Each of these two categories—signal-based and polling-based—has its own advantages and disadvantages. In low-level coding, polling-based methods tend to be more precise. Signal-based methods, on the other hand, are easier to implement and understand. In object-oriented programming (OOP), signal-based approaches can sometimes be more precise or efficient than polling, but they come with a downside: you must have control over the code you want to be notified by, or the code must provide a way to register for the signal.

When implementing “when” functionality, two important concepts must be considered: precision and performance. These two factors often conflict—the more precision you require, the lower the overall performance may be. One benefit of polling-based methods is that they allow you to balance precision and performance according to your specific use case. For instance, in a scenario where precision is not critical (e.g., checking if it’s the first of the month in a long-running program), the performance impact will be minimal. However, achieving maximum precision is typically constrained by the target hardware and comes at the cost of significant performance degradation in other parts of the program.

Below, I will provide examples of both signal-based and polling-based methods in C#, as well as examples of how to implement this functionality in the Unity game engine using specific methods it offers.

Events And The Observer Pattern

The signal-based method in C# is easy to implement using delegates and the event keyword. While these tools are primarily used to implement the Observer pattern in C#, we can also use the Observer pattern with a more traditional interface-based approach. Below, I’ll provide an example for each method. I won’t go into detail about delegates and the event keyword, as both topics could easily warrant a couple of posts on their own, but their basic usage is straightforward.

Using C# events

Let’s suppose that we have a Duck class that has a TakeDamage method, and we want another class to be notified of when the damage taken is enough to have caused the death of a duck. Here we can have two different scenarios: Either we want this notification to happen when any duck dies, or we want it to happen when a specific duck dies. Let’s look at the first case:

public class Duck
{
   public static event Action Death;

   private readonly int _hitPoints;
   private readonly string _name;
   private int _currentHp;
   
   public Duck(string name, int hitPoints)
   {
      _hitPoints = hitPoints;
      _currentHp = hitPoints;
      _name = name;
   }

   public void TakeDamage(int damage)
   {
      _currentHp -= damage;
      
      if (_currentHp <= 0)
      {
         Console.WriteLine($" {_name} just died!");
         OnDeath();
      }
   }

   private static void OnDeath() => Death?.Invoke();
}

Here we check if the _currentHp is less or equal to zero, and when this happens the Death event is invoked in the OnDeath method. Now we can register in that event, so that we can have something happen when a duck dies:

Duck Huey = new Duck("Huey", 10); 
Duck Dewey = new Duck("Dewey", 10); 
Duck Bob = new Duck("Bob", 10);

Duck.Death += Cry;

Huey.TakeDamage(9);
Dewey.TakeDamage(11);
Huey.TakeDamage(2);
Bob.TakeDamage(99);

void Cry() => Console.WriteLine($"Donald cries because a duck died!");

The result of the above will be:

 Dewey just died!
Donald cries because a duck died!
 Huey just died!
Donald cries because a duck died!
 Bob just died!
Donald cries because a duck died!

Because our Death event is static, we get notified when any duck dies. If we wanted to be notified only for specific ducks, then our event and its method would be declared like this:

public event Action Death;
   .
   .
   .
private void OnDeath() => Death?.Invoke();

We would need to subscribe to each instance of the class that we care about:

Duck Huey = new Duck("Huey", 10); 
Duck Dewey = new Duck("Dewey", 10); 
Duck Bob = new Duck("Bob", 10);

Huey.Death += Cry;
Dewey.Death += Cry;

Huey.TakeDamage(9);
Dewey.TakeDamage(11);
Huey.TakeDamage(2);
Bob.TakeDamage(99);

void Cry() => Console.WriteLine($"Donald cries because a duck died!");

and the result would be:

 Dewey just died!
Donald cries because a duck died!
 Huey just died!
Donald cries because a duck died!
 Bob just died!

Using The Observer Pattern With Interfaces

The same functionality can be achieved by using interfaces and implementing the Observer pattern manually. While this approach involves more boilerplate code, takes longer to write, and introduces more complex architectural dependencies, it allows us to avoid the overhead of an additional object in the event delegate that the garbage collector will eventually have to manage. Typically, this cost is minimal, but in performance-critical situations where caching the event for later collection isn’t feasible, a manual Observer pattern implementation becomes a valuable tool in our toolbox.

First we create an interface that all our observers have to implement:

public interface ICallback
{ 
   void Invoke();
}

Then we have our subjects implement methods to register and unregister the observers as well a method to call the Invoke method of our interface:

public class Duck
{
   private readonly HashSet<ICallback> _callbacks = [];
   private readonly int _hitPoints;
   private readonly string _name;
   private int _currentHp;
   
   public Duck(string name, int hitPoints)
   {
      _hitPoints = hitPoints;
      _currentHp = hitPoints;
      _name = name;
   }

   public void StartObservingDeath(ICallback callback) => _callbacks.Add(callback);

   public void StopObservingDeath(ICallback callback) => _callbacks.Remove(callback);

   public void TakeDamage(int damage)
   {
      _currentHp -= damage;
      
      if (_currentHp <= 0)
      {
         Console.WriteLine($" {_name} just died!");
         OnDeath();
      }
   }
   
   private void OnDeath()
   {
      var enumerator = _callbacks.GetEnumerator();
      while(enumerator.MoveNext())
      {
         enumerator.Current?.Invoke();
      }
   }
}

Finally our observers register to the classes that they are interested:

public class Donald : ICallback
{
   public Donald(List<Duck> ducks)
   {
      foreach (var duck in ducks)
      {
         duck.StartObservingDeath(this);
      }
   }
   
   public void Invoke() => Cry();

   private void Cry() => Console.WriteLine($"Donald cries because a duck died!");
}

Now we can do the same as with the event-based approach from before:

Duck Huey = new Duck("Huey", 10); 
Duck Dewey = new Duck("Dewey", 10); 
Duck Bob = new Duck("Bob", 10);

Donald donald = new Donald([Huey, Dewey]);

Huey.TakeDamage(9);
Dewey.TakeDamage(11);
Huey.TakeDamage(2);
Bob.TakeDamage(99);

And the result will be the same:

 Dewey just died!
Donald cries because a duck died!
 Huey just died!
Donald cries because a duck died!
 Bob just died!

Checking The Values In Fields

The same as the event approach, can be done, for checking when a value of a field changed. The method that invokes the event, is called inside the set property of a field instead of a normal method. In fact there is a convention for that, when we have a class that we want to notify for changes in many of its properties, with the implementation of the INotifyPropertyChanged interface, but essentially it is the same procedure. You can see the convention in more detail here: INotifyPropertyChanged

For example the implementation in the Duck class, for when the _currentHp becomes less or equal to zero, would be like this:

public class Duck : INotifyPropertyChanged
{
   public int CurrentHp
   {
      get => _currentHp;
      set
      {
         SetField(ref _currentHp, value);
      }
   }

   private readonly int _hitPoints;
   private readonly string _name;
   private int _currentHp;
   
   public Duck(string name, int hitPoints)
   {
      _hitPoints = hitPoints;
      _currentHp = hitPoints;
      _name = name;
   }

   public void TakeDamage(int damage)
   {
      CurrentHp = CurrentHp - damage;
      
      if (CurrentHp <= 0)
      {
         Console.WriteLine($" {_name} just died!");
      }
   }

   public event PropertyChangedEventHandler? PropertyChanged;

   protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) 
      => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

   protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
   {
      if (EqualityComparer<T>.Default.Equals(field, value)) return false;
      field = value;
      OnPropertyChanged(propertyName);
      return true;
   }
}

And the observer would make the check like this:

void Cry(object? sender, PropertyChangedEventArgs propertyChangedEventArgs)
{
   if(propertyChangedEventArgs.PropertyName == nameof(Duck.CurrentHp) && (sender as Duck)?.CurrentHp <= 0)
      Console.WriteLine($"Donald cries because a duck died!");
}

This might be more complicated for one property, but works well when we have a class that needs to notify for changes in many of its properties and we don’t want to create a different event for each one.

Polling

The methods described above are signal-based and easy to implement, as long as we have access to the code. However, there are times when we need to detect when something happens in a type we don’t have access to and that doesn’t provide events to subscribe to. Let’s suppose that we have the following Duck class:

public class Duck
{
   public int CurrentHp => _currentHp;
   
   private readonly int _hitPoints;
   private readonly string _name;
   private int _currentHp;
   
   public Duck(string name, int hitPoints)
   {
      _hitPoints = hitPoints;
      _currentHp = hitPoints;
      _name = name;
   }

   public void TakeDamage(int damage)
   {
      _currentHp = CurrentHp - damage;
      
      if (CurrentHp <= 0)
      {
         Console.WriteLine($" {_name} just died!");
      }
   }
}

This class does not provide a mechanism to notify us when CurrentHp becomes less than or equal to zero. If we do not own the code and cannot modify it, polling is the only way to check when this condition occurs.

Polling Asynchronously

To perform this polling, we will use asynchronous programming:

public class Donald
{
   public void NotifyOnDeath(List<Duck> ducks)
   {
      foreach (var duck in ducks)
      {
         ExecuteWhenTrue(() => duck.CurrentHp <= 0, Cry);
      }
   }
   
   private static async void ExecuteWhenTrue(Func<bool> expression, Action methodToExecute)
   {
      while (!expression())
      {
         await Task.Delay(1000);
      }

      methodToExecute.Invoke();
   }

   private void Cry() => Console.WriteLine($"Donald cries because a duck died!"); 
}

Here the ExecuteWhenTrue static method is generalized to accept any expression. If we have only one expression and we want to avoid the Func instance creation, we can directly add the boolean expression to its while check.

We can adjust the frequency of our polling based on what suits our use case; in this example, it runs every second. You can test the above with:

Duck Huey = new Duck("Huey", 10); 
Duck Dewey = new Duck("Dewey", 10); 
Duck Bob = new Duck("Bob", 10);

Donald donald = new Donald();

donald.NotifyOnDeath([Huey, Dewey]);

Huey.TakeDamage(9);
Dewey.TakeDamage(11);
Huey.TakeDamage(2);
Bob.TakeDamage(99);

Console.ReadLine();

The Console.ReadLine(); is necessary, because our program may end before the background thread has finished waiting. Another way to do the same thing is:

private void ExecuteWhenTrue(Func<bool> expression, Action methodToExecute)
{
   Task.Factory.StartNew(() =>
   {
      while (!expression())
         Thread.Sleep(1000);

      methodToExecute.Invoke();
   });
}

Unity Update, Coroutines And Awaitables

In Unity, there are several ways to check when something happens aside from using events. I’ll keep this brief since the post is getting long, but we can use the new Awaitable feature (you can read more about it in my post: Asynchronous Code In Unity Using Awaitable and AwaitableCompletionSource), coroutines, or even the update event method.

In a game, precision usually isn’t critical. The most precise timing we typically need is within a single game loop. Since the engine handles rendering during the loop, being more precise won’t offer any benefits, as the result won’t be displayed to the player until the next rendering cycle.

That said, I want to briefly discuss the Update method, which is often overlooked in favor of coroutines. While coroutines have their place, many “when something happens” checks—if not done with events—can be handled more efficiently in the Update method. A prime example is checking when a certain amount of time has passed. Instead of creating a coroutine, checking the elapsed time in Update can be just as precise and more efficient. For instance, if we want something to happen after two seconds, with a coroutine, we might do something like this:

 private void Start()
{
    StartCoroutine(nameof(Spawn));
}
IEnumerator Spawn()
{
    yield return new WaitForSeconds(2f);
    
    Debug.Log("An enemy spawns!");
}

But in the update method, we can be more performant by checking the time instead of creating a new object, a simple example:

private Spawn _spawn;

private void Start() => _spawn.Start(2f);
private void Update() => _spawn.Check();

private struct Spawn
{
    private float _spawnTime;
    private bool _hasSpawned;

    public void Start(float duration)
    {
        _spawnTime = Time.time + duration;
        _hasSpawned = false;
    }

    public void Check()
    {
        if (_hasSpawned || !(Time.time >= _spawnTime)) return;
        
        Debug.Log("An enemy spawns!");
        _hasSpawned = true;
    }
}

Conclusion

Events are usually sufficient for creating code that executes when something happens. However, if for some reason we prefer not to use C# events, we can implement the Observer pattern ourselves using interfaces and fewer allocations. When we don’t own the code we need to monitor, a polling-based solution with asynchronous code becomes our only option

If you have other methods for implementing code that executes when something happens, or if you would like a more in-depth explanation of events, delegates, or a comparison between Unity’s coroutines, Awaitable, and the Update method for handling such scenarios, feel free to use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.


Follow me: