Build-Along: BetterCooldowns¶
Estimated time: 30 minutes Difficulty: Intermediate-Advanced Mode: Enhancement Artist Interface: 120001 (Midnight 12.0.1)
Step 1: What We're Building¶
BetterCooldowns adds OmniCC-style countdown text to Blizzard's native cooldown spirals. Instead of replacing Blizzard's cooldown system, we overlay it — adding readable countdown text, smooth color transitions, and a pulse animation when abilities are about to come off cooldown.
Here is what the finished addon does:
- Countdown text on every cooldown frame — action bars, inventory, pet bar, everything
- Color transitions — green text above 30 seconds, yellow from 10-30 seconds, red under 10 seconds
- Decimal precision — shows "5.1" for the final 5 seconds, "30s" for longer, "2m" for minutes
- Pulse animation — a subtle scale bounce when cooldowns drop below 3 seconds
- Fully configurable — toggle, minimum duration threshold, font size, pulse on/off
The Enhancement Artist philosophy guides every decision. We hook Blizzard's cooldown system with hooksecurefunc, add our FontString on top of existing frames, and let Blizzard handle the actual cooldown tracking. We never call SetCooldown ourselves, we never replace any frame, and we never touch protected state during combat.
What You Will Learn¶
| Concept | Where |
|---|---|
| Duration Objects (new in 12.0) | Step 2 |
SetCooldownFromDurationObject() | Step 2 |
hooksecurefunc on Cooldown methods | Step 4 |
IsForbidden() guards on secure frames | Step 4 |
Throttled OnUpdate handlers | Step 5 |
AnimationGroup with ScaleAnimation | Step 6 |
| Action bar button enumeration | Step 7 |
| Combat lockdown queue pattern | Step 7, Step 9 |
| Modern Settings API | Step 8 |
Enhancement Artist pattern
This tutorial follows the Enhancement Artist mode's core rule: skin, hook, extend — never replace. Blizzard tracks the cooldowns. Blizzard draws the spiral. We just add text on top.
Step 2: Understanding Duration Objects¶
Duration Objects are one of the most important new concepts in WoW 12.0. They wrap time-based combat data — spell cooldowns, buff durations, GCD timers — into opaque containers that UI widgets can consume directly.
Duration Objects in 12.0
Duration Objects were introduced alongside Secret Values to solve a specific problem: how can addons display cooldown spirals when the raw start time and duration might be secret? The answer is a sealed object that Blizzard's cooldown widget knows how to render, without exposing the underlying numbers to addon code.
How Duration Objects Work¶
A Duration Object is obtained from API functions like C_Spell.GetSpellCooldownDuration(). It cannot be opened, inspected, or used in math — it is a sealed container. But it can be passed directly to a Cooldown frame:
local durationObj = C_Spell.GetSpellCooldownDuration(spellID)
cooldownFrame:SetCooldownFromDurationObject(durationObj)
The Cooldown frame reads the duration internally and renders the spiral animation. Your addon code never sees the raw numbers. This is the Secret Values system at work — display is permitted, logic is not.
What This Means for BetterCooldowns¶
Our addon hooks SetCooldown and SetCooldownFromDurationObject — both are called by Blizzard when a cooldown starts. In the open world, SetCooldown(start, duration) gives us real numbers we can use for text formatting. In instances, the same frame might receive a Duration Object instead, and the start and duration arguments to SetCooldown may be Secret Values.
The golden rule
Duration Objects are for display — pass them to widgets. They are not for logic — you cannot read their values, do arithmetic, or branch on them. Our addon must detect when it is inside a restricted context and gracefully hide its countdown text.
The Dual-Path Architecture¶
BetterCooldowns uses a simple dual-path approach:
- Unrestricted (open world, non-encounter): read
startanddurationfromSetCooldown, compute remaining time, display text - Restricted (instances, encounters, M+, PvP): detect secrets with
issecretvalue(), hide our countdown text, let Blizzard's native spiral handle display
This graceful degradation is the hallmark of a well-built Midnight addon.
Step 3: Project Setup¶
Create the following directory structure:
BetterCooldowns.toc¶
## Interface: 120001
## Title: BetterCooldowns
## Notes: Countdown text overlay for Blizzard cooldown frames
## Author: You
## Version: 1.0.0
## SavedVariables: BetterCooldownsDB
## IconTexture: Interface\Icons\Spell_Holy_BorrowedTime
## Category: Combat
Core.lua
Display.lua
Config.lua
The file load order matters. Core.lua initializes the namespace and event system. Display.lua contains the cooldown hook and text rendering. Config.lua adds the Settings panel.
Icon choice
Spell_Holy_BorrowedTime is a clock icon — fitting for a cooldown addon. Browse icons at wowhead.com/icons or use /dump GetSpellTexture(spellID) in-game.
Step 4: Hooking Blizzard's Cooldown System¶
This is the core of the addon. We hook into Blizzard's cooldown pipeline so that every time a cooldown starts, our code runs afterward to add countdown text.
Core.lua — Namespace and Events¶
-- Mode: Enhancement Artist | Enhance don't replace
local addonName, ns = ...
-- Defaults
ns.defaults = {
enabled = true,
minDuration = 2.5,
fontSize = 14,
pulseEnabled = true,
pulseThreshold = 3,
}
-- Event dispatch
local eventHandlers = {}
function eventHandlers:ADDON_LOADED(loadedAddon)
if loadedAddon ~= addonName then return end
-- Initialize SavedVariables with defaults
if not BetterCooldownsDB then
BetterCooldownsDB = CopyTable(ns.defaults)
else
for k, v in pairs(ns.defaults) do
if BetterCooldownsDB[k] == nil then
BetterCooldownsDB[k] = v
end
end
end
ns.db = BetterCooldownsDB
self:UnregisterEvent("ADDON_LOADED")
end
function eventHandlers:PLAYER_LOGIN()
ns:InitDisplay()
end
local eventFrame = CreateFrame("Frame")
eventFrame:SetScript("OnEvent", function(self, event, ...)
local handler = eventHandlers[event]
if handler then
handler(self, ...)
end
end)
for event in pairs(eventHandlers) do
eventFrame:RegisterEvent(event)
end
Display.lua — The Cooldown Hook¶
This is where the Enhancement Artist philosophy comes alive. We hook SetCooldown on the Cooldown frame metatable — one hook that catches every cooldown frame in the entire UI.
-- Mode: Enhancement Artist | Enhance don't replace
local addonName, ns = ...
-- Active cooldown overlays
local activeFrames = {}
-- ================================================================
-- Cooldown Metatable Hook
-- ================================================================
-- Get the shared metatable for all Cooldown frames
local tempCD = CreateFrame("Cooldown", nil, nil, "CooldownFrameTemplate")
local Cooldown_MT = getmetatable(tempCD).__index
tempCD = nil -- discard the temp frame
local function OnCooldownSet(self, start, duration)
-- CRITICAL: Never touch forbidden frames
if self:IsForbidden() then return end
-- Skip disabled or short cooldowns (GCD, etc.)
if not ns.db or not ns.db.enabled then return end
if duration and not issecretvalue(duration) and duration < ns.db.minDuration then
-- Hide any existing overlay
if self.BetterCD then
self.BetterCD:Hide()
end
return
end
-- Secret Values check: if start or duration is secret, hide our text
if issecretvalue(start) or issecretvalue(duration) then
if self.BetterCD then
self.BetterCD:Hide()
end
return
end
-- Create or retrieve our overlay
if not self.BetterCD then
self.BetterCD = ns:CreateOverlay(self)
end
-- Store timing data for OnUpdate
local overlay = self.BetterCD
overlay.start = start
overlay.duration = duration
overlay.endTime = start + duration
overlay:Show()
-- Track for cleanup
activeFrames[self] = true
end
-- Hook with hooksecurefunc — runs AFTER Blizzard's SetCooldown
hooksecurefunc(Cooldown_MT, "SetCooldown", OnCooldownSet)
function ns:InitDisplay()
-- Hook is already installed via the metatable.
-- This function exists for future initialization if needed.
end
IsForbidden() is not optional
Some cooldown frames belong to secure Blizzard UI elements. Accessing properties on a forbidden frame crashes the client. The IsForbidden() check on line 3 of OnCooldownSet is mandatory — remove it and you will get hard crashes in dungeons.
Why metatable hook instead of individual hooks?
Hooking the metatable's SetCooldown catches every Cooldown frame created anywhere in the UI — action bars, inventory, pet bar, other addons. No need to enumerate individual frames. This is the same technique ElvUI and OmniCD use.
The Recursion Guard Question¶
In this case, we do not need a recursion guard. Our hook only reads data and creates overlay frames — it never calls SetCooldown itself. If your hook called a method on the same frame that triggers the hook, you would need the guard:
-- NOT needed here, but shown for reference:
local isProcessing = false
hooksecurefunc(Cooldown_MT, "SetCooldown", function(self, ...)
if isProcessing then return end
isProcessing = true
-- ... code that might trigger SetCooldown again ...
isProcessing = false
end)
Step 5: Countdown Text Display¶
Now we build the visual overlay — a FontString that sits on top of each cooldown frame, showing formatted countdown text with color transitions.
Add this to Display.lua, above the hook:
-- ================================================================
-- Color Thresholds
-- ================================================================
local COLOR_LONG = { r = 0.3, g = 1.0, b = 0.3 } -- Green: > 30s
local COLOR_MEDIUM = { r = 1.0, g = 0.82, b = 0.0 } -- Yellow: 10-30s
local COLOR_SHORT = { r = 1.0, g = 0.2, b = 0.2 } -- Red: < 10s
local COLOR_EXPIRING = { r = 1.0, g = 0.0, b = 0.0 } -- Bright red: < 3s
local function GetColorForRemaining(remaining)
if remaining > 30 then
return COLOR_LONG
elseif remaining > 10 then
return COLOR_MEDIUM
elseif remaining > 3 then
return COLOR_SHORT
else
return COLOR_EXPIRING
end
end
-- ================================================================
-- Time Formatting
-- ================================================================
local function FormatTime(remaining)
if remaining >= 3600 then
return format("%dh", remaining / 3600)
elseif remaining >= 60 then
return format("%dm", remaining / 60)
elseif remaining >= 10 then
return format("%d", remaining)
else
return format("%.1f", remaining)
end
end
-- ================================================================
-- Overlay Creation
-- ================================================================
function ns:CreateOverlay(cooldownFrame)
local overlay = CreateFrame("Frame", nil, cooldownFrame)
overlay:SetAllPoints(cooldownFrame)
overlay:SetFrameLevel(cooldownFrame:GetFrameLevel() + 5)
-- Countdown text
local text = overlay:CreateFontString(nil, "OVERLAY")
text:SetFont("Fonts\\FRIZQT__.TTF", ns.db.fontSize or 14, "OUTLINE")
text:SetPoint("CENTER", overlay, "CENTER", 0, 0)
text:SetJustifyH("CENTER")
overlay.text = text
-- Throttled OnUpdate
local elapsed_total = 0
overlay:SetScript("OnUpdate", function(self, elapsed)
elapsed_total = elapsed_total + elapsed
if elapsed_total < 0.1 then return end
elapsed_total = 0
local now = GetTime()
local remaining = self.endTime - now
if remaining <= 0 then
self:Hide()
activeFrames[cooldownFrame] = nil
return
end
-- Update text
text:SetText(FormatTime(remaining))
-- Update color
local color = GetColorForRemaining(remaining)
text:SetTextColor(color.r, color.g, color.b)
-- Trigger pulse check
if ns.db.pulseEnabled and remaining <= ns.db.pulseThreshold then
ns:CheckPulse(self, remaining)
end
end)
return overlay
end
Why 0.1 Second Throttle?¶
The OnUpdate handler fires every frame — 60+ times per second. Updating text that frequently is wasteful and creates garbage from format() calls. A 0.1 second throttle means we update 10 times per second, which looks perfectly smooth to the eye while generating 6x less garbage.
Performance rule of thumb
For visual updates that humans read as text, 10 updates per second (0.1s throttle) is the sweet spot. For smooth animations like progress bars, 30 updates per second (0.033s throttle) is sufficient. Only truly frame-critical rendering needs every-frame updates.
Font Scaling¶
The FontString inherits the cooldown frame's scale. On a 32x32 action button, 14pt text fills the space well. On a 16x16 inventory cooldown, it will be proportionally smaller. This is intentional — the text scales with the cooldown frame automatically.
Step 6: Pulse Animation¶
When a cooldown drops below 3 seconds, we play a subtle scale bounce to draw the player's eye. This uses WoW's AnimationGroup system — smooth, GPU-accelerated, and zero garbage.
Add this to Display.lua:
-- ================================================================
-- Pulse Animation
-- ================================================================
local function CreatePulseAnimation(overlay)
local ag = overlay:CreateAnimationGroup()
-- Scale up
local scaleUp = ag:CreateAnimation("Scale")
scaleUp:SetScale(1.4, 1.4)
scaleUp:SetDuration(0.15)
scaleUp:SetOrder(1)
scaleUp:SetSmoothing("IN")
-- Scale back down
local scaleDown = ag:CreateAnimation("Scale")
scaleDown:SetScale(1 / 1.4, 1 / 1.4)
scaleDown:SetDuration(0.15)
scaleDown:SetOrder(2)
scaleDown:SetSmoothing("OUT")
overlay.pulseAnim = ag
return ag
end
function ns:CheckPulse(overlay, remaining)
-- Only pulse once per second crossing
local secondMark = math.floor(remaining)
if overlay.lastPulseMark == secondMark then return end
overlay.lastPulseMark = secondMark
-- Create animation on first use
if not overlay.pulseAnim then
CreatePulseAnimation(overlay)
end
-- Play if not already playing
if not overlay.pulseAnim:IsPlaying() then
overlay.pulseAnim:Play()
end
end
The key detail is the threshold crossing check. We track lastPulseMark — the last integer second we pulsed on. Without this, the pulse would fire every 0.1 seconds during the final 3 seconds, creating a jittery mess. Instead, it fires once at 3, once at 2, and once at 1.
Animation performance
WoW's animation system is hardware-accelerated. An AnimationGroup with ScaleAnimation costs essentially nothing compared to manually setting scale in OnUpdate. Always prefer the animation system for visual effects.
Step 7: Action Bar Integration¶
Our metatable hook already catches action bar cooldowns automatically. But there are edge cases: dynamic bar swaps, vehicle bars, and the initial login state where buttons exist before our hook runs. This step handles those.
Add this to Display.lua:
-- ================================================================
-- Action Bar Integration
-- ================================================================
-- Combat queue for deferred operations
local combatQueue = {}
local function RunAfterCombat(fn)
if InCombatLockdown() then
combatQueue[#combatQueue + 1] = fn
return false
end
fn()
return true
end
-- Flush combat queue
local combatFrame = CreateFrame("Frame")
combatFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
combatFrame:SetScript("OnEvent", function()
for i = 1, #combatQueue do
combatQueue[i]()
combatQueue[i] = nil
end
end)
-- Action bar button names to scan
local ACTION_BAR_BUTTONS = {}
local barPrefixes = {
"ActionButton", -- Main bar (1-12)
"MultiBarBottomLeftButton",
"MultiBarBottomRightButton",
"MultiBarRightButton",
"MultiBarLeftButton",
"MultiBar5Button",
"MultiBar6Button",
"MultiBar7Button",
}
for _, prefix in ipairs(barPrefixes) do
for i = 1, 12 do
ACTION_BAR_BUTTONS[#ACTION_BAR_BUTTONS + 1] = prefix .. i
end
end
-- Scan existing cooldowns on login
function ns:ScanExistingCooldowns()
for _, buttonName in ipairs(ACTION_BAR_BUTTONS) do
local button = _G[buttonName]
if button then
local cooldown = button.cooldown or button.Cooldown
if cooldown and not cooldown:IsForbidden() then
local start, duration = cooldown:GetCooldown()
if start and duration and duration > 0
and not issecretvalue(start)
and not issecretvalue(duration)
and duration >= (ns.db.minDuration or 2.5) then
OnCooldownSet(cooldown, start, duration)
end
end
end
end
end
Combat lockdown
Action bar buttons are protected frames. Any operation that modifies their structure — adding children, changing attributes, showing/hiding — can taint them during combat. Our overlay frames are created as children of the Cooldown frame (not the action button itself), which is safe. But if you ever need to restructure the frame hierarchy, always use RunAfterCombat().
Handling Bar Swaps¶
When a player enters a vehicle, uses a bonus action bar, or overrides their action bar, Blizzard calls SetCooldown on the new bar's cooldown frames. Our metatable hook catches this automatically — no special handling needed.
-- Hook for bar swap events — re-scan after swap completes
hooksecurefunc("ActionButton_UpdateCooldown", function(self)
if self:IsForbidden() then return end
local cooldown = self.cooldown or self.Cooldown
if cooldown and not cooldown:IsForbidden() then
local start, duration = cooldown:GetCooldown()
if start and duration and duration > 0
and not issecretvalue(start)
and not issecretvalue(duration)
and duration >= (ns.db.minDuration or 2.5) then
OnCooldownSet(cooldown, start, duration)
end
end
end)
Update ns:InitDisplay() in Display.lua to call the initial scan:
function ns:InitDisplay()
-- Delay initial scan slightly so all bars are loaded
C_Timer.After(1, function()
ns:ScanExistingCooldowns()
end)
end
Step 8: Settings Panel¶
Add a Settings panel so users can configure the addon without editing Lua.
Config.lua¶
-- Mode: Enhancement Artist | Enhance don't replace
local addonName, ns = ...
local function RegisterSettings()
local category, layout = Settings.RegisterVerticalLayoutCategory(addonName)
ns.categoryID = category:GetID()
-- Enable/Disable
do
local variable = "enabled"
local name = "Enable Countdown Text"
local tooltip = "Show countdown numbers on cooldown frames"
local defaultValue = ns.defaults.enabled
local setting = Settings.RegisterAddOnSetting(
category, variable, variable,
ns.db, type(defaultValue), name, defaultValue
)
Settings.CreateCheckbox(category, setting, tooltip)
end
-- Minimum Duration
do
local variable = "minDuration"
local name = "Minimum Duration (seconds)"
local tooltip = "Only show text for cooldowns longer than this"
local defaultValue = ns.defaults.minDuration
local setting = Settings.RegisterAddOnSetting(
category, variable, variable,
ns.db, type(defaultValue), name, defaultValue
)
local options = Settings.CreateSliderOptions(1, 10, 0.5)
Settings.CreateSlider(category, setting, options, tooltip)
end
-- Font Size
do
local variable = "fontSize"
local name = "Font Size"
local tooltip = "Size of the countdown text"
local defaultValue = ns.defaults.fontSize
local setting = Settings.RegisterAddOnSetting(
category, variable, variable,
ns.db, type(defaultValue), name, defaultValue
)
local options = Settings.CreateSliderOptions(8, 24, 1)
Settings.CreateSlider(category, setting, options, tooltip)
end
-- Pulse Animation
do
local variable = "pulseEnabled"
local name = "Pulse Animation"
local tooltip = "Pulse the countdown text when cooldowns are almost ready"
local defaultValue = ns.defaults.pulseEnabled
local setting = Settings.RegisterAddOnSetting(
category, variable, variable,
ns.db, type(defaultValue), name, defaultValue
)
Settings.CreateCheckbox(category, setting, tooltip)
end
-- Pulse Threshold
do
local variable = "pulseThreshold"
local name = "Pulse Threshold (seconds)"
local tooltip = "Start pulsing when cooldown is below this many seconds"
local defaultValue = ns.defaults.pulseThreshold
local setting = Settings.RegisterAddOnSetting(
category, variable, variable,
ns.db, type(defaultValue), name, defaultValue
)
local options = Settings.CreateSliderOptions(1, 10, 1)
Settings.CreateSlider(category, setting, options, tooltip)
end
Settings.RegisterAddOnCategory(category)
end
-- Slash command
SLASH_BETTERCOOLDOWNS1 = "/bcd"
SlashCmdList["BETTERCOOLDOWNS"] = function(msg)
if msg == "reset" then
BetterCooldownsDB = CopyTable(ns.defaults)
ns.db = BetterCooldownsDB
print("|cff00ccffBetterCooldowns|r: Settings reset to defaults. /reload to apply.")
else
Settings.OpenToCategory(ns.categoryID)
end
end
EventUtil.ContinueOnAddOnLoaded(addonName, RegisterSettings)
The modern Settings API automatically binds to your ns.db table — when the user moves a slider, the value is written directly to BetterCooldownsDB.fontSize. No manual callback wiring needed.
Step 9: Edge Cases and Combat Lockdown¶
The Combat Lockdown Dance¶
Action bar cooldown frames are children of secure button frames. While our overlay is a child of the Cooldown frame (not the button), some operations can still propagate taint in subtle ways.
Never do these during combat
- Do not call
SetParent()on any frame attached to an action button - Do not call
SetFrameStrata()on the overlay if its parent is protected - Do not iterate
_Gto find action button frames during combat - Do not create new frames as children of secure frames during combat
The combat queue pattern handles the rare cases where we need to defer:
-- Example: applying font size changes during combat
local function ApplyFontSize(overlay, size)
RunAfterCombat(function()
if overlay and overlay.text then
overlay.text:SetFont("Fonts\\FRIZQT__.TTF", size, "OUTLINE")
end
end)
end
Instance vs. Open World Behavior¶
Full functionality. SetCooldown(start, duration) provides real numbers. Our countdown text, color transitions, and pulse animation all work as designed. The player gets the complete BetterCooldowns experience.
Graceful degradation. When issecretvalue(start) or issecretvalue(duration) returns true, we hide our overlay text. Blizzard's native cooldown spiral continues to function because it receives Duration Objects internally. The player sees the standard spiral without our text — functional, just less informative.
Restriction Detection¶
For more granular control, detect restriction state changes:
function eventHandlers:ADDON_RESTRICTION_CHANGED()
if C_Secrets.HasSecretRestrictions() then
-- Hide all overlays
for cd, _ in pairs(activeFrames) do
if cd.BetterCD then
cd.BetterCD:Hide()
end
end
else
-- Re-scan to restore overlays
ns:ScanExistingCooldowns()
end
end
Do not cache secret state
Restriction state can change mid-session — entering a dungeon, starting a boss encounter, queuing for PvP. Always check issecretvalue() on each update cycle rather than caching a "we're in an instance" flag. The ADDON_RESTRICTION_CHANGED event gives you transition points, but individual values can become secret at different times.
Step 10: Complete Code¶
Here is the full, copy-pasteable code for all four files. Create a BetterCooldowns folder in your Interface/AddOns/ directory and paste each file.
BetterCooldowns.toc¶
## Interface: 120001
## Title: BetterCooldowns
## Notes: Countdown text overlay for Blizzard cooldown frames
## Author: You
## Version: 1.0.0
## SavedVariables: BetterCooldownsDB
## IconTexture: Interface\Icons\Spell_Holy_BorrowedTime
## Category: Combat
Core.lua
Display.lua
Config.lua
Core.lua¶
-- Mode: Enhancement Artist | Enhance don't replace
local addonName, ns = ...
-- Defaults
ns.defaults = {
enabled = true,
minDuration = 2.5,
fontSize = 14,
pulseEnabled = true,
pulseThreshold = 3,
}
-- Event dispatch
local eventHandlers = {}
function eventHandlers:ADDON_LOADED(loadedAddon)
if loadedAddon ~= addonName then return end
if not BetterCooldownsDB then
BetterCooldownsDB = CopyTable(ns.defaults)
else
for k, v in pairs(ns.defaults) do
if BetterCooldownsDB[k] == nil then
BetterCooldownsDB[k] = v
end
end
end
ns.db = BetterCooldownsDB
self:UnregisterEvent("ADDON_LOADED")
end
function eventHandlers:PLAYER_LOGIN()
ns:InitDisplay()
end
function eventHandlers:ADDON_RESTRICTION_CHANGED()
if C_Secrets.HasSecretRestrictions() then
for cd, _ in pairs(ns.activeFrames or {}) do
if cd.BetterCD then
cd.BetterCD:Hide()
end
end
else
C_Timer.After(0.5, function()
ns:ScanExistingCooldowns()
end)
end
end
local eventFrame = CreateFrame("Frame")
eventFrame:SetScript("OnEvent", function(self, event, ...)
local handler = eventHandlers[event]
if handler then
handler(self, ...)
end
end)
for event in pairs(eventHandlers) do
eventFrame:RegisterEvent(event)
end
Display.lua¶
-- Mode: Enhancement Artist | Enhance don't replace
local addonName, ns = ...
-- Active cooldown overlays (shared with Core for restriction cleanup)
local activeFrames = {}
ns.activeFrames = activeFrames
-- ================================================================
-- Combat Queue
-- ================================================================
local combatQueue = {}
local function RunAfterCombat(fn)
if InCombatLockdown() then
combatQueue[#combatQueue + 1] = fn
return false
end
fn()
return true
end
local combatFrame = CreateFrame("Frame")
combatFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
combatFrame:SetScript("OnEvent", function()
for i = 1, #combatQueue do
combatQueue[i]()
combatQueue[i] = nil
end
end)
-- ================================================================
-- Color Thresholds
-- ================================================================
local COLOR_LONG = { r = 0.3, g = 1.0, b = 0.3 }
local COLOR_MEDIUM = { r = 1.0, g = 0.82, b = 0.0 }
local COLOR_SHORT = { r = 1.0, g = 0.2, b = 0.2 }
local COLOR_EXPIRING = { r = 1.0, g = 0.0, b = 0.0 }
local function GetColorForRemaining(remaining)
if remaining > 30 then
return COLOR_LONG
elseif remaining > 10 then
return COLOR_MEDIUM
elseif remaining > 3 then
return COLOR_SHORT
else
return COLOR_EXPIRING
end
end
-- ================================================================
-- Time Formatting
-- ================================================================
local function FormatTime(remaining)
if remaining >= 3600 then
return format("%dh", remaining / 3600)
elseif remaining >= 60 then
return format("%dm", remaining / 60)
elseif remaining >= 10 then
return format("%d", remaining)
else
return format("%.1f", remaining)
end
end
-- ================================================================
-- Pulse Animation
-- ================================================================
local function CreatePulseAnimation(overlay)
local ag = overlay:CreateAnimationGroup()
local scaleUp = ag:CreateAnimation("Scale")
scaleUp:SetScale(1.4, 1.4)
scaleUp:SetDuration(0.15)
scaleUp:SetOrder(1)
scaleUp:SetSmoothing("IN")
local scaleDown = ag:CreateAnimation("Scale")
scaleDown:SetScale(1 / 1.4, 1 / 1.4)
scaleDown:SetDuration(0.15)
scaleDown:SetOrder(2)
scaleDown:SetSmoothing("OUT")
overlay.pulseAnim = ag
return ag
end
local function CheckPulse(overlay, remaining)
local secondMark = math.floor(remaining)
if overlay.lastPulseMark == secondMark then return end
overlay.lastPulseMark = secondMark
if not overlay.pulseAnim then
CreatePulseAnimation(overlay)
end
if not overlay.pulseAnim:IsPlaying() then
overlay.pulseAnim:Play()
end
end
-- ================================================================
-- Overlay Creation
-- ================================================================
local function CreateOverlay(cooldownFrame)
local overlay = CreateFrame("Frame", nil, cooldownFrame)
overlay:SetAllPoints(cooldownFrame)
overlay:SetFrameLevel(cooldownFrame:GetFrameLevel() + 5)
local text = overlay:CreateFontString(nil, "OVERLAY")
text:SetFont("Fonts\\FRIZQT__.TTF", ns.db.fontSize or 14, "OUTLINE")
text:SetPoint("CENTER", overlay, "CENTER", 0, 0)
text:SetJustifyH("CENTER")
overlay.text = text
local elapsed_total = 0
overlay:SetScript("OnUpdate", function(self, elapsed)
elapsed_total = elapsed_total + elapsed
if elapsed_total < 0.1 then return end
elapsed_total = 0
local now = GetTime()
local remaining = self.endTime - now
if remaining <= 0 then
self:Hide()
activeFrames[cooldownFrame] = nil
return
end
text:SetText(FormatTime(remaining))
local color = GetColorForRemaining(remaining)
text:SetTextColor(color.r, color.g, color.b)
if ns.db.pulseEnabled and remaining <= ns.db.pulseThreshold then
CheckPulse(self, remaining)
end
end)
return overlay
end
ns.CreateOverlay = CreateOverlay
-- ================================================================
-- Cooldown Metatable Hook
-- ================================================================
local tempCD = CreateFrame("Cooldown", nil, nil, "CooldownFrameTemplate")
local Cooldown_MT = getmetatable(tempCD).__index
tempCD = nil
local function OnCooldownSet(self, start, duration)
if self:IsForbidden() then return end
if not ns.db or not ns.db.enabled then return end
if duration and not issecretvalue(duration) and duration < ns.db.minDuration then
if self.BetterCD then
self.BetterCD:Hide()
end
return
end
if issecretvalue(start) or issecretvalue(duration) then
if self.BetterCD then
self.BetterCD:Hide()
end
return
end
if not self.BetterCD then
self.BetterCD = CreateOverlay(self)
end
local overlay = self.BetterCD
overlay.start = start
overlay.duration = duration
overlay.endTime = start + duration
overlay.lastPulseMark = nil
overlay:Show()
activeFrames[self] = true
end
hooksecurefunc(Cooldown_MT, "SetCooldown", OnCooldownSet)
-- Also hook Clear to hide overlay when cooldown is cancelled
hooksecurefunc(Cooldown_MT, "Clear", function(self)
if self:IsForbidden() then return end
if self.BetterCD then
self.BetterCD:Hide()
end
activeFrames[self] = nil
end)
-- ================================================================
-- Action Bar Scanning
-- ================================================================
local ACTION_BAR_BUTTONS = {}
local barPrefixes = {
"ActionButton",
"MultiBarBottomLeftButton",
"MultiBarBottomRightButton",
"MultiBarRightButton",
"MultiBarLeftButton",
"MultiBar5Button",
"MultiBar6Button",
"MultiBar7Button",
}
for _, prefix in ipairs(barPrefixes) do
for i = 1, 12 do
ACTION_BAR_BUTTONS[#ACTION_BAR_BUTTONS + 1] = prefix .. i
end
end
function ns:ScanExistingCooldowns()
for _, buttonName in ipairs(ACTION_BAR_BUTTONS) do
local button = _G[buttonName]
if button then
local cooldown = button.cooldown or button.Cooldown
if cooldown and not cooldown:IsForbidden() then
local start, duration = cooldown:GetCooldown()
if start and duration and duration > 0
and not issecretvalue(start)
and not issecretvalue(duration)
and duration >= (ns.db.minDuration or 2.5) then
OnCooldownSet(cooldown, start, duration)
end
end
end
end
end
hooksecurefunc("ActionButton_UpdateCooldown", function(self)
if self:IsForbidden() then return end
local cooldown = self.cooldown or self.Cooldown
if cooldown and not cooldown:IsForbidden() then
local start, duration = cooldown:GetCooldown()
if start and duration and duration > 0
and not issecretvalue(start)
and not issecretvalue(duration)
and duration >= (ns.db.minDuration or 2.5) then
OnCooldownSet(cooldown, start, duration)
end
end
end)
function ns:InitDisplay()
C_Timer.After(1, function()
ns:ScanExistingCooldowns()
end)
end
Config.lua¶
-- Mode: Enhancement Artist | Enhance don't replace
local addonName, ns = ...
local function RegisterSettings()
local category, layout = Settings.RegisterVerticalLayoutCategory(addonName)
ns.categoryID = category:GetID()
do
local variable = "enabled"
local name = "Enable Countdown Text"
local tooltip = "Show countdown numbers on cooldown frames"
local defaultValue = ns.defaults.enabled
local setting = Settings.RegisterAddOnSetting(
category, variable, variable,
ns.db, type(defaultValue), name, defaultValue
)
Settings.CreateCheckbox(category, setting, tooltip)
end
do
local variable = "minDuration"
local name = "Minimum Duration (seconds)"
local tooltip = "Only show text for cooldowns longer than this value. Filters out the GCD."
local defaultValue = ns.defaults.minDuration
local setting = Settings.RegisterAddOnSetting(
category, variable, variable,
ns.db, type(defaultValue), name, defaultValue
)
local options = Settings.CreateSliderOptions(1, 10, 0.5)
Settings.CreateSlider(category, setting, options, tooltip)
end
do
local variable = "fontSize"
local name = "Font Size"
local tooltip = "Size of the countdown text on cooldown frames"
local defaultValue = ns.defaults.fontSize
local setting = Settings.RegisterAddOnSetting(
category, variable, variable,
ns.db, type(defaultValue), name, defaultValue
)
local options = Settings.CreateSliderOptions(8, 24, 1)
Settings.CreateSlider(category, setting, options, tooltip)
end
do
local variable = "pulseEnabled"
local name = "Pulse Animation"
local tooltip = "Play a scale pulse when cooldowns are almost ready"
local defaultValue = ns.defaults.pulseEnabled
local setting = Settings.RegisterAddOnSetting(
category, variable, variable,
ns.db, type(defaultValue), name, defaultValue
)
Settings.CreateCheckbox(category, setting, tooltip)
end
do
local variable = "pulseThreshold"
local name = "Pulse Threshold (seconds)"
local tooltip = "Start the pulse animation below this many seconds remaining"
local defaultValue = ns.defaults.pulseThreshold
local setting = Settings.RegisterAddOnSetting(
category, variable, variable,
ns.db, type(defaultValue), name, defaultValue
)
local options = Settings.CreateSliderOptions(1, 10, 1)
Settings.CreateSlider(category, setting, options, tooltip)
end
Settings.RegisterAddOnCategory(category)
end
SLASH_BETTERCOOLDOWNS1 = "/bcd"
SlashCmdList["BETTERCOOLDOWNS"] = function(msg)
if msg == "reset" then
BetterCooldownsDB = CopyTable(ns.defaults)
ns.db = BetterCooldownsDB
print("|cff00ccffBetterCooldowns|r: Settings reset to defaults. /reload to apply.")
else
Settings.OpenToCategory(ns.categoryID)
end
end
EventUtil.ContinueOnAddOnLoaded(addonName, RegisterSettings)
What You Have Built¶
BetterCooldowns is a complete Enhancement Artist addon that demonstrates the patterns used by production addons like OmniCD, Arc UI, and ElvUI's cooldown module:
- Metatable hook catches every cooldown in the UI with a single
hooksecurefunc - IsForbidden() guards prevent crashes on secure frames
- issecretvalue() checks gracefully degrade in restricted contexts
- Throttled OnUpdate keeps CPU usage minimal
- AnimationGroup provides smooth, GPU-accelerated pulse effects
- Combat queue defers protected operations safely
- Modern Settings API gives users a native-feeling configuration panel
The Enhancement Artist philosophy kept us honest at every step: we hooked, we overlaid, we enhanced — but we never replaced a single Blizzard frame.
Next steps
- Add Masque support so skin addons can style your cooldown overlays
- Hook
SetCooldownFromDurationObjectfor completeness - Add per-frame font scaling based on cooldown frame size
- Register with EditModeExpanded for user-positionable overlay anchors
- Try switching to
/wow-mode boundaryand refactoring to use the Cooldown metatable__indexhook for even broader coverage