Scripting
Mods are scripted in Lua 5.2, run by MoonSharp inside a sandbox. If you know any Lua, you know most of this. This page covers what is specific to Project Avion.
What is available
| Available | Not available |
|---|---|
string, table, math, bit32, coroutine | io, os.execute, os.remove, os.exit |
pairs, ipairs, select, type, tostring, tonumber, setmetatable, pcall, error, assert | load, loadstring, dofile, loadfile |
os.time, os.clock, os.date | debug, package, getfenv, setfenv, string.dump |
print (writes to the mod log) | collectgarbage other than "count" |
require for your own files | anything from the game not in the reference |
string.rep refuses results larger than 16 MB. Every API module is a global table: ui, world, net and so on. Modules that don't apply to a side are nil there. Test with if ui then … end in shared code, or check mod.client and mod.server (also mod.isClient() and mod.isServer()).
Each mod has its own globals. Two mods cannot see or overwrite each other's variables, even by changing string or math. To share code with other mods, use exports.
Modules and require
local util = require("util.math") -- util/math.lua, util/math/init.lua, scripts/util/math.lua or scripts/util/math/init.lua
local data = require("scripts/data.lua") -- an exact path from the mod rootA module runs once, and later requires return the same value (whatever the file returns, or true). Circular requires are reported with the chain that caused them.
To read files that aren't Lua, use mod.readText("data/names.txt") and mod.readJson("data/waves.json"). mod.list("maps/*.json") lists files and mod.exists(path) checks for one.
Values: vec3, quat, color
Positions, rotations and colours are values with operators:
local a = vec3(1, 2, 3)
local b = a + vec3.up() * 2 -- + - * / and unary -
print(a:length(), a:distance(b), a:normalized(), a:dot(b), a:cross(b), vec3.lerp(a, b, 0.5))
local q = quat.euler(0, 90, 0) -- degrees; also quat.angleAxis(deg, axis), quat.lookRotation(forward, up)
local turned = q * vec3.forward() -- rotate a vector
local both = q * quat.euler(10, 0, 0)
local c = color("#ff8800") -- also color(r, g, b, a) in 0..1, color.rgb255(255, 136, 0)
local faded = c:withAlpha(0.5)
print(c:hex(), color.lerp(c, color.white(), 0.3))Helpers: vec3.zero/one/up/down/forward/back/right/left(), v:sqrLength(), v:copy(), v:unpack(), quat.identity(), q:inverse(), q:euler(), color.black/white/clear(), math.clamp, math.lerp, math.round(v, decimals), math.sign, table.keys/values/contains/count/shallowCopy/deepCopy, string.split(s, sep), string.trim(s).
Positions are world positions that mean the same place on every player's machine. The game moves its origin as you travel, but the API hides this. A position you get is always safe to send to another player or store.
Handles
Functions return handles for things in the game: a Block, an Object your mod created, a Sound, a Player, a UiElement. Read fields with a dot and call methods with a colon:
local ball = world.shape("sphere", { position = vec3(0, 3, 0) })
ball.position = ball.position + vec3(0, 1, 0)
ball:setColor(color("#40c0ff"))A handle stays valid until the thing it refers to is gone: a block removed, a player who left, an object you destroyed. Using it afterwards raises an error. Check first when something else may have removed it:
if block:isValid() then log.info(block.position) endHandles to other mods' objects can be read but not changed. Handles compare equal when they refer to the same thing, so players.host() == player works.
Events
events.on("player.joined", function(player) log.info(player.name, "joined") end)
local sub = events.on("wave-started", function(data) spawnWave(data.number) end)
events.emit("wave-started", { number = 3 }) -- your own event: named freely, scoped to your mod
sub:off()- Game events such as
player.joined,map.loadedandui.screen.opened, plus everygame.*event, are listed on the events list. Mods can listen to them but never raise them. - Your events are automatically prefixed with your mod id, so two mods can both have
"wave-started". - Other mods' events:
events.on("their.mod-id:wave-started", fn). - Handlers run after the current function returns, never in the middle of your code.
sub:off()takes effect immediately, even for an event already on its way.
Timers and tasks
timer.after(2, function() log.info("two seconds later") end)
local blink = timer.every(0.5, function() lamp.visible = not lamp.visible end)
blink:cancel()
task.spawn(function()
for wave = 1, 10 do
spawnWave(wave)
task.wait(30) -- pause this task, not the game
end
end)timer.every(0, fn) runs every frame. A task is a coroutine the game resumes each frame. It may call task.wait(seconds), and a long loop in a task is paused automatically when it has used its share of the frame, so heavy work belongs in tasks. time.now() counts seconds since start in real time (it keeps running while the game is paused), time.delta() is the last frame's length and time.unix() is the wall clock.
Errors and time budgets
An error in a callback is logged with the file, line and a Lua stack trace, and the mod keeps running. The next event calls your handler again. Errors while the entry script loads stop the mod, since it is only half set up.
To keep the game smooth, every call from the game into your script has a budget:
| Limit | Value | What happens |
|---|---|---|
| Slow call warning | 8 ms | Logged with the callback's name: "timer took 12.3 ms" |
| Hard limit per call | 250 ms | The call is stopped with an error. An endless loop cannot freeze the game. |
| Loading the entry script | 3 s | Same, for startup |
| Task slice | 20 000 instructions, 4 ms per frame across tasks | The task pauses until next frame |
| Strikes | 5 stopped or refused calls within 60 s | The mod is stopped for the session and Mods shows why |
Use pcall for things that may fail, such as JSON from a website or a block that may have been removed:
local ok, data = pcall(json.decode, text)
if not ok then log.warn("bad data:", data) endCalls the game may refuse for an ordinary reason have a try form that returns false, reason instead of raising — building.tryMoveInto, Block:trySet, maps.tryLoad, maps.trySetWater, maps.tryResetWater, planets.trySetWeather. Wrong arguments, a missing permission or the wrong side still raise, because those are mistakes in the script:
local ok, err = block:trySet("Force", 2500000)
if not ok then notify.toast(err) endStrict names
The API is strict about names. Reading a field that does not exist — on a handle (block.positon) or a module (ui.pannel) — is an error that names the nearest real one: Block has no field 'positon' (did you mean 'position'?). A new option field a function does not know is ignored with one warning in your log (ui.panel argument #1: unknown field 'widht' is ignored (did you mean 'width'?)). Option tables accept camelCase and snake_case alike (showInputField or show_input_field).
A module that does not exist in a script — ui in a server script, net in the main menu — is a nil global, so if not ui then return end keeps working, and an accidental use explains itself in the log: attempt to index a nil value — ui is only available in client scripts; test if ui then first. mod.whyUnavailable("ui") returns that reason. To test for a function a newer game may add, use mod.has("building.onSelectionChanged") — not if building.onSelectionChanged then, which raises.
Renamed functions keep working under their old names for a while, with one "deprecated" warning per mod.
Trying code while the game runs
You don't have to reload a mod to find out what a line does. In the script editor, select some lines and press Ctrl+Enter to run them inside your mod, or type into its Console tab. The code runs with your mod's globals and permissions, on the client or the server copy, and prints its result. /lua your.mod-id <code> in the developer console does the same. Only the host can run code this way.
Logging
log.debug/info/warn/error(...) and print(...) write to your mod's log, which appears in Mods → your mod under Recent messages, in the script editor's Log tab, in the developer console (/mods.log your.mod-id) and in the game's log file. Logging is rate-limited to 120 lines a second per mod.
Storage and settings
storage.set(key, value) and storage.get(key, fallback) persist data between sessions (4 MB per side). mod.settings.get(key) reads the settings declared in mod.json. See Settings and storage.
Sharing with other mods
A mod can publish functions and data:
-- library mod "someone.shared-lib"
mod.export({ version = 2, round = function(v, step) return math.floor(v / step + 0.5) * step end })-- a mod that lists "someone.shared-lib" in dependencies
local lib = mods.get("someone.shared-lib")
if lib then log.info(lib.round(3.7, 0.5)) endExported functions run inside the mod that exported them, with its permissions, and arguments and results are copied. One mod cannot hand another its handles or reach its globals.
Cleaning up
You rarely need to. Everything created through the API (objects, effects, sounds, panels, changes to game screens, timers, tasks, subscriptions, key bindings, console commands, raised limits, registered content) is removed when the mod stops. Use mod.onUnload(fn) for your own bookkeeping, such as saving to storage.