Script entity behaviors in Lua¶
This guide writes behaviors in Lua: scripts that move entities, react to the game's events
and create or destroy entities, so that a new pattern needs a script rather than a
recompilation. It runs the engine's own example, aegis2d_example_scripted, then explains
each binding a script has, the limits of the sandbox it runs in, and how to debug a script
that fails.
| Step | What you get |
|---|---|
| 1. Run the example | 60 squares moved by a script, sparks where they blow up |
| 2. Give the game a script state | ScriptState, bind_components(), EventBridge |
| 3. Write a behavior | start(), update(dt), components read and written by field |
| 4. React to events | events.on(), entities created and destroyed |
| 5. Know the sandbox | what scripts can use, and what stops them |
| 6. Debug a script | errors with their file and line, print, the common messages |
The module is aegis2d::script (see the Modules reference). Lua 5.4
and sol2 are built into it: a game links it like any module and never sees a Lua header.
1. Run the example¶
With the client preset built (see Getting started):
./build/client/examples/aegis2d_example_scripted
The C++ code creates 60 squares in a grid, standing still, and draws them: it never moves
them. assets/swarm.lua, next to the program, gives each a velocity, moves them, bounces
them on the edges and colors them by their direction. Every 1.5 s the game blows the
oldest square up; the script hears it and creates eight sparks there, which it fades and
destroys. Edit the copy of swarm.lua next to the program (the source is in
examples/scripted/assets/) and run it again: the swarm changes, with no recompilation.
2. Give the game a script state¶
A script::ScriptState is a Lua state for the game's scripts. bind_components() gives
its scripts the components of a registry, in a global table ecs, and an EventBridge
sends them the gameplay events of a bus (ecs::Explosion, ecs::LifeLost) while it
lives. The example sets them up once, after the registry and the bus:
core::EventBus events;
script::ScriptState lua;
script::bind_components(lua, registry);
const script::EventBridge bridge{lua, events};
if (lua.run_file(core::asset_path("swarm.lua")) && lua.call("start")) {
core::log_info("{} squares moved by swarm.lua", registry.size());
}
Then, each tick, it calls the script's update with the tick's duration in seconds:
lua.call("update", std::chrono::duration<double>{step}.count());
run(), run_file() and call() return false when the script fails: its error is
logged, and the game goes on (see Debug a script). Keep the registry
and the bus alive as long as the state uses them, and destroy the bridge before them; the
declaration order above does it. On a networked game, run the behaviors where the game is
simulated: the server.
script::emit(state, name, fields) sends the scripts any other event of the game, with its
fields as numbers ({.name = "wave", .value = 3}); whole numbers, such as entity ids, reach
Lua as integers.
3. Write a behavior¶
A script is Lua 5.4. The game calls the functions it defines; here start() once, then
update(dt) each tick:
-- Gives every square a speed, from its place in the grid.
function start()
for index, entity in ipairs(ecs.entities("Transform", "Sprite")) do
local angle = index * 0.7
ecs.add(entity, "Velocity", { x = math.cos(angle) * 220, y = math.sin(angle) * 220 })
end
end
-- Moves everything, bounces the squares on the edges, and colors them by their direction.
function update(dt)
for _, entity in ipairs(ecs.entities("Transform", "Velocity", "Sprite")) do
local transform = ecs.get(entity, "Transform")
local velocity = ecs.get(entity, "Velocity")
local sprite = ecs.get(entity, "Sprite")
transform.x = transform.x + velocity.x * dt
transform.y = transform.y + velocity.y * dt
if sprite.width > 8 then
bounce(transform, velocity, sprite)
end
end
end
Entities are numbers, the Entity::id() of the C++ code. A component is read and written
by field, vectors flattened (Transform.x, Sprite.width, Sprite.r):
ecs. |
Does |
|---|---|
entities("Transform", "Velocity") |
the entities that have all the components named, as an array |
get(entity, "Transform") |
the entity's component, or nil if it has none |
add(entity, "Velocity", {x = -120}) |
gives it a component, the fields from the table, the others at their defaults; keeps and updates one it has |
has(entity, "Sprite"), remove(entity, "Sprite") |
tells whether it has one, removes it |
create({Transform = {x = 10}, Sprite = {width = 6}}) |
a new entity with its components; create() for a bare one |
destroy(entity), alive(entity) |
destroys it, tells whether it lives |
| Component | Fields |
|---|---|
| Transform | x, y |
| Velocity | x, y |
| Sprite | texture, width, height, frame, layer, r, g, b, a |
| Animation | first, count, frame_time, loop |
| Collider | width, height, layer, mask |
| Health | points |
| Damage | amount, consumed_on_hit |
| Score | points |
| SineMotion | x, y (its amplitude), period |
A component a script holds is found again at each access: keep it in a local for the tick, not for longer. Once its entity is destroyed or the component removed, using it is an error.
4. React to events¶
events.on(name, function(event) ... end) calls the function for each event of that name,
with a table of its fields, and returns an id that events.off(id) takes to stop. Scripts
send events to each other with events.emit(name, {...}). The example creates sparks where
a square blows up, and destroys them once faded:
-- Sparks where a square blew up, which fade and are destroyed after half a second.
local sparks = {}
events.on("Explosion", function(event)
for i = 1, 8 do
local angle = i * math.pi / 4
local spark = ecs.create({
Transform = { x = event.x, y = event.y },
Velocity = { x = math.cos(angle) * 160, y = math.sin(angle) * 160 },
Sprite = { width = 6, height = 6, r = 1, g = 0.75, b = 0.2 },
})
sparks[spark] = 0.5
end
end)
local move_squares = update
function update(dt)
move_squares(dt)
for spark, left in pairs(sparks) do
left = left - dt
if left <= 0 then
ecs.destroy(spark)
sparks[spark] = nil
else
sparks[spark] = left
ecs.get(spark, "Sprite").a = left * 2
end
end
end
| Event | Fields |
|---|---|
Explosion (ecs::Explosion) |
entity, killer, x, y |
LifeLost (ecs::LifeLost) |
player, lives_left, score |
your own, through script::emit() |
the fields you give |
Listeners run in the order they were added. An error in one is logged once all have run, so the others still get the event.
5. Know the sandbox¶
Scripts run in a sandbox: a broken or hostile script never stops the game nor reaches outside it.
| Scripts have | Scripts do not have |
|---|---|
the base functions, coroutine, math, string, table, utf8 |
io, os, debug, package, require, dofile, loadfile |
load for text |
precompiled chunks, which can crash the virtual machine |
setmetatable |
finalizers (__gc), which Lua runs out of reach of the limits below |
collectgarbage(), "step", "count" |
stopping the collector |
print, to the engine's log |
Each run or call of a script, and each event, may take 10 million instructions
(set_instruction_limit()), each step of the pattern matcher (string.find, match,
gmatch, gsub) counting as one, and the whole state may use 64 MB
(set_memory_limit()). A script that takes more is stopped, wherever it is: a pcall in
the script does not catch the stop, and its coroutines share its budget.
6. Debug a script¶
Every error of a script is logged with its file and line, then kept in last_error()
(chunk, line, message):
2026-11-06T14:02:11.204Z [ERROR] script assets/swarm.lua:21: attempt to index a nil value (local 'transform')
| Message | Means |
|---|---|
attempt to index a nil value |
a get of a component the entity does not have, or a typo in a name |
no component named "Sprit" |
a component's name is wrong: see the table of step 3 |
Transform has no field "z" |
a field's name is wrong |
Sprite.layer takes a whole number |
a value of the wrong type for the field |
entity 42 is not alive |
the entity was destroyed |
the Transform of entity 42 is gone |
a component kept after its entity or itself went away |
too many instructions, stopped (an endless loop?) |
the line where the script was looping when its budget ran out |
finalizers (__gc) are not allowed in scripts |
a metatable with __gc |
not a function |
the game called a function the script does not define |
print(...) writes to the engine's log, prefixed with lua:. A script fails without
stopping the others: run the example with a typo in update, and the squares stop where
they are while the game runs on.
The whole example¶
-- Moves the swarm of aegis2d_example_scripted: the C++ code only creates the squares and
-- draws them, every move is made here. Edit and run the example again, no recompilation.
local width, height = 1280, 720
-- Gives every square a speed, from its place in the grid.
function start()
for index, entity in ipairs(ecs.entities("Transform", "Sprite")) do
local angle = index * 0.7
ecs.add(entity, "Velocity", { x = math.cos(angle) * 220, y = math.sin(angle) * 220 })
end
end
-- Moves everything, bounces the squares on the edges, and colors them by their direction.
function update(dt)
for _, entity in ipairs(ecs.entities("Transform", "Velocity", "Sprite")) do
local transform = ecs.get(entity, "Transform")
local velocity = ecs.get(entity, "Velocity")
local sprite = ecs.get(entity, "Sprite")
transform.x = transform.x + velocity.x * dt
transform.y = transform.y + velocity.y * dt
if sprite.width > 8 then
bounce(transform, velocity, sprite)
end
end
end
-- Keeps a square on screen and colors it by its direction.
function bounce(transform, velocity, sprite)
local half = sprite.width / 2
if transform.x < half or transform.x > width - half then
velocity.x = -velocity.x
transform.x = math.max(half, math.min(width - half, transform.x))
end
if transform.y < half or transform.y > height - half then
velocity.y = -velocity.y
transform.y = math.max(half, math.min(height - half, transform.y))
end
sprite.r = velocity.x > 0 and 0.95 or 0.35
sprite.g = velocity.y > 0 and 0.85 or 0.45
end
-- Sparks where a square blew up, which fade and are destroyed after half a second.
local sparks = {}
events.on("Explosion", function(event)
for i = 1, 8 do
local angle = i * math.pi / 4
local spark = ecs.create({
Transform = { x = event.x, y = event.y },
Velocity = { x = math.cos(angle) * 160, y = math.sin(angle) * 160 },
Sprite = { width = 6, height = 6, r = 1, g = 0.75, b = 0.2 },
})
sparks[spark] = 0.5
end
end)
local move_squares = update
function update(dt)
move_squares(dt)
for spark, left in pairs(sparks) do
left = left - dt
if left <= 0 then
ecs.destroy(spark)
sparks[spark] = nil
else
sparks[spark] = left
ecs.get(spark, "Sprite").a = left * 2
end
end
end
The C++ side is examples/scripted/main.cpp; the tests of the bindings,
tests/script/, show every binding and its errors.