What's actually running
A persistent 4-character party companion crew, plus three standalone "oracle" NPCs you can whisper or party-chat for real answers -- all voiced by a local LLM, all posted into the game through real character identities.
The companion crew (see the functional page for who they actually are) are real DB characters, each on its own dedicated account. So are the three oracles. Neither is a scripted illusion -- every line you see in chat is generated on request by a locally-hosted 3B model and posted through a real core-level chat command.
No paid API anywhere in this stack. Every generation call goes to KoboldCpp running on a second machine on the LAN. This was a deliberate choice after a prior, unrelated bad experience with a metered API burning through credits fast -- see the "why the LLM runs on a separate machine" section for the actual selection process.
Three commands that don't exist in stock VMaNGOS
Nothing in VMaNGOS lets you post a chat message under an existing character's name without that character having a live session. Three small, deliberate core patches fixed that, one per chat surface -- each is a one-time addition to Chat.h/Chat.cpp/PlayerBotMgr.cpp, rebuilt and swapped into the live binary.
| Command | Reaches | How |
|---|---|---|
.chansay | World channels (General/Trade) | Channel::Say, works even if the speaking character is offline |
.partysay | A live party/raid | Group::BroadcastPacket -- the real player must be online and grouped; there's no offline fallback for party chat since it fundamentally doesn't exist without a live group |
.whispersay | A specific player's session | Builds a real CHAT_MSG_WHISPER packet and sends it directly, mirroring the game's own whisper-sending code path |
All three resolve the speaking character's GUID by name -- even offline -- then build a chat packet with that identity as the sender. The client has no way to tell the difference between this and a real player typing.
Party chat and world channels are architecturally separate systems. The first version of this only had .chansay, which is built on the channel-membership system -- it flatly cannot reach party chat no matter how it's called. Realizing this early avoided a lot of wasted debugging later.
Why the LLM runs on a separate machine
KoboldCpp runs on a second box on the LAN, not on the same Raspberry Pi as the actual game server. LLM inference is CPU-heavy, and the world server (mangosd) already had one mysterious hang during unrelated work -- deliberately adding a second heavy, competing workload to the same hardware was the wrong call.
KoboldCpp specifically (over Ollama, llama.cpp directly, LM Studio, or LocalAI) won on being a single static binary with a persistent-character/roleplay tuning pedigree and one less moving part than the alternatives, all of which could have worked. It exposes an OpenAI-compatible endpoint, so the actual Python calling code is a small, boring HTTP client with no vendor lock-in if that decision ever gets revisited.
The service lifecycle is tied together, not managed by hand. Starting or stopping the game server through the admin panel also starts/stops the LLM backend on the other machine, over a forced-command SSH key that can do nothing except that one action.
A two-axis relationship model, not a chat log
Each companion has two independent, slowly-growing numbers instead of one hand-tuned dial: affinity (how often they speak up at all) and warmth (how they speak once they do). Both grow from real accumulated interaction history in a SQLite table, not from a flat random chance.
- Affinity grows fast, caps around 90% response chance -- being addressed by name overrides it entirely for a near-guaranteed reply.
- Warmth grows slower and gates tone, not frequency, across four tiers from guarded-and-transactional up to a romantic-eligible top tier -- a companion can be at max response frequency while still early in their warmth arc, or the reverse.
- A rude message costs a real but temporary mood dip, never the permanent warmth score -- and a genuine apology clears it immediately instead of only ever waiting out a timer.
Every companion "hears" every line in party chat, whether they're the one who replies or not -- that was a deliberate fix after an early version only credited the one companion who happened to get picked, which meant anyone starting from zero relationship almost never accumulated enough history to grow out of it.
Standalone oracles: a different mechanism entirely
Three separate NPCs -- a Game Master, a Lore Master, and an Engineer -- work nothing like the ambient companion crew. No frequency roll, no relationship score: whisper one, or say their name in party chat, and they always answer.
One of them is provably grounded in fact rather than model improvisation: the Engineer's status reports are backed by a real check -- actual local service status plus a real reachability check against the LLM backend and this project's own issue tracker -- run fresh every single time he's asked, never cached, never guessed.
The other two will occasionally invent a specific-sounding but false detail. A small local model asked something outside its actual knowledge will confidently make something up rather than admit it doesn't know -- an explicit instruction reduces this, but doesn't eliminate it. Worth knowing before treating anything oddly specific from them as fact.
Automated level-sync, manual gearing
A background thread checks the player's real level against each companion's every 60 seconds and syncs anyone behind, via the same SOAP path everything else uses. Gear is deliberately not automated -- it's handled by hand while a companion is loaded, since that's already the moment you'd want to gear them anyway.
Real bugs found in real play
The wrong-turns-included part.
Characters spoke in party chat regardless of who was actually grouped. The delivery command has no concept of real party membership on its own -- it'll post as anyone regardless. Caught live when a companion answered while only one other character was actually in the party. Fixed with a real live-roster check before any party-chat reply fires, now shared by the whole crew and every oracle.
Generation started timing out once real knowledge got added. Longer, more grounded prompts pushed processing time right past the original timeout on CPU-only inference. An easy fix once measured directly -- but a good reminder that adding real context isn't free.
Every real bug here was caught through actual play, not code review alone -- a running theme worth stating plainly rather than pretending this was designed perfectly the first time.