Making an Add-on for World of Warcraft 🦑

10 min read

As a long time World of Warcraft player I’ve always been interested in making an add-on. I’ve used them a lot over the years and I’ve always wondered how they are made. On a day off I decided it’s finally time to sit down and make something. I read a couple of tutorials I found around the internet and got started.

I love the aesthetic and creepiness of the old gods in World of Warcraft and I wanted to make something related to them. I came up with the idea to create an add-on that performed the same whispers you get from the old gods in their respective raids, except no matter where you are, and from all of them. (Personalized Cthulhu mug by KachaktanoMugs).

Hello World

World of Warcraft add-ons are written in Lua, and get loaded into the game with the help of a .toc file. These are similar to a package.json in a JavaScript project. They tell the game which associated scripts it should load, along with meta data about the add-on itsself.

The formatting of these files is quite simple. They should include the current interface version which you can get by running /run print((select(4, GetBuildInfo()))); in-game, along with some information about the name and author of the add-on. They should also include any associated Lua files that should get loaded.

## Interface: 80300
## Title: Old God Whispers
## Author: James Ives
## Version: 0.0.1

OldGodWhispers.lua

In my OldGodWhispers.lua file I have a simple message('Hello World') call. There’s a bunch of methods available in the global scope provided by Blizzard that you are able to use in your scripts, the most complete list I’ve found is here.

Once I had all of the files in place I bundled them into a folder and placed them within the games’ AddOns folder. The folder structure should look something like the following;

AddOns
└── DBM-Core
├── OldGodWhispers
│ └── OldGodWhispers.lua
│ ├── OldGodWhispers.toc

Once loaded into the game I was able to see the Hello World alert so I knew everything was loading correctly.

Creating a Button

I wanted to create a button that you could press to get a whisper on-demand. I wanted the button to be small, movable, and look like an old god eye.

Interface components are constructed using frames, and they can be created using the CreateFrame global function. In the case of my add-on I wanted it to behave like a button, so the first argument that gets passed in is 'Button' which makes a series of button specific handlers available such as OnClick. I also needed to apply some textures to the button. For this I re-used the textures from the adventure guide, and the backpack icon for a new old god themed item that was added in patch 8.3.

-- Registers the frame that renders the button in-game. --
local frame = CreateFrame("Button", "DragFrame", UIParent)

frame:SetPoint("Center", 0, 0)
frame:SetSize(45, 45)

local icon = frame:CreateTexture("Texture", "Background")
-- N'Zoth eyeball texture. --
icon:SetTexture("3004126")

-- Makes the area behind the background invisible. --
icon:SetMask("Interface\\CharacterFrame\\TempPortraitAlphaMask")
icon:SetAllPoints(frame)

local ring = frame:CreateTexture("Texture", "Overlay")
ring:SetAtlas("adventureguide-ring")
ring:SetPoint("Center", frame)
ring:SetSize(60, 60)

-- The texture that appears when the button is highlighted. --
local highlightTexture = frame:CreateTexture("Texture", "Overlay")
highlightTexture:SetAtlas("adventureguide-rewardring")
highlightTexture:SetPoint("Center", frame)
highlightTexture:SetSize(60, 60)
highlightTexture:SetBlendMode("Add")
highlightTexture:SetVertexColor(1, 1, 1, 0.25)

-- When the user mouses over the button the highlight texture shows. --
frame:SetScript("OnEnter", function(self)
  highlightTexture:Show()
end)

-- When the user mouses out of the button the highlight texture is hidden. --
frame:SetScript("OnLeave", function(self)
  highlightTexture:Hide()
end)

-- Plays a sound when the button is clicked.
frame:SetScript('OnClick', function(self)
  PlaySounds()
end)

I also wanted to make the frame draggable so the user can move the button to their desired location. To do this you use the following methods.

frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", frame.StartMoving)
frame:SetScript("OnDragStop", frame.StopMovingOrSizing)

With this setup the button displays and plays an old god whisper whenever the player clicks on it. They can drag it around if they hold their mouse down on it too!

Persisted State

I wanted to create a way to disable whispers from a specific old god, along with a way to enable/disable the random whispers in favor for the button. In order to create state that persists between game sessions you need to add a SavedVariables parameter to your .toc file.

## Interface: 80300
## Title: Old God Whispers
## Author: James Ives
## Version: 0.0.2
## URL: https://jamesiv.es
## SavedVariables: OldGodWhispersDatabase

OldGodWhispers.lua

Every time a value is assigned to this variable the game will commit it automatically. The default value for the variable will be nil, and some initial defaults will need to be setup in order for it to be useful. To do this an event listener can be attached to our add-on frame that watches for the ADDON_LOADED event. We can then check to see if the variable name is nil, and if it is attach a default state.

frame:SetScript("OnEvent", function(self, event, arg1)
  if event == "ADDON_LOADED" and arg1 == "OldGodWhispers" then
      -- Checks to see if the session already has data for the add-on. --
      if OldGodWhispersDatabase == nil then
          -- If no data is found some initial values get set. --
          OldGodWhispersDatabase = {
              random = false,
              addonShow = true,
              cthunEnabled = true,
              nzothEnabled = true,
              ghuunEnabled = true,
              yoggSaronEnabled = true,
              ilgynothEnabled = true
          }
      end
  else
      -- If there is add-on data some initial calls get made to ensure the saved preferences are correctly respected. --
      if OldGodWhispersDatabase['addonShow'] == false then
          frame:Hide()
      end
      if OldGodWhispersDatabase['random'] == true then
          PerformRandom()
      end
  end
end)

Toggling the state is as simple as assigning a value. In the below example I set addonShow to true/false depending on what it was previously. If the player leaves the game and re-enters their preference will remain what it was when they last used it. If a player deletes their games temporary file it will restore the add-on preferences back to their default state.

-- Handles slash commands / toggling. --
local function AvailableCommands(msg)
  if msg == 'toggle' then
      if OldGodWhispersDatabase['addonShow'] == true then
          frame:Hide()
      else
          frame:Show()
      end
      OldGodWhispersDatabase['addonShow'] = not OldGodWhispersDatabase['addonShow']
  end
end

Playing a Sound

I knew I wanted my add-on to be able to play sounds, so the next thing I did was find the sound ids for all of the available old god whispers. To do this I visited a World of Warcraft database website and did a lot of manual searches for the old god names, listened to all of the sound files, and then placed each applicable sound id in their own table.

-- Sound ids for all of the whispers, divided into seperate tables for each old god. --
local CthunSounds = {
  546633, 546620, 546621, 546623, 546626, 546627, 546628, 546636
}

local NzothSounds = {
  2529827, 2529828, 2529829, 2529830, 2529831, 2529832, 2529833, 2529834, 2529835, 2529836, 2529837, 2529838, 2529839, 2529840, 2529841, 2529842, 2529843, 2529844, 2529846, 2564962, 2564963, 2564964, 2564965, 2564966, 2564967, 2564968, 2564969, 2564970, 2618480, 2618483, 2618486,
  2923228, 2923229, 2923230, 2923231, 2923232, 2923233, 2923236, 2923237, 2959164, 2959166, 2959167, 2959168, 2959169, 2959170, 2959189, 2959190, 2959191, 2959192, 2959193, 2959194, 2960030
}

local IlgynothSounds = {
  1360537, 1360538, 1360539, 1360540, 1360541, 1360542, 1360543, 1360544,1360545,
  1360546, 1360547, 1360553, 1360554, 1360555, 1360556, 1360557, 1360558,  1360559,
  1360560, 1360561, 1360562, 3178932, 3178933, 3178934, 3178935, 3178936,3178937, 3180746, 3180788, 3180789, 3180790,  3180791, 3180792, 3180900, 3180901,
  3180902, 3180903, 3180904, 3180905, 3180906, 3180907, 3180910, 3180911, 3180938,
  3180939, 3180940, 3180944
}

local YoggSaronSounds = {
  564844, 564858, 564838, 564877, 564865, 564834, 564862, 564868, 564857, 564870, 564856, 564845, 564823
}

local GhuunSounds = {
  2000113, 2000114, 2000115, 2000119, 2000120, 2000121, 2000149
}

World of Warcraft allows you to play a sound using the global PlaySounds function. It requires a sound id as the first argument and the sound channel for the second. In my function I wanted to only play sound files for the old god so long as the player has them enabled, so some additional checks needed to be made. If the condition is true it merges an empty table with the applicable old god sound table, and then picks a random item from the newly created table to play.

-- Plays a random sound depending on what configuration settings are enabled. --
local function PlaySounds(click)
  -- availableSounds always starts of with a number of shared whispers. --
  local availableSounds = {}

  if OldGodWhispersDatabase['cthunEnabled'] == true then
      for k, v in pairs(CthunSounds) do
          table.insert(availableSounds, v)
      end
  end

  if OldGodWhispersDatabase['nzothEnabled'] == true then
      for k, v in pairs(NzothSounds) do
          table.insert(availableSounds, v)
      end
  end

  if OldGodWhispersDatabase['ghuunEnabled'] == true then
      for k, v in pairs(GhuunSounds) do
          table.insert(availableSounds, v)
      end
  end

  if OldGodWhispersDatabase['yoggSaronEnabled'] == true then
      for k, v in pairs(YoggSaronSounds) do
          table.insert(availableSounds, v)
      end
  end

  if OldGodWhispersDatabase['ilgynothEnabled'] == true then
      for k, v in pairs(IlgynothSounds) do
          table.insert(availableSounds, v)
      end
  end
  
  -- Plays a random sound from the availableSounds table. --
  if OldGodWhispersDatabase['random'] == true or click == true then
      PlaySoundFile(availableSounds[math.random(#availableSounds)], "Dialog")
  end
end

Timeouts

The last part was making an option so the whispers could be random in true old god fashion. To do this I created a function that will play the sound on a time delay anywhere between five and thirty minutes.

local function PerformRandom()
  -- C_Timer is a global function and works similar to a setTimeout. --
  C_Timer.After(math.random(300, 1800), PlaySounds)
end

Whenever the player toggles the random state to true this function gets fired. Within the PlaySounds function it checks to see if random mode is enabled after playing the sound, and if it is it will then fire this function again. This makes it so there is always a random sound file queued up.

With the way this is currently setup it will keep queuing up sounds if the user clicks the button while they have random mode enabled. In order to solve this I created an argument called click that gets a boolean value whenever the player presses the button. I extended the check in the sound function that makes sure that random is true and that the click argument is nil before queuing up a new one. This ensures that there’s not a ton of sound files queued up in the call stack if the player keeps pressing the button while having random mode enabled.

local function PlaySounds(click)
  local availableSounds = {2494907, 2494908, 2494909, 2494910, 2494911, 2494912, 2494913, 2494914, 2494915, 2494916}

  
  -- Prevents random mode from lingering by checking if its true before playing a souns. --
  if OldGodWhispersDatabase['random'] == true or click == true then
      PlaySoundFile(availableSounds[math.random(#availableSounds)], "Dialog")
  end

  -- This should only fire if it's a callback from an interval. --
  -- The click arg gets passed as true on all button presses to prevent double firing. --
  if OldGodWhispersDatabase['random'] == true and click == nil then
      PerformRandom(math.random(300, 1800), PlaySounds)
  end
end

Closing Thoughts

I really enjoyed creating my first add-on for World of Warcraft and I hope to make more in the future. If you’d like to give it a try you can download it on CurseForge or you can check out the source on GitHub. If you have any questions please feel free to reach out via my contact form or on Twitter.