Skip to content

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 (0 for 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 TypeDescriptionExample
0World-level events for global communicationworld:observe(0, GamePaused, ...)
>0Events for an entity by IDworld: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.

teal
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:

teal
-- 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:

teal
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 up

World methods

These methods are available on every World.

MethodDescription
world:observeSubscribe to an event at a world or entity address.
world:emitEmit an event instance or construct-and-emit an event type.
world:hasObserversCheck whether any observer exists for an address and event type.
world:stopObservingRemove a callback or named observer.
world:clearObserversRemove all observers at one address.

world:observe

Registers an observer for an event at a specific address.

teal
function World:observe<E is Event>(
    address: integer,
    eventType: E,
    observer: function(E),
    id?: string
)

Parameters:

  • address: Address to observe (0 for 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.
teal
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.

teal
function World:emit(address: integer, eventOrType: Event, ...: any)

Parameters:

  • address: Address to emit to (0 for world-level events, entity ID for entity events).
  • eventOrType: Event instance to emit, or an event type followed by constructor args.
  • ...: Constructor args, when eventOrType is an event type.
teal
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, ...).

teal
function World:hasObservers<E is Event>(address: integer, eventType: E): boolean
teal
if world:hasObservers(entityId, DamageReceived) then
    world:emit(entityId, DamageReceived, expensiveDamagePayload())
end

world:stopObserving

Stops observing an event at an address.

teal
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 to world:observe.
teal
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.

teal
function World:clearObservers(address: integer)

Event functions

Event management functions are available directly on the tecs module:

teal
local tecs = require("tecs")

tecs.newEvent

Configures an event to have an appropriate __call-based initialization path.

teal
function tecs.newEvent<E is Event>(event: E)

Parameters:

  • event: The event instance to configure
  • event.init: A function that populates an event instance (mutates in place, does not return)

Example:

teal
-- 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.
teal
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.

teal
function tecs.newFFIEvent<E is Event>(
    event: E,
    fields: {{string, string}},
    structName?: string
)

Parameters:

  • event: The event type to configure
  • fields: Field definitions in format { {"name", "type"}, ...} where type uses C FFI types
  • structName: 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 integers
  • int64_t, uint64_t - 64-bit signed/unsigned integers
  • float, double - floating point numbers
  • bool - boolean values
  • char[N] - fixed-size character arrays (e.g., char[64])

Example:

teal
-- 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:

teal
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.

MethodDescription
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.