Skip to content

Authoring Lua Custom Access Rules

EmoTracker Community edited this page Apr 8, 2026 · 1 revision

Authoring Lua — Custom Access Rules

When a location's access_rules need logic that can't be expressed as plain code requirements — multi-step routing, conditional dungeon entrances, runtime settings — you can write the check as a Lua function and reference it from the rule with a $ prefix.

This page is the deep dive on the Lua side of that mechanism. The rule-string syntax is documented on Authoring Locations — Accessibility Logic → Custom logic in Lua; read that first if you want the JSON side.

See Authoring Lua Scripts for the broader scripting overview.

How $ access rules dispatch to Lua

When the runtime parses a rule code that begins with $, it strips the prefix and asks the script manager to provide the count for the resulting code. The script manager treats that code as a Lua function name (with optional |-separated arguments), looks up a global Lua function with that name, calls it, and uses the function's return values to drive the rule.

The dispatch is implemented in EmoTracker.Data/ScriptManager.cs:ProviderCountForCode. The path is:

  1. Strip the $ prefix in Tracker.GetFilteredCodeAndProvider.
  2. Split the remaining string on | to separate the function name from any arguments.
  3. Look up the global Lua function (mLua[name]).
  4. Call it with the arguments (as strings).
  5. Read the return values:
    • First return is the count (converted to uint).
    • Optional second return is an AccessibilityLevel enum value that caps the rule's level.
  6. Cache the result until the next accessibility refresh.

The function contract

Custom-rule Lua functions must return either a count or a (count, AccessibilityLevel) pair:

function some_rule()
    return 1                              -- present at Normal accessibility (default)
end

function some_other_rule()
    return 1, AccessibilityLevel.SequenceBreak    -- present, but cap at SequenceBreak
end

function not_satisfied()
    return 0                              -- the code is "not provided" — rule fails
end
Return Effect on the rule
0 The code is missing. The rule fails (or downgrades, if the call site uses a sequence-break modifier like [$func]).
>= 1 (count only) The code is satisfied at the rule's natural accessibility level (typically Normal).
>= 1, AccessibilityLevel.X The code is satisfied but caps the rule at level X. Use this to express "this works, but only as a sequence break".

If the function returns nil (no return statement, or return with no value), the runtime logs an error to the script console: "Lua function <name> did not return a count. All Lua functions used as logical expressions must return a count." Always return at least a count.

Calling functions with arguments

A $ token can carry arguments separated by |. The runtime splits the token on |, uses the first piece as the function name, and passes the remaining pieces as string arguments:

"access_rules": [
  "$has_n_keys|2"
]
function has_n_keys(n)
    local count = tonumber(n) or 0    -- arguments arrive as strings
    if get_total_keys() >= count then
        return 1
    end
    return 0
end

Multiple arguments are also supported:

"$reach_via|moonpearl|flippers"
function reach_via(item_a, item_b)
    if has(item_a) and has(item_b) then
        return 1
    end
    return 0
end

There's no limit on the number of pipe-separated arguments, but they're always passed as strings — convert them to numbers, booleans, etc. inside your function as needed.

Where to define the functions

Custom-rule functions are looked up as Lua globals at the moment a rule is evaluated. The simplest pattern is to define them in a script file that you load from init.lua before the location file that references them:

-- scripts/init.lua
ScriptHost:LoadScript("scripts/logic.lua")
Tracker:AddItems("items/common.json")
Tracker:AddLocations("locations/overworld.json")  -- access_rules can now reference logic.lua functions
-- scripts/logic.lua
function can_reach_dam()
    if has("flippers") then
        return 1, AccessibilityLevel.Normal
    end
    if has("moonpearl") then
        return 1, AccessibilityLevel.SequenceBreak
    end
    return 0
end

You can technically define them later (e.g., from inside tracker_on_pack_ready) but rules that fire before then will see them as missing — easier to load logic up front.

Helpers you'll typically write

The runtime doesn't provide a has() helper — you'll write your own thin wrapper around Tracker:ProviderCountForCode(...):

-- Common helper: returns true if the player has at least one of the code
function has(code)
    return Tracker:ProviderCountForCode(code) > 0
end

-- Quantity check
function count(code)
    return Tracker:ProviderCountForCode(code)
end

These two functions cover the vast majority of what most pack scripts need. Drop them in scripts/logic.lua and load it from your init.

Caching

The runtime caches the result of every custom-rule call until the next accessibility refresh. The first call to $can_reach_dam during a refresh actually invokes your Lua function; subsequent calls during the same refresh just return the cached count. The cache is cleared at the start of every refresh.

The implication: your custom functions should be pure. Return the same count for the same arguments throughout a refresh cycle. Don't track per-call state, don't use random numbers, don't read from external sources that might change mid-refresh.

You also don't need to memoize inside your function — the runtime handles that already.

A complete example

-- scripts/logic.lua

function has(code)
    return Tracker:ProviderCountForCode(code) > 0
end

function can_use_medallion(medallion)
    -- Need a sword to use a medallion
    if not has("sword") then return 0 end

    if has(medallion) then
        return 1, AccessibilityLevel.Normal
    end

    -- If you don't know which medallion the dungeon needs, mark as inspect-only
    return 1, AccessibilityLevel.Inspect
end

function can_reach_misery_mire()
    if not has("moonpearl") or not has("flute") then
        return 0
    end

    -- Misery Mire requires a medallion (Bombos, Ether, or Quake)
    -- The pack's medallion items track which one is required
    if has("mm_medallion") then
        return can_use_medallion("mm_medallion")
    end

    return 0
end
// locations/dark_world.json
{
  "name": "Misery Mire",
  "access_rules": [
    "$can_reach_misery_mire, glove:1, [pegasusboots]"
  ],
  "sections": [ ... ]
}

The rule mixes a custom function with two regular item codes — $can_reach_misery_mire runs the medallion logic, then glove:1 and [pegasusboots] are normal item-database lookups.

Tips and pitfalls

  • Always return a count. Returning nil (forgetting return) logs an error and the rule fails. The minimum legal return is return 0.
  • Wrap Tracker:ProviderCountForCode in a has() helper. Calling it directly throughout your rule code is verbose; a thin wrapper is much more readable.
  • Arguments arrive as strings. Convert them to numbers / bools as needed.
  • Functions are global. Don't define them as local function ... at file scope — locals aren't visible to the runtime lookup.
  • Cache is per-refresh, not per-call. Don't try to "save work" with module-level memoization; the runtime already does it for you and clears at the right time.
  • Pure functions only. Don't change item state from inside a custom rule function. Anything that mutates the world should be in a callback instead.
  • Returning Inspect caps the rule at inspect level — useful for "you can see what's there but the actual reach logic isn't satisfied yet".

See also

Clone this wiki locally