How TEngine's Zero-GC Event System Works Internally: Architecture and Implementation

TEngine’s zero-GC event system eliminates runtime allocations by using compile-time generated integer event IDs, generic delegate storage in EventDispatcher, and direct invocation chains that avoid boxing and temporary array creation.

The alex-rachel/tengine repository provides a high-performance event bus specifically engineered for Unity's garbage-collected runtime. Unlike traditional C# event systems that allocate memory through string hashing, boxing, and params object[] arrays, TEngine's implementation maintains zero garbage collection pressure even when dispatching millions of events per frame during intensive gameplay scenarios.

Compile-Time Event ID Generation

The foundation of the zero-GC architecture rests on replacing string-based event identifiers with integer constants generated at compile time.

In EventInterfaceAttribute.cs, a simple marker attribute identifies which interfaces should participate in code generation:

// EventInterfaceAttribute.cs
[AttributeUsage(AttributeTargets.Interface)]
public class EventInterfaceAttribute : Attribute { }

The EventInterfaceGenerator.cs source generator then scans for these attributes and emits a static class containing const int fields for each method in the interface. This occurs during compilation, eliminating reflection and string hashing at runtime. For an interface IGameEvents with methods OnHealthChanged(float) and OnPlayerDied(), the generator produces:

public static class GameEvents
{
    public const int OnHealthChanged = 10001;
    public const int OnPlayerDied = 10002;
}

Because these are compile-time constants, the JIT compiler can inline the values, and the EventDispatcher performs dictionary lookups using raw int keys rather than computing string hashes.

EventDispatcher and Generic Delegate Storage

The central hub resides in EventDispatcher.cs, which maintains a single dictionary mapping event IDs to their handler containers:

// EventDispatcher.cs
private readonly Dictionary<int, EventDelegateData> _eventTable = new();

Unlike traditional event systems that use object or EventArgs parameters (which force boxing), EventDispatcher provides generic Send<T> overloads that forward arguments with exact type matching. When you call GameEvent.Send<float>(GameEvents.OnHealthChanged, 100f), the dispatcher retrieves the EventDelegateData instance and invokes the delegate chain without allocating temporary arrays or boxing the float value.

The dispatcher stores delegates as concrete types (Action, Action<T>, Action<T1, T2>) rather than wrapping them in Delegate objects that require allocation, ensuring that adding a listener performs zero heap allocations after the initial dictionary entry creation.

EventDelegateData Container

Each event type maintains its handlers in an EventDelegateData instance, defined in EventDelegateData.cs. This container caches the delegate invocation list to prevent per-send allocations:

// EventDelegateData.cs
private readonly List<Delegate> _delegates = new();

public void Add(Delegate del) => _delegates.Add(del);
public void Remove(Delegate del) => _delegates.Remove(del);

When EventDispatcher.Send<T>() triggers a dispatch, it calls into EventDelegateData.Callback(T arg), which iterates the internal List<Delegate> and invokes each handler directly. Because the list is reused and never reallocated during the iteration, and because the generic method signature matches the stored delegate exactly, the entire operation completes without generating garbage.

Automatic Listener Cleanup with GameEventMgr

Memory leaks from dangling event listeners traditionally require weak references (which allocate) or manual cleanup (which is error-prone). GameEventMgr.cs solves this by recording registration metadata without extra allocations:

// GameEventMgr.cs
private readonly List<(int eventId, Delegate handler)> _tracked = new();

public void AddEvent<T>(int eventId, Action<T> handler)
{
    _tracked.Add((eventId, handler));
    GameEvent.AddEventListener(eventId, handler);
}

When a component is destroyed or GameEventMgr.Clear() is invoked, the manager iterates its tracked list and calls GameEvent.RemoveEventListener for each pair. This O(N) cleanup operation removes delegates from EventDispatcher._eventTable without allocating temporary collections or triggering GC sweeps.

The Zero-GC Execution Flow

Understanding how these components cooperate clarifies why the system produces zero garbage:

  1. Definition: A developer annotates IGameEvents with [EventInterface]. The generator creates constant integers—no runtime reflection occurs.

  2. Registration: Calling GameEvent.AddEventListener(GameEvents.OnHealthChanged, handler) stores the Action<float> directly in _eventTable[10001]. The first registration creates an EventDelegateData instance; subsequent registrations reuse this container.

  3. Dispatch: GameEvent.Send<float>(GameEvents.OnHealthChanged, 95.5f) resolves to EventDispatcher.Send<float>, which retrieves the EventDelegateData and invokes Callback(95.5f). The float passes directly without boxing, and the delegate invocation uses the cached method pointers.

  4. Cleanup: GameEventMgr.Clear() iterates its internal list and unregisters each handler, returning the EventDispatcher to its previous state without allocating removal buffers.

Practical Implementation Example

The following pattern demonstrates proper usage across all core files:

// 1. Define events (triggers EventInterfaceGenerator.cs)
[EventInterface]
public interface IGameEvents
{
    void OnHealthChanged(float hp);
    void OnPlayerDied();
    void OnItemPicked(int id, int count);
}

// Generated output: static class GameEvents with const int fields

// 2. Component usage with automatic cleanup
public class PlayerController : MonoBehaviour
{
    private readonly GameEventMgr _eventMgr = new();

    void Awake()
    {
        // Zero-allocation registration
        _eventMgr.AddEvent(GameEvents.OnHealthChanged, HandleHealth);
        _eventMgr.AddEvent(GameEvents.OnPlayerDied, HandleDeath);
    }

    void HandleHealth(float hp) { /* Update UI */ }
    void HandleDeath() { /* Play animation */ }

    void OnDestroy()
    {
        // Zero-allocation cleanup
        _eventMgr.Clear();
    }
}

// 3. Dispatch from anywhere in the codebase
public class DamageSystem
{
    public void ApplyDamage(float amount)
    {
        float newHealth = CalculateHealth(amount);
        // Zero-allocation, zero-boxing dispatch
        GameEvent.Send<float>(GameEvents.OnHealthChanged, newHealth);
    }
}

Why It Avoids Garbage Collection

TEngine's architecture eliminates the four primary sources of GC pressure in traditional event systems:

  • No string hashing: Event IDs are const int values, not strings, preventing hash code calculations and string comparisons.
  • No boxing: Generic Send<T> methods preserve value types (structs, floats, ints) without casting to object.
  • No temporary arrays: The system never uses params object[] for argument passing, avoiding array allocations per dispatch.
  • No closure allocations: Delegates are stored as concrete Action types rather than compiler-generated closure classes that capture variables.

By reusing the Dictionary<int, EventDelegateData> and internal List<Delegate> collections, the system amortizes all memory costs to the first listener registration, remaining allocation-free during steady-state operation.

Summary

  • Compile-time generation: EventInterfaceGenerator.cs creates integer constants from attributed interfaces, eliminating runtime string operations.
  • Type-safe storage: EventDispatcher.cs uses generic methods and concrete delegate types to prevent boxing and maintain exact signatures.
  • Allocation-free dispatch: EventDelegateData.cs caches invocation lists, ensuring Send operations create no temporary objects.
  • Automatic cleanup: GameEventMgr.cs tracks registrations in reusable lists, enabling O(N) removal without GC churn.
  • Static API: GameEvent.cs provides the public façade while delegating to a singleton EventDispatcher, keeping engine code concise and performant.

Frequently Asked Questions

How does TEngine prevent boxing when sending value types?

The system uses generic Send<T> overloads in EventDispatcher.cs that accept exact type parameters. When you call GameEvent.Send<int>(GameEvents.OnScoreChanged, 100), the integer passes directly to the stored Action<int> delegate without boxing to object. This preserves value types on the stack throughout the entire invocation chain.

What happens if I don't use GameEventMgr for listener cleanup?

Without GameEventMgr, you must manually call GameEvent.RemoveEventListener before destroying components. Failure to do so leaves delegates in EventDispatcher._eventTable, causing memory leaks as the dispatcher holds references to destroyed objects. The GameEventMgr pattern eliminates this risk by automating cleanup through its tracked list.

Is the event system thread-safe for multi-threaded dispatching?

The current implementation in EventDispatcher.cs uses standard Dictionary<int, EventDelegateData> and List<Delegate> collections without explicit locking mechanisms. For Unity's single-threaded main loop architecture, this design maximizes performance, but dispatching from background threads requires external synchronization to prevent race conditions during delegate iteration.

How are event ID constants generated to avoid collisions?

EventInterfaceGenerator.cs employs a deterministic hashing algorithm combined with incremental counters to generate unique const int values for each method across all [EventInterface]-marked types. Because generation occurs at compile time, the build fails immediately if collisions are detected, ensuring runtime uniqueness without requiring centralized registration or string-based lookups.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →