← All projects
Game AI

Smart NPCs

Roblox NPCs that sense the world honestly, remember what they saw, and take instructions from an LLM without ever being trusted by it.

Challenge

An NPC driven straight from a language model does whatever the model says — walks through walls, teleports, uses abilities it does not have. An NPC driven only by a behaviour tree cannot hold a conversation.

Solution

Split interpretation from execution. The model may only pick from capabilities the server advertises, and every choice it makes is normalised, validated and routed through the same server-authoritative systems that players go through.

  • Roblox
  • Luau
  • Game AI
  • Pathfinding
  • LLM
Voxel cells coloured red where blocked and green where clear, with the NPC's search state overlaid
Voxel pathfinding and clearance probes1/2
Smart NPCs media

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.

Two independent paths drive one NPC: an autonomous loop of perception, memory, decision and action that always runs, and an optional language-model path that resolves untrusted JSON into a validated plan. The two converge only at SetPlan. ALWAYS RUNS Perceptioncone + raycast Memorydecaying facts Brainscore, pick one OPTIONAL model JSON resolve validate untrusted — rejected, never guessed SetPlanthe only join Actuatorlocomotion, abilities
Cut the lower path entirely and the NPC still plays. That is the test the split exists to pass.

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:

The Bot folder is grouped into nine areas: Perception, Runtime and AI on the first row; Navigation, Actions and Social on the second; Workflows, Games and Support on the third. ServerStorage.Modules.Bot PerceptionBotPerception · BotMemoryBotSmartZoneService RuntimeBotDirector · BotBehaviorTreeBotRuntime · BotInitiative AIBotBrain · BotChatAIBotActionResolver NavigationBotLocomotion · BotVoxelPathBotGrounding · BotCrowd ActionsBotActorActionRouterBotActuator · BotAnimation SocialBotConversationServiceBotInteractionService WorkflowsBotWorkflowServiceBotGameSessionService Gamespluggable adaptersTicTacToeGame SupportBotModuleLocatorBotCommandQueue · BotDebug
Plus a Tests folder — the pure modules are unit-testable without a match running, which is most of why they are pure.

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

a  =  argmaxaC  (rawτ(a)  +  0.15[a=aprev])a^{*} \;=\; \arg\max_{a \in C}\; \Bigl(\, r_a \cdot w_{\tau(a)} \;+\; 0.15 \cdot [\,a = a_{\text{prev}}\,] \Bigr)

with rar_a the raw score, τ(a)\tau(a) the trait the action appeals to, and ww 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 pt\mathbf{p}_t is a candidate for an NPC at ps\mathbf{p}_s facing f^\hat{\mathbf{f}} only when

f^ptpsptps    0.77andptps90\hat{\mathbf{f}} \cdot \frac{\mathbf{p}_t - \mathbf{p}_s}{\lVert \mathbf{p}_t - \mathbf{p}_s \rVert} \;\ge\; 0.77 \qquad\text{and}\qquad \lVert \mathbf{p}_t - \mathbf{p}_s \rVert \le 90

which is a half-angle of arccos0.7739.7\arccos 0.77 \approx 39.7^\circ, 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:

  c(t)  =  c02t/t1/2  t1/2={20sposition600ssuspicion\boxed{\;c(t) \;=\; c_0 \cdot 2^{-t / t_{1/2}}\;} \qquad t_{1/2} = \begin{cases} 20\,\mathrm{s} & \text{position} \\ 600\,\mathrm{s} & \text{suspicion} \end{cases}

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

f(n)  =  g(n)cost so far  +  h(n)admissible estimatef(n) \;=\; \underbrace{g(n)}_{\text{cost so far}} \;+\; \underbrace{h(n)}_{\text{admissible estimate}}

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:

hmax=vy22gdmax=vxz2vygh_{\max} = \frac{v_y^2}{2g} \qquad d_{\max} = v_{xz} \cdot \frac{2 v_y}{g}

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.

What it shipped

  • Honest perception: 90-stud sight, a 40-degree cone, occlusion raycasts and a 60-stud hearing radius — no omniscient state
  • Memory with per-fact half-lives, from 20 s for positions to 600 s for suspicion
  • Voxel A* as the primary router, Roblox navmesh as fallback, with jump arcs validated before a Humanoid ever sees them
  • Wall hugging as a locked traversal state instead of a per-frame facing flip
  • Actor-agnostic action registry, so one weapon system serves players and NPCs alike
  • Bounded FIFO between model output and execution, with generation tokens that discard superseded commands
  • Behaviour stays valid when the model is unavailable
  • Seeded six-trait personalities scaling every decision, reproducible from the seed
  • Thirty-odd modules grouped by responsibility, addressed by name rather than by path

More projects