DEMIENGINEDOCUMENTATION Everything is free
Lua scripting · Create

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. Returns true when a subscription was removed.
  • Events.emit(name, payload) — fires name with 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.

NameWhen
physics_collision_enter/stay/exitA 2D solid contact began, keeps overlapping, or ended
physics_trigger_enter/stay/exitA 2D trigger overlap began, keeps overlapping, or ended
physics_contactExtracted 2D contact-point data (only when the collider reports contacts)
physics3d_collision_enter/stay/exitThe same contact lifecycle for 3D bodies
physics3d_trigger_enter/stay/exit3D trigger overlaps (character controllers fire these too)
physics3d_contactExtracted 3D contact-point data
animation_event / sprite_animation / animation_collisionClip markers, sprite frame changes, and animation-driven collision toggles
scene_*scene_loaded, scene_unloading, scene_unloaded, active_scene_changed
ui_event / ui_submitHUD interactions and form submits (also delivered to on_ui_event / on_ui_submit on the owning script)
application_focus / application_blur / application_suspend / application_resumeWindow and app lifecycle
data_asset_reloadedA 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 as demi.events, but emit queues 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)
CallMeaningReturns
Timer.delay(secs, fn)Run once after secsInteger 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 cancelledInteger timer ID
Timer.cancel(timer_id)Stop a pending timertrue 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