Events#
An event is a record marked @derive(nupp.events.Event). An observer subscribes to one event type at one integer address: address 0 belongs to the world, and an entity ID addresses that entity. The world constructs the event into storage it already owns, and only when something is observing, then hands it to each observer as a borrow for the call.
Observers receive only that event. Capture other state in the callback when it already belongs to the surrounding scope. OnDespawn also provides get and despawn operations for the narrow teardown work that must happen before the entity is removed.
The entry plugin below watches every despawn at the world address:
world:observe(0, tecs.ecs.OnDespawn, function(event: tecs.ecs.OnDespawn): nil
local transform = event:get(tecs.ecs.Transform2D)
if transform then
spawnDebrisAt(world, transform.x, transform.y)
end
end, "debris")The platform event stream uses the same bus. The host delivers each platform kind at address 0; tecs.platform.events declares one event type per kind.
Declaring an event#
A payload record is the event. Its fields, defaults, and constructor are the ones the language already checks, and @event(name = ...) sets the name tools see when the declaration's own name is not the surface you want to pin:
local events = require("nupp.events")
(events.Event)
(name = "game.PlayerDamaged")
local record PlayerDamaged
amount: number
source: string = "unknown"
endEvery declaration is its own event identity, so two events with the same fields are two declarations. A declaration with several constructors, an affine field, or a generic parameter is refused, because none of those can be constructed into storage the world reuses.
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. emit takes the event's fields after the declaration, positional or named, and applies a field default where an argument is left out:
world:observe(player, PlayerDamaged, function(event: PlayerDamaged)
applyDamage(player, event.amount)
end)
world:emit(player, PlayerDamaged, amount = 15)
world:emit(player, PlayerDamaged, 15, "fire")When an entity despawns, the world clears every observer at that address before the slot can belong to another entity. World-address observers remain, and world:clearEntities keeps them too.
Observer timing#
world:emit invokes matching observers before it returns, in registration order, and each sees what the earlier ones wrote to the event. The observer runs in the emitter's phase and joins that phase's structural transaction. A delivery reads the observer list when it starts: an observer added during it joins the next emission, and one removed during it does not run if the delivery had not reached it yet.
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.
What an observer may do with the event#
The event is borrowed for the call. An observer may read it and write to it, and cannot store it, return it, or hand it to anything that keeps it: the checker refuses those, because the storage is the world's and the next emission reuses it. Copy the fields out when something has to outlive the delivery.
An observer may suspend through any waiting library call, such as a timer or an asset load; the world keeps the event's storage leased until it resumes.
Subscription lifetime#
world:observe accepts an optional string ID. Remove a subscription by ID, or by the callback it was registered with, which removes every registration of that callback:
world:observe(0, GamePaused, onGamePaused, "pause-ui")
world:stopObserving(0, GamePaused, "pause-ui")
world:stopObserving(0, GamePaused, onGamePaused)world:observeOnce registers an observer that is consumed before its first delivery, so nothing nested or interleaved reaches it twice. world:clearObservers(address) clears an address that game code manages. Entity despawn handles entity addresses automatically. world:hasObservers answers whether anything is registered; an emission nobody observes already costs nothing beyond the argument expressions, so the check is for deciding whether to do other work.
Delivering an instance you hold#
world:deliver hands observers an event the caller built, without acquiring or releasing anything. What observers write to it is there when the call returns, which is how the UI bubbles one interaction through its ancestors and reads consumed back after each hop:
local hit = new PlayerDamaged(amount = 3)
world:deliver(player, PlayerDamaged, hit)
print(hit.amount)Storage#
A record event draws from a per-world nupp.mem.pool and a struct event from a per-world nupp.mem.arena, each made the first time that event type is emitted. Pooled storage is cleared between uses, so a field not set by an emission reads nil rather than the previous value. world:setAllocator(EventType, allocator) installs storage you own instead, sized how you like, for an event type you emit in volume.