On this page
Events
An observer subscribes to one event type at one integer address. Address 0 belongs to the world; an entity ID addresses that entity.
The entry plugin below watches every despawn at the world address:
local Transform2D <const> = tecs.Transform2D
return tecs.newApplication({
plugin = function(world: tecs.World)
world:observe(
0,
tecs.ecs.OnDespawn,
function(event: tecs.ecs.OnDespawn)
-- OnDespawn runs before commit removes the row.
local transform <const> = world:get(
event.entity, Transform2D
)
if transform then
spawnDebrisAt(world, transform.x, transform.y)
end
end
)
end,
})The platform event stream uses the same bus. The host emits each platform kind at address 0; tecs.platform.events defines those event types.
World and entity addresses
Use address 0 for messages that belong to the world:
world:observe(0, GamePaused, onGamePaused)
world:emit(0, GamePaused)Use an entity ID for a subscription tied to that entity:
world:observe(
player,
DamageReceived,
function(event: DamageReceived)
applyDamage(player, event.amount)
end
)
world:emit(player, DamageReceived, 15)When an entity despawns, the world clears every observer at that address before the slot can belong to another entity. World-address observers remain.
Observer timing
world:emit invokes matching observers before it returns. The observer runs in the emitter's phase and deferred scope.
Platform events arrive before world:update, so their observers run outside the phase tree. They do not receive fixed-step timing, phase order, or state gating. Fold an event into state when a reaction needs those properties. Input follows that pattern for keyboard, pointer, and gamepad events.
Observers suit immediate notification. Systems suit ordered frame work.
Subscription lifetime
world:observe accepts an optional string ID. Remove a subscription with its callback or ID:
world:observe(0, GamePaused, onGamePaused, "pause-ui")
world:stopObserving(0, GamePaused, onGamePaused)
world:stopObserving(0, GamePaused, "pause-ui")Passing a function removes every matching registration of that function. Passing an ID removes the first matching registration. When an observer unsubscribes during dispatch, the bus waits until the current dispatch unwinds before changing its list.
world:clearObservers(address) clears an address that game code manages. Entity despawn handles entity addresses automatically.
world:hasObservers matters when building the payload itself costs work:
if world:hasObservers(enemy, PathChanged) then
world:emit(enemy, PathChanged, buildPathSnapshot(enemy))
endFor ordinary constructor arguments, call world:emit(address, EventType, ...) directly. The world checks for observers before it constructs an event.
Table events
Define a record, give it an in-place initializer, then register it:
local record PlayerDamaged is tecs.events.Event
amount: number
source: string
__call: function(
self, amount: number, source: string
): PlayerDamaged
end
PlayerDamaged.init = function(
event: PlayerDamaged, amount: number, source: string
)
event.amount = amount
event.source = source
end
tecs.events.newEvent(PlayerDamaged)
world:emit(player, PlayerDamaged, 10, "fire")Registration assigns the event type its ID. Register each type once.
PlayerDamaged(10, "fire") allocates an independent instance. Use that form when code must retain the value or send it through a standalone MessageBus.
world:emit(player, PlayerDamaged, 10, "fire") leases pooled backing storage and returns it after dispatch. Do not retain the instance passed to an observer. The emitter owns the payload during dispatch, so observers should treat its fields as read-only and copy any values that must outlive the callback.
FFI events
newFFIEvent stores fixed-size fields in a C struct:
local record DamageEvent is tecs.events.Event
amount: number
entity: integer
__call: function(
self, amount: number, entity: integer
): DamageEvent
end
tecs.events.newFFIEvent(
DamageEvent,
{
{"amount", "float"},
{"entity", "double"},
},
"Game_DamageEvent"
)
world:emit(0, DamageEvent, 15.5, enemy)Without a custom init, the generated initializer follows field order. Field names must form unique C identifiers. eventId and typeId belong to Tecs and cannot appear in fields.
Use double for an entity ID. The packed slot and generation do not fit in a 32-bit integer. FFI events cannot carry Lua strings, tables, functions, or userdata; table events can.
OnSpawn and OnDespawn use FFI storage with a double entity field.
Standalone message buses
Each world owns a MessageBus. tecs.events.newMessageBus() creates the same address router without a world.
observeOnce, per-address clearing, entity-address clearing, and a full reset. World methods add lazy construction, pooled emission, and automatic cleanup when entities die.