Lua scripting (design)

Status: first cut. The interpreter (src/OpenPax.Lua), the event, step and tick hooks, file reading, and save handling across mod changes are built and tested; the battle and rating hooks are planned (see the end). Bounty ships in both forms, samples/Bounty (C#) and samples/BountyLua (Lua), and does the same in each.

Why

C# mods need .NET and a build. A Lua mod is a folder of text files: write main.lua in any editor, turn the mod on, start a game. Lua is the language most game players who mod have met, small enough to learn in an afternoon, and easy to sandbox.

The shape

A Lua mod is an ordinary mod (manifest, optional data pack) whose mod.toml names a script instead of an assembly:

id = "bounty-lua"
name = "Bounty (Lua)"
version = "1.0"
script = "main.lua"

The script runs once when the mods load, and says what it changes by calling the mod table's hooks, as a C# mod does from IMod.Load. It uses the same hooks underneath (ModContext), so a Lua mod has the same place in the game as a C# one: the same load order, the same mod set check in network games, the same saved state.

The interpreter

Our own, in src/OpenPax.Lua, written for the game (no NuGet package, no native library). It reads Lua 5.4 source and runs it by turning each function into C# closures once, when the script loads.

The language as Lua 5.4 has it, except:

Safe and the same everywhere

A mod's script runs in every network game on the host, and in every replay; it must come out the same on every machine (rules.md). So the interpreter leaves out or replaces whatever could differ or reach outside the game:

The API

The script sees the standard library above, plus:

mod

MemberWhat it does
mod.id, mod.versionfrom mod.toml
mod.log(text)a line for the player when the game starts, and the console
mod.read_text(file)a text file of the mod's folder, or nil
mod.read_toml(file)a TOML file of the mod's folder as a table (empty when missing)
require(name)runs name.lua from the mod's folder once and gives back what it returns
mod.on_new_game(f)f(game) once on a new game, before its first step
mod.on_load(f)f(game, saved_version) when a saved game is loaded; see Saves
mod.on_step(f)f(game) after every step
mod.every(ticks, f)f(game) after every step whose tick is a multiple of ticks
mod.on_tech_learned(f)f(game, player, tech)
mod.on_ship_built(f)f(game, ship, planet)
mod.on_ship_destroyed(f)f(game, ship, owner, by); by is nil when no one did it
mod.on_colony_founded(f)f(game, player, planet)
mod.on_colony_changed_hands(f)f(game, new_owner, old_owner, planet)
mod.on_colony_destroyed(f)f(game, player, planet)
mod.on_treaty_changed(f)f(game, from, toward, slot, value)
mod.on_player_eliminated(f)f(game, player)

Files are named relative to the mod's folder, with / between folders. A name that leaves the folder (.., an absolute path) is refused. Pictures and sounds belong in the mod's data pack (data-packs.md), which the game loads itself.

game

MemberWhat it is
game.tickthe game's step count
game.playersthe players, a list from 1
game:player(index)a player by the game's index (from 0)
game.storethe mod's saved values: game.store:get(key), game.store:set(key, value) with text, numbers or booleans (nil removes), game.store:remove(key); a value comes back as what it reads as ("7" as 7, "true" as true)
game:tell(player, text)a message in that player's history
game:give_money(player, credits)adds (or with a negative amount, takes) credits

Players, ships, planets, techs

Read-only views of the game's objects: a player's index, name, money, human, eliminated; a ship's name, type (hull type), structure, max_structure; a planet's name and class; a tech's id and kind. Two views of the same object are equal (==). Changing the game goes through the hooks and game's functions, so the API can grow without letting a script break the game's own bookkeeping.

Saves across mod changes

A game records the mods it plays with, id and version, and each mod keeps its state in the game's mod state under its own id, which is saved. Loading a save then goes:

A mod's script itself is not saved: it runs again when the mods load, and anything a hook needs later must be in game.store, not in a script's variables (as rules.md says for C# fields).

One limit stays: a mod that changes the game tables (new techs, hulls, as Arsenal does) is part of what a save is made of, and a save made with it may not load without it. Naming the missing mod when that happens is planned.

A whole mod

samples/BountyLua is Bounty (samples/Bounty) in Lua, and ships with the game as "Bounty (Lua)":

local settings = mod.read_toml("settings.toml")
local per_hull = settings.credits_per_hull or 50
local double_chance = settings.double_chance or 0.1

mod.on_ship_destroyed(function(game, ship, owner, by)
  if by == nil or by == owner then
    return
  end

  local bonus = math.random() < double_chance
  local credits = per_hull * (ship.type + 1) * (bonus and 2 or 1)
  game:give_money(by, credits)

  local key = "kills." .. by.index
  local kills = (game.store:get(key) or 0) + 1
  game.store:set(key, kills)
  game:tell(by, string.format("Bounty: %s credits (%d kills)", credits, kills))
end)

Turn it on in Options, MODS, and start a game.

Planned next