Two systems, one NPC
The interesting problem in an LLM-driven NPC is not getting the model to say something plausible. It is what happens next.
The model produces JSON. That JSON is untrusted input from an external service, in exactly the way a request body from a browser is untrusted. So it goes through the same shape of pipeline:
player/event -> BotChatAI history -> DeepSeek JSON -> BotActionResolver
-> BotActorActionRouter shared, actor-agnostic server systems
-> BotWorkflowService stateful, conditional, multi-step
-> BotBehaviorTree.SetPlan or BotActuator.UseAbility
Everything a bot does on its own runs down a second path that never touches the model at all:
MatchService
-> BotRuntime.Init
-> BotDirector spawn, registry, think scheduling
-> BotBehaviorTree.Step
-> BotPerception.Sense -> BotMemory.Record / decay
-> plan and action selection
-> BotActuator | BotLocomotion | BotSocialService
The two meet at exactly one place — SetPlan — which is why a model timeout
degrades to a bot that behaves normally rather than a bot that stops.
The model never supplies a
module path, a coordinate, or anything executable.
It picks a capability the
server advertised, names a target in plain language,
and the server resolves that name itself — case-insensitively, with fuzzy
matching, rejecting anything it cannot resolve rather than guessing.
How it is organised
Thirty-odd
modules under one Bot folder, grouped by responsibility rather
than by type:
Grouping by responsibility usually costs you brittle script.Parent.Parent
chains. It does not here, because nothing addresses a module by path:
-- BotModuleLocator: one stable lookup, so modules can be reorganised freely.
-- Module names must remain unique.
function BotModuleLocator.Require(name: string): any
return require(BotModuleLocator.Wait(name))
end
Every module asks for its dependencies by name — BotModules.Require("BotDirector")
— and the locator caches the lookup. Moving a file between folders breaks
nothing, which is what made a thirty-module reorganisation survivable at all.
Deciding is one pure function
The part that chooses what a bot does next reads nothing from the world. Candidates and a personality go in, one candidate comes out:
export type Personality = {
curiosity: number,
caution: number,
sociability: number,
fear: number,
aggression: number,
reactionLatency: number,
}
-- How much the action already running is favoured. Without this a bot flips
-- between two near-equal options every tick and reads as a malfunctioning robot.
BotBrain.HYSTERESIS = 0.15
function BotBrain.Score(candidate: Candidate, personality: Personality, currentAction: string?): number
local weight = (personality :: any)[candidate.trait]
if typeof(weight) ~= "number" then
-- An untraited action scores on its own merit rather than vanishing.
weight = 1
end
local score = candidate.rawScore * weight
if currentAction ~= nil and candidate.action == currentAction then
score += BotBrain.HYSTERESIS
end
return score
end
So the choice is
with the raw score, the trait the action appeals to, and the bot’s personality vector.
“Chance of reacting” is therefore not a random roll bolted onto a script. It is the personality weight scaling the score, so the same noise makes a curious bot walk over and a cautious one stay put with no branch anywhere saying so.
Personalities are drawn from a seed:
function BotBrain.newPersonality(seed: number): Personality
local rng = Random.new(seed)
return {
curiosity = rng:NextNumber(),
caution = rng:NextNumber(),
sociability = rng:NextNumber(),
fear = rng:NextNumber(),
aggression = rng:NextNumber(),
-- Humans take 200-600ms to react. An instant head-snap toward a noise
-- is the single loudest bot tell there is.
reactionLatency = rng:NextNumber(0.2, 0.6),
}
end
Same seed, same bot — which is what makes a failing round reproducible instead of a story about something that happened once.
Perception is not state access
The behaviour tree could trivially read the position of every
player. It does
not. Sensing goes through raycasts and a sight cone. A target at
is a candidate for an NPC at facing
only when
which is a half-angle of , and then only if the gaze ray reaches them unobstructed. Hearing is a plain radius on broadcast noise events at 60 studs.
Facts land in memory keyed by subject and kind, and decay on a half-life that depends on what kind of fact it is:
Recall only returns facts still above a confidence floor, so an NPC forgets in a way that reads as forgetting rather than as a sudden state flip.
This matters more than it sounds. An NPC that knows where you are, but behaves as though it does not, is a bug waiting to be found. An NPC that genuinely does not know is just correct.
Pathfinding that admits failure
Routing prefers a
voxel A* pass and falls back to the Roblox
navmesh. The
search is ordinary A* over the voxel grid, ordered by
Long searches get a finer grid and a bounded multi-second budget, and the jump arcs that come out of them are checked against the rig’s real capability before being handed to a Humanoid — a jump only survives if its apex clears the step and its range covers the gap:
If neither a physically valid voxel route nor an authored traversal graph route exists, the bot reports a traversal failure. It does not run at a wall, and it does not pretend a twenty-stud jump is possible. Maps can author the hard cases explicitly with a portal graph of node parts, which is how obby-style routes work without the pathfinder inventing a teleport.
Wall hugging is a locked state — rotate once, move sideways, probe for clearance, then exit or repath. The naive version alternates facing every frame, which looks exactly as broken as it is.
Sharing systems with players
The rule that keeps this maintainable: gameplay logic is actor-agnostic. A
weapon system takes a Model, not a Player.
BotActorActionRouter.RegisterShared("Shoot", {
aliases = { "shoot", "fire weapon", "take a shot" },
description = "fire the equipped weapon at a server-resolved target",
module = SharedWeaponSystem,
method = "Fire",
mapArguments = function(intent)
-- The server resolves the named target. Raw coordinates from the
-- model are never accepted.
return { ResolveTargetPosition(intent.target) }
end,
})
Registering it advertises the capability to the model automatically. The player remote and the NPC router call the same function, so there is one place where cooldown, ammo, range and damage are enforced, and it is not duplicated for the NPCs.
The same rule governs the public surface. Anything outside the bot modules gets one predicate and one movement call, and nothing else:
local BotDirector = require(ServerStorage.Modules.Bot.Runtime.BotDirector)
local BotLocomotion = require(ServerStorage.Modules.Bot.Navigation.BotLocomotion)
if BotDirector.IsBot(model) then
-- Never Humanoid:MoveTo. Locomotion owns routing, wall hug, jump arcs
-- and fall recovery; a direct move call skips all four.
BotLocomotion.MoveTo(model, targetPosition)
end
Games work the same way: each one is a module under Games/ that advertises an
id, aliases and a description, and the session coordinator discovers it. The
coordinator has no rules for any specific game. Participants are generic
actors, so the same adapter runs player-versus-NPC and NPC-versus-NPC. There is
one adapter today — noughts and crosses — which exists mostly to prove the
coordinator really is empty of game-specific rules.
BotInitiative applies the same shape to a bot starting something rather than
being asked. It is a bounded opportunity router: an activity registers an
opportunity with a choose and a start, and gets offered bots that match. It
owns no rules and no movement of its own, so adding an activity is a
registration rather than another branch in a growing if chain.
Stopping is a first-class problem
An NPC that will not stop is worse than one that never starts. Stop handling is deliberately hybrid: a short lexical guard on the server is the safety net, and the model is instructed to emit an explicit stop action for the paraphrases — being bored, wanting to do something else, declining to continue.
That way a model timeout cannot keep a workflow alive, and a player does not have to find one magic keyword.
Backpressure is the other half. Everything asynchronous — generated speech, queued intents — goes through one bounded FIFO with a lane per actor:
local MAX_DEPTH = 24
function BotCommandQueue.Enqueue(owner: any, kind: string, callback: () -> (), options: {[string]: any}?): boolean
if type(callback) ~= "function" then return false end
local lane = owner or BotCommandQueue
local depth = depths[lane] or 0
local limit = math.max(1, math.floor(tonumber(options and options.maxDepth) or MAX_DEPTH))
if depth >= limit then
-- Keep the queue bounded. Low-priority work is dropped before it can
-- monopolize a bot; callers can retry on the next model tick.
return false
end
...
end
The lane is what makes it safe: one bot flooded with events cannot starve
another, because depth is counted per owner. And Enqueue returns a boolean
rather than throwing, so a caller that loses the race degrades to trying again
next tick instead of dying.
Spacing is enforced on the same structure — tails[lane] carries the earliest
time the next item may run, so three lines of dialogue arrive paced like speech
rather than as one burst, no matter how fast the model returned them.
What is next
The system runs, and it is not finished. The four things it most needs are all extensions of a seam that already exists, which is the point of having built the seams first.
Personalities that persist and show. Six traits drawn from a seed is enough to make two bots behave differently in the same room, and not enough to make one memorable. The trait vector currently only scales action scores. It should also reach dialogue — vocabulary, sentence length, how readily a bot commits to an accusation — and survive a round, so the cautious one who was right last game is recognisably the same bot this game. The seed already makes that reproducible; nothing stores it yet.
Pathfinding that reads the room. Voxel A* handles geometry. It does not
handle social space: bots take the shortest route through a conversation,
queue by standing inside each other, and cut corners no person would cut.
BotCrowd does separation and ring placement today, which is the primitive.
What is missing is cost — treating other actors, doorways and sightlines as
terrain the search prices in, rather than as obstacles avoided after the fact.
An extension system with no privileged core. Games/ proved a coordinator
can be empty of domain rules, and BotInitiative proved an activity can
register instead of being branched into. The next step is making that the only
way anything is added: perception sources, actions, workflows and activities all
declared through one registration surface, discovered by BotModuleLocator, with
nothing in the core knowing what exists. At that point a new behaviour is a file
someone drops in a folder, and the ceiling on what a bot can do stops being a
function of how much of this codebase you are willing to read.
Tool use. The model can pick an advertised capability, which is the hard,
safety-critical half. What it cannot do is chain — check something, use the
result, decide what to do next — because a capability returns to the model only
as accepted-or-rejected. BotConditionalWorkflow is the beginning of the
answer. Real tool use means a capability can return structured data the model
reads on the next turn, without ever letting it name a module or a coordinate:
the same trust boundary, one step wider.
The thread through all four: none needs the LLM to be trusted with more. They need the server to advertise more, and to say more clearly what happened.



