Moving humanoids in a formation

Hey, and thanks for reading in advance.

I’m working on a small-scale tower defense game of sorts, and while I’ve got the enemy pathing down to something functional, I’m looking to refine it a bit.

Currently, the zombies use the yellow goal parts (and their offset from it) to determine which direction to move and for how long. What I’m looking to do is create the capability for a zombie to move on the inside/outside of the path without needing to create extra goal points or input manual coordinates - my goal is for the AI to calculate the requested path on-the-fly.

If anyone has any tips or advice on how I can produce this, I’d be grateful.

Current AI:

local SS = game.ServerStorage
local RS = game.ReplicatedStorage

local Figure = script.Parent
local Humanoid = Figure:WaitForChild("Humanoid")
local Animator = Humanoid:WaitForChild("Animator")
local Root = Figure:WaitForChild("HumanoidRootPart")

local path = workspace.PathSet
local Display = script.Display

--
local ugly = {5379168692, 133322927, 1158091668, 185301839, 1158094454}
local order = {path.Start}
local goals = {}

local isDead = false
local inCombat = false

local lastAttack = tick() - 1
local attackRate = 1

local MinDamage = 1
local MaxDamage = 3

--
local function NewAnim(id, name)
	local Animation = Instance.new("Animation")
	Animation.AnimationId = "rbxassetid://"..id
	Animation.Name = name or Figure.Name.."Animation"
	Animation.Parent = Humanoid
	return Animator:LoadAnimation(Animation)
end

--
local attack = NewAnim(6038549630, "Attack")

local blockFolder = Instance.new("Folder")
blockFolder.Name = "Blockers"
blockFolder.Parent = Figure

local nextGoal = Instance.new("ObjectValue")
nextGoal.Name = "NextGoal"
nextGoal.Parent = Figure

local toNext = Instance.new("NumberValue")
toNext.Name = "ToNext"
toNext.Parent = Figure

--
local function Swipe(target)
	local tRoot = target:FindFirstChild("HumanoidRootPart")
	local gore = {4988621662, 4988621968, 4988622242, 4988625180}
	local hit = nil
	
	hit = attack:GetMarkerReachedSignal("Hit"):Connect(function()
		if target.Parent then
			target.Humanoid:TakeDamage(math.random(MinDamage, MaxDamage))
			RS.Remotes.EffectSpoof:FireAllClients("Sound", {id = gore[math.random(1, #gore)], v = .3, pos = tRoot})
		end
		
		hit:Disconnect()
	end)
	
	if tRoot then
		local tPos = target.HumanoidRootPart.Position
		Root.CFrame = CFrame.new(Root.Position, Vector3.new(tPos.X, Root.Position.Y, tPos.Z))
	end
	
	attack:Play()
	RS.Remotes.EffectSpoof:FireAllClients("Sound", {id = ugly[1], v = .3, pos = Figure.PrimaryPart})
	
	attack.Stopped:Wait()
	hit:Disconnect()
end

local function moveTo(targetPoint, andThen)
	local targetReached = false
	local connection
	
	connection = Humanoid.MoveToFinished:Connect(function(reached)
		if #blockFolder:GetChildren() <= 0 and not inCombat then
			targetReached = true; connection:Disconnect()
			connection = nil
			
			if andThen then
				andThen()
			end
		end
	end)

	Humanoid:MoveTo(targetPoint)

	while not targetReached do
		local blockers = blockFolder:GetChildren()
		
		if isDead then return
			
		elseif #blockers > 0 or inCombat then
			repeat
				Humanoid:MoveTo(Root.Position)
				blockers = blockFolder:GetChildren()
				
				if (tick() - lastAttack) >= attackRate and inCombat then
					if #blockers > 0 then
						local target = blockers[1].Value
						lastAttack = tick(); Swipe(target)
					else inCombat = false
					end
					
				else wait()
				end
			until #blockers <= 0 and not inCombat
		end
		
		Humanoid:MoveTo(targetPoint)
		toNext.Value = (targetPoint - Root.Position).Magnitude
		wait()
	end

	if connection then
		connection:Disconnect()
		connection = nil
	end
end

--
Display.Parent = Figure:WaitForChild("Head")

Humanoid.HealthChanged:Connect(function(health)
	Display.HP.Bar.Size = UDim2.new(health/Figure.Humanoid.MaxHealth, 0, 1, 0)
end)

Humanoid.Died:Connect(function()
	if not isDead then isDead = true
		Display.Enabled = false
		game:GetService("Debris"):AddItem(Figure, 1)
		attack:Stop()
	end
end)

Figure.CombatStart.Event:Connect(function()
	inCombat = true
end)

--
for _,part in pairs(Figure:GetDescendants()) do
	if part:IsA("BasePart") then
		part:SetNetworkOwner(nil)
	end
end

spawn(function()
	while Figure.Parent and wait() do
		local interval = math.random(3, 12)
		local now = tick()

		repeat wait() until tick() - now >= interval
			or not Figure.Parent

		if Figure.Parent and math.random(1, 100) <= 50 then
			RS.Remotes.EffectSpoof:FireAllClients("Sound", {
				id = ugly[math.random(1, #ugly)], pos = Figure.PrimaryPart,
				v = .3, p = 1.6, s = 4
			})
		end
	end
end)

--
for _,goal in pairs(path:GetChildren()) do
	if goal.Name:find("Goal") then
		table.insert(goals, goal)
	end
end

table.sort(goals, function(a, b)
	local nA = tonumber(a.Name:sub(5, -1))
	local nB = tonumber(b.Name:sub(5, -1))
	return nA < nB
end)

for _,goal in pairs(goals) do
	table.insert(order, goal)
end

table.insert(order, path.Exit)

for i,goal in ipairs(order) do
	if isDead then break
	elseif goal.Name ~= "Start" then
		local offset = CFrame.new()
		local previous = order[i - 1]

		if Figure:FindFirstChild("SpawnOffset") then
			offset = Figure.SpawnOffset.Value
		end

		local fromLast = (goal.Position - previous.Position).Unit
		local walkTo = CFrame.new(goal.Position, goal.Position + fromLast) * offset

		local dir = (walkTo.Position - Root.Position).Unit

		nextGoal.Value = goal
		moveTo(walkTo.Position + dir)
	end
end

if not isDead then
	Figure:Destroy()
end

How about creating a random value ‘zone’ around the target point? Your path is 12 studs wide, so make the zombie’s target point a random CFrame value + or - 4 studs from the target point Part’s X,Z values.

This may cause them to cross paths, but you seem to have them spaced apart a bit so I don’t think that’ll make a huge difference.

Thanks, but that’s more or less what they do now. They use their offset from the spawn plate to determine the offset from each goalpart to walk to - I’m looking to see if I can make them move on the inside/outside edge of the road.

Not sure if this would help but you could try doing pathfinding as that finds the quickest route but if thats not what you need you can do this.

You said you move them based on the yellow part so you could just make 2 sets of yellow parts for the inside and the outside and then have them move along those paths.

Ah, in that case you may need a set of 5 or so goal points in a diagonal line on each corner, with the inner track goals in one folder, the next ones over in another folder, and so on. Then assign each zombie a random folder of waypoints when they spawn.
I think trying to solve it as a human is easy using the description, but trying to get a series of zombies to walk straight routes across a wide path but staying offset & parallel to the central goal points is quite a mathematical formula.