Repository navigation
Creating a Lua mod
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.
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.luain it is ignored (loggedskipped (no info.json)). - Any number of
.luafiles per folder; each is run. - Add an empty file named
.disabledin the folder to skip the whole mod (loggedskipped (.disabled)).
{
"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.
This feature is deprecated.
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
endThis 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.
The game runs Lua at load/setup time, not per frame, there is no OnTick
event. Two distinct surfaces:
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
endThe 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.)
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
endlocal 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 threadGuard 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))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
endWatch 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 missinginfo.json. -
skipped (.disabled): <path>— folder has a.disabledmarker. -
loaded N/M mod file(s)— N of M scripts ran clean. -
=== ready ===— load cycle complete (the memory patcher also keys off this).
| 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. |