Lua Scripting
Softfire’s scripting runtime is built on MoonSharp — a pure C# Lua 5.4 subset interpreter, MIT licensed. Scripts are .lua files bundled in a .sfpak mod archive, registered as hot-reloadable assets in the content pipeline.
The sandbox
Mod scripts start with an empty global environment. No standard Lua libraries (io, os, package, require, debug, coroutine) are pre-loaded. Access is opt-in per module, controlled by the permissions declared in the mod manifest.
This means a freshly loaded script has access to the Lua language itself (if, for, while, tables, closures, metatables) but no way to reach the file system, the network, or C# internals until the sandbox grants specific modules.
The sandbox enforces an instruction budget per call site. The default is 500,000 instructions per Update hook and 1,000,000 instructions for one-shot hooks (scene load, event handlers). Scripts that exceed the budget are interrupted and log a warning. Three consecutive budget overruns on an Update hook disable the script until the player re-enables it in the mod browser.
Available built-in modules
| Module | Permission required | What it provides |
|---|---|---|
Math | None | Lua standard math library |
String | None | Lua standard string library |
Entity | ReadEntityState | Read-only entity queries |
World | ReadWorldState | IWorldStateQuery read-only access |
Game | TriggerGameActions | Game.EmitAction() — triggers registered handlers |
Modules not in this list are not accessible from mod scripts, regardless of what permissions are declared.
Entity module
Entity provides read-only access to entity state. Write access is not exposed — scripts cannot directly modify component values.
-- Get the local player entitylocal player = Entity.GetLocalPlayer()
-- Read a component valuelocal health = Entity.GetComponent(player, "HealthComponent")
if health ~= nil then print("Current HP: " .. health.Current .. " / " .. health.Max)end
-- Get all entities with a tag in the current scenelocal enemies = Entity.GetByTag("enemy")for _, enemy in ipairs(enemies) do local t = Entity.GetComponent(enemy, "TransformComponent") print("Enemy at: " .. t.Position.X .. ", " .. t.Position.Y)endWorld module
World exposes a read-only view of IWorldStateQuery. Queries are synchronous and return cached values from WorldStateCache.
-- Get the current zone IDlocal zone = World.GetCurrentZoneId()
-- Get weatherlocal weather = World.GetWeather(zone)print("Weather: " .. weather.State)
-- Get populationlocal wolves = World.GetPopulation(zone, "wolf")print("Wolf population: " .. wolves.Current .. " (" .. wolves.Trend .. ")")Triggering game actions
Scripts interact with game logic via Game.EmitAction(). This is the sole bridge between Lua and C# game code. As a developer, you define which action names are valid and what each one does by implementing IScriptActionHandler.
From Lua:
-- Trigger the "flee" action for the local playerGame.EmitAction("flee", { direction = "north", speed = 1.5 })
-- Trigger a custom action defined by the gameGame.EmitAction("summon_companion", { type = "wolf" })From C# — registering an action handler:
using Softfire.Engine.Scripting;
public class FleeActionHandler : IScriptActionHandler{ public string ActionName => "flee";
private readonly IInputService _input; private readonly World _world;
public FleeActionHandler(IInputService input, World world) { _input = input; _world = world; }
public void Handle(EntityId callerEntity, ScriptActionParams parameters) { string direction = parameters.Get<string>("direction", defaultValue: "away"); float speed = parameters.Get<float>("speed", defaultValue: 1.0f);
// Apply a velocity override to the player entity ref var controller = ref _world.Get<PlatformerControllerComponent>(callerEntity); controller.OverrideVelocity = DirectionToVector(direction) * speed * 200f; controller.OverrideDuration = 0.5f; }}Register the handler in Initialize:
Services.Get<LuaScriptEngine>().RegisterActionHandler(new FleeActionHandler(input, world));Game.EmitAction() calls are synchronous within the script but dispatched to the C# handler asynchronously — the handler runs on the main game thread at the next Update, not inline in the script. Scripts cannot await the result.
A complete example script
This script reads the local player’s health and emits a flee action when health drops below 20%:
-- Script: scripts/survival_instinct.lua-- Permissions required: ReadEntityState, TriggerGameActions
local FLEE_THRESHOLD = 0.2local wasFleeing = false
function OnUpdate() local player = Entity.GetLocalPlayer() if player == nil then return end
local hp = Entity.GetComponent(player, "HealthComponent") if hp == nil then return end
local ratio = hp.Current / hp.Max
if ratio < FLEE_THRESHOLD and not wasFleeing then Game.EmitAction("flee", { direction = "away_from_nearest_enemy", speed = 1.8 }) wasFleeing = true elseif ratio >= FLEE_THRESHOLD then wasFleeing = false endendDeclare the hook in ModManifest.json:
{ "entryPoints": { "onUpdate": "scripts/survival_instinct.lua" }, "permissions": ["ReadEntityState", "TriggerGameActions"]}What scripts cannot do
The following are explicitly prohibited and enforced by the sandbox:
- File system access (
io,os.execute,loadfile) - Network access
- Reflection or direct C# type instantiation
- Access to
EchoService(requiresModifyWorldInfluencepermission, not available via Lua directly — must go through aTriggerGameActionshandler that you expose) - Spawning coroutines or OS threads
- Accessing the registry of other loaded mods
- Modifying the mod manifest at runtime