Skip to content

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.