In this guide
Lifecycle hooks, explicit imports, typed properties, events, and timers.
Lua scripting
Gameplay rules live in Lua 5.4. A script is a module that returns a table of lifecycle functions and fields. Attach it to an entity with the LuaScript component and it receives engine callbacks every frame.
A complete script
local Input = require("demi.input")
local Transform3D = require("demi.transform3d")
local Player = {}
function Player:on_create()
-- self.id is the entity this script is attached to.
-- self.speed already holds the authored property value.
self.jump_held = false
end
function Player:on_update(dt)
-- dt is seconds since the previous rendered frame.
local x, y = Input.vector("move") -- -1 … 1 on each axis
local px, py, pz = Transform3D.get_position(self.id)
Transform3D.set_position(self.id,
px + x * self.speed * dt,
py,
pz + y * self.speed * dt)
end
function Player:on_fixed_update(fixed_dt)
-- fixed_dt is the project simulation step (default 1/60).
-- Put physics and other deterministic logic here.
end
function Player:on_destroy()
-- Release timers, subscriptions, and pooled entities.
end
return Player
Walkthrough: the file returns one table, Player. The engine instantiates it per entity and calls its methods. Inside every hook, self is that instance and self.id is the entity handle you pass to every other API. Properties written in the editor arrive as fields on self before on_create runs.
Lifecycle
| Hook | When it runs | Typical use |
|---|---|---|
on_create() | Entity is created; properties are already set | Cached handles, initial state, subscriptions |
on_start() | Scene activation finished | Cross-entity lookups that need the whole scene |
on_update(dt) | Every rendered frame | Input sampling, presentation, camera follow |
on_fixed_update(fixed_dt) | Every simulation step | Movement, combat resolution, anything that must stay deterministic |
on_destroy() | Entity or scene is torn down | Cleanup |
on_ui_event(e) | A UI node fires an event | Buttons, drags, focus changes |
on_ui_value_changed(e) | Slider/toggle/text value changes | Settings, form fields |
on_ui_submit(e) | Submit action | Confirm dialogs, text fields |
on_update and on_fixed_update can both run zero or more times per frame. Sample input in on_update, apply it in on_fixed_update, so replays stay aligned with the fixed step.
Service imports
local Input = require("demi.input")
local Events = require("demi.events")
local Health = require("demi.gameplay.health") -- from an installed package
Each require returns the service table. Import only what the script needs. Engine services ship with the engine; package modules come from installed packages.
Typed script properties
Annotations expose fields in the Inspector so designers can tune values without editing Lua:
---@demi_component
---@display_name Player Controller
---@category Gameplay
---@description Moves the player with the move action.
local Player = {}
---@demi_property
---@label Move Speed
---@range 0 20
Player.speed = 6.0
---@demi_property
---@options red, blue
Player.team = "red"
---@demi_property entity
---@label Spawn Point
Player.spawn_point = "player_spawn" -- stable entity ID
return Player
| Annotation | Effect |
|---|---|
@demi_component | Registers the script as a component. Display name defaults to the humanized filename; category defaults to Scripting. |
@demi_property | Exposes the assignment below it as an editable property. |
@label, @description | Inspector text. |
@range min max | Slider bounds for numbers. |
@options a, b, c | Dropdown choices for strings. |
@category | Inspector grouping. |
Property types come from the Lua value: boolean, number, integer, string, array, object, vec2, vec3, color, plus asset and entity when annotated. Defaults install before on_create — read them there, do not reassign them.
Scene or prefab overrides:
"LuaScript": {
"module": "script://scripts/player.lua",
"properties": { "speed": 8.5, "team": "blue" }
}
An override that fails validation is rejected and the script does not activate. Under hot reload the previous valid instance keeps running.
For stricter constraints, set a property_schema table on the returned module:
Player.property_schema = {
speed = { type = "number", default = 6.0, minimum = 0.0, maximum = 20.0 },
team = { type = "enum", values = { "red", "blue" }, default = "red" },
model = { type = "asset", required = true },
}
return Player
Types: boolean, number, integer, string, asset, entity, enum, array, object, vec2, vec3, color. Invalid overrides reject activation the same way.
Events
local Events = require("demi.events")
local id = Events.subscribe("physics_collision_enter", function(e)
if e.other_id == "lava" then
Events.emit("player_died", { cause = "lava" })
end
end)
-- later:
Events.unsubscribe(id)
Events.subscribe(event_name, handler) returns an integer token. The handler receives one payload table and runs immediately when the event fires. Events.emit sends a game-defined payload to every subscriber of that name.
Engine events you can subscribe to:
- Physics —
physics_collision_enter/stay/exit,physics_trigger_enter/stay/exit,physics_contact, and the 3D counterpartsphysics3d_collision_enter/stay/exit,physics3d_trigger_enter/stay/exit,physics3d_contact - Animation —
animation_event,sprite_animation,animation_collision - Scene —
scene_loaded,scene_unloading,scene_unloaded,active_scene_changed - UI —
ui_event,ui_submit - App —
application_focus,application_blur,application_suspend,application_resume - Data —
data_asset_reloaded
The optional demi.gameplay.events package is a caller-owned queue with an explicit flush() step — useful when you want to handle events at a controlled point in the frame.
Timers and time
local Timer = require("demi.timer")
local Time = require("demi.time")
local handle = Timer.after(1.5, function()
print("one-shot after 1.5 seconds")
end)
local repeat_handle = Timer.every(0.25, function() end)
Timer.delay(0.1, function() end) -- runs next opportunity
Timer.cancel(handle) -- stop a pending timer
print(Time.delta_time) -- seconds, this frame
print(Time.fixed_time) -- seconds, simulation clock
print(Time.frame_count) -- integer frame index
Time.set_paused(true) -- freezes update hooks
Time.set_scale(0.5) -- slow motion
after, every, and delay return a handle you can pass to cancel. Always cancel in on_destroy if the timer outlives the entity otherwise.
Editor stubs
Generate LuaLS/EmmyLua stubs for autocomplete:
demi lua-stubs generate .demi/lua
Add .demi/lua to LuaLS workspace.library. The generated files describe signatures for the editor and are not loaded at runtime.
Hot reload behavior
- Edits are applied at frame boundaries, only after the new script compiles and validates.
- A compile error or bad property override prints diagnostics and leaves the running instance untouched.
- Instance fields on
selfreset on reload. Keep durable state in entity fields, game data, or saves.
Next
- Lua API reference — return shapes and every module.
- Input — actions and bindings.
- Game data — schema-backed content.