MotaEngine

Lua Scripting API

MotaEngine can run gameplay scripts written in Lua. This page is the complete reference for what a script can call: the global mota table, the lifecycle callbacks the engine invokes on a script, and the rules scripts run under.

Lua scripting is a gameplay layer on top of a C++ game. The engine hosts the Lua runtime: any entity with a ScriptComponent runs its script, in windowed games, headless runs and dedicated servers alike. Your C++ game module decides which entities get which scripts; scripts then drive behaviour through the mota functions listed here. For the C++ side, see sdk-reference.md.

Contents

Overview

Runtime. On Windows, Linux and macOS scripts run on LuaJIT (Lua 5.1 compatible). On Android and iOS they run on Lua 5.4, because LuaJIT cannot be built for those platforms. If your game ships on mobile, write scripts in the subset both versions share: avoid LuaJIT-only libraries (bit, jit, ffi), Lua 5.4-only syntax (integer division //, bitwise operators, <const>), and use table.unpack or unpack where you need unpack.

One Lua state. Every script in a ScriptEngine shares a single Lua state. Global variables are therefore visible to (and can be overwritten by) every other script. Keep per-entity state in local variables, as described in Writing a script.

Sandbox. The standard libraries are opened, then os, io, loadfile, dofile, require, package and debug are removed (set to nil). Scripts cannot touch the file system, run system commands, load native modules or reach LuaJIT's ffi. string, table, math, coroutine and the rest of the base library remain available. Even so, treat the sandbox as a guard against accidents rather than a hardened security boundary: run scripts you ship yourself, not scripts downloaded from players.

The mota table. All engine functions live on the global table mota (for example mota.log, mota.getPosition). There are no other engine globals and no sub-tables.

Entity handles are numbers. An entity is passed to and returned from scripts as a single number that encodes both the entity's slot and its generation. Treat it as opaque: compare it, store it, pass it back to mota functions. A handle to a destroyed entity stays dead even after the engine reuses its slot for a new entity, so a stale handle can never move or destroy the wrong entity (mota.isAlive tells you whether it still exists). From C++, ScriptEngine::EntityToScript and ScriptEngine::EntityFromScript convert between an EntityID and the number a script sees.

Vectors are separate numbers. Positions, directions, velocities and scales are passed as three numbers x, y, z, and returned as three return values. Rotations are quaternions passed as four numbers x, y, z, w. The world is Y-up (gravity points along negative Y).

local x, y, z = mota.getPosition(entity)
mota.setPosition(entity, x, y + 1.0, z)

Missing arguments. Required arguments are checked: passing a non-number where a number is expected, or omitting a required argument, raises a Lua error ("bad argument #2 to ..."). Optional arguments are marked [name=default] in the signatures below.

Setting up scripting in your game

The engine creates the Lua runtime at startup, connects it to the world, physics, input and audio, and registers the scripting systems. Your game only attaches scripts to entities. To run a script on an entity, add a ScriptComponent with the script's path:

#include <motaware/scripting/script_component.h>

mota::scripting::ScriptComponent sc;
std::strncpy(sc.scriptPath, "projects/MyGame/Scripts/spinner.lua", sizeof(sc.scriptPath) - 1);
world.AddComponent<mota::scripting::ScriptComponent>(entity, sc);

ScriptComponent fields:

FieldMeaning
scriptPathPath to the .lua file (up to 255 characters).
enabledtrue by default. While false, the script is neither started nor updated.
handle, initializedManaged by the engine. Leave at their defaults.

Scripts are read through the engine's virtual file system: a mounted pak (for example the one --package produces) is searched first, then loose files on disk. Loose paths are relative to the process working directory, the same convention as other project assets.

The runtime is available to your game module as ctx.scriptEngine (mota::scripting::ScriptEngine*). Use it to run a chunk directly (LoadScriptFromString), read globals (GetGlobalNumber), or register your own functions: GetLuaState() returns the underlying lua_State* (as void*) for the standard Lua C API. Do not close that state.

Writing a script

A script file is a Lua chunk that returns a table. The engine looks up the callback functions on that table by name:

-- Scripts/spinner.lua
local angle = 0.0          -- per-entity state (see below)

return {
    OnCreate = function(entity)
        mota.log("spinner attached to entity " .. entity)
    end,

    OnUpdate = function(entity, dt)
        angle = angle + dt
        local half = angle * 0.5
        -- rotate around the Y axis
        mota.setRotation(entity, 0.0, math.sin(half), 0.0, math.cos(half))
    end,
}

All callbacks are optional. A script that returns something other than a table still loads (its top-level code runs once) but receives no callbacks.

Per-entity state. The script file is loaded and executed separately for each entity that uses it, so every entity gets its own copy of the chunk's local variables (like angle above). Globals, in contrast, are shared by every script and every entity, so avoid them for per-entity data.

Lifecycle callbacks

CallbackCalled withWhen
OnCreate(entity)the entity handleOnce, right after the script is loaded for that entity, and again after a hot reload.
OnUpdate(entity, dt)the entity handle and the step length in secondsEvery simulation step while the component is enabled.
OnDestroy(entity)the entity handleOnce, when the entity is destroyed, its ScriptComponent is removed, or the engine shuts down.

Timing details:

  • Scripts are loaded and OnCreate runs during the simulation step after the ScriptComponent is added (before the step's regular update work).
  • OnUpdate runs inside the fixed-timestep simulation, so dt is the simulation step (1/60 s by default; servers can change it with --tickrate). When a frame needs several simulation steps, OnUpdate runs once per step.
  • Setting enabled = false on the component pauses OnUpdate; setting it back to true resumes it. OnCreate is not called again.
  • When an entity is destroyed or loses its ScriptComponent, OnDestroy runs during the next simulation step. By then the entity is already gone, so use OnDestroy to clean up script-side state (tables, counters, spawned entities you track); mota.getPosition and similar functions return identity values for it.
  • At engine shutdown every running script receives OnDestroy while the world, physics and audio are still alive.

Errors

  • Load errors (syntax errors, or a runtime error in the chunk's top-level code) are written to the engine log as Script error in '<path>': <message>. A missing file is logged as Failed to read script: <path>. The entity's script stays unstarted and is not retried every step: the engine tries again once the file changes on disk (or the component's path changes), so fixing the file while the game runs starts the script.
  • Errors inside OnCreate, OnUpdate or OnDestroy stop that one call and are logged as OnUpdate error in '<path>': <message> (and likewise for the other callbacks). An error that repeats every step is logged once, and again only when the message changes. The script keeps running; the next step calls it again. The C++ side can read the most recent error with ScriptEngine::GetLastError().

Error messages name the script by its path and line number.

Reloading scripts

Outside Shipping builds (Debug and Development), the engine checks loose script files for changes every half second of simulation time. When a file changes, it is re-read and re-run, its new callback table replaces the old one, and OnCreate is called again for that entity. The chunk's local state starts fresh, so OnCreate is the place to rebuild it. If the new version fails to load, the error is logged and the previous version keeps running.

Shipping builds do not watch files. Scripts read from a pak never reload. From C++, ScriptEngine::ReloadScript(handle) reloads one script on demand (the handle is in the entity's ScriptComponent::handle).

Logging

mota.log

mota.log(message)
ParameterTypeDescription
messagestringText to write. Numbers are accepted and converted.

Returns nothing. Writes [Lua] <message> to the engine log at Info level in the Scripting category. Use tostring for other values (booleans, nil, tables).

mota.log("health = " .. health)
mota.log("grounded: " .. tostring(isGrounded))

Entities

mota.createEntity

local entity = mota.createEntity()

Creates a new entity with a default Transform (origin, no rotation, scale 1) and returns its handle, so the transform functions work on it straight away. Returns nil if no world is connected. Other components (meshes, bodies, scripts) are added from C++.

mota.isAlive

local alive = mota.isAlive(entity)

Returns true while the entity exists, false once it has been destroyed (even if its slot now belongs to a newer entity).

mota.destroyEntity

mota.destroyEntity(entity)
ParameterTypeDescription
entitynumberHandle of the entity to destroy.

Returns nothing. Destroys the entity if it is alive; does nothing otherwise. It is safe to call from any callback, including on the entity whose OnUpdate is running: that entity receives OnDestroy on the next step.

Transforms

These functions read and write the entity's Transform component. If no world is connected, or the entity is not alive or has no Transform, the getters return the identity values shown below and the setters do nothing.

FunctionReturnsDescription
mota.getPosition(entity)x, y, zWorld position. Identity value: 0, 0, 0.
mota.setPosition(entity, x, y, z)nothingSets the position.
mota.getRotation(entity)x, y, z, wRotation as a quaternion. Identity value: 0, 0, 0, 1.
mota.setRotation(entity, x, y, z, w)nothingSets the rotation. Pass a unit quaternion.
mota.getScale(entity)x, y, zScale. Identity value: 1, 1, 1.
mota.setScale(entity, x, y, z)nothingSets the scale.

All parameters after entity are numbers. The quaternion component order is x, y, z, w for both the getter and the setter.

Writing the Transform of an entity that also has a dynamic physics body only moves the visual transform; the physics simulation keeps driving the body. Move physics objects with the physics functions instead.

-- Bob up and down around the starting height.
local baseY
local t = 0.0

return {
    OnCreate = function(entity)
        local _, y, _ = mota.getPosition(entity)
        baseY = y
    end,
    OnUpdate = function(entity, dt)
        t = t + dt
        local x, _, z = mota.getPosition(entity)
        mota.setPosition(entity, x, baseY + math.sin(t * 2.0) * 0.25, z)
    end,
}

Physics

mota.raycast uses world coordinates. The body functions take a physics body ID, which is not the same as an entity handle; get it with mota.getBody(entity).

mota.getBody

local body = mota.getBody(entity)

Returns the physics body ID of the entity's RigidBody, or nil when the entity has no RigidBody or its body has not been added to the physics world yet (that happens during the first physics step after the component is added, so call getBody from OnUpdate, or retry, rather than relying on it in OnCreate).

mota.raycast

local hit, x, y, z, distance = mota.raycast(ox, oy, oz, dx, dy, dz [, maxDistance=100])
ParameterTypeDescription
ox, oy, oznumbersRay origin.
dx, dy, dznumbersRay direction. It is normalized for you. A zero vector returns no hit.
maxDistancenumber, optionalMaximum ray length. Default 100.
ReturnTypeDescription
hitbooleantrue if the ray hit a body.
x, y, znumbersHit point (0, 0, 0 when nothing was hit).
distancenumberDistance from the origin to the hit point (0 when nothing was hit).

Without a physics world it returns false, 0, 0, 0, 0.

mota.addForce

mota.addForce(bodyID, fx, fy, fz)

Applies a force (newtons) to the body's center of mass for the next physics step. Call it every step for a continuous push. Returns nothing.

mota.addImpulse

mota.addImpulse(bodyID, ix, iy, iz)

Applies an instantaneous impulse (an immediate change in momentum, suited to jumps, hits and explosions). Returns nothing.

mota.getVelocity

local vx, vy, vz = mota.getVelocity(bodyID)

Returns the body's linear velocity in units per second. Without a physics world it returns 0, 0, 0.

mota.setVelocity

mota.setVelocity(bodyID, vx, vy, vz)

Overwrites the body's linear velocity. Returns nothing.

The ray reports the first body it meets, including the body of the entity doing the cast, so start rays outside your own collider.

-- Jump when grounded. The character's collider extends 1.0 below its position.
OnUpdate = function(entity, dt)
    local body = mota.getBody(entity)
    if not body then return end
    local x, y, z = mota.getPosition(entity)
    local grounded = mota.raycast(x, y - 1.05, z, 0, -1, 0, 0.1)
    if grounded and mota.isActionPressed("Jump") then
        local vx, _, vz = mota.getVelocity(body)
        mota.setVelocity(body, vx, 0, vz)
        mota.addImpulse(body, 0, 400, 0)
    end
end,

Input

Input functions query the engine's input mappings by name: the action and axis names your game defines in its input bindings. They work once the input system is connected; otherwise they return false / 0. Unknown names also return false / 0.

FunctionReturnsDescription
mota.isActionPressed(action)booleantrue while the mapped action is triggered or held.
mota.isKeyDown(action)booleanSame as isActionPressed. Despite its name it takes an action name, not a key name or key code.
mota.getAxis(axis)numberCurrent value of the mapped axis, normally in the range -1 to 1.
OnUpdate = function(entity, dt)
    local speed = 5.0
    local x, y, z = mota.getPosition(entity)
    x = x + mota.getAxis("MoveRight") * speed * dt
    z = z + mota.getAxis("MoveForward") * speed * dt
    mota.setPosition(entity, x, y, z)
end,

Audio

Sounds are referenced by file path (WAV, MP3, FLAC and the other formats the audio engine decodes). A clip is loaded the first time it is played and kept for reuse. Both functions return true if the sound started, and false when the file cannot be loaded (a warning is logged once per path) or when the process has no audio engine, such as a dedicated server.

mota.playSound

local started = mota.playSound(path [, volume=1.0])
ParameterTypeDescription
pathstringSound file path.
volumenumber, optionalVolume multiplier. Default 1.0.

Plays the sound once, non-positional, on the SFX bus.

mota.playSound3D

local started = mota.playSound3D(path, x, y, z [, volume=1.0])
ParameterTypeDescription
pathstringSound file path.
x, y, znumbersWorld position of the sound.
volumenumber, optionalVolume multiplier. Default 1.0.

Plays the sound once at a world position, attenuated by distance from the listener, on the SFX bus.

Math

Helpers for working with x, y, z triples. These need no engine subsystem and always work. The standard math library is available as well.

FunctionReturnsDescription
mota.distance(x1, y1, z1, x2, y2, z2)numberDistance between two points.
mota.normalize(x, y, z)x, y, zThe vector scaled to length 1. A vector shorter than 0.0001 is returned unchanged.
mota.lerp(a, b, t)numbera + (b - a) * t. t is not clamped.
mota.randomRange(min, max)numberA random number between min and max (inclusive).
-- Move toward a target point at a fixed speed.
local function moveToward(entity, tx, ty, tz, speed, dt)
    local x, y, z = mota.getPosition(entity)
    local dist = mota.distance(x, y, z, tx, ty, tz)
    if dist < 0.01 then return true end
    local nx, ny, nz = mota.normalize(tx - x, ty - y, tz - z)
    local step = math.min(speed * dt, dist)
    mota.setPosition(entity, x + nx * step, y + ny * step, z + nz * step)
    return false
end

mota.randomRange uses the C runtime's random generator, which the engine does not seed; use math.random (after math.randomseed) if you need control over the sequence.

Time

mota.getTime

local seconds = mota.getTime()

Returns the simulation time in seconds since the script system started: the sum of every step's dt. It advances once per simulation step, before that step's OnUpdate calls, so all scripts see the same value within a step.

Data tables

Data tables let designers tune gameplay values in CSV files without touching code or scripts. At startup the engine loads every *.csv file in the Data/Tables folder next to the executable; each file becomes a table named after the file (without .csv). The engine also provides a built-in weapons table if the game does not supply one.

CSV format: the first line holds column names, and the first column of each row is the row key. Values are split on commas (quoted fields are not supported). Each cell gets a type from its text:

Cell textStored asRead with
true or falsebooleandataTableGetBool
contains a . (for example 15.0)number (float)dataTableGetFloat
a whole number (for example 15)integerdataTableGetInt (or dataTableGetFloat)
anything elsestringdataTableGetString

Each getter returns values of its own type, with one convenience: dataTableGetFloat also reads whole-number cells, so 15 and 15.0 both read as 15 through it.

FunctionReturnsDescription
mota.dataTableHas(table)booleantrue if a table with this name is loaded. Takes only the table name.
mota.dataTableGetFloat(table, row, column [, default=0])numberFloat value of the cell.
mota.dataTableGetInt(table, row, column [, default=0])integerInteger value of the cell.
mota.dataTableGetBool(table, row, column [, default=false])booleanBoolean value of the cell.
mota.dataTableGetString(table, row, column [, default=""])stringString value of the cell.

table, row and column are strings. Every getter returns default when the table, row or column does not exist, or when the cell holds a different type.

Given Data/Tables/weapons.csv:

key,damage,range,automatic,name
pistol,15.0,25.0,false,Pistol
rifle,28.0,80.0,true,Rifle
if mota.dataTableHas("weapons") then
    local dmg  = mota.dataTableGetFloat("weapons", "rifle", "damage")        -- 28.0
    local auto = mota.dataTableGetBool("weapons", "rifle", "automatic")      -- true
    local name = mota.dataTableGetString("weapons", "pistol", "name")        -- "Pistol"
    local none = mota.dataTableGetFloat("weapons", "missing", "damage", -1)  -- -1
end

Experiments

Experiments (A/B tests and feature flags) assign each player a stable variant of a named experiment. Your game registers its experiments in C++ at startup; scripts read the local player's variant.

mota.experimentVariant

local variant = mota.experimentVariant(name [, default=""])
ParameterTypeDescription
namestringExperiment name.
defaultstring, optionalReturned when the experiment does not exist or has no variant for this player. Default "".

Returns the variant name (string) assigned to the local player. The assignment is deterministic for a given player and experiment, so it stays the same across sessions. The engine registers one sample experiment, ui.menu_layout, with the variants classic and compact.

if mota.experimentVariant("ui.menu_layout", "classic") == "compact" then
    -- use the compact layout
end

Headless and dedicated servers

Lua scripts run the same way in windowed, --headless and --server processes. Things to keep in mind on a server:

  • There is no local player input, so input functions return false / 0.
  • There is no audio engine, so mota.playSound and mota.playSound3D return false without playing anything.
  • Entity, transform, physics, math, time, data table and experiment functions work normally.

Known limitations

  • One shared Lua state. Globals are visible to every script; keep per-entity data in local variables.
  • OnDestroy runs after the fact. It is called on the step after the entity is destroyed, when the entity can no longer be read.
  • Handles are exact up to about two million reuses of one entity slot. Beyond that, a stale handle may stop resolving rather than resolving to the wrong entity.
  • mota.isKeyDown takes an action name, like mota.isActionPressed; there is no raw key query.
  • Scripts cannot add or remove components other than through mota.createEntity (which adds a Transform) and mota.destroyEntity; register your own functions from C++ for anything more.

Complete example

A pickup that spins, bobs, plays a sound and removes itself when the player comes close. The game sets the global playerEntity from C++ (for example with ctx.scriptEngine->LoadScriptFromString("playerEntity = " + std::to_string( mota::scripting::ScriptEngine::EntityToScript(player)))).

-- Scripts/pickup.lua
-- Per-entity state: this chunk runs once for each entity that uses the script.
local t = 0.0
local baseY = 0.0
local spinSpeed = 2.0
local pickupRadius = 1.5

pickupsCollected = pickupsCollected or 0   -- shared across all pickups

return {
    OnCreate = function(entity)
        local _, y, _ = mota.getPosition(entity)
        baseY = y
        -- Tunable values come from Data/Tables/pickups.csv when present.
        spinSpeed    = mota.dataTableGetFloat("pickups", "coin", "spin", spinSpeed)
        pickupRadius = mota.dataTableGetFloat("pickups", "coin", "radius", pickupRadius)
    end,

    OnUpdate = function(entity, dt)
        t = t + dt

        -- Spin around Y and bob up and down.
        local half = t * spinSpeed * 0.5
        mota.setRotation(entity, 0.0, math.sin(half), 0.0, math.cos(half))
        local x, _, z = mota.getPosition(entity)
        local y = baseY + math.sin(t * 3.0) * 0.2
        mota.setPosition(entity, x, y, z)

        -- Collect when the player is close.
        if playerEntity and mota.isAlive(playerEntity) then
            local px, py, pz = mota.getPosition(playerEntity)
            if mota.distance(x, y, z, px, py, pz) < pickupRadius then
                mota.playSound3D("projects/MyGame/Audio/coin.wav", x, y, z, 0.8)
                mota.destroyEntity(entity)
            end
        end
    end,

    OnDestroy = function(entity)
        pickupsCollected = pickupsCollected + 1
        mota.log("pickups collected: " .. pickupsCollected)
    end,
}

Function index

FunctionGroup
mota.log(message)Logging
mota.createEntity() -> entityEntities
mota.isAlive(entity) -> booleanEntities
mota.destroyEntity(entity)Entities
mota.getPosition(entity) -> x, y, zTransforms
mota.setPosition(entity, x, y, z)Transforms
mota.getRotation(entity) -> x, y, z, wTransforms
mota.setRotation(entity, x, y, z, w)Transforms
mota.getScale(entity) -> x, y, zTransforms
mota.setScale(entity, x, y, z)Transforms
mota.raycast(ox, oy, oz, dx, dy, dz [, maxDistance]) -> hit, x, y, z, distancePhysics
mota.getBody(entity) -> bodyID or nilPhysics
mota.addForce(bodyID, fx, fy, fz)Physics
mota.addImpulse(bodyID, ix, iy, iz)Physics
mota.getVelocity(bodyID) -> vx, vy, vzPhysics
mota.setVelocity(bodyID, vx, vy, vz)Physics
mota.isActionPressed(action) -> booleanInput
mota.isKeyDown(action) -> booleanInput
mota.getAxis(axis) -> numberInput
mota.playSound(path [, volume]) -> booleanAudio
mota.playSound3D(path, x, y, z [, volume]) -> booleanAudio
mota.distance(x1, y1, z1, x2, y2, z2) -> numberMath
mota.normalize(x, y, z) -> x, y, zMath
mota.lerp(a, b, t) -> numberMath
mota.randomRange(min, max) -> numberMath
mota.getTime() -> numberTime
mota.dataTableHas(table) -> booleanData tables
mota.dataTableGetFloat(table, row, column [, default]) -> numberData tables
mota.dataTableGetInt(table, row, column [, default]) -> integerData tables
mota.dataTableGetBool(table, row, column [, default]) -> booleanData tables
mota.dataTableGetString(table, row, column [, default]) -> stringData tables
mota.experimentVariant(name [, default]) -> stringExperiments