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

> Explore TEngine's zero GC event system architecture and implementation. Discover how compile-time IDs, generic delegates, and direct chains eliminate runtime allocations for peak performance.

- Repository: [ALEX/tengine](https://github.com/alex-rachel/tengine)
- Tags: internals
- Published: 2026-02-24

---

**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`](https://github.com/alex-rachel/tengine/blob/main/EventInterfaceAttribute.cs), a simple marker attribute identifies which interfaces should participate in code generation:

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

```

The [`EventInterfaceGenerator.cs`](https://github.com/alex-rachel/tengine/blob/main/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:

```csharp
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`](https://github.com/alex-rachel/tengine/blob/main/EventDispatcher.cs), which maintains a single dictionary mapping event IDs to their handler containers:

```csharp
// 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`](https://github.com/alex-rachel/tengine/blob/main/EventDelegateData.cs). This container caches the delegate invocation list to prevent per-send allocations:

```csharp
// 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`](https://github.com/alex-rachel/tengine/blob/main/GameEventMgr.cs) solves this by recording registration metadata without extra allocations:

```csharp
// 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:

```csharp
// 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`](https://github.com/alex-rachel/tengine/blob/main/EventInterfaceGenerator.cs) creates integer constants from attributed interfaces, eliminating runtime string operations.
- **Type-safe storage**: [`EventDispatcher.cs`](https://github.com/alex-rachel/tengine/blob/main/EventDispatcher.cs) uses generic methods and concrete delegate types to prevent boxing and maintain exact signatures.
- **Allocation-free dispatch**: [`EventDelegateData.cs`](https://github.com/alex-rachel/tengine/blob/main/EventDelegateData.cs) caches invocation lists, ensuring `Send` operations create no temporary objects.
- **Automatic cleanup**: [`GameEventMgr.cs`](https://github.com/alex-rachel/tengine/blob/main/GameEventMgr.cs) tracks registrations in reusable lists, enabling O(N) removal without GC churn.
- **Static API**: [`GameEvent.cs`](https://github.com/alex-rachel/tengine/blob/main/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`](https://github.com/alex-rachel/tengine/blob/main/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`](https://github.com/alex-rachel/tengine/blob/main/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`](https://github.com/alex-rachel/tengine/blob/main/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.