Guides › Development

Plugin cookbook

How to use this page

Each recipe is a small piece of ordinary AzerothCore script code, taken from a plugin that ships with LonelyIce or from the core itself. The source is named under each recipe; read it when you need the full picture.

Every class shown is a ScriptObject that you create with new in your Add*Scripts() function, the one your entry macro names (see Your first plugin):

void AddExampleScripts()
{
    new ExamplePlayerScript();
    new ExampleWorldScript();
    new ExampleCommandScript();
}

Three rules hold for every recipe:

  • The code must also run in a plain worldserver of the fork. Use only core interfaces, never the launcher.
  • Reach the databases only through WorldDatabase, CharacterDatabase and LoginDatabase, in the AzerothCore SQL dialect. You never know which backend is behind them (Shipping SQL).
  • Never hard-code the id of a DBC row your plugin adds. Name it in a patch recipe and read the id at run time (Client patches).

PlayerScript, WorldScript and the other hook classes take the list of hooks they use in their constructor. With a list, a hook you override but do not list is never called; list exactly what you use, as the shipped plugins do. The hook classes and their signatures are in src/server/game/Scripting/ScriptDefines/ of the core.

Player login and logout

Use this to load per-character state, greet players or clean up.

#include "Chat.h"
#include "Player.h"
#include "ScriptMgr.h"
#include "WorldSession.h"

class ExamplePlayerScript : public PlayerScript
{
public:
    ExamplePlayerScript() : PlayerScript("ExamplePlayerScript",
        { PLAYERHOOK_ON_FIRST_LOGIN, PLAYERHOOK_ON_LOGIN, PLAYERHOOK_ON_BEFORE_LOGOUT, PLAYERHOOK_ON_LOGOUT }) { }

    void OnPlayerFirstLogin(Player* player) override
    {
        ChatHandler(player->GetSession()).SendSysMessage("Welcome to your first adventure.");
    }

    void OnPlayerLogin(Player* player) override
    {
        if (player->GetSession()->IsBot())
            return;                                   // playerbots log in through the same hook
        ChatHandler(player->GetSession()).PSendSysMessage("Welcome back, {}.", player->GetName());
    }

    // Still in the world here.
    void OnPlayerBeforeLogout(Player* /*player*/) override { }

    // The player is leaving; drop what you keep for it.
    void OnPlayerLogout(Player* /*player*/) override { }
};
  • OnPlayerLogin runs for playerbots as well. WorldSession::IsBot() tells them apart; lonelyice.qol teaches its Sprint spell to every character, bots included, while lonelyice.waystones skips bots.
  • OnPlayerDelete(ObjectGuid guid, uint32 accountId) (PLAYERHOOK_ON_DELETE) runs when a character is deleted; see Per-player data.

Source: mod-lonelyice-waystones/src/waystones.cpp (CustomWaystonesPlayerScript), mod-lonelyice-tactics/src/TacticsDataScripts.cpp.

Level-up

OnPlayerLevelChanged runs right after the new level is applied and gets the old level.

class ExampleLevelScript : public PlayerScript
{
public:
    ExampleLevelScript() : PlayerScript("ExampleLevelScript", { PLAYERHOOK_ON_LEVEL_CHANGED }) { }

    void OnPlayerLevelChanged(Player* player, uint8 oldLevel) override
    {
        if (player->GetSession()->IsBot() || player->GetLevel() <= oldLevel)
            return;
        ChatHandler(player->GetSession()).PSendSysMessage("You reached level {}.", player->GetLevel());
    }
};

Related hooks in PlayerScript.h: OnPlayerGiveXP(Player*, uint32& amount, Unit* victim, uint8 xpSource) runs before experience is given and may change amount.

Source: src/server/game/Scripting/ScriptDefines/PlayerScript.h of the core.

Kills

#include "Creature.h"

class ExampleKillScript : public PlayerScript
{
public:
    ExampleKillScript() : PlayerScript("ExampleKillScript",
        { PLAYERHOOK_ON_CREATURE_KILL, PLAYERHOOK_ON_CREATURE_KILLED_BY_PET, PLAYERHOOK_ON_PVP_KILL }) { }

    void OnPlayerCreatureKill(Player* killer, Creature* killed) override { OnKill(killer, killed); }

    void OnPlayerCreatureKilledByPet(Player* owner, Creature* killed) override
    {
        if (owner)                                    // a pet or totem without a player owner gives nullptr
            OnKill(owner, killed);
    }

    void OnPlayerPVPKill(Player* killer, Player* killed) override
    {
        ChatHandler(killer->GetSession()).PSendSysMessage("You defeated {}.", killed->GetName());
    }

private:
    static void OnKill(Player* player, Creature* killed)
    {
        ChatHandler(player->GetSession()).PSendSysMessage("Killed {} ({}).", killed->GetName(), killed->GetEntry());
    }
};

OnPlayerCreatureKill runs only when a player dealt the killing blow; a kill by a pet or totem calls OnPlayerCreatureKilledByPet with the owner instead. List both hooks if both count. The killer can be a bot.

Source: mod-lonelyice-qol/src/autoloot.cpp (CustomAutoLootPlayerScript); Unit::Kill in the core's Unit.cpp.

World startup and shutdown

OnStartup runs once the world is loaded and the server is ready, with the databases open. Load your tables here, not in constructors or in Add*Scripts(): the databases are not open yet when scripts are created.

class ExampleWorldScript : public WorldScript
{
public:
    ExampleWorldScript() : WorldScript("ExampleWorldScript", { WORLDHOOK_ON_STARTUP, WORLDHOOK_ON_SHUTDOWN }) { }

    void OnStartup() override
    {
        LoadExampleData();                            // e.g. SELECT ... FROM your world table
    }

    void OnShutdown() override { }
};

Source: mod-lonelyice-waystones/src/waystones.cpp (CustomWaystonesWorldScript loads custom_waystone and looks up its spell id), mod-lonelyice-qol/src/sprint.cpp.

Timers on the world tick

WorldScript::OnUpdate(uint32 diff) runs on every world tick on the world thread, with the milliseconds since the last one. Accumulate diff and do the work at your interval.

class ExampleTickScript : public WorldScript
{
public:
    ExampleTickScript() : WorldScript("ExampleTickScript", { WORLDHOOK_ON_UPDATE }) { }

    void OnUpdate(uint32 diff) override
    {
        _timer += diff;
        if (_timer < 10 * IN_MILLISECONDS)
            return;

        _timer = 0;
        // every 10 seconds
    }

private:
    uint32 _timer = 0;
};

Keep the work per tick small. The same pattern works per character in PlayerScript::OnPlayerUpdate(Player*, uint32 diff) (PLAYERHOOK_ON_UPDATE); lonelyice.qol keeps the timer in the character's CustomData and checks bots every 500 ms.

Source: mod-lonelyice-citizens/src/CityLife.cpp (CityLifeWorldScript), mod-lonelyice-qol/src/sprint.cpp.

A delayed action

For "do this to that character in 1.5 seconds", queue a lambda on the object's event list. Capture the GUID, not the pointer, and look the object up again when the event runs.

#include "ObjectAccessor.h"

ObjectGuid const guid = player->GetGUID();
player->m_Events.AddEventAtOffset([guid]()
{
    if (Player* p = ObjectAccessor::FindPlayer(guid))
        if (p->IsAlive())
            ChatHandler(p->GetSession()).SendSysMessage("A moment later...");
}, 1500ms);

Source: mod-lonelyice-waystones/src/waystones.cpp (OfferGroup: bots follow a teleport after 1.5 s).

Chat commands

A CommandScript returns a table of commands. Each entry names a handler, the lowest account level that may use it and whether the server console may run it.

#include "Chat.h"
#include "CommandScript.h"
#include "Player.h"
#include "ScriptMgr.h"

using namespace Acore::ChatCommands;

class ExampleCommandScript : public CommandScript
{
public:
    ExampleCommandScript() : CommandScript("ExampleCommandScript") { }

    ChatCommandTable GetCommands() const override
    {
        static ChatCommandTable exampleCommandTable =
        {
            { "hello",  HandleHello,  SEC_PLAYER,        Console::No  },
            { "status", HandleStatus, SEC_ADMINISTRATOR, Console::Yes },
        };
        static ChatCommandTable commandTable =
        {
            { "example", exampleCommandTable },
        };
        return commandTable;
    }

    // .example hello [name]
    static bool HandleHello(ChatHandler* handler, Optional<std::string> name)
    {
        handler->PSendSysMessage("Hello, {}!", name ? *name : handler->GetPlayer()->GetName());
        return true;
    }

    // .example status <anything>
    static bool HandleStatus(ChatHandler* handler, Tail args)
    {
        handler->PSendSysMessage("example: status, arguments '{}'", std::string(args));
        return true;
    }
};
Level Who
SEC_PLAYER (0) every account
SEC_MODERATOR (1) moderators and above
SEC_GAMEMASTER (2) game masters and above
SEC_ADMINISTRATOR (3) administrators
SEC_CONSOLE (4) the console only
  • Handler arguments are parsed from their types: numbers, bool, std::string, Optional<T> for optional trailing arguments, Tail for the rest of the line. The core's src/server/scripts/Commands/cs_*.cpp show more.
  • A handler with Console::Yes can run without a player: handler->GetPlayer() is then nullptr, so only use it in Console::No commands.
  • Help text comes from the world table command (name, security, help). For a command without a row the server logs a warning at start that the table is missing its help text. A row whose security differs from your table overrides your level. Add rows with your world SQL if you want .help example hello to show something.

Source: mod-lonelyice-citizens/src/CityLife.cpp (.citizen), mod-lonelyice-tactics/src/TacticsEngineScripts.cpp (.tactics), src/server/game/Chat/ChatCommands/ChatCommand.cpp of the core (security check, command table).

Config values

The plugin's .conf.dist and then modules/<name>.conf beside the server's config (configs/modules in LonelyIce) are loaded before scripts are created, so values are readable in Add*Scripts() already. .reload config reads both files again. Read the values into a struct and read them again after a reload:

#include "Config.h"

namespace
{
    struct ExampleConfig
    {
        bool enable = true;
        uint32 intervalMs = 10000;
    };

    ExampleConfig sConfig;

    void LoadConfig()
    {
        ExampleConfig cfg;
        cfg.enable = sConfigMgr->GetOption<bool>("Example.Enable", true, false);
        cfg.intervalMs = std::max<uint32>(1000, sConfigMgr->GetOption<uint32>("Example.IntervalMs", 10000, false));
        sConfig = cfg;
    }
}

class ExampleConfigScript : public WorldScript
{
public:
    ExampleConfigScript() : WorldScript("ExampleConfigScript", { WORLDHOOK_ON_AFTER_CONFIG_LOAD }) { }

    // At startup (reload == false) and after .reload config (reload == true)
    void OnAfterConfigLoad(bool /*reload*/) override { LoadConfig(); }
};
  • The third argument of GetOption is showLogs. With false a missing key silently gives the default, and a value taken from an AC_* environment variable is not logged.
  • Validate and clamp values in code; the file can be edited by hand.
  • Making the options appear in the launcher is covered in Plugin settings.

Source: mod-lonelyice-citizens/src/CityLife.cpp (LoadConfig, called from AddScripts and OnAfterConfigLoad), mod-lonelyice-tactics/src/TacticsEngine.cpp; src/common/Configuration/Config.h of the core.

Database reads and writes

Create your tables with the plugin's SQL (Shipping SQL); query them with the pools from DatabaseEnv.h. Queries are written in the AzerothCore (MySQL) dialect and translated for the active backend.

Ad-hoc queries. Query is synchronous and returns nullptr when no row matched; Execute queues the statement and returns at once. Both take {} placeholders.

#include "DatabaseEnv.h"
#include "Field.h"
#include "QueryResult.h"

ObjectGuid::LowType const guid = player->GetGUID().GetCounter();

// synchronous read
if (QueryResult result = CharacterDatabase.Query("SELECT `waystone` FROM `character_waystone` WHERE `guid` = {}", guid))
{
    do
    {
        uint32 waystone = result->Fetch()[0].Get<uint32>();
        // ...
    } while (result->NextRow());
}

// asynchronous write
CharacterDatabase.Execute("INSERT IGNORE INTO `character_waystone` (`guid`, `waystone`) VALUES ({}, {})", guid, 7);

Strings from players go through EscapeString and into quotes:

std::string text = input;                 // anything a player typed
CharacterDatabase.EscapeString(text);
CharacterDatabase.Execute("REPLACE INTO `example_note` (`guid`, `note`) VALUES ({}, '{}')", guid, text);

Several writes at once go into a transaction:

CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
trans->Append("DELETE FROM `example_note` WHERE `guid` = {}", guid);
trans->Append("INSERT INTO `example_note` (`guid`, `note`) VALUES ({}, '{}')", guid, text);
CharacterDatabase.CommitTransaction(trans);

Asynchronous reads for a player go through the session's query processor. The callback runs later, during the session's update; look the player up again from the session, as the core does:

WorldSession* session = player->GetSession();
session->GetQueryProcessor().AddCallback(CharacterDatabase.AsyncQuery(
    Acore::StringFormat("SELECT `waystone` FROM `character_waystone` WHERE `guid` = {}", guid))
    .WithCallback([session](QueryResult result)
    {
        if (!session->GetPlayer())                    // logged out meanwhile
            return;
        ChatHandler(session).PSendSysMessage("Attuned waycrystals: {}", result ? result->GetRowCount() : 0);
    }));

Prepared statements exist only for the core's own statements; a plugin cannot add statements to the core pools. You may use an existing one:

CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHARACTER_NAME_DATA);
stmt->SetData(0, guid);
if (PreparedQueryResult result = CharacterDatabase.Query(stmt))
{
    uint8 level = (*result)[3].Get<uint8>();   // SELECT race, class, gender, level FROM characters WHERE guid = ?
}

For your own tables use ad-hoc queries. A plugin that needs its own prepared statements owns a whole database, as playerbots does (A database of your own).

Source: mod-lonelyice-waystones/src/waystones.cpp, mod-lonelyice-tactics/src/TacticsStore.cpp (escaping, cache, REPLACE), mod-playerbots/src/Mgr/Guild/GuildTaskMgr.cpp (transactions); in the core Player.cpp (GetQueryProcessor().AddCallback(... AsyncQuery ...)), cs_character.cpp (CHAR_SEL_CHARACTER_NAME_DATA) and src/server/database/Database/DatabaseWorkerPool.h.

Messages to players

All go through a ChatHandler for the player's session (Chat.h).

ChatHandler handler(player->GetSession());

handler.SendSysMessage("A system message in the chat frame.");
handler.PSendSysMessage("With arguments: {} has {} items.", player->GetName(), 3);
handler.SendSysMessage("|cffb48cffColoured text|r");            // the client's colour codes work
handler.SendNotification("A notification on the screen.");

// To every player in the world: use the format + arguments form.
ChatHandler(nullptr).SendWorldText("{} reached level {}!", player->GetName(), player->GetLevel());

SendWorldText sends to every player only in its format form (char const* format plus arguments) or with an acore_string id; called with a single std::string it sends to the handler's own session.

Source: mod-lonelyice-waystones/src/waystones.cpp (Notify), src/server/game/Chat/Chat.h of the core.

Gossip menu on a creature

A CreatureScript answers when a player talks to a creature whose creature_template.ScriptName names the script (and whose npcflag includes 1, gossip). The text above the options is an npc_text row.

#include "Creature.h"
#include "Player.h"
#include "ScriptMgr.h"
#include "ScriptedGossip.h"

enum
{
    NPC_TEXT_EXAMPLE = 9200001,   // npc_text row your world SQL adds
};

class npc_example_healer : public CreatureScript
{
public:
    npc_example_healer() : CreatureScript("npc_example_healer") { }

    bool OnGossipHello(Player* player, Creature* creature) override
    {
        ClearGossipMenuFor(player);
        AddGossipItemFor(player, GOSSIP_ICON_CHAT, "Heal me.", GOSSIP_SENDER_MAIN, GOSSIP_ACTION_INFO_DEF + 1);
        AddGossipItemFor(player, GOSSIP_ICON_CHAT, "Goodbye.", GOSSIP_SENDER_MAIN, GOSSIP_ACTION_INFO_DEF + 2);
        SendGossipMenuFor(player, NPC_TEXT_EXAMPLE, creature);
        return true;
    }

    bool OnGossipSelect(Player* player, Creature* /*creature*/, uint32 /*sender*/, uint32 action) override
    {
        CloseGossipMenuFor(player);
        if (action == GOSSIP_ACTION_INFO_DEF + 1)
            player->SetFullHealth();
        return true;
    }
};

The world SQL that goes with it (ids of your own, replaced wholesale on every run):

DELETE FROM `creature_template` WHERE `entry` = 9200001;
INSERT INTO `creature_template` (`entry`, `name`, `subname`, `minlevel`, `maxlevel`, `faction`, `npcflag`, `ScriptName`) VALUES
(9200001, 'Field Medic', 'Example', 80, 80, 35, 1, 'npc_example_healer');
DELETE FROM `npc_text` WHERE `ID` = 9200001;
INSERT INTO `npc_text` (`ID`, `text0_0`, `Probability0`) VALUES
(9200001, 'Need patching up?', 1);

A real creature also needs a model row (creature_template_model); copy the full set of rows from 2026_09_25_01_waystones.sql. For menus deeper than one level, encode the page in sender and action, as lonelyice.waystones does (continent, zone, crystal).

Source: mod-lonelyice-waystones/src/waystones.cpp (npc_custom_waystone) and data/sql/db-world/2026_09_25_01_waystones.sql; ScriptedGossip.h of the core.

Gossip menu on an item

An ItemScript is bound by item_template.ScriptName. OnUse runs when the item is used; show a menu with the item's GUID as the source, and the player's choice comes back in OnGossipSelect.

#include "Item.h"

class item_example_book : public ItemScript
{
public:
    item_example_book() : ItemScript("item_example_book") { }

    bool OnUse(Player* player, Item* item, SpellCastTargets const& /*targets*/) override
    {
        ClearGossipMenuFor(player);
        AddGossipItemFor(player, GOSSIP_ICON_CHAT, "Read the first page.", GOSSIP_SENDER_MAIN, GOSSIP_ACTION_INFO_DEF + 1);
        SendGossipMenuFor(player, NPC_TEXT_EXAMPLE, item->GetGUID());
        return true;                                  // handled: the item's own spell is not cast
    }

    void OnGossipSelect(Player* player, Item* /*item*/, uint32 /*sender*/, uint32 action) override
    {
        CloseGossipMenuFor(player);
        if (action == GOSSIP_ACTION_INFO_DEF + 1)
            ChatHandler(player->GetSession()).SendSysMessage("It is written in an old tongue.");
    }
};
  • Returning true from OnUse stops the core from casting the item's spell. The core notes that a script which stops the cast must itself answer the client, or the item can stay greyed out (SpellHandler.cpp).
  • The client sends a use only for items that have a use spell.

Source: ItemScript.h, Handlers/SpellHandler.cpp and Handlers/MiscHandler.cpp (gossip choices on items) of the core. No shipped plugin uses item gossip yet.

Creature scripts and spawns

Permanent spawns are rows in the world table creature, added by your world SQL; the creature's ScriptName binds a CreatureScript. lonelyice.waystones spawns one crystal per inn this way, with its own GUID range, and deletes that range before inserting it again.

AI. Return a ScriptedAI from GetAI to react to the world around the creature:

#include "ScriptedCreature.h"

class npc_example_watcher : public CreatureScript
{
public:
    npc_example_watcher() : CreatureScript("npc_example_watcher") { }

    struct npc_example_watcherAI : public ScriptedAI
    {
        npc_example_watcherAI(Creature* creature) : ScriptedAI(creature) { }

        void MoveInLineOfSight(Unit* who) override
        {
            if (Player* player = who->ToPlayer())
                if (me->IsWithinDistInMap(player, 15.0f))
                    { /* the player came close */ }
        }

        void AttackStart(Unit* /*who*/) override { }   // never fights
        void UpdateAI(uint32 /*diff*/) override { }
    };

    CreatureAI* GetAI(Creature* creature) const override
    {
        return new npc_example_watcherAI(creature);
    }
};

A creature script can have both GetAI and the gossip hooks, as npc_custom_waystone does.

Temporary spawns at run time:

#include "ObjectMgr.h"
#include "TemporarySummon.h"

if (sObjectMgr->GetCreatureTemplate(entry))
    if (TempSummon* summon = player->SummonCreature(entry, player->GetPositionX(), player->GetPositionY(),
            player->GetPositionZ(), player->GetOrientation(), TEMPSUMMON_TIMED_DESPAWN, 60 * IN_MILLISECONDS))
        summon->SetReactState(REACT_PASSIVE);

TEMPSUMMON_MANUAL_DESPAWN keeps the creature until your code removes it.

Source: mod-lonelyice-waystones/src/waystones.cpp (npc_custom_waystoneAI), mod-lonelyice-tactics/src/TacticsSim.cpp (SummonCreature in the simulator); Object.h of the core.

Spell scripts

A SpellScript changes what a spell does. It is bound to a spell id through the world table spell_script_names. For a spell your plugin adds, that row belongs in the patch recipe's install list with {{id:name}}, so it gets the id given out on installation (Client patches).

#include "SpellScript.h"
#include "SpellScriptLoader.h"

class spell_example_blink : public SpellScript
{
    PrepareSpellScript(spell_example_blink);

    void HandleTeleport(SpellEffIndex effIndex)
    {
        PreventHitDefaultEffect(effIndex);          // replace the effect with your own
        if (Player* player = GetHitUnit() ? GetHitUnit()->ToPlayer() : nullptr)
        {
            // ...
        }
    }

    void Register() override
    {
        OnEffectHitTarget += SpellEffectFn(spell_example_blink::HandleTeleport, EFFECT_0, SPELL_EFFECT_TELEPORT_UNITS);
    }
};

void AddExampleScripts()
{
    RegisterSpellScript(spell_example_blink);
}

Source: mod-lonelyice-waystones/src/waystones.cpp (spell_custom_translocation) and its data/patches.json.

Translated texts

Declare the languages your player-facing texts are translated into with locales in plugin.json (the manifest reference). Then pick one of three ways, by where the text lives.

Content rows (creatures, gossip texts, quests) use the core's locale tables next to your rows, so the client shows them in its language:

DELETE FROM `creature_template_locale` WHERE `entry` = 9200001;
INSERT INTO `creature_template_locale` (`entry`, `locale`, `Name`, `Title`) VALUES
(9200001, 'ruRU', 'Полевой лекарь', 'Пример');
DELETE FROM `npc_text_locale` WHERE `ID` = 9200001;
INSERT INTO `npc_text_locale` (`ID`, `Locale`, `Text0_0`) VALUES
(9200001, 'ruRU', 'Подлатать вас?');

Messages go into module_string (English) and module_string_locale (koKR, frFR, deDE, zhCN, zhTW, esES, esMX, ruRU), keyed by a module name and a number. The core loads them at world start and picks the session's language, falling back to English:

DELETE FROM `module_string` WHERE `module` = 'example.greeter';
INSERT INTO `module_string` (`module`, `id`, `string`) VALUES
('example.greeter', 1, 'Welcome back, {}.');
DELETE FROM `module_string_locale` WHERE `module` = 'example.greeter';
INSERT INTO `module_string_locale` (`module`, `id`, `locale`, `string`) VALUES
('example.greeter', 1, 'ruRU', 'С возвращением, {}.');
ChatHandler(player->GetSession()).PSendModuleSysMessage("example.greeter", 1, player->GetName());

A missing string is logged (Module string module ... id ... not found in DB.) and shows as error.

Texts in code can choose by the session's client locale, which is what lonelyice.waystones does for its menus:

char const* Text(Player const* player, char const* en, char const* ru)
{
    return player->GetSession()->GetSessionDbcLocale() == LOCALE_ruRU ? ru : en;
}

Texts in patch recipes and in settings.json are localized objects instead; see Client patches and Plugin settings.

Source: mod-lonelyice-waystones (Text() in waystones.cpp, *_locale rows in its world SQL); mod-aoe-loot and mod-transmog, packaged by mod-aoe-loot-plugin and mod-transmog-plugin (module_string, PSendModuleSysMessage); ObjectMgr::LoadModuleStrings in the core.

Per-player data

In memory, for the session. Every WorldObject has CustomData, a key-value map of your own types. The data lives as long as the object, so it is gone after logout.

#include "DataMap.h"

struct ExampleState : public DataMap::Base
{
    uint32 timer = 0;
    bool active = false;
};

// in OnPlayerUpdate(Player* player, uint32 diff)
ExampleState* state = player->CustomData.GetDefault<ExampleState>("example_state");   // created on first use
state->timer += diff;

Use a key unique to your plugin.

In memory, keyed by character. A map by GUID, filled on login and cleared on logout. The core calls hooks from several threads (map updates, sessions, the world), so guard shared containers with a mutex:

#include <mutex>
#include <set>
#include <unordered_map>

namespace
{
    std::mutex sLock;
    std::unordered_map<ObjectGuid::LowType, std::set<uint32>> sKnown;
}

// OnPlayerLogin: query outside the lock, then store
std::set<uint32> loaded;   // filled from CharacterDatabase.Query(...)
{
    std::lock_guard<std::mutex> guard(sLock);
    sKnown[player->GetGUID().GetCounter()] = std::move(loaded);
}

// OnPlayerLogout
{
    std::lock_guard<std::mutex> guard(sLock);
    sKnown.erase(player->GetGUID().GetCounter());
}

Persistent. A table in the characters database keyed by the character's GUID, created by your SQL. Write on change, read on login, and delete the rows when the character is deleted:

void OnPlayerDelete(ObjectGuid guid, uint32 /*accountId*/) override       // PLAYERHOOK_ON_DELETE
{
    CharacterDatabase.Execute("DELETE FROM `example_note` WHERE `guid` = {}", guid.GetCounter());
}

Source: mod-lonelyice-qol/src/sprint.cpp (BotSprintState in CustomData), mod-lonelyice-waystones/src/waystones.cpp (sAttuned, sPlayerLock), mod-lonelyice-tactics/src/TacticsStore.cpp and TacticsDataScripts.cpp (cached key-value store, cleanup on delete); src/common/Utilities/DataMap.h of the core.

Talking to a client addon

A plugin and its addon (shipped with client.addons) exchange addon messages. The server sends a whisper in LANG_ADDON to the player from the player; the addon sends its requests the same way, and the plugin takes them out of the chat before the core handles them.

#include "WorldPacket.h"

void SendAddon(Player* player, std::string const& body)
{
    std::string const wire = std::string("EXMP\t") + body;       // "<prefix>\t<body>"
    WorldPacket data;
    ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER, LANG_ADDON, player, player, wire);
    player->SendDirectMessage(&data);
}

class ExampleAddonScript : public PlayerScript
{
public:
    ExampleAddonScript() : PlayerScript("ExampleAddonScript", { PLAYERHOOK_CAN_PLAYER_USE_PRIVATE_CHAT }) { }

    bool OnPlayerCanUseChat(Player* player, uint32 /*type*/, uint32 lang, std::string& msg, Player* receiver) override
    {
        if (lang != LANG_ADDON || msg.compare(0, 5, "EXMP\t") != 0)
            return true;                                          // not ours: let the core handle it

        if (receiver == player)
            SendAddon(player, "PONG\t" + msg.substr(5));
        return false;                                             // swallowed
    }
};

Source: mod-lonelyice-waystones/src/waystones.cpp (prefix KTWS), mod-lonelyice-tactics/src/TacticsDataScripts.cpp (prefix BTAC).

Another plugin and your plugin's folder

sPluginMgr (PluginMgr.h) knows every plugin the server found:

#if __has_include("PluginMgr.h")
#include "PluginMgr.h"
#endif

// Files shipped in the plugin's lua/ folder; the old module path when built as a classic static module.
std::string ScriptDir()
{
#if __has_include("PluginMgr.h")
    if (PluginInfo const* plugin = sPluginMgr->Find("example.greeter"))
        return (plugin->dir / "lua").generic_string();
#endif
    return "lua_scripts/example";
}
  • Find(id) returns a plugin whether or not it loaded (loaded, error); IsLoaded(id) tells whether it did. Use IsLoaded for an optional integration with a plugin you do not depends on.
  • PluginInfo::dir is the plugin's folder. Only data, sql, conf, lua and client are laid out there by AddPlugin, so ship run-time files in one of them.
  • Treat the folder as read-only. Installing or updating a package removes the old folder first. Write your own files elsewhere, for example under the server's LogsDir, as the tactics simulator does.
  • The __has_include guard keeps the code building as a classic module against a core without the plugin loader.

Source: mod-lonelyice-tactics/src/TacticsEngine.cpp (DefaultScriptDir), mod-npc-beastmaster-plugin/patches/ (the module's profanity.txt found through Find), mod-lonelyice-tactics/src/TacticsSim.cpp (OutputDir); lonelyice/src/packages/PackageManager.cpp (install and update).

Playerbots

playerbots is a plugin. A plugin that uses its code declares and links it:

"depends": { "playerbots": ">=1.0.0" }
AddPlugin(example-botpal SOURCES ${SOURCES} plugin/plugin.cpp LINK mod-playerbots)

The core must be built with -DWITH_PLAYERBOTS_HOOKS=ON (the hooks playerbots needs in the core). LonelyIce's build sets it; for a plain worldserver of the fork, pass it yourself.

Bot or player, and whose bot.

#include "Playerbots.h"

Player* RealMaster(Player* bot)
{
    PlayerbotAI* ai = GET_PLAYERBOT_AI(bot);                // nullptr for a real player
    Player* master = ai ? ai->GetMaster() : nullptr;
    if (!master || master == bot || !master->GetSession() || master->GetSession()->IsBot())
        return nullptr;
    return master;
}

New strategies, actions and triggers. PlayerbotExternalContexts (ExternalContexts.h) adds named objects to every bot's contexts. Register in Add*Scripts(), before playerbots builds its shared contexts:

#include "ExternalContexts.h"

class ExampleStrategyContext : public NamedObjectContext<Strategy>
{
public:
    ExampleStrategyContext() : NamedObjectContext<Strategy>(false, false)
    {
        creators["example"] = [](PlayerbotAI* botAI) -> Strategy* { return new ExampleStrategy(botAI); };
    }
};

void AddExampleScripts()
{
    PlayerbotExternalContexts::Register<Strategy>([]() -> NamedObjectContext<Strategy>* { return new ExampleStrategyContext(); });
    // Register<Action> and Register<Trigger> work the same way
}

ExampleStrategy is your Strategy subclass. The strategy is then available by name like the built-in ones.

Pinning and decorating bots. PlayerbotExternalHooks::Register(isPinned, decorate) (ExternalHooks.h) lets a plugin keep random bots in place (isPinned) and change their strategies whenever playerbots resets them (decorate(bot, engine, botState)). Every registered pair is kept: a bot is pinned when any isPinned says so, and every decorate runs, in registration order (lonelyice.citizens registers one too). Either function may be empty.

The context registries live once in the playerbots library (ExternalContexts.cpp, exported), so contexts your plugin registers reach playerbots even though your plugin is a separate library. Link against playerbots as the plugins above do. This holds from playerbots 1.0.4 on; before it each library had its own copy of the registries and the contexts never reached the bots. A plugin that calls PlayerbotExternalContexts::Register therefore declares "depends": { "playerbots": ">=1.0.4" }, as lonelyice.citizens does. That range also guarantees that every plugin's PlayerbotExternalHooks::Register is kept; early builds kept only the last one.

Source: mod-lonelyice-qol/src/sprint.cpp and CMakeLists.txt, mod-lonelyice-tactics/src/TacticsEngineScripts.cpp, mod-lonelyice-citizens/src/CityLife.cpp and CitizenStrategy.cpp; mod-playerbots/src/Bot/Engine/ExternalContexts.h, ExternalHooks.h/.cpp; CMakeLists.txt of the core and of LonelyIce (WITH_PLAYERBOTS_HOOKS).