Running a script

Your game is a Lua program. It can also run a second Lua script. This can be a level script, downloadable content, a tool, or a mod.

Do not use load() to run it in the main Lua state. It would share the same globals. An error in the second script could then stop the main script.

Natiny runs the second script in a separate Lua state. This guide starts with a small example. It then shows how to exchange values, call functions, update the script every frame, and handle errors.

How a second script works

Each script has its own globals, require, and standard library.

Both scripts use the same engine. They use the same windows, renderer, and physics world. If you pass a window handle to the second script, it can use that window.

TEXT
your game  ──create──▶  the script        two Lua states
     │                     │
     └──────── one engine ─┘           one window, one renderer

The scripts exchange copies of values. A table passed to the second script is a copy. A table returned to the main script is also a copy. Changing one copy does not change the other.

Write the second script

Create scripts/weather/main.lua next to the main main.lua file:

Lua
print("weather script loaded")

function on_frame(dt)
    -- nothing yet
end

This small file is enough to test loading.

Loading it

Create the script state, open a library, load the file, and run it:

Lua
local natiny = require("natiny")

local second = natiny.script.create()
natiny.script.open_std_safe(second)

local source = natiny.resource.load("scripts/weather/main.lua")
local status = natiny.script.run(second, source)
natiny.resource.destroy(source)

if status ~= natiny.script.OK then
    print("weather script: " .. natiny.script.get_error(second))
end

Run the game. The second Lua state prints weather script loaded.

If loading or running fails, get_error returns the file name, line number, and error message.

What those four lines did

create creates an empty Lua state. It also provides the engine modules included in the game. For example, the second script can use natiny.render when the game includes that module.

open_std_safe opens the common Lua libraries. It does not open io, os, debug, or the native library loader. Its require reads modules from mounted folders.

Use open_std when you need the full standard library and trust the script.

run takes a resource. The file can be a loose file or an asset in a pack. Destroy the resource after run; running it does not destroy it.

Pass data to the second script

Pass values from the main script by name with set:

Lua
natiny.script.set(second, "game.VERSION", 3)
natiny.script.set(second, "game.window", window)
natiny.script.set(second, "game.config", { rain = true, wind = 0.4 })

They become normal globals in the second script:

Lua
print(game.VERSION, game.config.wind)
natiny.window.bind(game.window, function()
    natiny.render.clear(natiny.color.from_hex("#121721"))
end)

game.window is the same handle used by the main script. The second script can use it because both scripts share the engine.

game.config is a copy. If the second script sets game.config.rain = false, the table in the main script does not change.

Call the second script

call runs a function defined by the second script:

Lua
local ok, message = natiny.script.call(second, "on_load", "winter")

if not ok then
    print("weather script on_load failed: " .. message)
end

The first return value is true when the call succeeds. The other return values come after it. On failure, the first value is false and the second value is the error message. This lets the main script handle the error.

Add this to scripts/weather/main.lua and run again:

Lua
function on_load(season)
    print("weather: preparing " .. season)
    return "ready"
end

Let the second script call the main script

Use the same set function to pass a function from the main script:

Lua
local rain = 0

natiny.script.set(second, "host.set_rain", function(amount)
    if type(amount) ~= "number" then
        error("set_rain: expected a number")
    end
    rain = math.max(0, math.min(1, amount))
    return rain
end)

It becomes a normal function in the second script:

Lua
local t = 0

function on_frame(dt)
    t = t + dt
    host.set_rain(math.sin(t) * 0.5 + 0.5)
end

A function is passed like any other value. The second script can call it by the name given to set. The call is synchronous. Its return values go back to the second script. An error from the function becomes a Lua error in the second script and can be caught with pcall.

The host table contains only the functions that you choose to provide.

Call the second script every frame

call searches for "on_frame" by name on every call. Avoid this repeated search for a function called every frame.

Read the function once with get. Then call the returned value with call_value:

Lua
local on_frame = natiny.script.get(second, "on_frame")

natiny.backend.loop(function(dt)
    if on_frame then
        local ok, err = natiny.script.call_value(second, on_frame, dt)
        if not ok then
            print("weather script stopped: " .. err)
            on_frame = nil          -- do not call a broken script every frame
        end
    end

    natiny.window.bind(window, function()
        natiny.render.clear(natiny.color.from_hex("#121721"))
    end)
    return true
end)

get returns nil when on_frame does not exist. This makes the callback optional. The returned value is the function itself. If the second script later changes on_frame, the saved value still refers to the old function.

Calling the returned function directly is also possible. A direct call raises errors in the main script. call_value returns false and an error message instead.

Read a table from the second script

The second script can also provide data:

Lua
-- in scripts/weather/main.lua
manifest = { name = "Winter", version = 2, needs = { "rain", "wind" } }

get returns a copy of the table. Nested tables are copied too:

Lua
local manifest = natiny.script.get(second, "manifest")

if manifest then
    print(manifest.name, manifest.version)
    for _, need in ipairs(manifest.needs) do
        print("needs: " .. need)
    end
end

Changing this copy does not change the table in the second script. Tables returned by call and tables passed to host functions use the same rule.

Update a table in the second script

Use a table reference when the main script must update a table that the second script reads. Create it with make_table, give it a name, and update it through the reference:

Lua
local state = natiny.script.make_table(second)
natiny.script.set(second, "state", state)

natiny.backend.loop(function(dt)
    natiny.script.table_set(second, state, "wind", current_wind)
    -- ...
end)

table_set writes through the reference, so the second script sees the new state.wind at once.

Lua collects the reference when it is no longer used.

End the second script

Lua
natiny.script.call(second, "on_unload")
natiny.script.destroy(second)

destroy closes the Lua state and releases its values. Do not use its references after this call.

It does not destroy engine objects. Windows, meshes, and bodies belong to the engine. If the second script creates engine objects, let on_unload destroy them before destroying the script.

The second script cannot destroy itself. The main script must destroy it.

Common problems

require cannot find a file. require searches mounted folders. It does not search relative to the current script file. Mount the folder before running the script:

Lua
natiny.resource.mount("scripts/weather")
-- now the script's own require("rain") finds scripts/weather/rain.lua

attempt to index a nil value (global 'string'). A new script has no standard library. Call open_std or open_std_safe before run.

A table change is not visible in the other script. Normal tables are copied. Use make_table and table_set when both scripts need the same table.

The second script hangs the game. open_std_safe does not stop an endless loop such as while true do end. Do not run code that you do not trust in the same process.

The whole thing

Lua
local natiny = require("natiny")

assert(natiny.backend.init())
local window = natiny.window.create("Weather", 960, 540)

-- Load the second script ---------------------------------------------------
natiny.resource.mount("scripts/weather")

local second = natiny.script.create()
natiny.script.open_std_safe(second)

local source = natiny.resource.load("main.lua")
local status = natiny.script.run(second, source)
natiny.resource.destroy(source)

if status ~= natiny.script.OK then
    print("weather script: " .. natiny.script.get_error(second))
    natiny.script.destroy(second)
    second = nil
end

-- What the second script may see and do ------------------------------------
local rain = 0

if second then
    natiny.script.set(second, "game.VERSION", 3)
    natiny.script.set(second, "game.window", window)
    natiny.script.set(second, "game.config", { rain = true, wind = 0.4 })

    natiny.script.set(second, "host.set_rain", function(amount)
        if type(amount) ~= "number" then
            error("set_rain: expected a number")
        end
        rain = math.max(0, math.min(1, amount))
        return rain
    end)

    natiny.script.call(second, "on_load", "winter")
end

local on_frame = second and natiny.script.get(second, "on_frame")

-- Run -------------------------------------------------------------------
natiny.backend.loop(function(dt)
    if on_frame then
        local ok, err = natiny.script.call_value(second, on_frame, dt)
        if not ok then
            print("weather script stopped: " .. err)
            on_frame = nil
        end
    end

    natiny.window.bind(window, function()
        natiny.render.clear(natiny.color.from_hex("#121721"))
        natiny.render.set_color(natiny.color.from_hex("#A8C238"))
        natiny.render.rectangle(40, 40, 200 * rain + 20, 20)
    end)
    return true
end)

if second then
    natiny.script.call(second, "on_unload")
    natiny.script.destroy(second)
end
natiny.backend.shutdown()

Summary

The main points are:

  • Two states, one engine. Globals are private, handles are shared.
  • Everything is a value, and values are copied both ways. make_table is how you ask for a table both sides share.

The same API is available in C with the natiny_script_ prefix. Switch this page to Native for the C version. See the script API reference for each function.