Unity game development brings its own set of challenges, particularly around event handling and delegate management. Because Unity code typically runs on the main thread, we can take advantage of this by optimizing delegate implementations without the usual concerns about thread safety. In this article, we'll explore how the NDelegate classes offer meaningful improvements over standard C# delegates—specifically designed to leverage Unity's single-threaded architecture.

Limitations of C# delegates

Delegates in C# are a general solution that covers a lot of use cases. However, we often have very specific needs around them in Unity. For example, built-in delegates have:

  • High allocation overhead
  • Lack of exception isolation
  • Limited flexibility in the types of methods that can be subscribed
  • No built-in recursion detection
  • Susceptibility to double subscription bugs
  • No automatic unsubscription when an object is disposed

The following sections explain how NDelegate addresses each of these shortcomings, offering a more robust and Unity-friendly alternative.

NDelegate Features and Benefits

Reduced Memory Allocations

In C#, delegates are immutable. Every time you subscribe a new method, the runtime creates a new delegate instance, copies the existing invocation list, and adds the new method. The same thing happens when unsubscribing.

This design ensures thread safety, which is essential in multithreaded applications. But in Unity, where nearly all code runs on the main thread, immutability isn't necessary, and the extra allocations can hurt performance, especially in tight loops or frequently updated systems. While these allocations may seem small, minimizing them can contribute to smoother gameplay and better frame times.

Here’s how NDelegate improves the situation:

private object? handlers;

protected void SubscribeImpl(Delegate newHandler)
{
  switch (handlers)
  {
    case null:
      handlers = newHandler; // Single delegate - no allocation
      return;
    case Delegate singleHandler when singleHandler == newHandler:
      return; // Duplicate - no allocation
    case Delegate singleHandler:
      // Only allocate when needed
      handlers = new List<Delegate> { singleHandler, newHandler };
      return;
  }
  
  // If we already have a list of delegates just add a new handler to it
  var collection = (List<Delegate>)handlers;
  if (!collection.Contains(newHandler)) 
    GetModifiableHandlersList(collection).Add(newHandler);
}

This approach:

  • Stores single delegates directly, avoiding unnecessary wrapper objects
  • Creates a collection only when needed, i.e., when multiple handlers are added
  • Reuses existing collections whenever possible, reducing garbage generation

Prevents Double Subscription

In C#, it's easy to accidentally subscribe the same method multiple times to a delegate, which can lead to confusing and hard-to-trace bugs, especially when handlers run more than once unexpectedly.

To guard against this, developers often use a pattern like:

someDelegate -= MyMethod;
someDelegate += MyMethod;

This ensures the method is only subscribed once, but it’s repetitive and not very elegant.

With NDelegate, this workaround isn't necessary. It automatically checks whether a method is already subscribed and only adds it if it’s not already in the handler list. This built-in safeguard helps prevent duplicate subscriptions and makes your code cleaner and safer by default.

Exception Isolation

In C#, when multiple methods are subscribed to a delegate, an exception thrown by one handler during invocation will propagate up the call stack, halting execution and preventing the remaining handlers from running.

NDelegate avoids this issue by wrapping each handler invocation in a try-catch block. If a handler throws an exception, the error is caught and logged, but execution continues for the remaining subscribers. NDelegates can also be named through their constructor for additional context in their logs.

This behavior is particularly valuable in game development, where each handler typically represents an independent system. A bug in one system shouldn't cause others to fail. Since events are often used to decouple systems, it makes little sense to let exceptions in one handler tightly couple them through shared failure.

By isolating exceptions, NDelegate ensures greater stability and resilience in your event-driven architecture.

Recursive Dispatch Detection

Each NDelegate instance can be configured with a maximum allowed number of recursive dispatches. This acts as a safeguard against accidental infinite recursion.

If a bug causes an event to dispatch itself (directly or indirectly) NDelegate will track the recursion depth. Once the configured limit is reached, it logs an error and aborts further invocation. This prevents the application from crashing due to a StackOverflowException.

In complex event-driven systems, especially in games, recursive event chains can be difficult to detect and debug. NDelegate provides a built-in safety net, helping you catch and diagnose these issues early.

Multiple Action Type Support

When using Action<T> as a delegate in C#, all subscribed methods are required to accept a parameter of type T. This can be limiting, especially when some handlers aren’t interested in the event data and only need to react to the event itself.

With NDelegate<T>, you get much greater flexibility. You can subscribe:

  • Methods with no parameters
  • Methods with a single parameter of type T
  • Methods with two parameters: T and T?

This last case is particularly useful for "changed data" events, where both the new value and an optional previous value are relevant. It allows you to dispatch richer event data without forcing all subscribers to conform to a single signature.

Here’s how dispatching is handled internally:

protected override void InvokeSingle(Delegate singleHandler, T param, T? secondParam)
{
  switch (singleHandler)
  {
    case Action<T, T?> action2:
      action2(param, secondParam);
      break;
    case Action<T> action:
      action(param);
      break;
    default:
      ((Action)singleHandler)();
      break;
  }
}

This design provides powerful flexibility while keeping the subscription process clean and intuitive.

Controlled Access Through Interfaces

To prevent external classes from dispatching events on a delegate they don't own, NDelegate offers two options for controlled access:

  1. Use the INDelegate Interface
    You can expose the NDelegate as an INDelegate property, restricting external access to only subscription-related methods. This ensures that only the owning class can dispatch events, while others can safely subscribe or unsubscribe.
public interface INDelegate
{
  void Clear();
  void Subscribe(Action handler);
  void Unsubscribe(Action handler);
}

public interface INDelegate<out T> : INDelegate
{
  void Subscribe(Action<T> handler);
  void Unsubscribe(Action<T> handler);
  void Subscribe(Action<T, T?> handler);
  void Unsubscribe(Action<T, T?> handler);
}
  1. Expose as a Standard C# Event
    Alternatively, you can expose a NDelegate as a traditional C# event:
public class GameManager
{
  private NDelegate<int> scoreChanged = new();
  
  public event Action<int> ScoreChanged
  {
    add => scoreChanged.Subscribe(value);
    remove => scoreChanged.Unsubscribe(value);
  }
}

This approach integrates well with existing C# patterns, but comes with a tradeoff: you lose access to some of NDelegate's enhanced features, such as automatic unsubscription.

Automatic Unsubscribe

In event-driven systems, it's important to manage lifetimes carefully, especially when objects with shorter lifespans subscribe to events from longer-lived ones. Failing to unsubscribe properly can lead to memory leaks, unexpected behavior, or even crashes.

With NDelegate, it’s easy to build helper methods that automate unsubscription when an object is disposed. A common approach is to implement a SafeSubscribe method in a base class (like DisposableService), allowing event subscriptions to be tracked and safely cleaned up.

Here’s an example implementation:

public class NDelegateUnsubscriber : IDisposable
{
  private INDelegate nDelegate;
  private Delegate action;
  
  public NDelegateUnsubscriber(INDelegate nDelegate, Delegate action)
  {
    this.nDelegate = nDelegate;
    this.action = action;
  }

  public void Dispose()
  {
    nDelegate.Unsubscribe(action);
  }
}

public class DisposableService : IDisposable
{
  private readonly List<IDisposable> disposables = new();

  protected void SafeSubscribe(INDelegate dispatcher, Action action)
  {
    dispatcher.Subscribe(action);
    disposables.Add(new NDelegateUnsubscriber(dispatcher, action));
  }

  void IDisposable.Dispose()
  {
    foreach (var disposable in disposables) disposable.Dispose();
    OnDestroy();
  }

  protected virtual void OnDestroy() {}
}

public class MyService : DisposableService
{
  public MyService(OtherService otherService)
  {
    // MyHandler will be automatically unsubscribed when MyService is disposed
    SafeSubscribe(otherService.SomeNDelegateEvent, MyHandler);
  }

  private void MyHandler()
  {
    // Process the event here
  }
}

This pattern ensures that all subscriptions are properly cleaned up when the object is disposed, making your code more robust and less error-prone, especially in larger or long-running systems.

Conclusion

The NDelegate system represents a significant improvement over built-in C# delegates for Unity development. By leveraging Unity's single-threaded nature, it provides:

  • Improved memory efficiency through minimal and smart allocations
  • Robust exception handling that isolates failures and avoids cascading errors
  • Flexible subscription patterns that support various use cases
  • Built-in safety features like recursion detection
  • Clean APIs that promote good architectural practices

For Unity developers dealing with complex event systems, NDelegate offers a production-ready solution that addresses the common pain points of delegate management while maintaining excellent performance characteristics.

The only downside of this design is that stack traces for methods that are called through the NDelegate will contain a few extra lines for NDelegate internal methods. In most cases, this is a minor concern compared to the clarity, safety, and performance gains you get in return.

You can find all the code at: https://github.com/Nordeus/NDelegate. Happy coding!