Skip to main content
The interaction framework is a Lua system that gives structure to quests, missions, and any other content that needs to react differently to NPC triggers depending on player state. It eliminates the repetitive conditional code that would otherwise fill every NPC file.
The common term for quests and missions in the framework is containers, since they contain everything that makes a quest or mission work.

Sections

A container is made up of one or more sections. Each section has a check function and a table of zone handlers. The framework runs through all sections in order and executes the action from the highest-priority matching one.
{
    -- If this returns true, this section is a candidate for the current interaction
    check = function(player, status, questVars, globalVars)
        return status == QUEST_AVAILABLE
    end,

    -- Handlers grouped by zone ID
    [xi.zone.SOME_AREA] = {
        ['Some_NPC'] = {
            onTrigger = function(player, npc)
                return quest:progressEvent(101)
            end,
        },

        onEventFinish = {
            [101] = function(player, csid, option, npc)
                -- do something when event 101 finishes
            end,
        },
    },

    [xi.zone.SOME_OTHER_AREA] = {
        ['Another_NPC'] = {
            -- interactions in a different zone
        },
    }
}

The check function

The check function receives four arguments:
ArgumentTypeDescription
playerentityThe player object
statusintegerQuest/mission status constant
questVarsproxy tableQuest variables, fetched lazily by name
globalVarsproxy tableNon-quest character variables, fetched lazily by name
questVars and globalVars use lazy lookup: accessing questVars.Prog automatically fetches the Quest[X][Y]Prog char variable. Names with special characters use bracket syntax: questVars['my var'].
check = function(player, status, questVars, globalVars)
    return status == QUEST_AVAILABLE and questVars.Prog == 0
end,

Framework flow

1

Player interacts with an NPC or zone event

The C++ core calls the framework’s dispatch function for the relevant event type (e.g., onTrigger).
2

Framework collects candidates

All containers registered in interaction_containers.lua that cover this NPC/zone are iterated. For each, the check function is called. All sections where check returns true are collected.
3

Actions are sorted by priority

The framework collects the action returned by each matching section’s handler and sorts them. Progress events rank highest, regular events next, default actions last.
4

Highest-priority action executes

The winning action is executed. For events, this means calling player:startEvent(). For messages, it calls player:messageSpecial().
5

Fallback to NPC/zone Lua file

If no framework action applies, the NPC’s own npcs/ file handler runs (if it exists).
6

Fallback to DefaultActions

If neither the framework nor the NPC file acts, the NPC’s entry in DefaultActions.lua runs.

Priority order

  1. Progress events from the interaction framework (highest)
  2. Regular events from the interaction framework, alternating with the NPC/zone Lua file
  3. Default actions from DefaultActions.lua (lowest)

Handlers

Here is the full set of handlers that can appear inside a section’s zone table:
[xi.zone.SOME_AREA] = {

    -- NPC handlers: indexed by NPC name string
    ['Some_NPC_Name'] = {
        onTrigger = function(player, npc)
            if player:getFreeSlotsCount() > 0 then
                return quest:progressEvent(101)
            else
                return quest:event(100)
            end
        end,

        onTrade = function(player, npc, trade)
            if npcUtil.tradeHasExactly(trade, xi.items.SOME_ITEM) then
                return quest:progressEvent(111)
            end
        end,
    },

    -- onEventFinish: indexed by cutscene/event ID
    onEventFinish = {
        [101] = function(player, csid, option, npc)
            quest:setVar(player, 'Prog', 1)
        end,

        [111] = function(player, csid, option, npc)
            quest:setVar(player, 'Prog', 2)
        end,
    },

    -- onRegionEnter: indexed by trigger area ID
    onRegionEnter = {
        [2] = function(player, csid, option, npc)
            quest:setVar(player, 'Prog', 3)
        end,
    },
},

[xi.zone.SOME_OTHER_AREA] = {
    -- onZoneIn: single-entry list, return an event ID or nil
    onZoneIn = {
        function(player, prevZone)
            return 543
        end,
    },
}

Framework internals

All containers are registered in scripts/globals/interaction_containers.lua. The framework’s core files are:
FileRole
interaction/interaction_global.luaServer init, zone loading, !reloadquest support
interaction/interaction_lookup.luaIndexes containers by zone and NPC for fast lookup
interaction/container.luaBase Container class with action and variable helpers
interaction/quest.luaQuest subclass with begin() and complete()
interaction/mission.luaMission subclass
interaction/hidden_quest.luaHiddenQuest subclass for non-log-entry quests
interaction/action_util.luaAction priority resolution
interaction/actions/Event, Message, Sequence, KeyItem action classes
Backwards compatibility with pre-framework NPC Lua files is maintained by the priority system: framework containers are tried first, then the NPC’s own file, then DefaultActions.lua.

GM commands

Three GM commands support debugging the interaction framework. Reload a quest container (name must match exactly as in interaction_containers.lua, case-sensitive):
!reloadquest Three_Men_and_a_Closet
Print a quest variable for the player:
!checkquestvar TOAU THREE_MEN_AND_A_CLOSET Prog
Print the bits set in a quest variable:
!checkquestbits TOAU ARTS_AND_CRAFTS Prog

Build docs developers (and LLMs) love