Scripting runtime (psychlua)
This page is about the engine side of scripting. If you are writing a mod, start at Modding overview instead.
The three hosts
| Host | Module | Flag | Backend |
|---|---|---|---|
| Lua | psychlua.FunkinLua | LUA_ALLOWED | hxluajit (LuaJIT) |
| Python | psychlua.PythonScript | PYTHON_ALLOWED | hython, a pure-Haxe interpreter |
| HScript | psychlua.HScript | HSCRIPT_ALLOWED | hscript-improved — standalone .hx/.hscript files and embedded in Lua |
FunkinLua was reduced from 3455 to ~807 lines by moving the standard library into
psychlua/callbacks/* (fifteen topic modules, each with a register(funk:FunkinLua)).
Python mirrors that layout in psychlua/pystdlib/* with register(py:PythonScript).
Registering a callback
// psychlua/callbacks/GameCallbacks.hx
FunkinLua.registerFunction("setHealth", function(value:Float) {
PlayState.instance.health = value;
});
registerFunction stores the function and installs it as a global in the VM.
PythonScript.registerFunction is the Python equivalent. Both the
Lua and Python
reference pages on this site are generated by scanning for exactly these calls, so a new
callback is documented as soon as it is registered.
psychlua.Convert
The marshalling bridge between Haxe values and the Lua stack (based on ideas from
hxluajit-wrapper, kept API-compatible with linc_luajit):
addCallback(state, name, func)/removeCallback(state, name)push and remove globals, tracking them in acallbacksmap.- Haxe → Lua and Lua → Haxe conversion for primitives, arrays, maps and objects.
- Direct-call fast paths for 0–8 arguments, avoiding array allocation on every call. This was a measured bottleneck: script-heavy charts call into Haxe thousands of times per second.
Control-flow sentinels
All hosts share three sentinel values:
| Constant | Meaning |
|---|---|
Function_Stop | cancel the engine's default behaviour for this hook |
Function_Continue | neutral; proceed normally |
Function_StopLua (Function_StopAll in PythonScript) | stop propagating to further scripts |
A hook returns one of these; play.helpers.PlayStateScripts interprets the result.
Hook dispatch
PlayState keeps luaArray, pythonArray and hscriptArray and broadcasts through
callOnScripts:
callOnScripts('onUpdate', [elapsed]); // Lua + Python + HScript, in that order
callOnLuas('onCreatePost', []); // compatibility alias, same broadcast
callOnHScript('onBeatHit', []); // standalone HScript scripts only
setOnScripts('curBeat', curBeat); // publish a variable to every script
setOnHScript('customSubstate', sub); // publish to HScript scripts only
Performance measures baked into the dispatcher:
- Missing-call caching — when a script does not define a hook, that fact is cached so the engine stops paying the lookup cost every frame.
- Fast-path variable splitting in
FunkinLuafor theobject.fieldsyntax used bygetProperty/setProperty. - HScript AST caching — parsed ASTs are cached instead of being re-parsed, which was the single biggest HScript cost.
HScript
One class, two modes. Standalone: HScript.fromFile(path, ?code) loads a
.hx/.hscript of its own — PlayState discovers them in the standard script
scopes (scripts/, data/<song>/, stages/, characters/, custom_notetypes/,
custom_events/), keeps them in hscriptArray, and drives them with the same
call/set/get/stop host interface and missing-call caching as Python. Each
script owns an Interp (native import support, public/static top-level
variables in separate buckets); the scope is seeded by seedScope() and the flat
engine globals by seedInitialGlobals(). PlayState.startHScriptOnFolder(path) /
removeHScript(path) resolve and load/unload at runtime (extension optional).
Embedded: HScript.initHaxeModule(parent) creates the interpreter lazily for a Lua
script that uses it; execute(code, func, args) and executeFunction(func, args) run
code; implement(funk) installs the runHaxeCode-style callbacks into the Lua host.
HScript no longer requires Lua at compile time — parentLua and every Lua access is
LUA_ALLOWED-guarded, so standalone HScript works at -DMODDING_LEVEL=0. With
hscriptPos defined (set by project.hxp when HSCRIPT_ALLOWED is on), errors carry
line numbers, and standalone files are parsed with their file path as origin.
Python
PythonScript mirrors the Lua host: call(func, args), set(variable, data),
get(variable), getVar(variable), stop() and pyTrace(text, ...) for on-screen
output. It keeps customFunctions and registeredFunctions maps and tracks
lastCalledScript the way Lua does. Because Hython is pure Haxe, no external Python
runtime is bundled or required.
Objects scripts can touch
| Type | Purpose |
|---|---|
psychlua.ModchartSprite | the sprite type created by makeLuaSprite |
psychlua.ModchartAnimateSprite | the FlxAnimate-backed variant |
psychlua.CustomSubstate | a script-defined substate (also loads data/states/<name>.hx) |
psychlua.HaxeScript | data/states/ script runner for scripted states (PlayState-scope standalone HScript is psychlua.HScript) |
psychlua.StateScripts | discovers/runs data/states/<Name>.hx on every beat state |
states.ModState | blank state whose UI is entirely the named state script |
states.substates.ModSubState | blank substate counterpart |
psychlua.DebugLuaText | on-screen debug output |
psychlua.LuaUtils | shared helpers (property resolution, colour parsing, tweens) |
psychlua.LuaFunction | wrapper for script-provided callbacks |
psychlua.EtternaFunctions | Etterna-style timing helpers |
psychlua.FlxAnimateFunctions | Animate atlas control |
Adding a callback
- Pick the right module in
psychlua/callbacks/(create one only for a genuinely new topic, and register it fromFunkinLua). - Register it with
FunkinLua.registerFunction("name", function(...) { ... }). - Add the Python twin in
psychlua/pystdlib/so both languages stay in parity. - Validate arguments and fail soft — a script error must not crash the song.
- Put a
//comment directly above the registration: the generator picks it up as the function's description in the API reference. - Regenerate the docs:
cd website && npm run gen:api.