Writing World of Warcraft Addons in Lua
Overview
Writing World of Warcraft addons with Lua dates back to the original 2004 release. WoW’s entire interface is built this way. Action bars, the character panel, raid frames — every piece of the UI runs as an addon on 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. You write handlers; WoW calls them.
TL;DR:
- WoW runs a Lua 5.1 sandbox — no filesystem access, no
require(), no bitwise operators. - Everything is event-driven. Register for events, handle them when they fire.
- Saved variables persist data across sessions; use the
orpattern for safe upgrades. - Slash commands need unique global names. Pick a prefix tied to your addon.
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)
Each file in the folder serves a distinct role. The .toc file is the addon’s manifest, listing metadata and every script or XML file WoW should load. Lua files contain your logic, and optional XML files define frames for visual elements like panels and buttons. WoW reads the .toc first, then loads the listed files in the order they appear.
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, so updating this value is essential after each major patch. The ## Title and ## Notes fields appear in the in-game addon list, while ## Author and ## Version help users track who wrote the addon and whether they have the latest release.
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. WoW respects the dependency order declared in the .toc. Libraries provide reusable modules for common tasks like event handling, configuration panels, and unit frame management. Many popular addons are built on top of Ace3 because it handles much of the boilerplate.
Writing your first World of Warcraft 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 vararg expression ... unpacks the addon’s private namespace, letting you store version strings and other state without polluting the global environment. Every addon gets its own namespace automatically.
The event system
The event system drives all addon logic in WoW. Your addon subscribes to events it cares about, and the game engine fires them when relevant things happen in the world. This keeps addons dormant until something actually changes. For a complete list of events and their arguments, see the WoW Event List on Wowpedia.
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. Each event type passes different parameters. CHAT_MSG_WHISPER provides the message text and sender name, while PLAYER_LEVEL_UP passes the new level as the second argument. WoW fires events frequently, so keep your handlers fast. Long-running work blocks the UI thread.
Unregistering events
Remove handlers when you no longer need them. This stops unnecessary callbacks and reduces CPU overhead, especially for high-frequency events like UNIT_HEALTH that fire on every health change.
frame:UnregisterEvent("UNIT_HEALTH")
frame:UnregisterAllEvents() -- clean up on disable
Slash commands
Slash commands let players interact with your addon through the chat input. Every command starts with a forward slash and maps to a global string identifier. WoW resolves the shortest unambiguous prefix, so /my works if no other addon registered /m. Commands can accept arguments, making them useful for toggling settings, opening panels, or running diagnostic checks from inside the game.
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, so use a prefix tied to your addon name. Define both short and long forms via numbered suffixes. The SLASH_MYADDON1 and SLASH_MYADDON2 globals register two ways for players to reach your addon: a long descriptive form and a short alias for frequent use.
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. This is how addons remember configuration choices, tracked statistics, and user preferences across logins. Because the data is written on logout, crash recovery relies on periodic manual saves.
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. The character scope is ideal for UI layouts and spec-specific configs, while account-wide storage works better for cross-character tracking like auction data or achievement progress.
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. When a user installs a new version of your addon, the saved variables file still holds the old table; merging defaults into it preserves configurations. Without this pattern, every update would reset the user’s preferences back to factory settings. The pairs loop only writes missing keys, never clobbers existing ones.
Per-character VS account-wide
| Declaration | Scope |
|---|---|
## SavedVariables: MyAddonConfig | Account-wide |
## SavedVariablesPerCharacter: MyAddonDB | Per 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. The first argument is the message text, and the second is the destination: "SAY" for local chat, "PARTY" for group, "GUILD" for guild. The third argument lets you specify a language, and the fourth targets a channel number when using custom chat channels.
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
WoW’s UI toolkit is built on frames. Every button and panel is a frame with scripts attached; text elements work the same way. You create a button by calling CreateFrame with the Button type, then give it a size, a position, and an OnClick handler. The UIPanelButtonTemplate provides the default Blizzard button styling.
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)
Frames anchor to reference points. SetPoint("CENTER", UIParent, "CENTER", 0, -200) places the button’s center 200 pixels below the screen center. You can anchor to any corner, edge, or center of any existing frame, which makes building complex nested layouts straightforward even without XML.
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 without interfering with each other. Use short timers for debouncing rapid-fire events like combat log updates, or long timers for periodic tasks like refreshing auction data every few minutes. Avoid nesting timers too deeply; deep callback chains make addon logic hard to follow and debug.
Units
WoW identifies game entities with unit IDs. Every targetable thing in the game world, from players and NPCs to mobs and interactive objects, maps to a unit token. These tokens give you access to a rich set of query functions for reading combat state.
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
Once you have a unit token, you can read detailed state from it. The API makes no distinction between friendly and hostile units; you get the same functions for both. Remember that unit data is snapshot-based: values reflect the last server update, not necessarily the current tick.
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"
These functions return numbers, strings, or booleans. UnitHealth and UnitHealthMax together let you display percentage-based health bars, a common pattern in raid frame addons. UnitIsPlayer distinguishes players from NPCs, which matters when filtering nameplates or highlighting hostile targets.
Inspecting the environment
Beyond individual units, the API exposes global game state. You can ask what zone you are in, what class and spec the player runs, and whether certain content is available. These calls work anywhere — inside event handlers, timer callbacks, or slash command functions.
-- 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))
Calling these functions repeatedly inside tight loops can hurt performance. Cache results when the zone or spec changes rather than polling every frame. Use the ZONE_CHANGED and PLAYER_SPECIALIZATION_CHANGED events as triggers for refreshing cached values.
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. Declarative markup separates visual structure from logic, which makes multi-frame interfaces easier to maintain as your addon grows.
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. List it after your Lua files to ensure scripts load before the UI tries to reference named frames. XML-defined frames become global variables accessible from any Lua file in the addon, which means you can separate layout from logic cleanly.
MyAddon.lua
MyAddon.xml
In Lua, refer to the frame by the name attribute: MyAddonFrame is a global. You can call MyAddonFrame:SetPoint(...) or MyAddonFrame:Show() from any script file loaded after the XML. This global-name binding makes XML frames feel native to Lua code. It also means you should prefix frame names to avoid colliding with other addons or Blizzard UI elements.
Common patterns
Production addons reuse a handful of structural patterns that handle initialization, modularity, and inter-addon coordination. Learning these patterns early saves you from redesigning your addon when it grows beyond a single file.
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
The guard pattern runs initialization exactly once, even across /reload UI reloads. After the first PLAYER_LOGIN, the script unregisters all events so the handler never fires again. The IsLoggedIn() fallback handles the edge case where your addon loads after the player is already in the world, which happens during development when you toggle addons without fully restarting.
Modular organization
As your addon grows, a single Lua file becomes unwieldy. Split logic across multiple files and use the shared namespace ns to connect them. WoW loads all listed files in alphabetical order, so name them carefully: core.lua loads before events.lua, which loads before ui.lua.
-- 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, so any global variable set in core.lua is immediately visible to events.lua. Avoid creating too many globals; funnel shared state through the namespace table ns instead. For more on managing scope across files, see the guide on Lua environments. If you need even more structure, libraries like Ace3 provide a :NewModule() pattern that mimics classes.
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.
The practice of writing world addons means working inside a deliberately restricted Lua 5.1 sandbox with no filesystem and no module loader. The constraints are real, but the API surface is enormous. You can build anything from a simple stat tracker to a full raid boss mod.
The minimal viable addon is just a .toc file and a single Lua script:
-- HelloWorld/HelloWorld.lua
local frame = CreateFrame("Frame")
frame:RegisterEvent("PLAYER_LOGIN")
frame:SetScript("OnEvent", function()
print("Hello, Azeroth!")
end)
That is all it takes. From there, the event system, saved variables, and unit API give you everything you need to build richer tools.
See also
- Environments: environment and settings management patterns
- String Patterns: Lua patterns for parsing chat messages and unit names
- Metatables: metatables for building addon object systems and custom data structures