luaguides

Writing World of Warcraft Addons in Lua

Overview

World of Warcraft supports Lua addons dating back to the original 2004 release. Every part of the WoW UI — action bars, the character panel, raid frames — is built as an addon using the same API available to third-party developers. The client embeds a modified Lua 5.1 interpreter with access to a large API surface for UI manipulation, event handling, networking, and data storage.

The key difference from standard Lua is the execution model. WoW addons are event-driven: your code registers handlers for events, and the game calls those handlers when things happen. There is no require() — all inter-addon communication uses the shared WoW API directly.

Addon structure

An addon lives in a folder under World of Warcraft/Interface/AddOns/. The folder must contain a .toc file that tells WoW how to load it.

MyAddon/
  MyAddon.toc        -- metadata, load order
  MyAddon.lua        -- main script
  MyAddon.xml        -- XML UI definitions (optional)

The .TOC file

## Interface: 100205
## Title: MyAddon
## Notes: A useful addon.
## Author: YourName
## Version: 1.0.0

MyAddon.lua
MyAddon.xml

## Interface specifies which WoW patch version the addon targets. 100205 means patch 10.2.5. WoW refuses to load addons with a mismatched interface number.

Loading order

Dependencies load before dependents. List them in the .toc:

## Dependencies: LibStub, AceAddon-3.0

If your addon uses a library like Ace3, it loads after that library is available.

Your first addon

Myaddon.lua

-- MyAddon.lua
local _, ns = ...
ns.version = "1.0.0"

local frame = CreateFrame("Frame")

frame:RegisterEvent("PLAYER_LOGIN")

frame:SetScript("OnEvent", function(self, event, ...)
    if event == "PLAYER_LOGIN" then
        print("MyAddon loaded! Version: " .. ns.version)
    end
end)

PLAYER_LOGIN fires once when the player character is fully loaded. CreateFrame("Frame") creates a generic frame to hold your event handlers.

The event system

Registering and handling events

local f = CreateFrame("Frame")

f:RegisterEvent("CHAT_MSG_WHISPER")
f:RegisterEvent("PLAYER_LEVEL_UP")
f:RegisterEvent("UNIT_HEALTH")

f:SetScript("OnEvent", function(self, event, ...)
    if event == "CHAT_MSG_WHISPER" then
        local message, sender = ...
        print("Whisper from " .. sender .. ": " .. message)
    elseif event == "PLAYER_LEVEL_UP" then
        local newLevel = select(2, ...)
        print("You reached level " .. newLevel .. "!")
    elseif event == "UNIT_HEALTH" then
        local unitId = ...
        -- health changed for this unit
    end
end)

OnEvent receives the triggering event name first, then any arguments specific to that event.

Unregistering events

frame:UnregisterEvent("UNIT_HEALTH")
frame:UnregisterAllEvents()  -- clean up on disable

Slash commands

Registering a slash command

SLASH_MYADDON1 = "/myaddon"
SLASH_MYADDON2 = "/ma"

SlashCmdList["MYADDON"] = function(msg)
    if msg == "config" then
        OpenConfig()
    elseif msg == "version" then
        print("MyAddon " .. ns.version)
    else
        print("Usage: /myaddon config | version")
    end
end

Slash commands are global — use a prefix tied to your addon name. Define both short and long forms via numbered suffixes.

Saved variables

SavedVariables persist data between game sessions. WoW serializes them to a file when the client exits and reloads them when your addon loads.

Declaring saved variables in .TOC

## SavedVariablesPerCharacter: MyAddonDB

MyAddonDB is created as a global when the character loads. Use a character-scoped table for per-character settings, or a global table for account-wide settings.

Initializing the database

MyAddonDB = MyAddonDB or {}
local defaults = {
    enabled = true,
    threshold = 50,
    notify = false,
}

for k, v in pairs(defaults) do
    if MyAddonDB[k] == nil then
        MyAddonDB[k] = v
    end
end

Use the or pattern to avoid overwriting existing settings on upgrade.

Per-character VS account-wide

DeclarationScope
## SavedVariables: MyAddonConfigAccount-wide
## SavedVariablesPerCharacter: MyAddonDBPer character

Chat frames and messaging

Sending chat messages

C_ChatInfo.SendChatMessage("Hello world!", "SAY")

-- Send to a specific channel
C_ChatInfo.SendChatMessage("Hello channel!", "CHANNEL", nil, channelNumber)

Use the C_ChatInfo API rather than the older SendChatMessage() — it handles channel registration correctly.

Receiving messages

frame:RegisterEvent("CHAT_MSG_WHISPER")
frame:RegisterEvent("CHAT_MSG_GUILD")
frame:RegisterEvent("CHAT_MSG_PARTY")

frame:SetScript("OnEvent", function(self, event, message, sender, ...)
    if event == "CHAT_MSG_WHISPER" then
        -- sender is the character who whispered
    end
end)

Creating a custom button

local button = CreateFrame("Button", "MyAddonButton", UIParent, "UIPanelButtonTemplate")
button:SetSize(80, 24)
button:SetPoint("CENTER", UIParent, "CENTER", 0, -200)
button:SetText("Click Me")

button:SetScript("OnClick", function()
    print("Button clicked!")
end)

The WoW Lua API

Timers

C_Timer.After(5, function()
    print("This runs after 5 seconds.")
end)

C_Timer.After(0.1, function()
    print("This runs after 100 milliseconds.")
end)

C_Timer.After is non-blocking and runs after the specified delay. Multiple timers can run concurrently.

Units

WoW identifies game entities with unit IDs:

UnitName("player")     -- your character
UnitName("target")      -- your current target
UnitName("party1")      -- first party member
UnitName("raid1")       -- first raid member
UnitName("mouseover")   -- unit under the mouse

Fetch unit attributes:

UnitHealth("player")          -- current health
UnitHealthMax("player")       -- maximum health
UnitPower("player")           -- current primary power (rage, mana, etc.)
UnitLevel("target")           -- level of your target
UnitIsPlayer("target")        -- true if target is a player
UnitFactionGroup("target")    -- "Alliance" or "Horde"

Inspecting the environment

-- Get the current zone name
local zoneName = GetRealZoneText()

-- What class is the player?
local _, class = UnitClass("player")

-- What spec does the player have (for dual specialization)?
local specId = GetSpecialization()
local specName = select(2, GetSpecializationInfo(specId))

XML layouts

WoW’s UI is defined in XML. You can create frames entirely in Lua, but XML provides a cleaner structure for complex layouts.

A simple XML frame

<Ui xmlns="http://www.blizzard.com/wiki/ui/">
  <Frame name="MyAddonFrame" parent="UIParent">
    <Size x="200" y="100"/>
    <Anchors>
      <Anchor point="CENTER"/>
    </Anchors>
    <Layers>
      <Layer level="BACKGROUND">
        <Texture>
          <Color r="0" g="0" b="0" a="0.5"/>
        </Texture>
      </Layer>
      <Layer level="OVERLAY">
        <FontString name="$parentText" text="Hello" inherits="GameFontNormal">
          <Anchors>
            <Anchor point="CENTER"/>
          </Anchors>
        </FontString>
      </Layer>
    </Layers>
  </Frame>
</Ui>

Load the XML in your .toc with the filename:

MyAddon.lua
MyAddon.xml

In Lua, refer to the frame by the name attribute: MyAddonFrame is a global.

Common patterns

Initialization guard

local ADDON_NAME, ns = ...
local frame = CreateFrame("Frame")

local function OnEvent(self, event, ...)
    if event == "PLAYER_LOGIN" then
        -- Initialize addon
        ns.initialized = true
        frame:UnregisterAllEvents()
    end
end

frame:SetScript("OnEvent", OnEvent)
frame:RegisterEvent("PLAYER_LOGIN")

-- Also fire for already-loaded addon (UI reload)
if IsLoggedIn() then
    frame:GetScript("OnEvent")(frame, "PLAYER_LOGIN")
end

Modular organization

-- MyAddon/core.lua
local _, ns = ...
function ns.Init()
    -- init logic
end

-- MyAddon/events.lua
local _, ns = ...
local f = CreateFrame("Frame")
f:SetScript("OnEvent", function(self, event, ...)
    -- handle events
end)

Split logic across files in the same addon folder. Each file runs in the same global environment.

Checking if another addon is loaded

if IsAddOnLoaded("SomeOtherAddon") then
    -- interact with SomeOtherAddon's global API
end

Gotchas

No require() or dofile(). There is no filesystem access and no module loader. All Lua files in your addon’s folder are loaded automatically in alphabetical order. Share state through global namespaces or through a library like Ace3’s :NewModule().

Slash command names must be unique globally. Choose a prefix unlikely to collide. Use MYADDON (all caps) as the command list key.

UI is hidden during loading screens. PLAYER_LOGIN fires after the character is loaded but before entering the world. For code that needs the world to be available, use PLAYER_ENTERING_WORLD.

The secure execution environment. Certain functions like hooksecurefunc() and SecureHandler are used to modify protected UI elements without violating the anti-cheat system. Do not attempt to hook or modify protected functions outside of documented secure patterns.

WoW’s Lua is Lua 5.1. Bitwise operations are through bit.band(), bit.bor(), etc. — there is no native bitwise syntax. Pattern matching works as standard Lua patterns.

Never assume a global exists. Check with if MyAddonDB then before using a saved variable on first load.

See also