Hey, I’m making a tower defense game, working on an enemy system, I want a smooth and clean way to move enemies and be able to have 500+ enemies easily without lag issues (that’s why I’m doing client-side rendering). But when I was working on the system I found some problems:
Problems:
Let’s say I spawn 5 enemies with an interval of 0.25 seconds, this happens:
as you can see it looks like some enemies are taking longer than 0.25 seconds to spawn, I tried remove this chunk of code and just do the move loop:
count += 1
if count >= 10 then
--move loop
count = 0
end
and it works perfectly, but it’s really not recommended to send this amount of data too fast.
Sometimes when enemies need to turn there is some desynchronization, that is, some enemies are a little further back than others.
Sometimes it seems to lag a little (it seems like enemies are slowing down a little for milliseconds, I wonder if it’s because of the ping)
That being said, I want to know if there is a way to fix and improve the current code or another way to make this system run smoothly, since I love tower defense games I would really like to make one myself, If you need more information just tell me.
Here’s the main loop that moves enemies on the server: (ignore the fact that the remote event looks different, it’s because I’m using knit)
local clientEnemies = {}
local enemiesInGame = {}
function EnemyService:OnHeartbeat(deltaTime)
count += 1
overallDeltaTime += deltaTime
if count >= 10 then
for enemyID, enemyInfo in pairs(enemiesInGame) do
local node = nodes[enemyInfo.Node]
local newZ = (enemyInfo.CFrame.Position - node.Position).Magnitude - (enemyInfo.Speed * overallDeltaTime)
enemyInfo.CFrame = node.CFrame:ToWorldSpace(CFrame.new(0, 0, newZ))
if node.CFrame:ToWorldSpace():ToObjectSpace(enemyInfo.CFrame).Position.Z <= 0 then
enemyInfo.CFrame = CFrame.new(node.Position, nodes[enemyInfo.Node + 1].Position)
enemyInfo.Node += 1
end
local updatedEnemyInfo = {
["Name"] = enemyInfo.Name,
["CFrame"] = enemyInfo.CFrame,
}
clientEnemies[enemyID] = updatedEnemyInfo
end
count = 0
overallDeltaTime = 0
self.Client.Syncer:FireAll(clientEnemies) -- this is a remote event
end
end
Hello, it seems to be good so far, but there are a few things I would like to change.
First off, you can send a workspace.DistributedGameTime with clientEnemies, This would allow for syncing enemies with what the server sees. Make sure to use this to add an offset on the enemy to keep it in sync with different clients and server.
local synctime = workspace.DistributedGameTime - ServerSyncTime -- compares client and server gametime
Second off, updatedEnemyInfo currently uses a lot more data than it should, it can lead to so much data used with so few enemies. What I would do is instead of sending the name and CFrame, I would send references instead of their name.
local updatedEnemyInfo = {
[1] = enemyInfo.Name, -- Name
[2] = enemyInfo.CFrame, -- CFrame
}
Another way is to change enemyInfo.Name into just a number, and reference that number in a table on the client to get the name of the enemy. Using the reference on the client, you would just need to make a table with each number indicating a name (1 = “zombie”, 2 = “brute”).
Finally, you can also change the CFrame into only x and z positions (if enemies don’t go vertically) and use a single number as a rotation (if enemies don’t rotate sideways or backward).
Utilizing workspace.DistributedGameTime along with ServerSyncTime for synchronization is a great way to ensure consistency across clients and the server. This will contribute to smoother gameplay and minimize discrepancies between different players.
The idea of sending references instead of full information within updatedEnemyInfo is efficient in terms of data usage. Storing references locally on the client side can reduce unnecessary data transmission and enhance the overall efficiency of your synchronization process.
Simplifying the CFrame information to only include x and z positions, along with a rotation value, can be a viable optimization if your enemies don’t require vertical movement or complex rotations. This can potentially reduce the complexity of data transmission and contribute to smoother animation.
Here’s a code example for you to analyze:
function EnemyService:OnHeartbeat(deltaTime)
count += 1
overallDeltaTime += deltaTime
if count >= 10 then
for enemyID, enemyInfo in pairs(enemiesInGame) do
local node = nodes[enemyInfo.Node]
local newZ = (enemyInfo.CFrame.Position - node.Position).Magnitude - (enemyInfo.Speed * overallDeltaTime)
local rotation = enemyInfo.CFrame:ToEulerAnglesYXZ()
enemyInfo.CFrame = CFrame.new(node.Position.x, enemyInfo.CFrame.Position.y, newZ) * CFrame.fromEulerAnglesYXZ(0, rotation.y, 0)
if node.CFrame:ToWorldSpace():ToObjectSpace(enemyInfo.CFrame).Position.Z <= 0 then
enemyInfo.CFrame = CFrame.new(node.Position, nodes[enemyInfo.Node + 1].Position)
enemyInfo.Node += 1
end
clientEnemies[enemyID] = {
[1] = enemyInfo.CFrame.Position.x,
[2] = enemyInfo.CFrame.Position.z,
}
end
count = 0
overallDeltaTime = 0
self.Client.Syncer:FireAll(clientEnemies) -- this is a remote event
end
end
Hey! Thanks for your reply, I made some changes to sending references to the client, but I didn’t really understand how I would use synctime, sorry if it’s a stupid question, I’m not very good at doing calculations, etc.
On the client what I did was just add the model and a simple tween:
function EnemyController:Syncer(enemiesInGame)
for ID, enemyInfo in pairs(enemiesInGame) do
if not models[ID] then
local model = ReplicatedStorage.Enemy[enemyInfo[1]]:Clone()
model.Name = ID
model:PivotTo(workspace.Nodes["1"].CFrame)
model.Parent = enemies
local animationController = Instance.new("AnimationController")
animationController.Parent = model
local animator = Instance.new("Animator")
animator.Parent = animationController
local animation = model:WaitForChild("Walk")
local track = animator:LoadAnimation(animation)
track:Play()
models[ID] = model
end
local enemy = models[ID]
local tween = TweenService:Create(enemy.PrimaryPart, TweenInfo.new(0.2, Enum.EasingStyle.Linear), {CFrame = enemyInfo[2]})
tween:Play()
end
for ID, model in pairs(models) do
if not enemiesInGame[ID] then
model:Destroy()
end
end
end
Here’s the testing place if you wanna see the performance.
Sorry for the late response, but what you would need to do is get the direction of where the enemy is heading by subtracting the starting position and end position, multiply that by the sync time, and then add it to the starting CFrame.
local SyncOffset = (model.Position - enemyInfo[2].Position) * (SyncTime * SpeedOfEnemy) -- gets the direction and multiply it by the time between client receiving data and the speed
model.CFrame *= CFrame.new(SyncOffset) -- changes position into CFrame to add to the current model position
Hi there! I have developed a few systems like this in the recent past and would like to let you know some things.
Although your code looks fine, you will end up with severe desync after a certain amount of players/high ping. The way to combat this is by using Humanoid for the NPCs and Humanoid:MoveTo for movement combined with a pathfinding API or a preset set of path points. The reason for this is that Humanoid:MoveTo uses direct packet interference to sync up movement across all sessions rather than remotes that come with delay, as well as the fact that it uses application-level (the core engine code) optimization methods for the least amount of network pollution as possible.
I strongly recommend using preset path points with a few studs in distance in your case as pathfinding will be tedious and over-calculating. If you need any help feel free to reply with what exactly it is.
A resource for Humanoid:MoveTo can be found here from the official dev docs for ‘Pathfinding’
local PathfindingService = game:GetService("PathfindingService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local path = PathfindingService:CreatePath()
local player = Players.LocalPlayer
local character = player.Character
local humanoid = character:WaitForChild("Humanoid")
local TEST_DESTINATION = Vector3.new(100, 0, 100)
local waypoints
local nextWaypointIndex
local reachedConnection
local blockedConnection
local function followPath(destination)
-- Compute the path
local success, errorMessage = pcall(function()
path:ComputeAsync(character.PrimaryPart.Position, destination)
end)
if success and path.Status == Enum.PathStatus.Success then
-- Get the path waypoints
waypoints = path:GetWaypoints()
-- Detect if path becomes blocked
blockedConnection = path.Blocked:Connect(function(blockedWaypointIndex)
-- Check if the obstacle is further down the path
if blockedWaypointIndex >= nextWaypointIndex then
-- Stop detecting path blockage until path is re-computed
blockedConnection:Disconnect()
-- Call function to re-compute new path
followPath(destination)
end
end)
-- Detect when movement to next waypoint is complete
if not reachedConnection then
reachedConnection = humanoid.MoveToFinished:Connect(function(reached)
if reached and nextWaypointIndex < #waypoints then
-- Increase waypoint index and move to next waypoint
nextWaypointIndex += 1
humanoid:MoveTo(waypoints[nextWaypointIndex].Position)
else
reachedConnection:Disconnect()
blockedConnection:Disconnect()
end
end)
end
-- Initially move to second waypoint (first waypoint is path start; skip it)
nextWaypointIndex = 2
humanoid:MoveTo(waypoints[nextWaypointIndex].Position)
else
warn("Path not computed!", errorMessage)
end
end
followPath(TEST_DESTINATION)
The problem with this is that Humanoids are very inefficient. This would be fine with like 40-60 enemies, but he is working with over 500 enemies at any given time.
Sorry about that! I didn’t have time to test the code I sent you. Here is the new code:
-- Path is just the position of where to send the enemy
local offset = enemy.CFrame.Position - Path
local distance = offset.Magnitude
local direction = offset.Unit
local speed = distance / enemyinfo[3]
local cframe = CFrame.new((Path + direction) * (speed * enemyinfo[4]))
local tween = game:GetService("TweenService"):Create(enemy, TweenInfo.new(speed, Enum.EasingStyle.Linear), {CFrame = cframe})
tween:Play()