Custom TrinityCore scripts that put socket-less fake players into the world:
real Player objects with a null-socket WorldSession, driven by their own
decision loop. They fight, gather, craft, quest, repair, trade at the auction
house, group up and talk. Nobody has to be logged in for any of it to happen.
Written for TrinityCore master, retail 12.0.7 (build 68974). Almost all of it
lives under src/server/scripts/Custom/Bots/ and uses public engine APIs, with
one nine-line core patch — bots/core-patch/, applied automatically by the
Docker build. A socket-less bot never runs the login pipeline that loads its
social list, m_social is private with no setter, and the first piece of engine
code to consult it dereferences null.
Each bot runs one decision ladder per world tick. Exactly one stage claims the
tick, so the tally of stages is the bot’s day — and that tally is readable
live with .pbot world time:
29% fighting 22% travelling 20% questing
14% gathering 8% idle 3% town errands
| Stage | Behaviour |
|---|---|
| Death | Dies, waits, revives. After two deaths in one place it moves house — to a different zone, because the zone it is dying in is the trap — and the log says so when there is no refuge outside it |
| Retreat | Breaks off a fight already lost. Decided once and then honoured, because a bot that re-decides every tick swings, steps back, swings again and dies anyway |
| Combat | Class rotations through the engine’s own SpellHistory; ranged classes hold a 20–30 y band, melee close. Cast damage now reaches its target: before the visibility fix every spell was discarded after CheckTarget and only the staff swing worked |
| Rest | Eats and sits when hurt |
| Loot | Loots its own kills |
| Hunt | Picks a fair fight from local wildlife; hostile players outrank wildlife |
| Gather | Mines and picks herbs — the modern GAMEOBJECT_TYPE_GATHERING_NODE, not just old-style chests |
| Town errands | Sells junk, repairs per slot (weapons first), visits the auction house, walks to a forge, buys the profession tool a recipe needs. Whichever destination is nearest wins; worn-out gear jumps the queue |
| Market | Reads its mail, lists surplus, buys upgrades it can actually wear, and wears them. Gear is re-checked every time a bot is reloaded, so bots that predate a gear rule are brought up to their level |
| Craft | Practises one profession per bot, walks to a workbench when a recipe needs one, and buys the tool when the recipe needs that instead |
| Quest | Takes quests, goes where the objectives are — kill targets by their spawn points when a quest has no map marker, item objectives by the creatures that drop them — and returns to hand them in. A giver it could not reach is left alone for 15 minutes instead of being asked for again |
| Delivery | A finished quest whose taker stands on another continent is collected by a deliberate trip, once the waiting work is worth the journey |
| Travel / roam | Stepped long-distance movement, mounts for the long stretches, migrates zone when it outgrows one |
Bots also have personality: greed, diligence, aggression and wanderlust are derived from the guid, so one stops for every ore vein and another walks past them for its whole career. Over an hour that reads as different players.
Every number below is a 30–40 minute soak over 60 bots, before → after. They are here because each one was a defect that looked like working software.
| before | after | what was actually wrong | |
|---|---|---|---|
| Deaths per kill | 15–20% | 4.4% | Not a balance problem. A socket-less bot is invisible to the engine’s own targeting, so every spell target was discarded before damage; with that flag fixed, 8,305 kills against 398 deaths over 6.3 h |
| Deaths | 205 | 9–17 | Not combat weakness. Bots were pinned in loops and died where they stood |
| Quest accepts that did nothing | 3498 | ~1 | AddQuestAndCheckCompletion can decline after CanTakeQuest says yes; the code counted the attempt |
| Day spent on town errands | 20.7% | 3.3% | Errands never finished: the travel step asked the motion generator whether the bot was moving, and a generator outlives its spline |
| Vendor transactions per 30 min | single digits | 74–90 | Same cause — bots now arrive |
| Nodes gathered per 40 min | 6 | 84 | Modern ore and herbs are a game-object type the filter had never accepted, so bots had literally never seen one |
| Bots with a craft skill | 0 | 60 of 60 | Setup was hung on bot creation, which stopped happening once the population became persistent |
Four separate walls stood between a bot and a crafted item, each hidden behind the last: a cast interrupted by walking, a missing forge, sitting on a mount, and — the root of all of it — never being allowed to look at a gathering node.
Full manual: COMMANDS.md — every command, every argument, the config switches, the chat vocabulary companions understand, and what to look at when nothing seems to happen.
The short version, all GM-gated (RBAC_PERM_COMMAND_SERVER) and all usable from
the server console:
.pbot world populate <count> spread a population across the world
.pbot world list who is alive and where
.pbot world time [reset] where the day goes, per activity
.pbot world craft craft -> recipes known -> materials -> workbench
.pbot world market goods, auctioneer reach, mail
.pbot world upkeep bags, durability, vendor reach
.pbot world combat kill/death and target selection
.pbot spawn <count> companion bots that follow YOU
The diagnostics matter as much as the behaviour. Every fix above was found by one of them contradicting an assumption.
The Docker image in this repository does it for you — docker/Dockerfile copies
bots/src into the TrinityCore tree before compiling:
RUN git -C /src apply /core-patch/0001-player-initialize-empty-social.patch
COPY bots/src /src/src/server/scripts/Custom/Bots/
COPY bots/custom_script_loader.cpp /src/src/server/scripts/Custom/custom_script_loader.cpp
By hand, into an existing checkout — all three steps:
cd /path/to/TrinityCore
git apply /path/to/bots/core-patch/0001-player-initialize-empty-social.patch
cp /path/to/bots/src/* src/server/scripts/Custom/Bots/
cp /path/to/bots/custom_script_loader.cpp src/server/scripts/Custom/
cd build && cmake .. -DSCRIPTS=static && make -j$(nproc) && make install
The second file matters more than it looks. bot_script_loader.cpp follows the
standard per-directory convention and defines AddBotsScripts(), but the core
only ever calls AddCustomScripts() — and TrinityCore ships that as an empty
stub. Copy only the sources and you get a clean build, a server that starts
normally, and no .pbot command anywhere: the scripts are compiled in and never
registered.
TrinityCore collects source files at CMake configure time, so re-run cmake
after adding files, not just make.
Cost: a bot is a full Player tick. Sixty bots on five continents run
comfortably in the RAM figures given in docker/INSTALL.md — the memory goes on
the maps and zones they occupy, not on the bots themselves, so spreading them
thin costs more than stacking them.
The bot scripts are built and run continuously on a live server (Ubuntu 24.04,
gcc 13.3.0, TrinityCore c9e3fd8df5cb, SCRIPTS=dynamic) — that is where every
measurement above comes from.
Verified end to end in Docker. docker compose build produces an image with
the bots and the core patch in it; the stack comes up; the world initialises in
about 90 seconds; the scripts register and load their six tables (quest hubs,
population spots, repairers, auctioneers, workbenches, turn-in locations); and
Pbot.WorldPopulation = 10 brings ten bots into the world at startup, which then
gather, learn recipes and break off fights they are losing.
Getting there took three attempts, and each failure was invisible from the source alone:
pbot_mgr.cpp:349: error: 'class Player' has no member
named 'InitializeEmptySocial' — the core patch above, which had lived only in
the live server’s working tree. This README used to claim the bots patch
nothing; the server’s own version string had been printing c9e3fd8df5cb+ for
months, where the + means exactly this.AddCustomScripts(); see custom_script_loader.cpp above.Logger.root = 5 means errors only, and
TrinityCore’s levels run the opposite way to intuition, so every line they
wrote was discarded — a server that looked exactly like one with no bots in
it. Fixed by Logger.scripts.bots = 3,Console in the config template.And one limit, measured the hard way: twenty gigabytes is not enough for the
full retail world plus a scattered population. The host stopped answering SSH
while still replying to pings — starved rather than crashed, which reads as a
hang. See the RAM row in docker/INSTALL.md.
sql/12_zero_hp_templates.sql. Their intended health is not derivable from the
data: the modifier encodes absolute health per expansion and runs to 5e7.These are listed because a reader deserves to know where the edge is. Everything in the table above is measured; everything here is measured too.
These scripts are original work under the repository’s MIT licence. They contain no game assets. Creature, quest and item identifiers referenced in comments are © Blizzard Entertainment and appear only as the coordinates any server-side fix must name.