Skip to content

Walk NPCs around the world

An agent wraps a model. Give it a bag and its cleanup is somebody else’s problem.

ServerScriptService/Services/GuardService
local Twill = require("@game/ServerScriptService/Twill")
local GuardService = {}
function GuardService.Start()
local guard = workspace.Guard
local agent = Twill.Navigation.new(guard, nil, Twill.Scope.Framework())
agent.Arrived:Connect(function()
agent:GoTo(pickAPost())
end)
agent.Failed:Connect(function(reason)
Twill.Log.new("Guard"):Warn(`gave up: {reason}`)
agent:GoTo(pickAPost())
end)
agent:GoTo(pickAPost())
end
return GuardService

A model with a Humanoid needs no configuration at all. The agent uses its walking and its jumping.

The wrong way is to ask for a route every frame. Working one out is expensive, and a target that shifted two studs does not need a new one.

Ask again when the goal has actually moved:

local REPLAN_DISTANCE = 12
local function chase(agent, target: Model, bag)
local asked = nil
Twill.Loop.Every(0.5, function()
local at = target:GetPivot().Position
if asked and (at - asked).Magnitude < REPLAN_DISTANCE then
return
end
asked = at
agent:GoTo(at)
end, bag)
end

Half a second, not every frame, and only when the target has left the area the last route was aimed at. A hundred agents doing this is a hundred cheap distance checks and very few routes.

GoTo accepts a Model or a BasePart, and reads its position at the moment the route is worked out. That is enough for a target that strolls. The pattern above is for one that runs.

function CombatService.OnPlayerReady(player, data, bag)
local pet = spawnPet(player)
-- Closes when they leave, which takes the agent with it.
Twill.Navigation.new(pet, nil, bag)
end

An agent in a bag is destroyed when the bag closes: its loop entry, its connections, its Path, and any markers it was showing. There is no teardown to write. An agent is also destroyed when its model is destroyed, whether or not it has a bag.

For an NPC that belongs to a round rather than a player, use the round’s own bag. For one that lives as long as the server, use Scope.Framework().

Supply a Move. It is called with a destination, repeatedly, while the agent is heading there, so it states where to go rather than starting a journey.

local mover = Instance.new("AlignPosition")
mover.Mode = Enum.PositionAlignmentMode.OneAttachment
mover.Attachment0 = drone.PrimaryPart.Attachment
mover.Parent = drone.PrimaryPart
Twill.Navigation.new(drone, {
Agent = { AgentCanJump = false },
Move = function(position)
mover.Position = position
end,
}, bag)

Nothing has to report that a move finished. Arrival is decided from how near the model actually is, which is the same test for every kind of agent.

Pathfinding costs are passed straight through. Label the parts, price the labels, and routes bend around them.

Twill.Navigation.new(npc, {
Agent = {
AgentRadius = 3,
AgentCanClimb = true,
Costs = {
Water = 20,
Lava = math.huge,
SafePath = 0.5,
},
},
})

Water and Lava are materials. SafePath is whatever you named a PathfindingModifier. A cost of math.huge is avoided unless there is no other way at all.

Two numbers matter, and both are read rather than guessed.

local stats = Twill.Navigation.GetStats()
print(stats.Waiting, stats.Working, stats.Listed)

Waiting climbing and staying high means agents are asking for routes faster than the budget allows. Either raise it:

Twill.Navigation.SetBudget(8)

or, better, ask for fewer: widen the replan distance, lengthen the interval, and stop agents nobody is near.

-- Whether a far-away NPC should still be walking is a decision about your
-- game, so the module does not make it for you.
if (npcAt - nearestPlayer).Magnitude > 250 then
agent:Stop()
end

Raising the budget makes the server work harder. Asking for fewer routes makes it work less. Reach for the second one first.

Nothing, by default. The server walks the model and Roblox replicates the parts.

Where a client needs to know something about the journey rather than the body, publish that rather than positions:

Twill.Replication.SetFor(player, "Pet", {
State = "following",
Distance = math.floor(distance),
})

Sending a state and a number costs a fraction of sending a position every frame, and it is the thing the interface actually wanted.