Lua Scripting API

Scripts are attached to individual entities via 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:

  1. The file is loaded and compiled by LuaJIT via Urho3D's ResourceCache.
  2. A LuaScriptInstance component is attached to the entity's Urho3D Node.
  3. 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 local for all non-intentionally-shared data.
  4. On the first game update after Start() completes, the script's Start() function is called once.
  5. Every frame, Update(dt) is called with dt in seconds.
  6. 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
FunctionWhen calledNotes
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.

VariableUrho3D typeDescription
selftable The LuaScriptInstance script component table. Contains self.node.
self.nodeNode The Urho3D Node that owns this script component. Alias it as entity in Start().
sceneScene The current Urho3D scene. Used for physics raycasts, component queries across the scene, and scene-level operations.
cacheResourceCache Asset cache. Load resources: cache:GetResource("Model", "Models/hero.mdl").
inputInput Low-level Urho3D input subsystem. Prefer the Input: (capital I) Simple3D helper for game scripts.
audioAudio Urho3D audio subsystem. For most use-cases, use the PlaySound() and PlayMusic() global functions instead.
timeTime Timer subsystem. time:GetTimeStep() returns the same value as dt in Update(dt). time:GetElapsedTime() returns seconds since application start.
InputInput 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().

MethodParametersReturnsDescription
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.

StringKeyStringKey
"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.

FunctionParametersDescription
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

FunctionReturnsDescription
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 messageLikely causeFix
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

Common Pitfalls

PitfallExplanation
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).