Skip to main content

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​

HostModuleFlagBackend
Luapsychlua.FunkinLuaLUA_ALLOWEDhxluajit (LuaJIT)
Pythonpsychlua.PythonScriptPYTHON_ALLOWEDhython, a pure-Haxe interpreter
HScriptpsychlua.HScriptHSCRIPT_ALLOWEDhscript-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 a callbacks map.
  • 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:

ConstantMeaning
Function_Stopcancel the engine's default behaviour for this hook
Function_Continueneutral; 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 FunkinLua for the object.field syntax used by getProperty/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​

TypePurpose
psychlua.ModchartSpritethe sprite type created by makeLuaSprite
psychlua.ModchartAnimateSpritethe FlxAnimate-backed variant
psychlua.CustomSubstatea script-defined substate (also loads data/states/<name>.hx)
psychlua.HaxeScriptdata/states/ script runner for scripted states (PlayState-scope standalone HScript is psychlua.HScript)
psychlua.StateScriptsdiscovers/runs data/states/<Name>.hx on every beat state
states.ModStateblank state whose UI is entirely the named state script
states.substates.ModSubStateblank substate counterpart
psychlua.DebugLuaTexton-screen debug output
psychlua.LuaUtilsshared helpers (property resolution, colour parsing, tweens)
psychlua.LuaFunctionwrapper for script-provided callbacks
psychlua.EtternaFunctionsEtterna-style timing helpers
psychlua.FlxAnimateFunctionsAnimate atlas control

Adding a callback​

  1. Pick the right module in psychlua/callbacks/ (create one only for a genuinely new topic, and register it from FunkinLua).
  2. Register it with FunkinLua.registerFunction("name", function(...) { ... }).
  3. Add the Python twin in psychlua/pystdlib/ so both languages stay in parity.
  4. Validate arguments and fail soft — a script error must not crash the song.
  5. Put a // comment directly above the registration: the generator picks it up as the function's description in the API reference.
  6. Regenerate the docs: cd website && npm run gen:api.