Help with NPC locomotion architecture (server-driven R15 humanoids)

Help with NPC locomotion architecture (server-driven R15 humanoids)

Hi all, I run a game with a handful of server-driven R15 NPCs (real Humanoid rigs built to look like players). They walk around, path to points of interest, approach players, etc. I’ve gotten them mostly working, but I’ve hit a wall on locomotion polish and I’d love advice from people who’ve shipped believable NPCs.

What I want

  • Stutter-free movement — smooth walking, no micro-pauses, no hitching when they get near a waypoint or near a player.
  • Real map traversal — they currently can’t climb ladders, and jumps over ledges/gaps look janky.
  • Scales to a small crowd (5–10 NPCs) without each one being expensive.

The core of what I have

Server-authoritative, PathfindingService (via a SuperPath/SimplePath-style wrapper) driving Humanoid:MoveTo waypoint-by-waypoint.

1) Calculating the path. Create an agent path and ComputeAsync from the NPC’s current position to the goal:

local path = PathfindingService:CreatePath({
    AgentRadius     = 2.75,   -- deliberately fat: full R15 bodies + shoulder/hat clearance
    AgentHeight     = 5.25,
    AgentCanJump    = true,
    AgentJumpHeight = 8,
    AgentMaxSlope   = 35,
    WaypointSpacing = 3.5,
})

local ok = pcall(function()
    path:ComputeAsync(origin, goal)      -- origin = NPC root position, goal = target
end)
if not ok or path.Status == Enum.PathStatus.NoPath then
    return -- fail / retry
end

local waypoints = path:GetWaypoints()

2) Moving the NPC along the waypoints. This is the actual movement primitive — walk to each waypoint with Humanoid:MoveTo, advance on MoveToFinished, jump if the waypoint says so, and bail via a timeout if it never arrives:

local function travelTo(index)
    local waypoint = waypoints[index]
    if not waypoint then return end

    -- Timeout so a waypoint that never completes doesn't hang the route.
    observer = task.delay(WAYPOINT_TIMEOUT, function()
        onArrived(false)
    end)

    humanoid:MoveTo(waypoint.Position)
    if waypoint.Action == Enum.PathWaypointAction.Jump and not agentInAir then
        humanoid.Jump = true
    end

    moveConn = humanoid.MoveToFinished:Once(function(reached)
        onArrived(reached)
    end)
end

function onArrived(reached)
    if observer then task.cancel(observer) end
    if moveConn then moveConn:Disconnect() end

    -- MoveToFinished reports false at tight corners even when we effectively
    -- arrived, so accept "close enough" instead of failing the route.
    if not reached then
        local d = waypoints[currentIndex].Position - root.Position
        if Vector3.new(d.X, 0, d.Z).Magnitude <= 2.5 then reached = true end
    end

    if reached and waypoints[currentIndex + 1] then
        currentIndex += 1
        travelTo(currentIndex)                 -- next waypoint
    elseif reached then
        -- goal reached
    else
        -- TargetUnreachable -> repath (see below)
    end
end

Things I’ve already tried

Network ownership. I re-assert server ownership every frame while a route is active:

-- inside the route coroutine
while routeActive do
    local ok, owner = pcall(function() return root:GetNetworkOwner() end)
    if ok and owner ~= nil then
        pcall(function() root:SetNetworkOwner(nil) end) -- keep the server driving it
    end
    RunService.Heartbeat:Wait()
end

Bounded repath on stuck:

path.Error:Connect(function(errorType)          -- TargetUnreachable / AgentStuck
    if attempts >= REPATH_LIMIT then finish(true) return end
    attempts += 1
    task.spawn(function()
        task.wait(0.2)                          -- clear the lib's rate-limit
        pcall(function() agent:Run(target) end) -- recompute from current position
    end)
end)

Where I’m still stuck

  1. Residual stutter. There’s still occasional micro-hitching, most noticeable as an NPC closes in on a target/player and around dense waypoints near the goal. (In the logs below you can also see the NPC repeatedly TargetUnreachable-ing while trying to reach a player standing on open floor.)

  2. Ladders / vertical traversal. PathfindingService routes ignore my ladders entirely.

  3. Overall architecture. Is per-waypoint Humanoid:MoveTo even the right primitive for smooth movement, or should I be steering with Humanoid:Move(direction) continuously toward the next waypoint? Should I consider another approach entirely?

Video showing the stutter

Console logs for the session above
Please, if you have any ideas or suggestions comment below.
Happy to share more code. Thanks!

EDIT: It seems that, with :Move() everything works smoothly, no stuttering. DO NOT USE :MoveTo() .

1 Like