Venus Lua API › Core
Scripting Model
The script file you return, the self you receive, and every callback the engine calls.
lua/venus_lua/src/script_behavior.rs
Scripting Model
A gameplay script is a .lua file that returns a table of named functions. The engine loads
it once into a Script asset, and a Behavior runs an instance of it on a
GameObject, calling the functions whose names it recognizes as the object moves
through its lifecycle. Everything a script is — the table it returns, the self it receives, and
the callbacks it may define — is described here.
Two component types run scripts, and their callback sets differ:
- 3D scenegraph —
ScriptBehavior, attached to a GameObject. 33 callbacks.
(lua/venus_lua/src/script_behavior.rs) - UI —
UIScriptBehavior, attached to a UIObject. 15 callbacks.
(lua/venus_lua/src/ui_script_behavior.rs)
The loader reads only the names it knows. Unknown fields are ignored — a misspelled Updte
silently never runs, with no error. Defining a callback the component doesn’t support (a
FixedUpdate on a UI script, say) is the same: harmlessly ignored.
The script file
The chunk must return a table. If it returns anything else — or fails to load/execute — the
attach is rejected and the reason is logged to stderr (did not return a table, Load error,
Exec error); the object simply runs no script.
local M = {}
function M.OnCreate(self) end
function M.Update(self) end
return M
Only functions whose keys match a known callback name (see the catalogs below) are captured, as Lua
registry references; the returned table itself is discarded after the scan. Two consequences:
- Helper functions must be reachable from the callbacks (closures/upvalues in the same chunk),
not left as extra table fields —M.helperis not retrievable later. - The chunk runs once per loaded Script. Every Behavior attached from
that Script shares the same function objects (each instance gets its own registry refs pointing at
them) — which is why file-scope state is shared; see Shared vs per-instance state.
Each script runs under a sandboxed _ENV: globals you assign land in a per-script table, and
reads fall through to a frozen snapshot of the engine globals. One script cannot clobber another’s
globals, nor the engine’s. This also means the standard library is a reduced set — io, os,
debug, and several base globals are absent. See Environment & Sandbox for the
exact surface.
The self context table
Every callback receives, as its first argument, the behavior’s per-instance context table. The
engine populates exactly two fields and reads nothing else back — the rest of the table is yours to
use as per-instance state:
| Field | Type | Meaning |
|---|---|---|
self.object |
GameObject / UIObject | The object this behavior is attached to. |
self.behavior |
integer | The raw component handle (rarely needed directly). |
YAML-injected params
When a script is attached from a YAML scene layout, the layout’s parameter block is merged onto
self as self.<key> fields, available from OnCreate onward. Values are marshaled by type: whole
numbers → Lua integers, other numbers → floats, booleans → booleans, strings → strings.
# in a scene/prefab layout
- type: script
script: cube_behavior
params: { speed: 2.0, hostile: true }
function M.OnCreate(self)
-- `self.speed` arrives from the layout; default it for code paths that
-- attach without params (e.g. Behavior.Attach from Lua injects none).
self.speed = self.speed or 1.0
end
Params are injected only by the YAML installer. Behavior.Attach from Lua creates
the context with object/behavior only — no param merge.
Lifecycle & ordering
The recognized names line up with the engine’s component lifecycle. The ordering
guarantees that trip people up:
OnCreatefires lazily, on the first enable — not when the component is added. It runs at
most once, immediately before the firstOnEnable. Theselftable exists from this point.OnEnablefires on every activation;OnDisableon every deactivation.OnDestroyfires only ifOnCreatefired. A behavior that was attached but never enabled
(soOnCreatenever ran) gets noOnDestroy— don’t rely on it for cleanup you didn’t set up
inOnCreate.
add ─▶ (first enable) OnCreate ─▶ OnEnable ─▶ … ─▶ OnDisable ─▶ (re-enable) OnEnable ─▶ … ─▶ OnDestroy*
*only if OnCreate fired
Per-frame callbacks (Update, FixedUpdate, LateUpdate, OnPreCull, OnTransformChanged) run
between enable and disable while the behavior is active in hierarchy. Update runs once per rendered
frame; FixedUpdate runs once per fixed physics step (zero or more times per frame); LateUpdate
runs after every Update that frame.
3D callbacks — ScriptBehavior
All names are optional; implement only what you need. self is always the context table.
Lifecycle
| Callback | Arguments | Fires |
|---|---|---|
OnCreate |
(self) |
Once, on first enable, before OnEnable. |
OnEnable |
(self) |
On each activation. |
OnDisable |
(self) |
On each deactivation. |
OnDestroy |
(self) |
On teardown — only if OnCreate fired. |
Per-frame
| Callback | Arguments | Fires |
|---|---|---|
Update |
(self) |
Once per rendered frame. |
FixedUpdate |
(self) |
Once per fixed physics step. |
LateUpdate |
(self) |
After all Updates that frame. |
OnPreCull |
(self) |
Before the camera cull, each frame. |
OnTransformChanged |
(self) |
When this object’s transform changes. |
Pointer / input. Two families, mirroring the engine’s component input-responder callbacks. The
signal family passes only the receiver; the named-source family adds source, the input source
name (a string).
| Callback | Arguments |
|---|---|
OnHoverEnter, OnHoverExit |
(self) |
OnButtonDown, OnButtonUp, OnButtonCancel |
(self) |
OnDrag |
(self) |
OnScrollWheel |
(self) |
OnHoverBegin, OnHoverEnd |
(self, source) |
OnClickDown, OnClickHeld, OnClickRelease |
(self, source) |
OnCancel |
(self, source) |
OnScroll |
(self, source, amount) — on 3D, amount is always 0. |
Character controller (fired by a KinematicController /
DynamicCharacter on the same object). nx, ny, nz are the surface normal.
| Callback | Arguments | Fires |
|---|---|---|
OnLand |
(self, nx, ny, nz, impactSpeed) |
Character became grounded; impactSpeed = downward speed at contact. |
OnLeaveGround |
(self) |
Character left the ground. |
OnWallHit |
(self, nx, ny, nz) |
Character hit a wall. |
OnHeadHit |
(self) |
Character hit an overhead. |
Triggers (require a trigger Collider with events enabled). other is the other
GameObject.
| Callback | Arguments |
|---|---|
OnTriggerEnter, OnTriggerStay, OnTriggerExit |
(self, other) |
Collisions (require FixedRigidBody:SetCollisionEvents(true) on this
object). Note the asymmetry — enter/stay hand you a table, exit hands you just the other object:
| Callback | Arguments |
|---|---|
OnCollisionEnter |
(self, collision) |
OnCollisionStay |
(self, collision) — same table, every fixed step while touching. |
OnCollisionExit |
(self, other) — the other GameObject only, not a table. |
The collision table:
| Field | Type | Meaning |
|---|---|---|
gameObject |
GameObject | The other collider’s object. |
point |
Vector3 |
Contact point. |
normal |
Vector3 |
Contact normal. |
impulse |
number |
Normal impulse magnitude. |
collision.gameObjectfor a non-body contact. For voxel-terrain contact — or a body that has
already departed — the other handle is 0, andgameObjectis a GameObject userdata wrapping that
null handle: a stale object (its getters returnnil,GameObject.Existsisfalse). It is
a userdata, so it is truthy —if collision.gameObject thenis always true. Test it with
GameObject.Exists(collision.gameObject)to tell a real body from terrain.
UI callbacks — UIScriptBehavior
The UI component’s set is a strict subset: no FixedUpdate, and none of the physics,
trigger, collision, or signal-family pointer callbacks. self.object is a UIObject.
| Callback | Arguments | Notes |
|---|---|---|
OnCreate, OnEnable, OnDisable, OnDestroy |
(self) |
Same lifecycle rules as 3D. |
Update, LateUpdate, OnPreCull, OnTransformChanged |
(self) |
Per-frame. No FixedUpdate. |
OnHoverBegin, OnHoverEnd |
(self, source) |
source = input source name (string). |
OnClickDown, OnClickHeld, OnClickRelease |
(self, source) |
|
OnCancel |
(self, source) |
|
OnScroll |
(self, source, scrollY) |
scrollY = the UI object’s current scroll offset (real value, unlike 3D). |
State-machine sub-tables
Beyond the flat callbacks above, a UI script table may also carry per-state FSM sub-tables
(M.idle = { update = function(self, fsm, data, time, dt) ... end, ... }). These are dispatched by
a finite state machine, which hands itself in as argument 2. That mechanism — the per-state
callback names, their signatures, and the can_transition exception — is documented on its own
pages: FSM (UI), FixedUpdateFSM (3D / fixed step), and
UIFsm (requesting transitions).
Shared vs per-instance state
Because the chunk runs once and all instances share the same function objects, file-scope
locals (and script globals) are shared across every instance of that script. Per-instance state
must live on self:
local counter = 0 -- SHARED by every instance of this script
local M = {}
function M.OnCreate(self)
self.phase = counter * 0.1 -- per-instance: read the shared counter, keep our own phase
counter = counter + 1 -- mutate the shared counter
end
function M.Update(self)
-- self.phase is this instance's; `counter` is everyone's
end
return M
Reach for a file-scope local when you want one value across all instances (an id allocator, a
shared constant like Vector3.new(0,1,0)); reach for self for anything that must differ per
object. Mixing them up — per-instance data in a file local — is the classic bug: every object stomps
the same variable.
Example
A projectile that spins, reads a YAML-injected speed, and reacts to collisions — exercising the
self table, a param, lifecycle, per-frame Update, and the collision table.
local Y_AXIS = Vector3.new(0, 1, 0)
local M = {}
function M.OnCreate(self)
self.speed = self.speed or 90.0 -- deg/sec; overridable from the layout's params
self.hits = 0
end
function M.Update(self)
local angle = Time.Time() * self.speed
self.object:SetLocalRotation(Quaternion.AngleAxis(angle, Y_AXIS))
end
function M.OnCollisionEnter(self, collision)
self.hits = self.hits + 1
-- Terrain vs. body: the handle may be stale — test it, don't just `if`.
local what = GameObject.Exists(collision.gameObject) and "a body" or "terrain"
print(string.format("hit %s at impulse %.2f (total %d)", what, collision.impulse, self.hits))
end
function M.OnDestroy(self)
-- Only runs because OnCreate ran. Safe place for teardown.
end
return M