Subscribing, emitting, and scheduling work.
Events & timers
Not everything happens on a frame boundary: a button gets pressed, a collision lands, two seconds pass, a scene finishes loading. Events deliver named messages to interested scripts; timers run a function later or repeatedly. Together they keep scripts from polling.
What an event is
An event is a name plus a payload table. Any script can subscribe a handler to a name, any script can emit a name, and every subscriber runs when it fires. One event can have many subscribers; each gets the same payload.
local Events = require("demi.events")
local token = Events.subscribe("skyforge_rune_collected", function(payload)
-- payload: the table the emitter passed (fields are up to the emitter)
Debug.log("collected by " .. tostring(payload.by))
end)
Events.subscribe(name, handler) registers handler for name and returns an integer token. Keep the token if you will unsubscribe later; otherwise discard it. The handler receives exactly one argument: the payload table (possibly empty, never nil).
Events.unsubscribe(token) -- remove one subscription; safe to call twice
Events.emit("skyforge_rune_collected", { by = self.id })
-- runs every handler subscribed to that name, each with the same payload table
Events.unsubscribe(token)— removes the subscription the token came from. Returnstruewhen a subscription was removed.Events.emit(name, payload)— firesnamewith a payload table. Returns the number of handlers delivered. Handlers run in subscription order before the emit call returns.
Payload tables can be empty (Events.emit("ui_closed", {})) when the name alone is the message. Do not reuse and mutate a payload table after emitting — handlers may hold onto it.
Engine events
The engine emits these names through the same subscribe mechanism. Payload fields depend on the event; each handler should read the fields it needs and ignore the rest.
| Name | When |
|---|---|
physics_collision_enter/stay/exit | A 2D solid contact began, keeps overlapping, or ended |
physics_trigger_enter/stay/exit | A 2D trigger overlap began, keeps overlapping, or ended |
physics_contact | Extracted 2D contact-point data (only when the collider reports contacts) |
physics3d_collision_enter/stay/exit | The same contact lifecycle for 3D bodies |
physics3d_trigger_enter/stay/exit | 3D trigger overlaps (character controllers fire these too) |
physics3d_contact | Extracted 3D contact-point data |
animation_event / sprite_animation / animation_collision | Clip markers, sprite frame changes, and animation-driven collision toggles |
scene_* | scene_loaded, scene_unloading, scene_unloaded, active_scene_changed |
ui_event / ui_submit | HUD interactions and form submits (also delivered to on_ui_event / on_ui_submit on the owning script) |
application_focus / application_blur / application_suspend / application_resume | Window and app lifecycle |
data_asset_reloaded | A data document was reloaded during development |
local Events = require("demi.events")
Events.subscribe("scene_unloading", function(payload)
-- payload.scene: URI string of the scene about to unload
end)
Events.subscribe("application_suspend", function(payload)
Save.write_state("profile1", build_state())
end)
Scene events carry the scene URI; application events carry little or no payload (an empty table). For the exact payload of a physics or animation event, read the fields in the handler and log them once — the name always tells you the family, the table tells you who and where.
A caller-owned event queue
Sometimes you want to collect events during a tick and handle them at one safe moment (for example, between physics and rendering). The demi.gameplay.events package is a caller-owned queue: your code pushes into it and calls flush() when it is ready to run handlers.
local GameplayEvents = require("demi.gameplay.events")
GameplayEvents.subscribe("skyforge_wave_cleared", handler)
GameplayEvents.emit("skyforge_wave_cleared", { wave = 2 })
GameplayEvents.flush() -- runs queued handlers now
subscribe/emit— same meanings asdemi.events, butemitqueues rather than running immediately.flush()— runs everything queued so far, in order. Returns nothing.
Use demi.events for direct delivery and demi.gameplay.events when you need ordering control over when handlers run. Both are plain tables of functions; neither is global.
Timers
A timer runs a function after a delay or on a repeating interval. Time is game time in seconds, so pausing the game pauses timers.
local Timer = require("demi.timer")
Timer.delay(1.0, function()
Debug.log("fuse lit")
end)
local handle = Timer.after(3, function()
Entity.destroy(self.id)
end)
local beat = Timer.every(0.25, function()
Hud.set_variable("pulse", math.floor(Time.time * 4) % 2)
end)
Timer.cancel(handle) -- stop one scheduled call
Timer.cancel(beat)
| Call | Meaning | Returns |
|---|---|---|
Timer.delay(secs, fn) | Run once after secs | Integer timer ID |
Timer.after(secs, fn) | Run once after secs (one-shot alias of delay) | Integer timer ID |
Timer.every(secs, fn) | Run every secs until cancelled | Integer timer ID |
Timer.cancel(timer_id) | Stop a pending timer | true when cancelled; false for unknown or spent IDs |
Prefer after over delay whenever you might change your mind (enemy spawn waves, dialogue beats): the ID makes cancellation possible. The callback receives the timer ID as its one argument.
Pausing and scaling time
demi.time exposes the clock as fields and two control pairs. Values are seconds.
local Time = require("demi.time")
Time.set_paused(true) -- freeze gameplay time
print(Time.is_paused()) -- true
Time.set_scale(0.5) -- half speed
print(Time.get_scale()) -- 0.5
Time.delta_time/Time.unscaled_delta_time— per-frame seconds, scaled and unscaled.Time.time/Time.fixed_time/Time.frame_count— totals since start.Time.set_paused(on)/Time.is_paused()— pause control and query (boolean).Time.set_scale(s)/Time.get_scale()— multiplier control and query (number).
Menus that must animate while the game is paused read Time.unscaled_delta_time. Gameplay movement reads Time.delta_time. More clock detail lives in Assets, time & timers.
Next steps
- Script lifecycle — hooks versus subscriptions.
- HUD API — nodes that raise
ui_event. - State machines — clip markers that raise animation events.