Events Guide
KeenEyes provides a comprehensive event system for reacting to entity and component lifecycle changes. This guide covers all event types and usage patterns.
Overview
The event system includes:
- Component events -
OnComponentAdded,OnComponentRemoved,OnComponentChanged - Entity events -
OnEntityCreated,OnEntityDestroyed - Custom events - Generic pub/sub via
EventBus - Automatic cleanup -
IDisposablesubscriptions
Component Events
OnComponentAdded
Fires when a component is added to an entity:
using var world = new World();
// Subscribe to Health component additions
var subscription = world.OnComponentAdded<Health>((entity, health) =>
{
Console.WriteLine($"Entity {entity} gained health: {health.Current}/{health.Max}");
});
// This triggers the event
var entity = world.Spawn()
.With(new Health { Current = 100, Max = 100 })
.Build();
// Output: Entity (0v1) gained health: 100/100
// Cleanup when done
subscription.Dispose();
OnComponentRemoved
Fires when a component is removed from an entity:
var subscription = world.OnComponentRemoved<Health>(entity =>
{
Console.WriteLine($"Entity {entity} lost health component");
});
// Triggers when explicitly removed
world.Remove<Health>(entity);
// Also triggers on despawn
world.Despawn(entity);
Note: The handler receives only the entity, not the component value, because the data may already be overwritten.
OnComponentChanged
Fires when a component is modified via World.Set():
var subscription = world.OnComponentChanged<Health>((entity, oldValue, newValue) =>
{
Console.WriteLine($"Entity {entity} health changed: {oldValue.Current} -> {newValue.Current}");
});
// Create entity
var entity = world.Spawn()
.With(new Health { Current = 100, Max = 100 })
.Build();
// This triggers the change event
world.Set(entity, new Health { Current = 75, Max = 100 });
// Output: Entity (0v1) health changed: 100 -> 75
Important: Direct modifications via ref do not trigger change events:
// This does NOT trigger OnComponentChanged
ref var health = ref world.Get<Health>(entity);
health.Current = 50;
// Use Set() to trigger the event
world.Set(entity, health with { Current = 50 });
Entity Events
OnEntityCreated
Fires when an entity is spawned:
var subscription = world.OnEntityCreated((entity, name) =>
{
if (name != null)
{
Console.WriteLine($"Created named entity: {name} ({entity})");
}
else
{
Console.WriteLine($"Created entity: {entity}");
}
});
world.Spawn().Build(); // Created entity: (0v1)
world.Spawn("Player").Build(); // Created named entity: Player ((1v1))
OnEntityDestroyed
Fires when an entity is despawned:
var subscription = world.OnEntityDestroyed(entity =>
{
Console.WriteLine($"Entity {entity} destroyed");
});
var entity = world.Spawn().Build();
world.Despawn(entity); // Entity (0v1) destroyed
The handler is called before the entity is fully removed, so the entity handle is still valid during the callback.
Custom Events with EventBus
For application-specific events, use the generic EventBus:
Defining Events
// Define event as readonly record struct
public readonly record struct DamageEvent(Entity Target, int Amount, Entity? Source);
public readonly record struct LevelUpEvent(Entity Player, int NewLevel);
public readonly record struct GameOverEvent(string Reason, int FinalScore);
Subscribing
// Access via world.Events
var damageSubscription = world.Events.Subscribe<DamageEvent>(evt =>
{
Console.WriteLine($"Entity {evt.Target} took {evt.Amount} damage");
});
var levelUpSubscription = world.Events.Subscribe<LevelUpEvent>(evt =>
{
Console.WriteLine($"Player {evt.Player} reached level {evt.NewLevel}!");
});
Publishing
// Publish events from systems
world.Events.Publish(new DamageEvent(targetEntity, 25, attackerEntity));
world.Events.Publish(new LevelUpEvent(playerEntity, 5));
Checking for Handlers
Skip expensive event creation when no one is listening:
if (world.Events.HasHandlers<ExpensiveEvent>())
{
var eventData = CreateExpensiveEventData();
world.Events.Publish(eventData);
}
Subscription Management
IDisposable Pattern
All event subscriptions return EventSubscription which implements IDisposable:
var subscription = world.OnEntityCreated((e, n) => { });
// Later, unsubscribe
subscription.Dispose();
Using Statement
For scoped subscriptions:
using (var subscription = world.OnComponentAdded<Health>((e, h) =>
{
Console.WriteLine($"Health added to {e}");
}))
{
// Events fire within this scope
world.Spawn().With(new Health { Current = 100, Max = 100 }).Build();
}
// Automatically unsubscribed
Multiple Handlers
Multiple handlers can subscribe to the same event type:
var sub1 = world.OnEntityCreated((e, n) => Console.WriteLine($"Handler 1: {e}"));
var sub2 = world.OnEntityCreated((e, n) => Console.WriteLine($"Handler 2: {e}"));
world.Spawn().Build();
// Handler 1: (0v1)
// Handler 2: (0v1)
Use Cases
Logging System
public class EventLogger
{
private readonly List<EventSubscription> subscriptions = [];
public void AttachTo(World world)
{
subscriptions.Add(world.OnEntityCreated((e, n) =>
Console.WriteLine($"[LOG] Entity created: {e} (name: {n ?? "unnamed"})")));
subscriptions.Add(world.OnEntityDestroyed(e =>
Console.WriteLine($"[LOG] Entity destroyed: {e}")));
}
public void Detach()
{
foreach (var sub in subscriptions)
sub.Dispose();
subscriptions.Clear();
}
}
Reactive UI
// Update UI when player health changes
world.OnComponentChanged<Health>((entity, oldHealth, newHealth) =>
{
if (world.Has<Player>(entity))
{
UpdateHealthBar(newHealth.Current, newHealth.Max);
if (newHealth.Current < oldHealth.Current)
{
PlayDamageAnimation();
}
}
});
Achievement System
world.Events.Subscribe<DamageEvent>(evt =>
{
totalDamageDealt += evt.Amount;
if (totalDamageDealt >= 10000)
{
UnlockAchievement("Damage Dealer");
}
});
world.OnEntityDestroyed(entity =>
{
if (world.Has<Enemy>(entity))
{
enemiesDefeated++;
if (enemiesDefeated >= 100)
{
UnlockAchievement("Monster Slayer");
}
}
});
Cleanup Chains
// Clean up related resources when entities are destroyed
world.OnEntityDestroyed(entity =>
{
// Clean up physics body
if (world.Has<PhysicsBody>(entity))
{
physicsWorld.RemoveBody(entity);
}
// Clean up audio sources
if (world.Has<AudioSource>(entity))
{
audioManager.StopAll(entity);
}
});
Performance Considerations
Minimal Overhead When Unused
When no handlers are registered for an event type, firing the event has minimal cost (a dictionary lookup returning false):
// No OnComponentAdded<Health> handlers registered
world.Spawn().With(new Health { Current = 100, Max = 100 }).Build();
// Very low overhead - just a dictionary check
Handler Execution Order
Handlers are invoked in reverse registration order (LIFO). This allows handlers to safely unsubscribe during iteration.
Exception Handling
If a handler throws an exception, subsequent handlers are not invoked:
world.OnEntityCreated((e, n) => Console.WriteLine("Handler 1"));
world.OnEntityCreated((e, n) => throw new Exception("Oops!"));
world.OnEntityCreated((e, n) => Console.WriteLine("Handler 3")); // Not called!
try
{
world.Spawn().Build();
}
catch (Exception)
{
// Handler 1 ran, handler 2 threw, handler 3 skipped
}
Wrap handlers in try-catch if fault tolerance is needed.
Best Practices
Do: Dispose Subscriptions
// Store subscriptions for later cleanup
private List<EventSubscription> subscriptions = [];
public void Initialize(IWorld world)
{
subscriptions.Add(world.OnEntityCreated((e, n) => { }));
subscriptions.Add(world.OnComponentAdded<Health>((e, h) => { }));
}
public void Cleanup()
{
foreach (var sub in subscriptions)
sub.Dispose();
subscriptions.Clear();
}
Do: Use Set() for Change Detection
// Use Set() to trigger OnComponentChanged
world.Set(entity, new Health { Current = newValue, Max = maxValue });
Don't: Modify World During Events (Without Care)
// Dangerous: Spawning during spawn event can cause issues
world.OnEntityCreated((entity, name) =>
{
// Be careful! This triggers another OnEntityCreated
world.Spawn().Build();
});
Don't: Keep Long-Lived References
// Bad: Captured entity may become stale
Entity capturedEntity;
world.OnEntityCreated((e, n) => capturedEntity = e);
// Much later...
world.Get<Position>(capturedEntity); // May throw!
// Good: Validate before use
if (world.IsAlive(capturedEntity))
{
ref var pos = ref world.Get<Position>(capturedEntity);
}
Next Steps
- Change Tracking - Track component modifications
- Relationships - Parent-child hierarchies
- Systems Guide - Processing entities