Skip to content

Navigation

local agent = Twill.Navigation.new(npc, nil, bag)
agent.Arrived:Connect(function(partial)
print("there", partial)
end)
agent:GoTo(workspace.Target)

PathfindingService supplies the route. What it does not supply is anything that keeps a hundred agents from costing a hundred times as much, and that is what this module is: one loop advancing every agent, one budget bounding how many routes are worked out at once, and a request that happens when something changes rather than on a timer.

The loop ticks ten times a second, not every frame, and stops entirely once no agent is moving and nothing is waiting for a route.

GoTo does not yield. It records the goal, takes its turn in the queue, and answers through a signal.

agent:GoTo(target) -- returns immediately
agent.Arrived:Connect(onThere)
agent.Failed:Connect(onGaveUp)

Asking again replaces whatever was asked before, however far along it was. The abandoned request is dropped when it comes back rather than being allowed to race the new one, so exactly one of the two ever reports.

Working out a route costs more the more of them are already under way, which is why a crowd of agents all wanting one at the same moment is the failure this module exists to prevent.

Requests wait in a queue. At most four are worked out at once until you say otherwise:

Twill.Navigation.SetBudget(8)

Nothing is dropped by the queue. A waiting agent keeps walking whatever route it already had.

The point you name is not the point that is routed to. PathfindingService answers against the nearest place an agent can stand, so a goal in the air or under the floor is reached on the ground beneath it rather than refused.

unreachable therefore means no route to anywhere near the goal, not that the point you named was unstandable. A goal five hundred studs below the map is usually an ordinary walk to the floor above it.

Distance is measured across the ground, on the X and Z axes only, so a waypoint directly above or below an agent counts as reached.

A model with a Humanoid needs no configuration. Anything else supplies its own movement:

local drone = Twill.Navigation.new(model, {
Move = function(position)
alignPosition.Position = position
end,
})

Move is called repeatedly while a waypoint is being approached, so it states a destination rather than starting a journey. Nothing has to report back that it finished, and there is no signal to fire: arrival is decided here, from how near the agent actually is.

That is the whole contract. Jump is optional and only called for a waypoint that asks for one. A humanoid’s own jump is skipped while it is in the air.

ReasonWhat happened
unreachableNo route to anywhere near the goal, or the engine refused the request.
too farThe goal is more than 3000 studs away, so no route was attempted.
stuckIt stopped getting nearer to the waypoint it was heading for.
goneThe model left the world mid-journey.
faultedIts Move or Jump raised. The error is reported, and every other agent keeps moving.

Stuck is measured as a lack of progress, not as a clock against an expected speed. An agent that has not closed the distance by half a stud for its whole Patience has stopped travelling whatever it looks like, and that works the same for a humanoid, a drone, and a rolling boulder without any of them being asked how fast they are.

A route is asked for again on its own when something appears across the part of it still ahead.

Two guards keep that from becoming a flood: a blockage already behind the agent is nothing to answer, and one that keeps appearing is answered no more often than Repath allows.

Nothing is reported when this happens. A repath that succeeds is not an event worth waking anybody for, and one that fails arrives as Failed like any other.

Twill.Navigation.new(npc, { Visualize = true })

Neon balls appear under a TwillNavigation folder in the workspace, green for an ordinary step, yellow for a jump, and red for the goal. They are taken down when the route ends.

The folder is made on the first ask, so a game that never visualizes anything has nothing added to its world.

[Server] | [Client]

Creates an agent that walks a model somewhere.

function Navigation.new(model: Model, options: Options?, owner: Scope.Bag?): Agent

Parameters

NameTypeDescription
modelModelWhat is being walked. No PrimaryPart is required.
optionsOptions?Pathfinding parameters, custom movement, and the patience to spend on a waypoint.
ownerScope.Bag?The bag the agent belongs to. Nothing owns it when left out.

Returns

Agent - An agent standing still, waiting for a goal.

Throws when given something other than a Model, when the model has no Humanoid and no Move was given, and on an Arrival, Repath, or Patience outside what it accepts.

On a server the model’s parts are taken into the server’s own simulation, then and for every part added to it later, so where it went is decided there rather than on somebody’s machine. A player’s own character is left alone, since taking that would take their control with it.

The agent is destroyed when its model is destroyed, whether or not it has an owner.

export type Options = {
Agent: { [string]: any }?,
Move: ((position: Vector3) -> ())?,
Jump: (() -> ())?,
Arrival: number?,
Repath: number?,
Patience: number?,
Visualize: boolean?,
}
FieldDefaultMeaning
AgentnonePassed to CreatePath unchanged: AgentRadius, AgentHeight, AgentCanJump, AgentCanClimb, WaypointSpacing, Costs, PathSettings.
Movethe humanoid’sCalled with where to go, repeatedly, while heading there.
Jumpthe humanoid’sCalled once for a waypoint that asks for a jump.
Arrival3How near, in studs, counts as having reached a waypoint. Above zero, and below WaypointSpacing.
Repath0.5Seconds between answering one blockage and the next. Zero or more.
Patience3Seconds without getting nearer before giving up. Above zero.
VisualizefalseWhether to show the route.

PathfindingModifier and PathfindingLink work as they always do; name them in Agent.Costs and Roblox does the rest.

[Server] | [Client]

Sends the agent to a place.

function Agent:GoTo(target: Target)
export type Target = Vector3 | BasePart | Model

Does not yield. The outcome arrives through Arrived or Failed.

Throws on a destroyed agent, and on a target that is none of the accepted shapes.

A part or a model is read at its pivot, once, when the route is worked out. A goal that moves afterwards is not followed; ask again.

[Server] | [Client]

Stops the agent where it stands and drops the route it was following.

function Agent:Stop()

Neither Arrived nor Failed follows, because nothing happened to the agent: stopping is something the caller did. Cancelled fires, and Move is called once with where the agent already is, which is what brings a humanoid to a halt.

[Server] | [Client]

Releases everything the agent holds.

function Agent:Destroy()

Safe to call more than once, and safe to leave to a Scope bag, which is the usual way.

FieldTypeMeaning
ModelModelWhat is being walked.
GoalTarget?What was last asked for.
Route{ PathWaypoint }The waypoints being followed.
IndexnumberWhich one it is heading for. Zero while standing still.
MovingbooleanWhether it is travelling.
PartialbooleanWhether the route stops short of the goal.

These are readings, not settings. Route is the held list, not a copy. Writing into any of them changes what the agent believes without changing where it goes. Steer with GoTo and Stop.

A route is thinned before it is walked: a waypoint less than two studs from the next is dropped, unless it asks for a jump.

Partial follows what the engine reports, and the engine only reports a route that stops short where one was asked for through Agent.PathSettings. Where it never reports one, Partial is always false and Arrived always carries false with it.

A route is read as refused when the engine names one of the ways a search can fail, and as usable otherwise. Testing it that way round means a route arriving under some status this module was never told about is still walked, while an empty one is still refused whatever it was called.

Arrived: Signal<(partial: boolean) -> ()>
Failed: Signal<(reason: Reason) -> ()>
Cancelled: Signal<() -> ()>
SignalFires when
ArrivedThe last waypoint was reached. partial is true when the route stopped short of the goal.
FailedThe journey ended without arriving. See the reasons.
CancelledA goal was asked for, the agent was stopped, or it was destroyed.

Cancelled fires on every GoTo, the first one included, so a listener that unwinds something has one shape to handle rather than two.

[Server] | [Client]

Sets how many routes may be worked out at once across every agent.

function Navigation.SetBudget(limit: number)

Throws when the limit is not a whole number of one or more. Four when never set.

[Server] | [Client]

Returns what has been worked out and what is still waiting.

function Navigation.GetStats(): Stats
export type Stats = {
Computed: number,
Refused: number,
Waiting: number,
Working: number,
Listed: number,
}

Returns

Stats - A snapshot, safe to keep. It does not follow later counting.

Computed and Refused are running totals for the life of the session. Waiting, Working, and Listed are the queue, the requests under way, and the agents in the loop right now. Read it twice a few seconds apart rather than once, and set the budget against what you see.

No sharing of one route between units. Fifty agents chasing the same player work out fifty routes, bounded by the budget rather than combined. That is the next thing to add if a wave-defence game needs it.

No turning agents off by distance. Whether an agent far from every player should still be walking is a decision about your game, not about pathfinding. Call Stop on it.

No pooling of models, and no replication of its own. What a client is told about an NPC is Replication’s job, and sending a route id with a progress number costs far less than sending positions.

LimitValue
Loop tick10 times a second
Routes worked out at once4, until SetBudget
Furthest goal3000 studs
Waypoints closer than this are dropped2 studs
Progress that counts as getting nearer0.5 studs