Events
Tecs provides a lightweight, type-safe event system with centralized address-based routing. Events allow decoupled systems to communicate.
Core concepts
The events system centers on three concepts:
- Events: Type-safe objects that carry information.
- Addresses: Integer routing destinations for events (
0for world-level, entity IDs for entity events). - Observers: Functions that respond to events at specific addresses.
Address types
Events are routed through integer addresses:
| Address Type | Description | Example |
|---|---|---|
0 | World-level events for global communication | world:observe(0, GamePaused, ...) |
>0 | Events for an entity by ID | world:observe(entityId, OnDespawn, ...) |
Example: react when entity despawns
Tecs emits a built-in event, tecs.builtins.OnDespawn, when you despawn an entity. Listen for this event to clean up references to the entity, spawn an explosion animation, play a sound effect, etc.
local tecs = require("tecs")
-- Spawn an entity and get the entity ID.
local entity: integer = world:spawn()
-- Listen for when the entity is despawned.
world:observe(entity, tecs.builtins.OnDespawn, function(e: tecs.builtins.OnDespawn)
print("Entity " .. e.entity .. " was despawned")
end)World-level events
Use address 0 for events that aren't tied to a specific entity:
-- Observe world-level events
world:observe(0, MyEvent, function(e: MyEvent)
print("Got MyEvent")
end)
-- Emit world-level events
world:emit(0, MyEvent, "hi")Entity-level events
Use the entity ID as the address for entity-specific events:
local entityId: integer = world:spawn()
-- Observe events on this specific entity
world:observe(entityId, tecs.builtins.OnDespawn, function(e: tecs.builtins.OnDespawn)
print("Entity " .. e.entity .. " was despawned")
end)
-- When the entity is despawned, all observers on its address are automatically cleaned upWorld methods
These methods are available on every World.
| Method | Description |
|---|---|
world:observe | Subscribe to an event at a world or entity address. |
world:emit | Emit an event instance or construct-and-emit an event type. |
world:hasObservers | Check whether any observer exists for an address and event type. |
world:stopObserving | Remove a callback or named observer. |
world:clearObservers | Remove all observers at one address. |
world:observe
Registers an observer for an event at a specific address.
function World:observe<E is Event>(
address: integer,
eventType: E,
observer: function(E),
id?: string
)Parameters:
address: Address to observe (0for world-level events, entity ID for entity events).eventType: Event type to observe.observer: Callback called when the event is emitted.id: Optional string ID for later removal.
world:observe(0, MyCustomEvent, function(e: MyCustomEvent)
print("Got MyCustomEvent")
end)
world:observe(entityId, tecs.builtins.OnDespawn, function(e: tecs.builtins.OnDespawn)
print("Entity despawned: " .. e.entity)
end)world:emit
Emits an event to all observers at a specific address.
function World:emit(address: integer, eventOrType: Event, ...: any)Parameters:
address: Address to emit to (0for world-level events, entity ID for entity events).eventOrType: Event instance to emit, or an event type followed by constructor args....: Constructor args, wheneventOrTypeis an event type.
world:emit(0, MyCustomEvent)
-- Passing the type plus constructor args lets the world skip construction
-- when no observers are registered.
world:emit(entityId, DamageReceived, 15)world:hasObservers
Checks if any observers exist for an event at an address. This is useful when computing the payload is expensive; you usually do not need it just to avoid event construction if you use world:emit(address, EventType, ...).
function World:hasObservers<E is Event>(address: integer, eventType: E): booleanif world:hasObservers(entityId, DamageReceived) then
world:emit(entityId, DamageReceived, expensiveDamagePayload())
endworld:stopObserving
Stops observing an event at an address.
function World:stopObserving<E is Event>(
address: integer,
eventType: E,
observer: function(E) | string
)Parameters:
address: Address to stop observing.eventType: Event type.observer: Callback function or string ID provided toworld:observe.
world:stopObserving(0, MyEvent, myCallback)
world:stopObserving(entityId, tecs.builtins.OnDespawn, "cleanup-handler")world:clearObservers
Clears all observers for an address. Entity-address observers are cleared automatically when that entity despawns.
function World:clearObservers(address: integer)Event functions
Event management functions are available directly on the tecs module:
local tecs = require("tecs")tecs.newEvent
Configures an event to have an appropriate __call-based initialization path.
function tecs.newEvent<E is Event>(event: E)Parameters:
event: The event instance to configureevent.init: A function that populates an event instance (mutates in place, does not return)
Example:
-- Define the PlayerDamaged record as an event.
local record PlayerDamaged is tecs.Event
damage: number
source: string
--- Create a new PlayerDamaged event.
metamethod __call: function(self, damage: number, source: string): self
end
-- Have Tecs set up the metatable, assign type, etc.
-- `.init` receives a pre-allocated instance and mutates it.
PlayerDamaged.init = function(e: PlayerDamaged, damage: number, source: string)
e.damage = damage
e.source = source
end
tecs.newEvent(PlayerDamaged)
-- Direct constructors always allocate a fresh instance:
local damageEvent: PlayerDamaged = PlayerDamaged(10, "fire")
-- For the optimized emission path, let the world construct lazily:
world:emit(0, PlayerDamaged, 10, "fire")Constructor vs emit
The two construction paths differ in allocation:
- Direct constructor (
PlayerDamaged(10, "fire")): always allocates a fresh, independent instance. Use it when you need to hold onto the event past the current emit. world:emit(address, EventType, ...): the fast path. It checks for observers before constructing anything, then reuses world-local backing storage across repeated emissions of the same type, so a hot emission loop allocates nothing.
world:emit(0, PlayerDamaged, 10, "fire")WARNING
Don't retain a reference to the instance an observer receives from world:emit past that callback; the world reuses the backing storage on the next emit of the same type.
tecs.newFFIEvent
Configures an FFI event with C struct backing and slice-scoped arena allocation for optimized world emission.
function tecs.newFFIEvent<E is Event>(
event: E,
fields: {{string, string}},
structName?: string
)Parameters:
event: The event type to configurefields: Field definitions in format{ {"name", "type"}, ...}where type uses C FFI typesstructName: Optional struct name (auto-generated if not provided)
FFI Field Types:
Common FFI field types you can use:
int32_t,uint32_t- 32-bit signed/unsigned integersint64_t,uint64_t- 64-bit signed/unsigned integersfloat,double- floating point numbersbool- boolean valueschar[N]- fixed-size character arrays (e.g.,char[64])
Example:
-- Define an FFI event for high-frequency damage events
local record DamageEvent is tecs.Event
damage: number
entityId: integer
damageType: integer
-- Constructor must match the order of the FFI fields.
metamethod __call: function(
self,
damage: number,
entityId: integer,
damageType: integer
): self
end
-- Configure as FFI event with C struct backing. Use `"double"` (not
-- `"int32_t"`) for any field that holds an entity id: packed ids can
-- exceed int32 range once the generation bits are populated, and
-- truncation would produce garbage ids on the receiving side.
tecs.newFFIEvent(DamageEvent, {
{"damage", "float"},
{"entityId", "double"},
{"damageType", "int32_t"}
}, "Game_DamageEvent")
-- Usage is identical to regular events
local damage: DamageEvent = DamageEvent(15.5, 1234, 2)Direct FFI constructors allocate a fresh cdata instance each call. For the optimized path, emit the event type through the world:
world:emit(0, DamageEvent, 15.5, 1234, 2)That path checks for observers first and allocates from the emitting world's leased FFI slice in the global event manager.
FFI event compatibility
FFI events cannot contain Lua objects, userdata, functions, or anything incompatible with LuaJIT FFI. Use tecs.newEvent for events that need Lua values and prefer world:emit(address, EventType, ...) for the optimized path.
MessageBus
world:observe / world:emit delegate to the world's MessageBus, the address-keyed router that holds observers and dispatches events. Each world owns one. tecs.newMessageBus() creates a standalone bus if you want routing without a world.
| Method | Description |
|---|---|
bus:observe(address, EventType, callback, id?) | Subscribe to an event type at an address. Optional id lets you unsubscribe by name. |
bus:observeOnce(address, EventType, callback) | Subscribe; the observer is removed after it fires once. |
bus:stopObserving(address, EventType, callbackOrId) | Unsubscribe a callback (or id) from an event type at an address. |
bus:emit(address, EventType, ...) | Construct and dispatch an event to observers at the address. Skips construction when there are none. |
bus:hasObservers(address, EventType) | Whether any observer exists for that event type at the address. |
bus:clearAddress(address) | Remove every observer at one address. Used when an entity despawns. |
bus:clearEntityObservers() | Remove every per-entity observer (all addresses except global 0), preserving global subscriptions. Used by world:clearEntities. |
bus:reset() | Remove all observers, global ones included. Full teardown. |