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.
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
-- 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:
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:
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
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:
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
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_tableis 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.