Skip to content

Creating a Lua mod

takattowo edited this page Jun 1, 2026 · 1 revision

Lua Script modding guide

Lua mods run inside the game's own Lua engine (Lua 5.0.2). The loader scans a mod folder at the start of every match and runs your scripts. Use them to print to the log, pop dialogs, read game state, and wrap the engine's action functions to change behavior.

Scope is client-side. Cosmetic, UI, camera, local-experience and the AI are moddable. Server-authoritative state (stats, match results, currency, validated moves) is not. Test in offline/practice first.


1. Folder layout

Each mod is a folder under the game's Data\Addons\LUA\ directory. Scanning is recursive, so subfolders are fine:

Data\Addons\LUA\
  my_mod\
    info.json          (required - marks a valid mod)
    init.lua           (your script; any *.lua name works, multiple allowed)

Rules:

  • The folder must contain info.json, or every .lua in it is ignored (logged skipped (no info.json)).
  • Any number of .lua files per folder; each is run.
  • Add an empty file named .disabled in the folder to skip the whole mod (logged skipped (.disabled)).

info.json

{
  "visible": true,
  "name": "my_mod",
  "type": "lua",
  "version": "1.0",
  "description": "What this mod does.",
  "author": "you"
}

The loader only checks that the file exists — its contents mark the folder as a valid mod. Keep it well-formed for the patcher UI.

Manifest fallback

This feature is deprecated.


2. When mods run (reload model)

There is no hot-reload tick and no manual reload. The game re-runs the loader bootstrap on every match start, so the loader re-scans and re-runs all mods each time. That gives you reload-on-match-start for free:

Edit your .lua, start a match, your change is live.

The loader logs the cycle:

=== load (clock=...) ===
loaded: Data\Addons\LUA\my_mod\init.lua
loaded 1/1 mod file(s)
=== ready ===

Critical consequence: your script may be re-run in the same Lua state (globals persist between matches). So any global monkeypatch must be guarded, or you will wrap your own wrapper and stack it every match:

if type(SetCameraTarget) == "function" and not _G.__mymod_wrapped then
    _G.__mymod_wrapped = true
    local orig = SetCameraTarget
    SetCameraTarget = function(...)
        print("[MOD] SetCameraTarget called")
        return orig(unpack(arg))   -- 5.0: varargs come in via `arg`
    end
end

3. The runtime (Lua 5.0.2 - read this before writing code)

This is Lua 5.0.2, not modern Lua, and it is a stripped build. Write for it:

Modern Lua This build
#t length operator Not available. Use string.len(s); track array lengths yourself.
table library Absent. No table.insert / table.getn. Manage tables by hand.
string.gmatch Use string.gfind.
load(s) Use loadstring(s)().
function f(...) then ... Varargs arrive in the arg table: arg.n, arg[1], unpack(arg).
integers All numbers are doubles.
a % b math.mod(a, b).

What is available and unsandboxed: io (full filesystem), os.execute, os.clock, loadstring, dofile, pcall, debug (sethook/getinfo), string.dump, pairs/ipairs, _G.

What is blocked / absent: io.popen (compiled-out stub — errors at runtime), load, package. Use os.execute redirected to a temp file instead of io.popen.


4. What you can actually change

The game runs Lua at load/setup time, not per frame, there is no OnTick event. Two distinct surfaces:

a) Engine action primitives (survive reliably)

Functions like Shoot, MoveToMark, KeepDistance, PassToCharacter, MoveAndDunk, DoDefenceAction, Out_Steal, SetCameraTarget are boot-time globals. The game's own scripts call them but never redefine them, so a wrapper you install in your mod stays installed across the later script loads. Wrapping these is the reliable way to change behavior.

-- Wrap a primitive: call original, add your behavior.
if type(MoveToMark) == "function" and not _G.__steal_wrapped then
    _G.__steal_wrapped = true
    local origMove = MoveToMark
    MoveToMark = function(ID)
        origMove(ID)                       -- keep original behavior
        if type(Out_Steal) == "function" then
            pcall(Out_Steal, ID)           -- add yours, guarded
        end
    end
end

b) Game script functions (get clobbered, so avoid)

The decision-threshold and handler functions defined in the game's own scripts (*_update AI handlers, TARGET_DIST_*, SCORE_*, camera getters like Get_Camera_Default_FOV) live in camera.lub / practiceAI.lub / GameInvironment.lub, which load after your mod every match and overwrite anything you set. Pre-overriding them in a mod does not stick. (Editing those .lub files directly would work but is out of scope — keep game files untouched.)


5. Recipes

Print to the log

print("[MOD:hello] loaded at clock=" .. os.clock())

print goes to the console; the loader also tees its own lines to lualoader.log at the game root. Mods commonly append there too:

local function log(s)
    local f = io.open("lualoader.log", "a")
    if f then f:write("[MOD:hello] " .. s .. "\n") f:close() end
end

Pop a native dialog (no winapi, shell out)

local cmd = [[start "" powershell -NoProfile -WindowStyle Hidden -Command "Add-Type -AssemblyName System.Windows.Forms;[void][System.Windows.Forms.MessageBox]::Show('Hello from a FreeStyle2 Lua mod!','FreeStyle2 Mod')"]]
pcall(os.execute, cmd)   -- `start ""` = detached, won't block the game thread

Call a primitive whose signature you don't know

Guard with pcall and log the result so you can confirm it accepts your args:

local ok, ret = pcall(Out_Steal, ID)
log("Out_Steal ok=" .. tostring(ok) .. " ret=" .. tostring(ret))

Bail out early

A bare return at file top level is fine — use it to skip when a prerequisite is missing:

if type(MoveToMark) ~= "function" then
    log("MoveToMark not present; skipping")
    return
end

6. Reading the log

Watch lualoader.log (game root) or the live console:

  • === load (clock=...) === — a match started; loader is re-scanning.
  • loaded: <path> — that script ran without error.
  • ERROR <path> : <msg> — your script threw; <msg> is the Lua error.
  • skipped (no info.json): <path> — folder is missing info.json.
  • skipped (.disabled): <path> — folder has a .disabled marker.
  • loaded N/M mod file(s) — N of M scripts ran clean.
  • === ready === — load cycle complete (the memory patcher also keys off this).

7. Troubleshooting

Symptom Likely cause / fix
Mod never loads Folder missing info.json, or it's not under Data\Addons\LUA\.
skipped (.disabled) Remove the .disabled file from the folder.
ERROR ... attempt to call a nil value Used a modern API. No #, no table.*, use string.gfind / loadstring / arg.
popen' not supported io.popen is blocked. Use os.execute to a temp file.
Wrapper stacks / fires multiple times Missing the _G.__name_wrapped guard; the bootstrap re-runs your mod each match.
Behavior override never fires You redefined a game-script function (clobbered after load). Wrap an engine primitive instead.
Behavior works nowhere You're not in practice-with-1-AI. Other modes run no Lua.
Override applied but disconnects online It's a server-validated action. Client can't change it.
Scripts not enumerated dir/os.execute blocked. Add a mods.txt manifest.