A fast 0GC and easy to use Event library for both .Net and Unity, which using code generation and without runtime reflection
Unity Project
Download the source from GitHub
README
Rendered from GitHubGenEvent
中文文档 / Chinese: README_zh.md
GenEvent is a high‑performance event library. It uses a source generator to emit all dispatching code at compile time, requires no runtime reflection, and works with both .NET and Unity (netstandard2.0).
Table of Contents
- GenEvent
- Table of Contents
- Key Features
- Getting Started
- Runtime Contract
- Core APIs
- .NET Benchmarks
- Source Generator Constraints & Diagnostics
- License
Key Features
- No runtime reflection: All dispatching and registration code is generated at compile time by a source generator.
- Zero-allocation hot path: Default publish and common built-in fluent filter chains stay allocation-free in steady state. Custom
WithFilter(Predicate<object>), chained custom predicates, or built-in chains that exceed the current inline rule capacity may still allocate. - IL2CPP‑friendly: Does not rely on runtime reflection and is safe to use in IL2CPP/AOT environments.
- Priority support:
[OnEvent(SubscriberPriority.XXX)]determines invocation order at compile time, so there is no sorting cost at runtime. - Propagation cancelation: A handler can return
false. Combined withCancelable(), this stops further event dispatch. - Flexible subscription lifetime:
StartListeningreturns a lightweightSubscriptionHandlevalue (IDisposable). You can rely onusingfor automatic unsubscribe or manually callStopListening. - Fluent publish APIs: Chain
Cancelable,WithFilter,OnlyType, and others to compose per‑publish behavior. - Async support: Handlers can return
Task/Task<bool>, andPublishAsyncawaits them in order. - Nested publish: Handlers can publish other events; each publish call has its own independent configuration.
Getting Started
Installation
Reference the core library and the source generator in your .csproj. The generator is plugged in as an Analyzer, only used at compile time to generate code and never loaded at runtime:
<ItemGroup>
<ProjectReference Include="path\to\GenEvent\GenEvent.csproj" />
<ProjectReference Include="path\to\GenEvent.SourceGenerator\GenEvent.SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
Unity Projects
In Unity, open Window > Package Manager, click Add in the top‑left corner, choose Add package from git URL..., and enter:
https://github.com/Puring103/GenEvent.git?path=src/GenEvent.Unity/Assets/Plugins/GenEvent
Automatic initialization: When the generator detects that the project references UnityEngine / UnityEditor, it automatically adds [RuntimeInitializeOnLoadMethod] to GenEventBootstrap.Init. This means all registrations are performed automatically when the game starts and you do not need to call it manually. If you prefer to control the exact timing, you can still call GenEventBootstrap.Init() yourself.
Minimal Example
using GenEvent;
using GenEvent.Interface;
// 1. Define the event: struct + IGenEvent<T>
public struct PlayerDeathEvent : IGenEvent<PlayerDeathEvent>
{
public int PlayerId;
}
// 2. Define a subscriber: class + [OnEvent] on the handler method
public class GameManager
{
[OnEvent]
public void OnPlayerDeath(PlayerDeathEvent e)
{
Console.WriteLine($"Player {e.PlayerId} died.");
}
}
// 3. Initialize (for non‑Unity projects call this once before the first subscribe or publish)
GenEventBootstrap.Init();
// 4. Subscribe; StartListening returns SubscriptionHandle (IDisposable)
var manager = new GameManager();
using var handle = manager.StartListening(); // automatically unsubscribes when the using scope ends
// 5. Publish the event
new PlayerDeathEvent { PlayerId = 1 }.Publish();
Runtime Contract
Initialization Rules
- In non-Unity projects, call
GenEventBootstrap.Init()before the first subscribe or publish. Init()is idempotent and can be called multiple times safely.- Use
GenEventBootstrap.IsInitializedto check whether the current assembly has completed bootstrap.
if (!GenEventBootstrap.IsInitialized)
{
GenEventBootstrap.Init();
}
If Publish(), PublishAsync(), StartListening(), or StopListening() is called before initialization, GenEvent throws a clear InvalidOperationException telling you to call GenEventBootstrap.Init() first.
Thread Model
- The current release does not guarantee concurrent thread safety.
- Nested publish from inside handlers is supported.
- Concurrent publish, subscribe, unsubscribe, or concurrent mutation of fluent publish configuration is not supported.
- If your host may touch GenEvent from multiple threads, serialize access in the host application.
Diagnostics
Use these APIs for quick runtime checks:
bool hasPublisher = PublisherHelper.HasPublisher<DamageEvent>();
int count = SubscriberHelper.GetSubscriberCount<DamageEvent, HUDDisplay>();
HasPublisher<TEvent>(): confirms whether the generated publisher is registered.GetSubscriberCount<TEvent, TSubscriber>(): reports how many subscriber instances are currently registered for that event/subscriber pair.
Core APIs
Defining Events
An event must be a struct and implement IGenEvent<T>. Using a value type avoids boxing and keeps the default publish path allocation-free in steady state.
public struct DamageEvent : IGenEvent<DamageEvent>
{
public int Amount;
public string Source;
}
Defining Subscribers and Handlers
A subscriber is a normal class. Use the [OnEvent] attribute to mark handler methods. Choose a return type based on whether the handler should participate in propagation control:
| Signature | Description |
|---|---|
void Method(TEvent e) |
Only receives the event; does not control propagation |
bool Method(TEvent e) |
Returning false stops subsequent subscribers (requires Cancelable()) |
async Task Method(TEvent e) |
Async processing; does not control propagation |
async Task<bool> Method(TEvent e) |
Async processing; returning false stops propagation (requires Cancelable()) |
public class HUDDisplay
{
// void: equivalent to always returning true
[OnEvent]
public void OnDamage(DamageEvent e)
{
UpdateHealthBar(e.Amount);
}
}
public class ShieldSystem
{
// bool: can intercept the event and prevent later subscribers from receiving it
[OnEvent]
public bool OnDamage(DamageEvent e)
{
if (HasShield)
{
AbsorbDamage(e.Amount);
return false; // stop propagation; later subscribers (such as HUDDisplay) will not be called
}
return true;
}
}
A single class can define at most one sync and one async handler for the same event type. They are invoked by Publish and PublishAsync respectively.
Initialization
Before the first subscribe or publish, call GenEventBootstrap.Init() to register all publishers and subscribers. If your solution has multiple assemblies, each one that uses GenEvent must call its own generated Init().
// Call once at application startup; repeated calls are safe
GenEventBootstrap.Init();
For Unity projects, the generator injects [RuntimeInitializeOnLoadMethod] automatically, so you usually do not need to call this manually.
bool ready = GenEventBootstrap.IsInitialized;
Subscription Lifetime
StartListening() registers a subscriber in the event system and returns a lightweight SubscriptionHandle value (IDisposable). Keep this handle and dispose it when appropriate to unsubscribe; this is equivalent to calling StopListening().
Recommended: keep the handle and dispose it on destruction
public class Enemy : MonoBehaviour
{
private SubscriptionHandle _handle;
void OnEnable() => _handle = this.StartListening();
void OnDisable() => _handle.Dispose(); // equivalent to this.StopListening()
}
Or use a using scope
using (subscriber.StartListening())
{
new DamageEvent { Amount = 10 }.Publish(); // receives the event
} // leaving the using scope automatically unsubscribes
new DamageEvent { Amount = 5 }.Publish(); // no longer receives the event
You can also call StopListening() directly (ignoring the handle)
subscriber.StartListening(); // ignore the return value; behaves like the old pattern
new DamageEvent { Amount = 10 }.Publish();
subscriber.StopListening(); // unsubscribe manually
SubscriptionHandle.Dispose() is idempotent and safe to call multiple times.
Subscribe to only one event type (when a subscriber handles multiple event types):
// Register only DamageEvent; other event types are unaffected
using var handle = subscriber.StartListening<MySubscriber, DamageEvent>();
A subscriber that is never unsubscribed will keep it alive and prevent GC. Always unsubscribe when the object is destroyed.
Publishing Events
Sync publish: Publish() returns bool, indicating whether the event was fully dispatched to all subscribers (it is always true unless propagation was canceled).
bool completed = new DamageEvent { Amount = 10 }.Publish();
Async publish: PublishAsync() awaits each handler in priority order and returns Task<bool> (again, true unless propagation was canceled).
bool completed = await new DamageEvent { Amount = 10 }.PublishAsync();
Sync
Publish()only invokes sync handlers;PublishAsync()invokes both sync and async handlers.
Event Priority
Use [OnEvent(SubscriberPriority.XXX)] to specify priority. The invocation order is determined by the generator at compile time, so there is no runtime sorting.
From highest to lowest: Primary > High > Medium (default) > Low > End
public class ShieldSystem
{
[OnEvent(SubscriberPriority.High)] // runs before default Medium
public bool OnDamage(DamageEvent e)
{
if (HasShield) { AbsorbDamage(e.Amount); return false; }
return true;
}
}
public class HUDDisplay
{
[OnEvent] // default Medium; runs after ShieldSystem
public void OnDamage(DamageEvent e) => UpdateHealthBar(e.Amount);
}
Canceling Propagation
Call .Cancelable() when publishing. After that, if any handler returns false, propagation stops immediately; later subscribers will not receive the event and Publish returns false.
If you do not call Cancelable(), handler return values are ignored and the event is always delivered to all subscribers.
// With Cancelable: if ShieldSystem (High) returns false, HUDDisplay (Medium) is not called
bool handled = new DamageEvent { Amount = 10 }
.Cancelable()
.Publish();
// handled == false means propagation was stopped
// Without Cancelable: all subscribers are called; return values are ignored
new DamageEvent { Amount = 10 }.Publish();
Publish Filters
The following APIs are fluent options on a configured publish value. They do not change subscription registration and can be freely combined:
| API | Description |
|---|---|
evt.Cancelable() |
Allow handlers to stop propagation by returning false |
evt.WithFilter(Predicate<object> filter) |
Skip a subscriber when filter(subscriber) returns true |
evt.OnlyType<TEvent, TSubscriber>() |
Deliver only to subscribers of type TSubscriber |
evt.ExcludeType<TEvent, TSubscriber>() |
Exclude subscribers of type TSubscriber |
evt.OnlySubscriber(subscriber) |
Deliver only to the specified instance |
evt.ExcludeSubscriber(subscriber) |
Exclude the specified instance |
evt.OnlySubscribers(HashSet<object>) |
Deliver only to the instances in the given set |
evt.ExcludeSubscribers(HashSet<object>) |
Exclude all instances in the given set |
Fluent configuration now returns a ConfiguredEvent<TEvent> value that carries the config for that publish path. Most chain-style code remains unchanged, but if you store the fluent result, use var or ConfiguredEvent<TEvent> instead of the raw event type.
Reusing the same ConfiguredEvent<TEvent> variable reuses the same configuration. In other words, configured.Publish(); configured.Publish(); will publish twice with the same filters / cancelable flag.
Built-in fluent filters such as OnlySubscriber, ExcludeSubscriber, OnlyType, and ExcludeType stay on the zero-allocation hot path in the common inline-rule path. WithFilter(Predicate<object>) remains the escape hatch for custom logic, and whether it allocates depends on the predicate you provide. A capturing lambda such as obj => obj == target will typically allocate. Multiple custom predicates, or built-in filter chains that overflow the current inline rule capacity, also fall back to predicate composition and may allocate.
// Notify only the UI layer and avoid game logic
new DamageEvent { Amount = 5 }
.OnlyType<DamageEvent, HUDDisplay>()
.Publish();
// Exclude self to avoid receiving events you published
new DamageEvent { Amount = 5 }
.ExcludeSubscriber(this)
.Publish();
// Combine: cancelable + only a specific type
new DamageEvent { Amount = 5 }
.Cancelable()
.OnlyType<DamageEvent, ShieldSystem>()
.Publish();
// Exclude multiple instances
var exclude = new HashSet<object> { enemyA, enemyB };
new DamageEvent { Amount = 5 }.ExcludeSubscribers(exclude).Publish();
// If you keep the fluent result, store the configured event rather than the raw event struct
var configured = new DamageEvent { Amount = 5 }.ExcludeSubscriber(this);
configured.Publish();
configured.Publish(); // publishes again with the same stored configuration
Compatibility Notes
- GenEvent is still pre-1.0, so low-level helper/runtime types may continue to evolve.
- Prefer the fluent publish API over constructing or persisting
PublishConfig<TEvent>directly. SubscriptionHandleis now a lightweight value type.GenEventFiltersremains available as a predicate helper API for compatibility, but the zero-allocation guarantee applies to the built-in fluent publish methods rather than direct use of those predicate helpers.
Async Support
Change the handler signature to return Task or Task<bool> to define an async handler—no extra configuration is required.
public class NetworkSync
{
[OnEvent]
public async Task OnDamage(DamageEvent e)
{
await SendToServerAsync(e);
}
}
// Async publish: awaits each handler in priority order
bool completed = await new DamageEvent { Amount = 10 }.PublishAsync();
A single subscriber can define both sync and async handlers for the same event type. They are triggered by Publish and PublishAsync respectively:
public class CombatLogger
{
[OnEvent]
public void OnDamage(DamageEvent e) // triggered by Publish()
{
LogToFile(e);
}
[OnEvent]
public async Task OnDamageAsync(DamageEvent e) // triggered by PublishAsync() (note: the sync handler is also invoked)
{
await LogToRemoteAsync(e);
}
}
.NET Benchmarks
The repository includes a dedicated BenchmarkDotNet project at Benchmarks/GenEvent.Benchmarks/ for repeatable .NET-side performance measurements. The suite distinguishes between the zero-allocation default / built-in fluent paths and custom predicate fallback paths.
Run the full benchmark suite with:
dotnet run -c Release --project Benchmarks/GenEvent.Benchmarks/GenEvent.Benchmarks.csproj
Run a single benchmark with:
dotnet run -c Release --project Benchmarks/GenEvent.Benchmarks/GenEvent.Benchmarks.csproj -- --filter *PublishBenchmarks.Publish_NoSubscribers*
Use benchmark results mainly for same-machine trend comparisons between revisions. Absolute timings from different machines or power profiles are not directly comparable.
Source Generator Constraints & Diagnostics
The generator enforces a clear set of rules for events and [OnEvent] methods. Violations are reported as compile‑time diagnostics; they never fail silently.
Event constraints
- Must be a
structthat implementsIGenEvent<T>.
Handler method constraints
- Must be a
publicinstance method. - Must take exactly one parameter whose type implements
IGenEvent<>. - Return type must be
void,bool,Task, orTask<bool>. - For a given class and event type, there can be at most one sync handler and one async handler.
Diagnostic codes
| Code | Severity | Meaning |
|---|---|---|
| GE001 | Warning | IGenEvent interface not found; check your GenEvent reference |
| GE002 | Warning | OnEventAttribute not found; check your GenEvent reference |
| GE010 | Error | [OnEvent] method must be public |
| GE011 | Error | [OnEvent] method must have exactly one parameter |
| GE012 | Error | [OnEvent] parameter type must implement IGenEvent |
| GE013 | Error | A class cannot have two sync or two async handlers for the same event |
| GE014 | Error | [OnEvent] return type must be void, bool, Task, or Task<bool> |
| GE999 | Error | Internal generator error; see compiler output for details |
If you see GE001/GE002, verify that both the core library and the source generator are correctly referenced. For GE010–GE014, adjust method signatures according to the table above. For GE999, inspect the full compiler output for more details.
License
This project is licensed under the MIT License.
Copyright (c) 2026 Puring.
See the LICENSE file for full text.
Versions 0
No versions tracked yet.
Dependencies 0
No dependencies.
Changelog 0 releases
No changelog entries yet. Run the admin Changelog & Version Scanner to pull from the repository's CHANGELOG.md.
Comments
No comments yet. Be the first!


Sign in to join the conversation
Sign In