on_create through on_destroy, with a full worked script.
Script lifecycle
A Lua script is a small module of functions the engine calls at known moments — when its entity is created, every frame, when physics ticks, when it is destroyed. This page explains what a script module is, who self is, and what each hook is for.
What a script module is
A script is a Lua file under your project's scripts/ folder, referenced from a scene as script://…. The file returns a table of hook functions. The engine creates one instance of that table per entity that uses the script, then calls the hooks at the right times.
local Debug = require("demi.debug")
local SkyforgeRune = {}
function SkyforgeRune:on_create()
Debug.log("rune created: " .. self.id)
end
return SkyforgeRune
The returned table is the script definition. on_create is one hook on it; use on_start for lookups that need the whole scene. The engine creates an instance per entity. Module locals and upvalues are shared by those instances, so put per-entity mutable state on self. Nothing runs until a scene or prefab attaches the script to an entity.
self and self.id
Inside every hook, self is the script instance. self.id is the entity this instance belongs to. Use self to store per-entity state (counters, flags, targets) and self.id with the entity and transform APIs.
local Transform3D = require("demi.transform3d")
function SkyforgeRune:on_create()
self.pulses = 0 -- per-entity state
end
function SkyforgeRune:on_update(dt)
self.pulses = self.pulses + 1
Transform3D.add_position(self.id, 0, dt * 0.2, 0) -- float upward
end
self— the script instance table (hooks plus whatever fields you set).self.id— entity handle for this instance; pass it toEntity,Transform3D,Animation, and friends.
Two runes in the same scene mean two instances, each with its own self.pulses. Never put per-entity state in a local outside the returned table and expect it to split per entity.
The hooks
| Hook | When it runs | Arguments |
|---|---|---|
on_create | Once, when the instance is built and its properties are in place | None |
on_start | Once, after every instance in the scene has been created | Setup that needs the whole scene: cross-entity lookups, timers, subscriptions. Most scripts do their setup here. |
on_update(dt) | Every rendered frame | dt: scaled seconds since the last frame |
on_fixed_update(fixed_dt) | Every fixed simulation tick | fixed_dt: fixed seconds for this tick |
on_destroy | Once, when the entity is being removed | None |
on_ui_event(payload) | When a HUD node this script owns fires an interaction event | One payload table (node ID, event name) |
on_ui_value_changed(payload) | When a HUD value (slider, toggle) changes | One payload table (node ID, new value) |
on_ui_submit(payload) | When a form-style HUD submit happens | One payload table |
Only define the hooks you need. A rendering-only script can be just on_update. Physics movement belongs in on_fixed_update so it stays in step with the simulation; visual smoothing belongs in on_update.
A full Skyforge example
local Transform3D = require("demi.transform3d")
local Input = require("demi.input")
local Time = require("demi.time")
local SkyforgeHoverRune = {}
function SkyforgeHoverRune:on_create()
self.base_y = select(2, Transform3D.get_position(self.id))
self.t = 0
end
function SkyforgeHoverRune:on_start()
-- safe place to look up other entities: every instance exists now
end
function SkyforgeHoverRune:on_update(dt)
self.t = self.t + dt
local x, _, z = Transform3D.get_position(self.id)
local y = self.base_y + math.sin(self.t * 2) * 0.15
Transform3D.set_position(self.id, x, y, z)
end
function SkyforgeHoverRune:on_destroy()
self.t = nil
end
return SkyforgeHoverRune
on_create reads the starting y and zeroes a timer. on_start is empty here but is the first safe moment to resolve other entities by name. on_update receives dt in scaled seconds (pause and time_scale affect it), computes a bobbing height with math.sin, and writes position back — all three coordinates, because set_position replaces the whole local position. on_destroy clears state; it runs before the entity goes away, so self.id is still valid inside it.
When a hook takes an argument, assign exactly that many names: function S:on_update(dt) takes one, function S:on_fixed_update(fixed_dt) takes one. For the UI hooks the argument is a single payload table — read payload.node and payload.event rather than expecting multiple returns.
Attaching a script to an entity
A scene entity carries a LuaScript component that names the module and its property overrides:
{
"id": "skyforge_rune",
"components": {
"LuaScript": {
"module": "script://scripts/hover_rune.lua",
"properties": {
"speed": 8.5
}
}
}
}
module— thescript://URI of the Lua file. It must return a hook table.properties— values installed on the instance beforeon_createruns, so the first hook already sees them.
Invalid property overrides reject scene activation — the load fails with a clear error instead of starting with half a script. If you reload a script during development and the new version does not parse, the previous instance keeps running. Property declaration is covered in Script properties.
Next steps
- Script properties — exposing fields to the editor.
- Events & timers — reacting to things that do not happen on a frame boundary.
- API conventions — how the calls used above return values.