Lua Scripting API
entity->AddScript("Scripts/name.lua") in C++.
Each script runs in Urho3D's LuaScriptInstance component using a LuaJIT VM.
Simple3D adds custom global functions and Node method extensions on top of Urho3D's own Lua bindings.
How Scripts Work
When entity->AddScript("Scripts/player.lua") is called in C++, the following happens:
- The file is loaded and compiled by LuaJIT via Urho3D's
ResourceCache. - A
LuaScriptInstancecomponent is attached to the entity's Urho3D Node. - The script runs in a Lua function environment (an upvalue table), not a separate Lua state.
All scripts in the same scene share one LuaJIT VM. This means global variables are shared —
avoid collisions by using
localfor all non-intentionally-shared data. - On the first game update after
Start()completes, the script'sStart()function is called once. - Every frame,
Update(dt)is called withdtin seconds. - When the entity is destroyed,
Stop()is called.
LuaJIT — Lua 5.1 compatible
Urho3D uses LuaJIT which is compatible with Lua 5.1. Avoid Lua 5.2/5.3/5.4-only features
(goto, table.move, integers, string.pack).
LuaJIT's JIT compiler makes tight loops very fast — prefer Lua for per-entity logic over
calling complex C++ APIs every frame.
Attaching Scripts in C++
// One script per entity: player->AddScript("Scripts/player.lua"); enemy->AddScript("Scripts/enemy.lua"); coin->AddScript("Scripts/rotate.lua"); // Scripts can also be added from other Lua scripts (advanced): -- self.node:CreateComponent("LuaScriptInstance"):LoadFile("Scripts/extra.lua")
If the file is not found, a [Warning] is logged and the call is a no-op.
The entity continues to exist and work normally. This allows development with missing assets.
Script Lifecycle Functions
-- ┌───────────────────────────────────────────────────────────────┐ -- │ Complete script template │ -- └───────────────────────────────────────────────────────────────┘ -- Module-level variables: shared across all frames. -- Use 'local' to avoid polluting the global Lua namespace. speed = 5 -- intentionally global (readable by other scripts) local anim = nil -- private to this script -- Start(): called once, after the game's Start() finishes. function Start() entity = self.node -- Simple3D convention: alias for the owning Node anim = entity:GetAnimationController() end -- Update(dt): called every frame. -- dt = seconds since last frame (same delta as C++ Update). function Update(dt) -- game logic here end -- Stop(): called when the entity is removed from the scene. -- Optional. Use for cleanup (e.g., removing spawned children). function Stop() -- cleanup end
| Function | When called | Notes |
|---|---|---|
Start() |
Once, on the first frame after the script is loaded | Always define. This is where you initialise entity = self.node and cache component references. |
Update(dt) |
Every frame if defined | dt is the frame delta in seconds. If not defined, no overhead. |
Stop() |
When entity is destroyed | Optional. Rarely needed — Lua GC handles most cleanup automatically. |
Urho3D Globals Available in Every Script
These variables are provided by Urho3D's Lua scripting subsystem and are available globally in every script without any require/import.
| Variable | Urho3D type | Description |
|---|---|---|
self | table | The LuaScriptInstance script component table. Contains self.node. |
self.node | Node | The Urho3D Node that owns this script component. Alias it as entity in Start(). |
scene | Scene | The current Urho3D scene. Used for physics raycasts, component queries across the scene, and scene-level operations. |
cache | ResourceCache | Asset cache. Load resources: cache:GetResource("Model", "Models/hero.mdl"). |
input | Input | Low-level Urho3D input subsystem. Prefer the Input: (capital I) Simple3D helper for game scripts. |
audio | Audio | Urho3D audio subsystem. For most use-cases, use the PlaySound() and PlayMusic() global functions instead. |
time | Time | Timer subsystem. time:GetTimeStep() returns the same value as dt in Update(dt). time:GetElapsedTime() returns seconds since application start. |
Input | Input | Same as input but capitalised. Simple3D convention uses Input: for the helper methods registered by the binding layer. |
Simple3D Node Extensions
These methods are added to the Node metatable by the Simple3D Lua binding layer.
They are available on any Node — use them via entity:MethodName().
| Method | Parameters | Returns | Description |
|---|---|---|---|
entity:MoveRelative(delta) |
delta: Vector3 | — | Moves the entity by delta in world space. If a RigidBody is present, adjusts velocity for physics-based movement. Otherwise, translates the node directly. Preferred for character controllers. |
entity:IsOnGround() |
— | bool | Fires a downward raycast of 0.7 m from the entity's origin. Returns true if it hits another rigid body (not itself). Use for jump gating. |
entity:ApplyImpulse(impulse) |
impulse: Vector3 | — | Calls RigidBody:ApplyImpulse(). Instantly changes velocity (mass-independent). Use for jump, knockback. |
entity:ApplyForce(force) |
force: Vector3 | — | Calls RigidBody:ApplyForce(). Accumulates each frame (mass-dependent). Use for thrust, wind. |
entity:SetLinearVelocity(v) |
v: Vector3 | — | Directly sets the rigid body's linear velocity. Use for precision control (e.g., set horizontal component without changing vertical). |
entity:GetLinearVelocity() |
— | Vector3 | Returns the current linear velocity in m/s. |
Standard Urho3D Node Methods
All Urho3D Node methods are available via the entity: alias.
Here are the most commonly used:
── Position, rotation, scale ──────────────────────────────────── entity:GetPosition() -- → Vector3 world-space entity:SetPosition(Vector3(0, 2, 0)) entity:GetWorldPosition() -- world-space (vs local for children) entity:Translate(Vector3(1, 0, 0)) -- built-in world-space translate entity:GetRotation() -- → Quaternion entity:SetRotation(Quaternion(45, 0, 0)) entity:GetDirection() -- → Vector3 forward direction entity:GetScale() -- → Vector3 entity:SetScale(Vector3(1, 2, 1)) ── Component access ───────────────────────────────────────────── entity:GetComponent("RigidBody") -- → RigidBody or nil entity:GetComponent("StaticModel") -- → StaticModel or nil entity:GetAnimationController() -- → AnimationController or nil ── Children ───────────────────────────────────────────────────── entity:GetNumChildren() -- → int entity:GetChild("WeaponBone") -- → Node by name entity:GetChild(0) -- → Node by index entity:GetParent() -- → Node or nil ── Identity ───────────────────────────────────────────────────── entity:GetName() -- → string entity:GetID() -- → int unique ID
AnimationController
local anim = entity:GetAnimationController() -- Play with all options anim:Play("run") -- defaults: layer=0, loop=true, fade=0.2s anim:Play("run", 0, true, 0.2) -- name, layer, looped, fadeTime anim:Play("jump", 0, false, 0.1) -- one-shot: loop=false anim:Play("shoot", 1, false, 0.05) -- upper-body layer: layer=1 -- Stop anim:Stop("run") -- stop specific clip anim:Stop("run", 0.3) -- stop with 0.3s fade anim:StopAll() -- stop all clips -- Query local playing = anim:IsPlaying("run") -- → bool local t = anim:GetTime("run") -- → float seconds local weight = anim:GetWeight("aim") -- → float 0–1 -- Control anim:SetWeight("aim", 0.7) -- blend weight 0–1 anim:SetSpeed("run", 1.5) -- playback speed multiplier anim:SetTime("run", 0.5) -- seek to position in seconds
Animation layers
Layers allow separate body-part animation blending. Convention: layer 0 = lower body / full body;
layer 1 = upper body (aiming, shooting). Calling Play("run", 0, …) while
Play("shoot", 1, …) is active blends both simultaneously.
Physics from Lua
Simple3D extensions cover the most common physics operations. For advanced cases, access
the RigidBody component directly:
local rb = entity:GetComponent("RigidBody") if rb then rb:SetLinearVelocity(Vector3(5, 0, 0)) rb:SetAngularVelocity(Vector3(0, 1, 0)) -- spin around Y axis rb:SetGravityOverride(Vector3(0, -20, 0)) -- heavier gravity for this body rb:SetMass(70) local v = rb:GetLinearVelocity() -- → Vector3 rb:ApplyImpulse(Vector3(0, 8, 0)) rb:SetEnabled(false) -- disable physics temporarily end -- 2D physics local rb2 = entity:GetComponent("RigidBody2D") if rb2 then rb2:SetLinearVelocity(Vector2(5, 0)) rb2:ApplyLinearImpulseToCenter(Vector2(0, 8), true) local v2 = rb2:GetLinearVelocity() -- → Vector2 end
Input
Use the Input: (capitalised) global for the Simple3D-style helper API.
The lowercase input is the raw Urho3D input subsystem.
── Keyboard ───────────────────────────────────────────────────── Input:IsKeyDown("W") -- true while key held (by string shortcut) Input:IsKeyDown(KEY_W) -- same, using Urho3D key code constant Input:IsKeyDown(KEY_SPACE) Input:IsKeyPress("Space") -- true only on first frame of key press Input:IsKeyPress(KEY_RETURN) ── Mouse ──────────────────────────────────────────────────────── Input:GetMousePosition() -- → IntVector2 (screen pixels; 0,0 = top-left) Input:GetMouseButtonDown(MOUSEB_LEFT) -- → bool Input:GetMouseButtonDown(MOUSEB_RIGHT) Input:GetMouseButtonPress(MOUSEB_LEFT) -- first frame only Input:GetMouseMoveX() -- → int pixel delta X this frame Input:GetMouseMoveY() -- → int pixel delta Y this frame
Key String Shortcuts
Simple3D registers common string shortcuts so you can write Input:IsKeyDown("W")
instead of Input:IsKeyDown(KEY_W). Both forms work.
| String | Key | String | Key |
|---|---|---|---|
"W" | W key | "Space" | Space bar |
"A" | A key | "Escape" | Escape |
"S" | S key | "Return" | Enter |
"D" | D key | "Tab" | Tab |
"Q" | Q key | "Backspace" | Backspace |
"E" | E key | "Shift" | Left Shift |
"R" | R key | "Ctrl" | Left Ctrl |
"F" | F key | "Alt" | Left Alt |
"Up" | Up arrow | "F1" … "F12" | Function keys |
"Down" | Down arrow | "0" … "9" | Number row |
"Left" | Left arrow | "KP0" … "KP9" | Numpad digits |
"Right" | Right arrow | "KPEnter" | Numpad Enter |
Audio — Global Functions
Status
These functions are part of the Simple3D custom Lua binding layer (Task #3). They are available when the binding layer is fully initialised, which is the case in all standard simple-3d builds.
| Function | Parameters | Description |
|---|---|---|
PlaySound(path) |
path: string | Play a one-shot SFX at full volume. Path relative to Data/. |
PlaySound(path, volume) |
path: string, volume: float 0–1 | Play a one-shot SFX at the specified volume. |
PlayMusic(path) |
path: string | Stream background music, looped. Replaces any currently playing music. |
PlayMusic(path, loop) |
path: string, loop: bool | Stream background music. loop = false plays once. |
StopMusic() |
— | Stop the currently playing music. |
Scene Queries — Global Functions
| Function | Returns | Description |
|---|---|---|
FindEntity(name) |
Node (or nil) | Find a scene entity by name. Returns the Urho3D Node; use its methods directly. Returns nil if not found. |
local playerNode = FindEntity("Player") if playerNode then local pos = playerNode:GetPosition() local dist = (entity:GetPosition() - pos):Length() end
Vector3
-- Construction local v = Vector3(1, 2, 3) local zero = Vector3(0, 0, 0) Vector3.ZERO -- constant: (0, 0, 0) Vector3.ONE -- constant: (1, 1, 1) Vector3.UP -- constant: (0, 1, 0) Vector3.FORWARD -- constant: (0, 0, 1) Vector3.RIGHT -- constant: (1, 0, 0) -- Fields v.x, v.y, v.z -- Arithmetic operators (returns new Vector3) v + other -- add v - other -- subtract v * scalar -- scale: Vector3(2,2,2) * 3 = Vector3(6,6,6) v / scalar -- divide -v -- negate -- Methods v:Length() -- → float magnitude v:LengthSquared() -- → float (cheaper than Length, avoids sqrt) v:Normalized() -- → unit Vector3 (length = 1). Returns ZERO if length is 0. v:Dot(other) -- → float dot product v:Cross(other) -- → Vector3 cross product v:Distance(other) -- → float distance between two points v:Lerp(other, t) -- → interpolated Vector3; t in 0..1 v:Abs() -- → component-wise absolute value
Vector2
local v2 = Vector2(3, 4) v2.x, v2.y v2:Length() v2:Normalized() v2:Dot(other) v2 + other v2 * scalar
Color
local c = Color(1, 0.5, 0, 1) -- r, g, b, a (each 0.0–1.0) c.r, c.g, c.b, c.a -- Constants Color.WHITE -- (1, 1, 1, 1) Color.BLACK -- (0, 0, 0, 1) Color.RED -- (1, 0, 0, 1) Color.GREEN -- (0, 1, 0, 1) Color.BLUE -- (0, 0, 1, 1) Color.YELLOW -- (1, 1, 0, 1) Color.CYAN -- (0, 1, 1, 1) Color.MAGENTA -- (1, 0, 1, 1) Color.TRANSPARENT -- (0, 0, 0, 0)
Quaternion
-- Construction (angles in degrees) local q = Quaternion(45, 0, 0) -- yaw=45° rotation around Y axis local q2 = Quaternion(0, 30, 0) -- pitch=30° rotation around X axis Quaternion.IDENTITY -- no rotation -- Composition local combined = q * q2 -- apply q first, then q2 -- Methods q:Normalized() q:Inverse() q:ToEulerAngles() -- → Vector3 (yaw, pitch, roll) in degrees q:Slerp(other, t) -- spherical interpolation
Example: 3D Platformer Controller
-- Scripts/player.lua -- 3D platformer: WASD movement, Space to jump, animations. speed = 6 -- m/s horizontal movement speed jumpForce = 8 -- impulse magnitude for jump local anim function Start() entity = self.node anim = entity:GetAnimationController() end function Update(dt) -- ── Horizontal movement ─────────────────────────────── local move = Vector3(0, 0, 0) if Input:IsKeyDown("W") then move.z = move.z + 1 end if Input:IsKeyDown("S") then move.z = move.z - 1 end if Input:IsKeyDown("A") then move.x = move.x - 1 end if Input:IsKeyDown("D") then move.x = move.x + 1 end local moving = move:Length() > 0 if moving then entity:MoveRelative(move:Normalized() * speed * dt) end -- ── Animation ───────────────────────────────────────── if anim then anim:Play(moving and "run" or "idle", 0, true, 0.2) end -- ── Jump ────────────────────────────────────────────── if Input:IsKeyPress("Space") and entity:IsOnGround() then entity:ApplyImpulse(Vector3(0, jumpForce, 0)) if anim then anim:Play("jump", 0, false, 0.1) end PlaySound("Sounds/jump.wav") end end
Example: Simple Enemy AI
-- Scripts/enemy.lua -- Chase the player when close enough, play attack animation when in range. chaseRange = 15 -- metres: start chasing when player within this range attackRange = 1.5 -- metres: attack when this close speed = 3 -- m/s movement speed local target = nil local anim = nil local state = "idle" -- "idle", "chase", "attack" function Start() entity = self.node target = FindEntity("Player") anim = entity:GetAnimationController() end function Update(dt) if not target then return end local dir = target:GetPosition() - entity:GetPosition() dir.y = 0 -- horizontal distance only local dist = dir:Length() -- ── State machine ───────────────────────────────────── if dist <= attackRange then state = "attack" elseif dist <= chaseRange then state = "chase" else state = "idle" end -- ── Behaviour ───────────────────────────────────────── if state == "chase" then entity:MoveRelative(dir:Normalized() * speed * dt) if anim then anim:Play("walk", 0, true, 0.2) end elseif state == "attack" then if anim then anim:Play("attack", 0, true, 0.05) end else if anim then anim:Play("idle", 0, true, 0.3) end end end
Example: Collectible (Rotating, Destroy on Contact)
-- Scripts/collectible.lua -- Spins in place. Calls C++ trigger callback to destroy on player contact. -- The C++ code must set up the TriggerSphere and SetOnTriggerEnter. local angle = 0 function Start() entity = self.node end function Update(dt) angle = angle + dt * 120 -- 120 degrees per second entity:SetRotation(Quaternion(angle, 0, 0)) -- Bob up and down local pos = entity:GetPosition() pos.y = 1.0 + math.sin(angle * 0.05) * 0.2 entity:SetPosition(pos) end
Example: Simple Tween / Timer
-- Scripts/tween.lua -- Move an entity from startPos to endPos over 'duration' seconds. duration = 2.0 local t = 0 local startPos = nil local endPos = nil function Start() entity = self.node startPos = entity:GetPosition() endPos = startPos + Vector3(0, 5, 0) -- move 5 m upward end function Update(dt) if t >= duration then return end t = t + dt local alpha = math.min(t / duration, 1.0) -- Smooth-step easing: s = 3α² - 2α³ local s = alpha * alpha * (3 - 2 * alpha) entity:SetPosition(startPos:Lerp(endPos, s)) end
Script Communication
Because all scripts share one Lua VM, you can use globals, but this couples scripts tightly.
A cleaner pattern is to use FindEntity() to locate entities and read their state:
-- In enemy.lua — find player and read health: local playerNode = FindEntity("Player") -- Player script must expose its health as a node uservalue or via a global. -- Simplest: use a shared global table indexed by node ID: -- EntityData = EntityData or {} (defined in player.lua) -- EntityData[self.node:GetID()] = { health = 100 } -- Enemy reads it: local playerHealth = EntityData[playerNode:GetID()].health
Debugging Lua Scripts
Use print() — output goes to stdout (same as the C++ log):
print("position:", entity:GetPosition().x, entity:GetPosition().y) print("velocity:", entity:GetLinearVelocity():Length())
Validate syntax before running (saves a round-trip launch):
luac -p Scripts/player.lua # or: luajit -b player.lua /dev/null
Common Lua runtime errors:
| Error message | Likely cause | Fix |
|---|---|---|
attempt to index a nil value (global 'entity') |
entity not assigned in Start(). |
Add entity = self.node as the first line of Start(). |
attempt to call a nil value (method 'Play') |
GetAnimationController() returned nil. |
Call entity->AddAnimationController() in C++ before the script runs. |
attempt to perform arithmetic on a nil value |
Math operation on uninitialized variable. | Check that all local variables are initialized in Start(). |
bad argument #1 to 'Play' (string expected, got nil) |
Animation name variable is nil. | Check the variable name passed to anim:Play(). |
Performance Tips
-
Cache component references. Calling
entity:GetComponent("RigidBody")every frame is slower than caching it inStart():local rb = entity:GetComponent("RigidBody"). -
Cache FindEntity results.
FindEntity()iterates the scene registry. Cache the result inStart(), not inUpdate(). -
Avoid string concatenation in Update.
"Pos: " .. pos.xallocates a new string object every frame. For HUD text that updates frequently, cache the format or update at a lower rate. -
Use LengthSquared() for distance comparisons.
dir:LengthSquared() < 4is faster thandir:Length() < 2(avoids a square root). - LuaJIT JIT-compiles hot paths. Tight Update loops with simple math are compiled to native code automatically — no manual optimization needed for typical game logic.
Common Pitfalls
| Pitfall | Explanation |
|---|---|
Forgetting local |
Lua variables without local are global. If two scripts use a global named speed, they overwrite each other. Always use local for private variables. |
Using entity before Start() |
Module-level code (outside any function) runs at load time, before Start(). self.node is not yet set at load time. Only access it inside Start() or later. |
Calling FindEntity every frame |
FindEntity walks the entity registry. Call it once in Start() and cache the result. |
| Destroying entities from Lua | Do not destroy entities directly from Lua scripts — call DestroyEntity() only from C++. Instead, set a flag in Lua and have C++ check it in Update(). |
| Vector3 mutation | In Urho3D Lua, Vector3 is a userdata, not a Lua table. You cannot set v.x = 5 directly if v is returned by a method — cache it as a local first: local pos = entity:GetPosition(); pos.x = 5; entity:SetPosition(pos). |