Intelligent NPC Dialogue
Moongate can optionally use OpenAI to let selected NPC brains:
- reply to nearby player speech
- speak on their own when players are nearby
- keep a compact long-term memory per NPC
This is wired through the Lua module ai_dialogue.
This feature is separate from authored dialogue:
- use
dialoguefor deterministic Lua conversation trees - use
ai_dialoguefor OpenAI-backed generative replies - use
common.npc_dialogueonly if you want deterministic dialogue first and OpenAI as fallback
Configuration
Enable it in moongate.json under Llm:
{
"Llm": {
"IsEnabled": true,
"Model": "gpt-5-mini"
}
}
API key options:
- recommended:
OPENAI_API_KEYenvironment variable - alternative:
Llm.ApiKeyinmoongate.json - precedence:
Llm.ApiKeywins if both are set
Prompt Files
Static NPC persona prompts live under:
moongate_data/templates/npc_ai_prompts/*.txt
Example:
moongate_data/templates/npc_ai_prompts/lilly.txt
These files should describe:
- identity
- tone and personality
- world knowledge
- roleplay rules
Memory Files
Persistent NPC memories live under:
moongate_data/runtime/npc_memories/<npcSerial>.txt
Example:
moongate_data/runtime/npc_memories/0x012314.txt
The runtime keeps these as compact summaries, not raw full chat logs.
Lua API
The module exposes:
ai_dialogue.init(npc, "lilly.txt")ai_dialogue.listener(npc, sender, text)ai_dialogue.idle(npc)
Typical pattern:
function lilly.on_spawn(npc_id, _ctx)
local npc = mobile.get(npc_id)
if npc == nil then
return
end
ai_dialogue.init(npc, "lilly.txt")
end
function lilly.on_speech(npc_id, speaker_id, text, _speech_type, _map_id, _x, _y, _z)
local npc = mobile.get(npc_id)
local speaker = mobile.get(speaker_id)
if npc == nil or speaker == nil then
return
end
if ai_dialogue.listener(npc, speaker, text) then
return
end
npc:say("hello to you, " .. speaker.name .. "!")
end
function lilly.on_think(npc_id)
while true do
local npc = mobile.get(npc_id)
if npc ~= nil then
ai_dialogue.idle(npc)
end
coroutine.yield(1000)
end
end
Recommended hybrid pattern:
local npc_dialogue = require("common.npc_dialogue")
local config = {
conversation_id = "innkeeper",
prompt_file = "innkeeper.txt",
}
function innkeeper.on_spawn(npc_id, _ctx)
local npc = mobile.get(npc_id)
if npc ~= nil then
npc_dialogue.init(npc, config)
end
end
function innkeeper.on_speech(npc_id, speaker_id, text, _speech_type, _map_id, _x, _y, _z)
local npc = mobile.get(npc_id)
local speaker = mobile.get(speaker_id)
if npc == nil or speaker == nil then
return
end
if npc_dialogue.listener(npc, speaker, text, config) then
return
end
npc:say("Posso aiutarti in altro?")
end
How It Works
At runtime, an intelligent NPC goes through this flow:
- A Lua brain decides when to trigger AI dialogue.
- The brain calls
ai_dialogue.listener(...)orai_dialogue.idle(...). - Moongate resolves the NPC prompt file and current memory summary.
- A dialogue request is built with:
- NPC name
- prompt text
- compact memory
- nearby player names
- player speech text for listener triggers
- The expensive OpenAI request is scheduled off the game loop.
- The model returns structured JSON:
should_speakspeech_textmemory_summarymood
- Back on the game loop, Moongate:
- saves updated memory if present
- makes the NPC speak if
should_speakis true
This keeps the shard responsive while still letting the NPC behave intelligently.
Runtime Components
The main pieces are:
- Lua brain script
- decides when to ask for AI dialogue
ai_dialogueLua module- entry point exposed to scripts
NpcDialogueService- builds request context and applies final response
OpenAiNpcDialogueClient- talks to
openai-dotnet
- talks to
NpcAiPromptService- loads persona prompt files from disk
NpcAiMemoryService- loads and saves compact NPC memory summaries
IAsyncWorkSchedulerService- runs slow LLM work in background and posts completion back to the game loop
Listener vs Idle
ai_dialogue.listener(npc, sender, text) is for reactive speech:
- a nearby player says something
- the NPC may answer that specific speech
- the sender name and heard text are included in the request
ai_dialogue.idle(npc) is for autonomous chatter:
- used from a brain loop or timer
- only runs when players are nearby
- the NPC may say a short in-character line even if nobody just spoke
Prompt and Memory Roles
Prompt files and memory files do different jobs:
- prompt file
- static persona and roleplay rules
- checked into the repo
- memory file
- compact evolving summary for one NPC instance
- updated at runtime
In practice:
lilly.txtdefines who Lilly is0x0016A5.txtstores what that spawned Lilly currently remembers
This separation keeps the persona stable while still letting the NPC learn and remember.
Runtime Behavior
- If
Llm.IsEnabledisfalse, AI dialogue stays silent. - If the prompt file is missing, the NPC will not call OpenAI.
- If OpenAI fails or returns no speech, the NPC stays silent unless your Lua brain provides a fallback.
- Idle chatter only runs when players are nearby and cooldown allows it.
Async Execution Model
ai_dialogue.listener(...) and ai_dialogue.idle(...) do not call OpenAI inline on the Lua brain tick.
Instead, Moongate uses a two-stage async flow:
- The Lua brain schedules dialogue generation work.
- The OpenAI request runs on a background worker.
- The final result is posted back onto the game loop.
- Only the compact world-state changes happen on the game loop:
- save updated NPC memory
- make the NPC speak
This matters because slow LLM calls would otherwise block the timer phase and stall the whole server.
Current behavior:
- one in-flight AI request per NPC
- listener speech is queued per NPC and processed in order
- idle requests still respect the single in-flight guard
- replies can arrive slightly later, which is expected and preferred over blocking the shard
Under the hood this is powered by IAsyncWorkSchedulerService, a reusable queue abstraction built on top of the background job service.
Benchmark
The benchmark suite includes NpcDialogueSchedulingBenchmark to measure the cost of the async dialogue scheduling path without putting OpenAI latency on the game loop.
Run it with:
dotnet run --project benchmarks/Moongate.Benchmarks/Moongate.Benchmarks.csproj -c Release -- --filter "*NpcDialogueSchedulingBenchmark*" --job Dry
Latest measured dry-run values on Apple M4 Max / .NET 10:
QueueListener_EnqueueOnly- median:
2.729 us - mean:
183.0 us - max first-iteration outlier:
2.133 ms - allocated:
592 B
- median:
ScheduleAndComplete_SingleNpc- median:
1.170 ms - mean:
1.258 ms - max first-iteration outlier:
2.169 ms - allocated:
1552 B
- median:
RejectDuplicate_InFlight- median:
1.270 ms - mean:
1.073 ms - max first-iteration outlier:
2.696 ms - allocated:
1288 B
- median:
These Dry results are intentionally cold-start heavy, so the median is the more useful value for the steady-state scheduling path.
Current Scope
The current v1 integration is focused on dialogue only:
- no autonomous gameplay actions
- no combat decisions
- no world mutation from model output
That keeps the system debuggable and safe while still making NPCs feel more alive.