Skip to main content

Lua scripting

Lua scripts need a build with LUA_ALLOWED (-DMODDING_LEVEL=1 or 2; desktop and mobile default to 2). The VM is LuaJIT through hxluajit.

Where scripts go​

LocationLoaded
mods/<mod>/scripts/*.luafor every song (global scripts)
mods/scripts/*.luasame, for loose files not in a mod
assets/preload/scripts/*.luaengine-level global scripts
mods/<mod>/data/<song>/<song>.luawhen that song plays
mods/<mod>/stages/<stage>.luawhen that stage loads
mods/<mod>/custom_notetypes/<type>.luawhen the chart uses that notetype
mods/<mod>/custom_events/<event>.luawhen the chart uses that event

Global scripts are deduplicated by file name; the first match in priority order (global mods → current mod → loose mods/ → preload) wins. Note the direction: an enabled global mod shadows a same-named script in the active mod, not the other way round. (Asset lookup uses the opposite order — the active mod wins there.)

A script can also be loaded on demand from another script with addLuaScript(path, ignoreAlreadyRunning) and removed with removeLuaScript(path).

Softmod sandbox

Lua files under mods/ are source-scanned before they run, and dangerous standard libraries/callbacks (OS, IO, package loading, native/process/network/file access) are blocked at runtime by default. A finding stops that script and logs its mod, file and line. See Script security sandbox, including narrowly scoped pack.json allowlists for trusted mods.

A minimal script​

function onCreate()
-- the earliest hook; many PlayState variables do not exist yet
end

function onCreatePost()
-- everything is loaded — do your setup here
makeLuaSprite('myBg', 'myImage', 0, 0)
setObjectCamera('myBg', 'camGame')
addLuaSprite('myBg', false)
end

function onUpdate(elapsed)
end

function onBeatHit()
end

function onDestroy()
end

docs/TemplateScript.lua contains the fully commented template with every hook.

Variables the engine publishes​

Before each call, the engine pushes a set of globals into the VM (setOnScripts keeps them current). The most used:

GroupVariables
TimingcurBeat, curStep, curDecBeat, curDecStep, crochet, stepCrochet, bpm, curBpm, songLength, playbackRate
SongsongName, songPath, difficulty, difficultyName, difficultyPath, week, weekRaw, isStoryMode, seenCutscene, startedCountdown
Chart statemustHitSection, gfSection, altAnim, scrollSpeed, noteOffset, curStage
CharactersboyfriendName, dadName, gfName, defaultBoyfriendX/Y, defaultOpponentX/Y, defaultGirlfriendX/Y
StrumsdefaultPlayerStrumX/Y, defaultOpponentStrumX/Y
Scorescore, misses, hits, rating, ratingName, ratingFC
CameracameraX, cameraY, cameraZoomOnBeat
Preferencesdownscroll, middlescroll, ghostTapping, hideHud, flashingLights, lowQuality, scoreZoom, healthBarAlpha, noteSkin, splashSkin, timeBarType, framerate, shadersEnabled
Modifierspractice, botPlay, instakillOnMiss, healthGainMult, healthLossMult, noResetButton, npsSpeedMult, polyphonyBF, polyphonyOppo
EnvironmentbuildTarget, version, jsVersion, phoenixVersion, screenWidth, screenHeight, scriptName, modFolder, currentModDirectory, user_path, inChartEditor, inGameOver, luaDebugMode, luaDeprecatedWarnings
Control flowFunction_Stop, Function_Continue, Function_StopLua

Anything not in that list is reachable with getProperty / setProperty:

local health = getProperty('health')
setProperty('health', health + 0.2)
setPropertyFromGroup('notes', id, 'multAlpha', 0.5)
setPropertyFromClass('backend.ClientPrefs', 'downScroll', true)

Control flow​

Hooks that can cancel engine behaviour check your return value:

function onStartCountdown()
startDialogue('myDialogue', 'myMusic')
return Function_Stop -- suppress the countdown; call startCountdown() later
end
ReturnMeaning
Function_Continue (or nothing)proceed normally
Function_Stopcancel the engine's default behaviour
Function_StopLuastop passing this hook to further Lua scripts

Talking to other scripts​

setGlobalFromScript('otherScript.lua', 'myVar', 10)
local v = getGlobalFromScript('otherScript.lua', 'myVar')
callScript('otherScript.lua', 'myFunction', {1, 2, 3})
callOnLuas('customHook', {})
getRunningScripts()

Common patterns​

Sprites

makeAnimatedLuaSprite('boombox', 'boombox', 100, 200)
addAnimationByPrefix('boombox', 'bop', 'boombox bop', 24, false)
addLuaSprite('boombox', true) -- true = in front of characters
objectPlayAnimation('boombox', 'bop', true)

Tweens and timers

doTweenX('moveIt', 'boombox', 500, 1.5, 'quadOut')
runTimer('later', 2, 1)

function onTweenCompleted(tag) if tag == 'moveIt' then ... end end
function onTimerCompleted(tag, loops, loopsLeft) if tag == 'later' then ... end end

Per-mod save data

initSaveData('MyMod')
setDataFromSave('MyMod', 'timesPlayed', getDataFromSave('MyMod', 'timesPlayed') + 1)
flushSaveData('MyMod')

Debugging

debugPrint('value is', value) -- draws on screen via DebugLuaText

Performance notes​

The engine optimises the script bridge aggressively — missing hooks are cached, calls with up to 8 arguments skip array allocation, and variable paths are split on a fast path. Help it:

  • Do work in onBeatHit / timers rather than every frame in onUpdate.
  • Cache values you read repeatedly instead of calling getProperty in a loop.
  • Create sprites in onCreatePost, not during gameplay.
  • Prefer setPropertyFromGroup over looping across notes from Lua.

Next​