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
- Setting up scripting in your game
- Writing a script
- Lifecycle callbacks
- Errors
- Reloading scripts
- Logging
- Entities
- Transforms
- Physics
- Input
- Audio
- Math
- Time
- Data tables
- Experiments
- Headless and dedicated servers
- Known limitations
- Complete example
- Function index
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:
| Field | Meaning |
|---|---|
scriptPath | Path to the .lua file (up to 255 characters). |
enabled | true by default. While false, the script is neither started nor updated. |
handle, initialized | Managed 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
| Callback | Called with | When |
|---|---|---|
OnCreate(entity) | the entity handle | Once, 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 seconds | Every simulation step while the component is enabled. |
OnDestroy(entity) | the entity handle | Once, when the entity is destroyed, its ScriptComponent is removed, or the engine shuts down. |
Timing details:
- Scripts are loaded and
OnCreateruns during the simulation step after theScriptComponentis added (before the step's regular update work). OnUpdateruns inside the fixed-timestep simulation, sodtis the simulation step (1/60 s by default; servers can change it with--tickrate). When a frame needs several simulation steps,OnUpdateruns once per step.- Setting
enabled = falseon the component pausesOnUpdate; setting it back totrueresumes it.OnCreateis not called again. - When an entity is destroyed or loses its
ScriptComponent,OnDestroyruns during the next simulation step. By then the entity is already gone, so useOnDestroyto clean up script-side state (tables, counters, spawned entities you track);mota.getPositionand similar functions return identity values for it. - At engine shutdown every running script receives
OnDestroywhile 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 asFailed 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,OnUpdateorOnDestroystop that one call and are logged asOnUpdate 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 withScriptEngine::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)
| Parameter | Type | Description |
|---|---|---|
message | string | Text 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)
| Parameter | Type | Description |
|---|---|---|
entity | number | Handle 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.
| Function | Returns | Description |
|---|---|---|
mota.getPosition(entity) | x, y, z | World position. Identity value: 0, 0, 0. |
mota.setPosition(entity, x, y, z) | nothing | Sets the position. |
mota.getRotation(entity) | x, y, z, w | Rotation as a quaternion. Identity value: 0, 0, 0, 1. |
mota.setRotation(entity, x, y, z, w) | nothing | Sets the rotation. Pass a unit quaternion. |
mota.getScale(entity) | x, y, z | Scale. Identity value: 1, 1, 1. |
mota.setScale(entity, x, y, z) | nothing | Sets 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])
| Parameter | Type | Description |
|---|---|---|
ox, oy, oz | numbers | Ray origin. |
dx, dy, dz | numbers | Ray direction. It is normalized for you. A zero vector returns no hit. |
maxDistance | number, optional | Maximum ray length. Default 100. |
| Return | Type | Description |
|---|---|---|
hit | boolean | true if the ray hit a body. |
x, y, z | numbers | Hit point (0, 0, 0 when nothing was hit). |
distance | number | Distance 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.
| Function | Returns | Description |
|---|---|---|
mota.isActionPressed(action) | boolean | true while the mapped action is triggered or held. |
mota.isKeyDown(action) | boolean | Same as isActionPressed. Despite its name it takes an action name, not a key name or key code. |
mota.getAxis(axis) | number | Current 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])
| Parameter | Type | Description |
|---|---|---|
path | string | Sound file path. |
volume | number, optional | Volume 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])
| Parameter | Type | Description |
|---|---|---|
path | string | Sound file path. |
x, y, z | numbers | World position of the sound. |
volume | number, optional | Volume 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.
| Function | Returns | Description |
|---|---|---|
mota.distance(x1, y1, z1, x2, y2, z2) | number | Distance between two points. |
mota.normalize(x, y, z) | x, y, z | The vector scaled to length 1. A vector shorter than 0.0001 is returned unchanged. |
mota.lerp(a, b, t) | number | a + (b - a) * t. t is not clamped. |
mota.randomRange(min, max) | number | A 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 text | Stored as | Read with |
|---|---|---|
true or false | boolean | dataTableGetBool |
contains a . (for example 15.0) | number (float) | dataTableGetFloat |
a whole number (for example 15) | integer | dataTableGetInt (or dataTableGetFloat) |
| anything else | string | dataTableGetString |
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.
| Function | Returns | Description |
|---|---|---|
mota.dataTableHas(table) | boolean | true if a table with this name is loaded. Takes only the table name. |
mota.dataTableGetFloat(table, row, column [, default=0]) | number | Float value of the cell. |
mota.dataTableGetInt(table, row, column [, default=0]) | integer | Integer value of the cell. |
mota.dataTableGetBool(table, row, column [, default=false]) | boolean | Boolean value of the cell. |
mota.dataTableGetString(table, row, column [, default=""]) | string | String 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=""])
| Parameter | Type | Description |
|---|---|---|
name | string | Experiment name. |
default | string, optional | Returned 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.playSoundandmota.playSound3Dreturnfalsewithout 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
localvariables. OnDestroyruns 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.isKeyDowntakes an action name, likemota.isActionPressed; there is no raw key query.- Scripts cannot add or remove components other than through
mota.createEntity(which adds a Transform) andmota.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
| Function | Group |
|---|---|
mota.log(message) | Logging |
mota.createEntity() -> entity | Entities |
mota.isAlive(entity) -> boolean | Entities |
mota.destroyEntity(entity) | Entities |
mota.getPosition(entity) -> x, y, z | Transforms |
mota.setPosition(entity, x, y, z) | Transforms |
mota.getRotation(entity) -> x, y, z, w | Transforms |
mota.setRotation(entity, x, y, z, w) | Transforms |
mota.getScale(entity) -> x, y, z | Transforms |
mota.setScale(entity, x, y, z) | Transforms |
mota.raycast(ox, oy, oz, dx, dy, dz [, maxDistance]) -> hit, x, y, z, distance | Physics |
mota.getBody(entity) -> bodyID or nil | Physics |
mota.addForce(bodyID, fx, fy, fz) | Physics |
mota.addImpulse(bodyID, ix, iy, iz) | Physics |
mota.getVelocity(bodyID) -> vx, vy, vz | Physics |
mota.setVelocity(bodyID, vx, vy, vz) | Physics |
mota.isActionPressed(action) -> boolean | Input |
mota.isKeyDown(action) -> boolean | Input |
mota.getAxis(axis) -> number | Input |
mota.playSound(path [, volume]) -> boolean | Audio |
mota.playSound3D(path, x, y, z [, volume]) -> boolean | Audio |
mota.distance(x1, y1, z1, x2, y2, z2) -> number | Math |
mota.normalize(x, y, z) -> x, y, z | Math |
mota.lerp(a, b, t) -> number | Math |
mota.randomRange(min, max) -> number | Math |
mota.getTime() -> number | Time |
mota.dataTableHas(table) -> boolean | Data tables |
mota.dataTableGetFloat(table, row, column [, default]) -> number | Data tables |
mota.dataTableGetInt(table, row, column [, default]) -> integer | Data tables |
mota.dataTableGetBool(table, row, column [, default]) -> boolean | Data tables |
mota.dataTableGetString(table, row, column [, default]) -> string | Data tables |
mota.experimentVariant(name [, default]) -> string | Experiments |
