Skip to main content

Zone directory structure

Each zone has its own directory under scripts/zones/. A fully scripted zone typically contains:
scripts/zones/<ZoneName>/
  Zone.lua           # Zone-level handlers (zone-in, events, conquest)
  IDs.lua            # Text message, NPC, and mob ID constants
  DefaultActions.lua # Default NPC events/messages for the interaction framework
  npcs/              # One .lua file per NPC
    Ailbeche.lua
    Antonian.lua
    ...
  mobs/              # One .lua file per notable mob or NM
    Bigmouth_Billy.lua
    ...
Not every zone has all directories. Simpler zones may only have Zone.lua and IDs.lua.

IDs.lua — zone-local constants

IDs.lua defines the zones[xi.zone.<NAME>] table for a zone. This is the standard way to reference text message IDs, NPC entity IDs, and mob entity IDs without hardcoding magic numbers.
-- scripts/zones/Northern_San_dOria/IDs.lua
zones = zones or {}

zones[xi.zone.NORTHERN_SAN_DORIA] =
{
    text =
    {
        HOMEPOINT_SET           = 188,  -- Home point set!
        ITEM_CANNOT_BE_OBTAINED = 6593, -- You cannot obtain the <item>...
        ITEM_OBTAINED           = 6599, -- Obtained: <item>.
        GIL_OBTAINED            = 6600, -- Obtained <number> gil.
        KEYITEM_OBTAINED        = 6602, -- Obtained key item: <keyitem>.
    },

    npc =
    {
        EXPLORER_MOOGLE = 17737845,
    },

    mob =
    {
        -- mob entity IDs
    },
}
All NPC and zone scripts load this table via:
local ID = zones[xi.zone.NORTHERN_SAN_DORIA]
-- then use ID.text.ITEM_OBTAINED, ID.npc.EXPLORER_MOOGLE, etc.

Zone.lua — zone handlers

Zone.lua returns a zoneObject table with handler functions. All handlers are optional; omit any that are not needed.
---@type TZone
local zoneObject = {}

-- Called once when the zone loads. Register trigger areas here.
zoneObject.onInitialize = function(zone)
    xi.helm.initZone(zone, xi.helmType.LOGGING)
    zone:registerCuboidTriggerArea(1, -7, -3, 110, 7, -1, 155)
end

-- Called when any player zones in. Return an event ID to play automatically,
-- or -1 (or nil) for no event.
zoneObject.onZoneIn = function(player, prevZone)
    local cs = -1

    if player:getXPos() == 0 and player:getYPos() == 0 and player:getZPos() == 0 then
        player:setPos(86.131, -65.817, 273.861, 25)
    end

    return cs
end

-- Called when conquest tallying updates
zoneObject.onConquestUpdate = function(zone, updatetype, influence, owner, ranking, isConquestAlliance)
    xi.conquest.onConquestUpdate(zone, updatetype, influence, owner, ranking, isConquestAlliance)
end

-- Called when a player enters a registered trigger area
zoneObject.onTriggerAreaEnter = function(player, triggerArea)
    switch (triggerArea:getTriggerAreaID()): caseof
    {
        [1] = function()
            player:startEvent(569)
        end,
    }
end

-- Called when a player leaves a registered trigger area
zoneObject.onTriggerAreaLeave = function(player, triggerArea)
end

-- Called during cutscene playback for mid-event updates
zoneObject.onEventUpdate = function(player, csid, option, npc)
end

-- Called when a cutscene/event finishes
zoneObject.onEventFinish = function(player, csid, option, npc)
    if csid == 569 then
        player:setPos(0, 0, -13, 192, 233)
    end
end

return zoneObject

NPC scripts

Each NPC file in npcs/ returns a local entity table with handler functions. The file name must match the NPC’s in-game name.
-- scripts/zones/Northern_San_dOria/npcs/Ailbeche.lua
---@type TNpcEntity
local entity = {}

-- Called when a player triggers (talks to) the NPC
entity.onTrigger = function(player, npc)
    local questStatus = player:getQuestStatus(xi.questLog.SANDORIA, xi.quest.id.sandoria.A_BOYS_DREAM)

    if questStatus == xi.questStatus.QUEST_AVAILABLE then
        player:startEvent(41)
    end
end

-- Called when a player trades items to the NPC
entity.onTrade = function(player, npc, trade)
    if npcUtil.tradeHasExactly(trade, xi.item.GIANT_SHELL_BUG) then
        player:startEvent(15)
    end
end

-- Called when an event/cutscene started by this NPC finishes
entity.onEventFinish = function(player, csid, option, npc)
    if csid == 41 and option == 1 then
        player:addQuest(xi.questLog.SANDORIA, xi.quest.id.sandoria.A_BOYS_DREAM)
        player:setCharVar('aBoysDreamCS', 2)
    end
end

return entity

NPC handler reference

HandlerSignatureWhen called
onTrigger(player, npc)Player talks to or examines the NPC
onTrade(player, npc, trade)Player trades items to the NPC
onEventFinish(player, csid, option, npc)A cutscene started by this NPC finishes
onEventUpdate(player, csid, option, npc)Mid-event update during a cutscene
onTimer(npc)NPC timer fires (set via npc:timer())

DefaultActions.lua — default NPC dialogue

The interaction framework automatically lowers the priority of default NPC responses so that quest/mission dialogue always takes precedence. Default actions are defined in DefaultActions.lua using the action table shorthand:
-- scripts/zones/Northern_San_dOria/DefaultActions.lua
local ID = zones[xi.zone.NORTHERN_SAN_DORIA]

return {
    ['Ailbeche'] = { event = 868 },                       -- plays event 868
    ['Maurinne'] = { text  = ID.text.MAURINNE_DIALOG },  -- shows text message
    ['_6fc']     = { messageSpecial = ID.text.ITS_LOCKED }, -- plays special message
}
See actions and handlers for the full shorthand syntax.
Any NPC listed in DefaultActions.lua does not need its own file in npcs/ unless it also participates in quests, missions, or other logic.

Trigger areas

Zones can define 3D trigger regions that fire onTriggerAreaEnter and onTriggerAreaLeave. Registered in onInitialize:
-- Cuboid trigger area (id, x1, y1, z1, x2, y2, z2)
zone:registerCuboidTriggerArea(1, -7, -3, 110, 7, -1, 155)

-- Cylindrical trigger area (id, centerX, centerZ, radius)
zone:registerCylindricalTriggerArea(2, 146.420, 127.601, 10)
The trigger area ID is passed to onTriggerAreaEnter as triggerArea:getTriggerAreaID().

Build docs developers (and LLMs) love