Tower Defense Game - Troubleshooting Wonky / Glitchy NPC Movement

Hi there, so I’ve been working recently on a Tower Defense game for quiet some time and I am still trying to refine my NPC Movement so it looks smooth, fluid like movement as tho the enemies glide across the path just like for example in Tower Defense X for the Enemy NPCs.

I am using the Catmull Rom Spline to generate points across a path for my NPCs to follow so I have smooth round corner movements, the performance is nice except I can’t really perfect the movement.

I tried playing around with lerping, tweening, CFrame Pivot, CFrame Lookat, and a lot more but I’m having trouble really figuring out the right thing for enemy movement system.

I could really use some help out here if someone could peer review the code and or suggest me ideas or show me examples of what I should do / use for my movement system since I’m stuck and out of ideas. Any help is much appreciated.

I have attached a copy of the game where I am testing the movement system, there’s only 2 Main Scripts really and some Events in Replicated Storage.

The server side script in Server Side Script Storage responsible for handling the movement every less then a second, spawning enemies, pre-generating random paths using the spline function, update the table of enemies, check when an enemy is done, and so on.

The client side script in Starter Player Scripts called the DummyMovementClientSide is responsible for actually rendering the enemies on the client side as well as synchronizing between the server and the client, check if it’s done on the client, and so on.

And then in Replicated Storage 1 Remote Event and 1 Remote Function, SpawnedEnemy responsible for firing to all clients whenever a new enemy is spawned, and RequestPaths responsible for sending a copy of a table of paths from the server to the client.

Thanks again.

prototypeMain19_StillTestingMovement1.rbxl (101.8 KB)

9 Likes

If you would like to see what I mean by janky weird glitchy movement of the NPCs, please download the copy of the game I attached and play test to see how the enemy movement looks like.

3 Likes

Try using ‘Humanoid:MoveTo()’ I’ve heard others had success with it.

3 Likes

Not good for the long term, way too laggy.

3 Likes

How many Mobs are we speaking about?

3 Likes

Hundreds. I’ve already tested with Humanoids and MoveTo, it’s not good.

1 Like

Also here’s an old way I used to move my enemies:

Server Side:

-- NPC Spawning Table Putting Function

local Precision = 10^2

local ServerTableOfEnemies = {}

local EnemyId = "1";

function SpawnNPC()

	local EnemyInfo = {
		["EnemyId"] = EnemyId;
		["Node"] = "1";
		["Speed"] = 4;
		["Path"] = math.random(1,100);
		["CFrame"] = CFrame.lookAt(game.Workspace.Points["1"].Position, game.Workspace.Points["2"].Position);
		["Done"] = false;
	}

	ServerTableOfEnemies[EnemyInfo.EnemyId] = EnemyInfo;
	EnemyId = EnemyId + 1;

	local ClientEnemyInfo = {
		[1] = EnemyInfo.EnemyId;
		[2] = Vector3int16.new(EnemyInfo.CFrame.X * Precision, EnemyInfo.CFrame.Y * Precision, EnemyInfo.CFrame.Z * Precision);
		[3] = EnemyInfo.Node; -- Node
		[4] = 4; -- Speed
		[5] = EnemyInfo.Path; -- Path
		[6] = workspace.DistributedGameTime, -- Time in server, used to sync
	}

	SpawnedEnemy:FireAllClients(ClientEnemyInfo)
end

-- Actual Movement Handling

function HandleMovement()
	for i,Enemy in pairs(ServerTableOfEnemies) do
		if (Enemy.Done == true) then
			print("Server is Done")
			ServerTableOfEnemies[i] = nil
		else
			Enemy.CFrame = Enemy.CFrame:ToWorldSpace(CFrame.new(0,0,-0.1 * Enemy.Speed))
			local magnitude = (Enemy.CFrame.Position - tableOfPaths[Enemy.Path][Enemy.Node + 1].Position).Magnitude
			-- print(Enemy.Node)
			if (Enemy.CFrame.Position - tableOfPaths[Enemy.Path][Enemy.Node + 1].Position).Magnitude <= 1 then
				Enemy.Node += 1
				if Enemy.Node == #tableOfPaths[Enemy.Path] then
					Enemy.Done = true
				else
					Enemy.CFrame = CFrame.new(tableOfPaths[Enemy.Path][Enemy.Node].Position, tableOfPaths[Enemy.Path][Enemy.Node + 1].Position)
				end
			end
		end
	end 
end

-- Main Routine

local enemyCount = 200;

task.spawn(function()
	for _ = 1, enemyCount do
		task.wait(0.05) -- Spawn delay between each unit
		SpawnNPC()
	end
end)

while true do
	task.wait(0.01)
	HandleMovement()
end

Client Side:

--- Some client stuff

local Precision = 10^2

local tableOfPaths = game.ReplicatedStorage.RequestPaths:InvokeServer()
local tableOfEnemies = {}

function Decompile(EnemyData)
	return {
		["EnemyId"] = EnemyData[1],
		["EnemyPosition"] = EnemyData[2],
		["Node"] = EnemyData[3],
		["Speed"] = EnemyData[4],
		["Path"] = tableOfPaths[EnemyData[5]],
		["SyncTime"] = workspace.DistributedGameTime - EnemyData[6],
		["Done"] = false,
	}
end

game.ReplicatedStorage.SpawnedEnemy.OnClientEvent:Connect(function(EnemyInfo)
	local ClientTableEnemyInfo = Decompile(EnemyInfo)
	local key = ClientTableEnemyInfo["EnemyId"]

	tableOfEnemies[key] = ClientTableEnemyInfo

	local CFrameNoOrientation = CFrame.new(ClientTableEnemyInfo["EnemyPosition"].X / Precision, ClientTableEnemyInfo["EnemyPosition"].Y / Precision, ClientTableEnemyInfo["EnemyPosition"].Z / Precision)

	-- local CFrameWithOrientation = CFrameNoOrientation * CFrame.Angles(0, ClientTableEnemyInfo["EnemyRotation"].Y / Precision, 0)

	local cloneClientSide = game.ReplicatedStorage.ClientSide:Clone()
	cloneClientSide.Name = ClientTableEnemyInfo["EnemyId"]
	cloneClientSide.Parent = game.Workspace.Mobs
	cloneClientSide:PivotTo(CFrame.new(cloneClientSide.Position, CFrameNoOrientation.Position))
end)

function HandleMovement()
	for i,Enemy in pairs(tableOfEnemies) do
		local clientTarget = game.Workspace.Mobs[Enemy["EnemyId"]]

		if Enemy.Done then
			print("Client is Done")
			tableOfEnemies[i] = nil

			game.Debris:AddItem(clientTarget, 0)
		else
			clientTarget.CFrame = clientTarget.CFrame:ToWorldSpace(CFrame.new(0, 0, -0.1 * Enemy.Speed))

			local magnitude = (clientTarget.CFrame.Position - Enemy.Path[Enemy.Node + 1].Position).Magnitude

			if magnitude <= 1 then
				Enemy.Node += 1

				if Enemy.Node == #Enemy.Path then
					Enemy.Done = true
				else
					if Enemy.SyncTime then
						clientTarget.CFrame = CFrame.new(Enemy.Path[Enemy.Node].Position, Enemy.Path[Enemy.Node + 1].Position + (Enemy.Path[Enemy.Node + 1].Position * Enemy.SyncTime))
						Enemy.SyncTime = nil
					else
						clientTarget.CFrame = CFrame.new(Enemy.Path[Enemy.Node].Position, Enemy.Path[Enemy.Node + 1].Position)
					end
				end
			end
		end
	end 
end

while true do
	task.wait(0.01)
	HandleMovement()
end

I preferred this solution way more but each time the enemies would pass at a corner or at place where there are a lot of pre-generated nodes then they would slow down, and in areas where there were less nodes they would speed up so I tried playing around and go to where I’m at now.

1 Like

I changed up the code a little, now I’m not noticing any sync issues but the code still doesn’t look right, sharp / weird turns as well as unbalanced movement even though they’re all going at the same speed, just different paths.

Server Side code:

-- Remote Events and Remote Functions
local SpawnedEnemy = game.ReplicatedStorage:WaitForChild("SpawnedEnemy")
local RequestPaths = game.ReplicatedStorage:WaitForChild("RequestPaths")

-- Spline Calculations
local tableOfPaths = {}

function DrawPath()
	local NodesTable = {}
	local tensionMain = math.random(1, 100) / 100
	local counter = 0
	local Points = {}

	for i = 1, #game.Workspace.Points:GetChildren() do
		Points[i] = game.Workspace.Points[i].Position
	end

	local NumPoints = #Points
	for i = 1, NumPoints - 1 do
		local p0 = Points[i - 1] or Points[1]
		local p1 = Points[i]
		local p2 = Points[i + 1]
		local p3 = Points[i + 2] or Points[NumPoints]
		for j = 0, 1, 0.05 do
			local tension = tensionMain
			local t = j
			local t2 = t * t
			local t3 = t2 * t

			local p = (
				(-tension * t + 2 * tension * t2 - tension * t3) * p0 +
					(2 + (tension - 6) * t2 + (4 - tension) * t3) * p1 +
					(tension * t - 2 * (tension - 3) * t2 + (tension - 4) * t3) * p2 +
					(-tension * t2 + tension * t3) * p3
			) * 0.5

			counter = counter + 1
			NodesTable[counter] = { Position = p }
		end
	end

	return NodesTable
end

for i = 1, 100 do
	tableOfPaths[i] = DrawPath()
end

function RequestPath()
	return tableOfPaths
end

RequestPaths.OnServerInvoke = RequestPath

task.wait(5)

local Precision = 10^2
local ServerTableOfEnemies = {}
local EnemyId = 1

function SpawnNPC()
	local EnemyInfo = {
		["EnemyId"] = EnemyId,
		["Node"] = 1,
		["Speed"] = 25,
		["Path"] = math.random(1, 100),
		["CFrame"] = CFrame.lookAt(game.Workspace.Points["1"].Position, game.Workspace.Points["2"].Position),
		["Done"] = false,
	}

	ServerTableOfEnemies[EnemyInfo.EnemyId] = EnemyInfo
	EnemyId = EnemyId + 1

	local ClientEnemyInfo = {
		[1] = EnemyInfo.EnemyId,
		[2] = Vector3int16.new(EnemyInfo.CFrame.X * Precision, EnemyInfo.CFrame.Y * Precision, EnemyInfo.CFrame.Z * Precision),
		[3] = EnemyInfo.Node,
		[4] = EnemyInfo.Speed,
		[5] = EnemyInfo.Path,
		[6] = workspace.DistributedGameTime,
	}

	SpawnedEnemy:FireAllClients(ClientEnemyInfo)
end

-- Actual Movement Handling

function HandleMovement()
	for i, Enemy in pairs(ServerTableOfEnemies) do
		if Enemy.Done then
			print("Server is Done")
			ServerTableOfEnemies[i] = nil
		else
			local path = tableOfPaths[Enemy.Path]
			if path then
				local currentNode = path[Enemy.Node]
				local nextNode = path[Enemy.Node + 1]

				if currentNode and nextNode then
					local direction = (nextNode.Position - Enemy.CFrame.Position).Unit
					local distanceToNextNode = (nextNode.Position - Enemy.CFrame.Position).Magnitude

					-- Move NPC by a constant speed
					local moveDistance = Enemy.Speed * 0.01 -- Assuming 100 Hz update rate
					local newPosition = Enemy.CFrame.Position + (direction * moveDistance)
					local newOrientation = CFrame.lookAt(newPosition, nextNode.Position)

					Enemy.CFrame = newOrientation

					if (newPosition - nextNode.Position).Magnitude <= moveDistance then
						Enemy.Node = Enemy.Node + 1
						if Enemy.Node > #path - 1 then
							Enemy.Done = true
						else
							currentNode = path[Enemy.Node]
							nextNode = path[Enemy.Node + 1]
							if currentNode and nextNode then
								Enemy.CFrame = CFrame.new(currentNode.Position, nextNode.Position)
							else
								print("Error: currentNode or nextNode is nil after updating Enemy.Node for Enemy ID:", Enemy.EnemyId)
							end
						end
					end
				else
					print("Error: currentNode or nextNode is nil for Enemy ID:", Enemy.EnemyId)
				end
			else
				print("Error: Path not found for Enemy ID:", Enemy.EnemyId)
			end
		end
	end
end

-- Main Routine 

local enemyCount = 200

task.spawn(function()
	for _ = 1, enemyCount do
		task.wait(0.5) -- Spawn delay between each unit
		SpawnNPC()
	end
end)

while true do
	task.wait(0.01)
	HandleMovement()
end

Client Side Code:

local Precision = 10^2

local tableOfPaths = game.ReplicatedStorage.RequestPaths:InvokeServer()
local tableOfEnemies = {}

function Decompile(EnemyData)
	return {
		["EnemyId"] = EnemyData[1],
		["EnemyPosition"] = EnemyData[2],
		["Node"] = EnemyData[3],
		["Speed"] = EnemyData[4],
		["Path"] = tableOfPaths[EnemyData[5]],
		["SyncTime"] = workspace.DistributedGameTime - EnemyData[6],
		["Done"] = false,
	}
end

game.ReplicatedStorage.SpawnedEnemy.OnClientEvent:Connect(function(EnemyInfo)
	local ClientTableEnemyInfo = Decompile(EnemyInfo)
	local key = ClientTableEnemyInfo["EnemyId"]

	tableOfEnemies[key] = ClientTableEnemyInfo

	local CFrameNoOrientation = CFrame.new(ClientTableEnemyInfo["EnemyPosition"].X / Precision, ClientTableEnemyInfo["EnemyPosition"].Y / Precision, ClientTableEnemyInfo["EnemyPosition"].Z / Precision)

	local cloneClientSide = game.ReplicatedStorage.ClientSide:Clone()
	cloneClientSide.Name = ClientTableEnemyInfo["EnemyId"]
	cloneClientSide.Parent = game.Workspace.Mobs
	cloneClientSide.Position = game.Workspace.Points["1"].Position
	cloneClientSide:PivotTo(CFrame.new(cloneClientSide.Position, CFrameNoOrientation.Position))
end)

function HandleMovement()
	for i, Enemy in pairs(tableOfEnemies) do
		local clientTarget = game.Workspace.Mobs[Enemy["EnemyId"]]

		if Enemy.Done then
			print("Client is Done")
			tableOfEnemies[i] = nil

			game.Debris:AddItem(clientTarget, 0)
		else
			local path = Enemy.Path
			if path then
				local currentNode = path[Enemy.Node]
				local nextNode = path[Enemy.Node + 1]

				if currentNode and nextNode then
					local direction = (nextNode.Position - clientTarget.CFrame.Position).Unit
					local distanceToNextNode = (nextNode.Position - clientTarget.CFrame.Position).Magnitude

					-- Move NPC by a constant speed
					local moveDistance = Enemy.Speed * 0.01 -- Assuming 100 Hz update rate
					local newPosition = clientTarget.CFrame.Position + (direction * moveDistance)
					local newOrientation = CFrame.lookAt(newPosition, nextNode.Position)

					clientTarget.CFrame = newOrientation

					if (newPosition - nextNode.Position).Magnitude <= moveDistance then
						Enemy.Node = Enemy.Node + 1
						if Enemy.Node > #path - 1 then
							Enemy.Done = true
						else
							currentNode = path[Enemy.Node]
							nextNode = path[Enemy.Node + 1]
							if currentNode and nextNode then
								clientTarget.CFrame = CFrame.new(currentNode.Position, nextNode.Position)
							else
								print("Error: currentNode or nextNode is nil after updating Enemy.Node for Enemy ID:", Enemy["EnemyId"])
							end
						end
					end
				else
					print("Error: currentNode or nextNode is nil for Enemy ID:", Enemy["EnemyId"])
				end
			else
				print("Error: Path not found for Enemy ID:", Enemy["EnemyId"])
			end
		end
	end
end

while true do
	task.wait(0.01)
	HandleMovement()
end

2 Likes

Maybe I should use tweenservice?

1 Like

I tried Lerp but Lerp seems to make them teleport around weirdly back and forth also ruins the synchronization.

1 Like

Just an idea but maybe its something to do with NetworkOwnership? Try setting it to nil or auto and see how it goes.

2 Likes

Hm, nope, that just breaks the code. Also Network Ownership doesn’t really have anything to do with here. But thanks for the suggestion.

1 Like

Have you tried using deltaTime instead of incrementing by a fixed movement? Add a dt parameter to both your HandleMovement functions and have moveDistance multiply to dt rather than the fixed rate of .01.

You would then update dt within the loop.

local lastTime = tick()
while true do
	local currentTime = tick()
	local deltaTime = currentTime - lastTime
	lastTime = currentTime

	HandleMovement(deltaTime)
	task.wait(0.01)
end

I usually use dt to ensure consistency for projectiles, but im sure it applies here as well.

2 Likes

Instead of deltatime I believe I have the [“SyncTime”] = workspace.DistributedGameTime - EnemyData[6] both on the sever and client to sync and handle the movement between them.

1 Like

I see that you didnt use SyncTime. If theyre similar in functionality, I dont see why you cant do the same thing. Init a SyncTime for enemyInfo. Set up a currentTime variable right after declaring HandleMovement and then an elapasedTime variable after checking currentNode. We then update SyncTime when moving nodes.

ServerScript:

function SpawnNPC()
	local EnemyInfo = {
		["EnemyId"] = EnemyId,
		["Node"] = 1,
		["Speed"] = 25,
		["Path"] = math.random(1, 100),
		["CFrame"] = CFrame.lookAt(game.Workspace.Points["1"].Position, game.Workspace.Points["2"].Position),
		["Done"] = false,
		["SyncTime"] = workspace.DistributedGameTime -- Initialized to current server time
	}

	ServerTableOfEnemies[EnemyInfo.EnemyId] = EnemyInfo
	EnemyId = EnemyId + 1

	local ClientEnemyInfo = {
		[1] = EnemyInfo.EnemyId,
		[2] = Vector3int16.new(EnemyInfo.CFrame.X * Precision, EnemyInfo.CFrame.Y * Precision, EnemyInfo.CFrame.Z * Precision),
		[3] = EnemyInfo.Node,
		[4] = EnemyInfo.Speed,
		[5] = EnemyInfo.Path,
		[6] = workspace.DistributedGameTime,
	}

	SpawnedEnemy:FireAllClients(ClientEnemyInfo)
end

-- Actual Movement Handling

function HandleMovement()
	local currentTime = workspace.DistributedGameTime
	
	for i, Enemy in pairs(ServerTableOfEnemies) do
		if Enemy.Done then
			print("Server is Done")
			ServerTableOfEnemies[i] = nil
		else
			local path = tableOfPaths[Enemy.Path]
			if path then
				local currentNode = path[Enemy.Node]
				local nextNode = path[Enemy.Node + 1]

				if currentNode and nextNode then
					local direction = (nextNode.Position - Enemy.CFrame.Position).Unit
					local distanceToNextNode = (nextNode.Position - Enemy.CFrame.Position).Magnitude

					-- Move NPC by a constant speed
					local elapsedTime = currentTime - Enemy.SyncTime -- Calculate time elapsed
					local moveDistance = Enemy.Speed * 0.01
					local newPosition = Enemy.CFrame.Position + (direction * moveDistance)

					-- Smooth transition and rotation
					local targetCFrame = CFrame.lookAt(newPosition, nextNode.Position)
					Enemy.CFrame = Enemy.CFrame:Lerp(targetCFrame, 0.5)

					if (newPosition - nextNode.Position).Magnitude <= moveDistance then
						Enemy.Node = Enemy.Node + 1
						Enemy.SyncTime = currentTime -- Update SyncTime
						
						if Enemy.Node > #path - 1 then
							Enemy.Done = true
						else
							currentNode = path[Enemy.Node]
							nextNode = path[Enemy.Node + 1]
							if currentNode and nextNode then
								Enemy.CFrame = CFrame.new(currentNode.Position, nextNode.Position)
							else
								print("Error: currentNode or nextNode is nil after updating Enemy.Node for Enemy ID:", Enemy.EnemyId)
							end
						end
					end
				else
					print("Error: currentNode or nextNode is nil for Enemy ID:", Enemy.EnemyId)
				end
			else
				print("Error: Path not found for Enemy ID:", Enemy.EnemyId)
			end
		end
	end
end

The only thing that changes in your client is HandleMovement, the EnemyData stays the same

2 Likes

I see, you’re right I didn’t notice I removed the SyncTime parameter from the movement calculation. I’m experiencing another issue now where the client finishes earlier then the server does, do you have an idea why?

Here’s the current code I have:

Server side:

local TweenService = game:GetService("TweenService")

-- Remote Events and Remote Functions
local SpawnedEnemy = game.ReplicatedStorage:WaitForChild("SpawnedEnemy")
local RequestPaths = game.ReplicatedStorage:WaitForChild("RequestPaths")

-- Spline Calculations
local tableOfPaths = {}

function DrawPath()
	local NodesTable = {}
	local tensionMain = math.random(35, 85) / 100 
	local counter = 0	
	local Points = {}

	for i = 1, #game.Workspace.Points:GetChildren() do
		Points[i] = game.Workspace.Points[i].Position
	end

	local NumPoints = #Points
	for i = 1, NumPoints - 1 do
		local p0 = Points[i - 1] or Points[1]
		local p1 = Points[i]
		local p2 = Points[i + 1]
		local p3 = Points[i + 2] or Points[NumPoints]
		for j = 0, 1, 0.05 do
			local tension = tensionMain
			local t = j
			local t2 = t * t
			local t3 = t2 * t

			local p = (
				(-tension * t + 2 * tension * t2 - tension * t3) * p0 +
					(2 + (tension - 6) * t2 + (4 - tension) * t3) * p1 +
					(tension * t - 2 * (tension - 3) * t2 + (tension - 4) * t3) * p2 +
					(-tension * t2 + tension * t3) * p3
			) * 0.5

			counter = counter + 1
			NodesTable[counter] = { Position = p }
		end
	end

	return NodesTable
end

for i = 1, 100 do
	tableOfPaths[i] = DrawPath()
end

function RequestPath()
	return tableOfPaths
end

RequestPaths.OnServerInvoke = RequestPath

task.wait(5)

local Precision = 10^2
local ServerTableOfEnemies = {}
local EnemyId = 1

function SpawnNPC()
	local EnemyInfo = {
		["EnemyId"] = EnemyId,
		["Node"] = 1,
		["Speed"] = 25,
		["Path"] = math.random(1, 100),
		["CFrame"] = CFrame.lookAt(game.Workspace.Points["1"].Position, game.Workspace.Points["2"].Position),
		["Done"] = false,
		["SyncTime"] = workspace.DistributedGameTime -- Initialized to current server time
	}

	ServerTableOfEnemies[EnemyInfo.EnemyId] = EnemyInfo
	EnemyId = EnemyId + 1

	local ClientEnemyInfo = {
		[1] = EnemyInfo.EnemyId,
		[2] = Vector3int16.new(EnemyInfo.CFrame.X * Precision, EnemyInfo.CFrame.Y * Precision, EnemyInfo.CFrame.Z * Precision),
		[3] = EnemyInfo.Node,
		[4] = EnemyInfo.Speed,
		[5] = EnemyInfo.Path,
		[6] = workspace.DistributedGameTime,
	}

	SpawnedEnemy:FireAllClients(ClientEnemyInfo)
end

-- Actual Movement Handling

function HandleMovement()
	local currentTime = workspace.DistributedGameTime

	for i, Enemy in pairs(ServerTableOfEnemies) do
		if Enemy.Done then
			print("Server is Done")
			ServerTableOfEnemies[i] = nil
		else
			local path = tableOfPaths[Enemy.Path]
			if path then
				local currentNode = path[Enemy.Node]
				local nextNode = path[Enemy.Node + 1]

				if currentNode and nextNode then
					local direction = (nextNode.Position - Enemy.CFrame.Position).Unit
					local distanceToNextNode = (nextNode.Position - Enemy.CFrame.Position).Magnitude

					-- Move NPC by a constant speed
					local elapsedTime = currentTime - Enemy.SyncTime -- Calculate time elapsed
					local moveDistance = Enemy.Speed * 0.01
					local newPosition = Enemy.CFrame.Position + (direction * moveDistance)

					-- Smooth transition and rotation
					local targetCFrame = CFrame.lookAt(newPosition, nextNode.Position)
					Enemy.CFrame = Enemy.CFrame:Lerp(targetCFrame, 0.5)

					if (newPosition - nextNode.Position).Magnitude <= moveDistance then
						Enemy.Node = Enemy.Node + 1
						Enemy.SyncTime = currentTime -- Update SyncTime

						if Enemy.Node > #path - 1 then
							Enemy.Done = true
						else
							currentNode = path[Enemy.Node]
							nextNode = path[Enemy.Node + 1]
							if currentNode and nextNode then
								Enemy.CFrame = CFrame.new(currentNode.Position, nextNode.Position)
							else
								print("Error: currentNode or nextNode is nil after updating Enemy.Node for Enemy ID:", Enemy.EnemyId)
							end
						end
					end
				else
					print("Error: currentNode or nextNode is nil for Enemy ID:", Enemy.EnemyId)
				end
			else
				print("Error: Path not found for Enemy ID:", Enemy.EnemyId)
			end
		end
	end
end


-- Main Routine 

local enemyCount = 200

task.spawn(function()
	for _ = 1, enemyCount do
		task.wait(0.1) -- Spawn delay between each unit
		SpawnNPC()
	end
end)

while true do
	task.wait(0.01)
	HandleMovement()
end

Client Side:

local Precision = 10^2

local tableOfPaths = game.ReplicatedStorage.RequestPaths:InvokeServer()
local tableOfEnemies = {}

function Decompile(EnemyData)
	return {
		["EnemyId"] = EnemyData[1],
		["EnemyPosition"] = EnemyData[2],
		["Node"] = EnemyData[3],
		["Speed"] = EnemyData[4],
		["Path"] = tableOfPaths[EnemyData[5]],
		["SyncTime"] = workspace.DistributedGameTime - EnemyData[6],
		["Done"] = false,
	}
end

game.ReplicatedStorage.SpawnedEnemy.OnClientEvent:Connect(function(EnemyInfo)
	local ClientTableEnemyInfo = Decompile(EnemyInfo)
	local key = ClientTableEnemyInfo["EnemyId"]

	tableOfEnemies[key] = ClientTableEnemyInfo

	local CFrameNoOrientation = CFrame.new(ClientTableEnemyInfo["EnemyPosition"].X / Precision, ClientTableEnemyInfo["EnemyPosition"].Y / Precision, ClientTableEnemyInfo["EnemyPosition"].Z / Precision)

	local cloneClientSide = game.ReplicatedStorage.ClientSide:Clone()
	cloneClientSide.Name = ClientTableEnemyInfo["EnemyId"]
	cloneClientSide.Parent = game.Workspace.Mobs
	cloneClientSide.Position = game.Workspace.Points["1"].Position
	cloneClientSide:PivotTo(CFrame.new(cloneClientSide.Position, CFrameNoOrientation.Position))
end)

function HandleMovement()
	for i, Enemy in pairs(tableOfEnemies) do
		local clientTarget = game.Workspace.Mobs[Enemy["EnemyId"]]

		if Enemy.Done then
			print("Client is Done")
			tableOfEnemies[i] = nil

			game.Debris:AddItem(clientTarget, 0)
		else
			local path = Enemy.Path
			if path then
				local currentNode = path[Enemy.Node]
				local nextNode = path[Enemy.Node + 1]

				if currentNode and nextNode then
					local direction = (nextNode.Position - clientTarget.CFrame.Position).Unit
					local distanceToNextNode = (nextNode.Position - clientTarget.CFrame.Position).Magnitude

					-- Move NPC by a constant speed
					local moveDistance = Enemy.Speed * Enemy.SyncTime -- Multiplying by SyncTime
					local newPosition = clientTarget.CFrame.Position + (direction * moveDistance)
					local newOrientation = CFrame.lookAt(newPosition, nextNode.Position)

					clientTarget.CFrame = newOrientation

					if (newPosition - nextNode.Position).Magnitude <= moveDistance then
						Enemy.Node = Enemy.Node + 1
						if Enemy.Node > #path - 1 then
							Enemy.Done = true
						else
							currentNode = path[Enemy.Node]
							nextNode = path[Enemy.Node + 1]
							if currentNode and nextNode then
								clientTarget.CFrame = CFrame.new(currentNode.Position, nextNode.Position)
							else
								print("Error: currentNode or nextNode is nil after updating Enemy.Node for Enemy ID:", Enemy["EnemyId"])
							end
						end
					end
				else
					print("Error: currentNode or nextNode is nil for Enemy ID:", Enemy["EnemyId"])
				end
			else
				print("Error: Path not found for Enemy ID:", Enemy["EnemyId"])
			end
		end
	end
end

while true do
	task.wait(0.01)
	HandleMovement()
end
1 Like

You didnt update the clients HandleMovement, they have to be consistent. Its the same as the server HandleMovement. More specifically, you didnt initialize currentTime and didnt update Enemy’s SyncTime. I actually didnt see you updated it, let me see

function HandleMovement()
	local currentTime = workspace.DistributedGameTime

	for i, Enemy in pairs(ServerTableOfEnemies) do
		if Enemy.Done then
			print("Server is Done")
			ServerTableOfEnemies[i] = nil
		else
			local path = tableOfPaths[Enemy.Path]
			if path then
				local currentNode = path[Enemy.Node]
				local nextNode = path[Enemy.Node + 1]

				if currentNode and nextNode then
					local direction = (nextNode.Position - Enemy.CFrame.Position).Unit
					local distanceToNextNode = (nextNode.Position - Enemy.CFrame.Position).Magnitude

					-- Move NPC by a constant speed
					local elapsedTime = currentTime - Enemy.SyncTime -- Calculate time elapsed
					local moveDistance = Enemy.Speed * 0.01
					local newPosition = Enemy.CFrame.Position + (direction * moveDistance)

					-- Smooth transition and rotation
					local targetCFrame = CFrame.lookAt(newPosition, nextNode.Position)
					Enemy.CFrame = Enemy.CFrame:Lerp(targetCFrame, 0.5)

					if (newPosition - nextNode.Position).Magnitude <= moveDistance then
						Enemy.Node = Enemy.Node + 1
						Enemy.SyncTime = currentTime -- Update SyncTime

						if Enemy.Node > #path - 1 then
							Enemy.Done = true
						else
							currentNode = path[Enemy.Node]
							nextNode = path[Enemy.Node + 1]
							if currentNode and nextNode then
								Enemy.CFrame = CFrame.new(currentNode.Position, nextNode.Position)
							else
								print("Error: currentNode or nextNode is nil after updating Enemy.Node for Enemy ID:", Enemy.EnemyId)
							end
						end
					end
				else
					print("Error: currentNode or nextNode is nil for Enemy ID:", Enemy.EnemyId)
				end
			else
				print("Error: Path not found for Enemy ID:", Enemy.EnemyId)
			end
		end
	end
end
3 Likes

The minimum time task.wait can yield for is the time of 1 frame (~0.015 seconds), so task.wait(0.01) does not yield for exactly or close to 0.01 seconds on the server and on some clients with a capped FPS.

task.wait actually yields for shorter time depending on the client’s framerate, so as a result, for clients running at >60 FPS, the client is calling HandleMovement more often or at a different rate than the server.

You can fix this by passing the return value of task.wait (the elapsed time) into HandleMovement to use as a time step.

while true do
	local dt = task.wait()
	HandleMovement(dt)
end

In HandleMovement, change local moveDistance = Enemy.Speed * 0.01 to local moveDistance = Enemy.Speed * dt

3 Likes

I know this is a different topic but its sorta related, hum:MoveTo() is the best you can do. You should focus on changing it only when you see issues or get bug reports in your game.

Tons of games use hum:MoveTo(), popular example is roblox piggy. it uses hum:MoveTo() and for the most part the NPC movement is smooth, if you really care about smoothness you can play around with client/server or networkownership. But yeah.

1 Like

You’re right, my bad. Also, I noticed that we aren’t actually doing anything with Enemy.SyncTime still? We just store it in the elapsedTime variable and don’t touch it? Do you want me to replace the 0.01 withh the elapsed time variable so we multiply by it?

1 Like