Skip to content

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

ModulePermission requiredWhat it provides
MathNoneLua standard math library
StringNoneLua standard string library
EntityReadEntityStateRead-only entity queries
WorldReadWorldStateIWorldStateQuery read-only access
GameTriggerGameActionsGame.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 entity
local player = Entity.GetLocalPlayer()
-- Read a component value
local 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 scene
local 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)
end

World module

World exposes a read-only view of IWorldStateQuery. Queries are synchronous and return cached values from WorldStateCache.

-- Get the current zone ID
local zone = World.GetCurrentZoneId()
-- Get weather
local weather = World.GetWeather(zone)
print("Weather: " .. weather.State)
-- Get population
local 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 player
Game.EmitAction("flee", { direction = "north", speed = 1.5 })
-- Trigger a custom action defined by the game
Game.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.2
local 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
end
end

Declare 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 (requires ModifyWorldInfluence permission, not available via Lua directly — must go through a TriggerGameActions handler that you expose)
  • Spawning coroutines or OS threads
  • Accessing the registry of other loaded mods
  • Modifying the mod manifest at runtime