Coming soon toSteam
Modding docsAPI 1

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

AvailableNot available
string, table, math, bit32, coroutineio, os.execute, os.remove, os.exit
pairs, ipairs, select, type, tostring, tonumber, setmetatable, pcall, error, assertload, loadstring, dofile, loadfile
os.time, os.clock, os.datedebug, package, getfenv, setfenv, string.dump
print (writes to the mod log)collectgarbage other than "count"
require for your own filesanything 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 root

A 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) end

Handles 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()

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:

LimitValueWhat happens
Slow call warning8 msLogged with the callback's name: "timer took 12.3 ms"
Hard limit per call250 msThe call is stopped with an error. An endless loop cannot freeze the game.
Loading the entry script3 sSame, for startup
Task slice20 000 instructions, 4 ms per frame across tasksThe task pauses until next frame
Strikes5 stopped or refused calls within 60 sThe 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) end

Calls 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) end

Strict 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)) end

Exported 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.