How would I render enemies on the client for a tower defense game?

In my server script, I have the enemies stats which contains their position, speed, health etc in a table which is being sent to the client every 10 heartbeats or 10 times a second after I fire a remote event (the position is being tweened on the server).

However, on the client script, how would I identify if a mob is newly spawned and not an already existing mob, and how would I assign a model to that specific enemy if it is newly spawned. I also don’t know how I would keep track of all of the enemy models in a way that doesn’t get confused for another enemy of the exact same type/name.

I tried using CollectionService but it was not very helpful
If you need something explaining a bit better please ask, thank you.

5 Likes

the way i did this in my tower defense game was pretty straight-forward:

  • every time an enemy is created server-sided, the enemy has its own unique id
    (you can accomplish this using HttpService’s :GenerateGUID(), or whatever method you want if you were to randomly generate an ID for your enemy)

if you wanted to know how i did my enemy IDs, i just did "[enemyName](space)[enemyID]"

once you’re done creating the id, store it into the enemy’s stats and send it to all clients 10 times a second (like what you were doing in the first paragraph).

  • on the client-side, i have a table that stores all enemies like this:
local activeEnemies = {
      --[enemyID] = enemy
}

--[[
the 'enemy' in this case will be an OOP class that handles the enemy's appearance, 
animations, and tweening the enemy model to where the enemy's position is on the
server.
]]
  • when your remote event is received on the client, create a function that does the following:
    • if the enemy’s id doesn’t exist in activeEnemies, store it using the pseudo-code i wrote above and have a function for the enemy class that renders its model and animations
    • if the enemy’s id does exist, update the enemy class with the stats that did changed

if you want to remove an enemy from activeEnemies, just do activeEnemies[enemyID] = nil.

i hope this covers what you wanted to solve, this is just a barebones example. if you need help, by all means, let me know!

6 Likes

Hello, thank you for the reply! I will test this out right now and if it works, I will mark this as a solution.

I got it working, however the mobs take turns to move, so once a mob has finished its journey to the end, the next move gets teleported to where it was supposed to be. Here is a clip of what I mean: https://i.gyazo.com/8012059fee455cfc75a8e8c1757eefb4.mp4

this code is inside the remote event function.
Here is my code:

if DisplayedMobs[ID] then
			local Visual = DisplayedMobs[ID]
			if Visual ~= nil then
				local MoveTween = TweenService:Create(Visual.HumanoidRootPart, TweenInfo.new(MoveSpeed, Enum.EasingStyle.Linear), {CFrame = EnemyPosition})
				MoveTween:Play()
			end
		else
			local Visual = Mobs:FindFirstChild(MobExists.Name):Clone()
			if not Visual then warn(MobExists.Name.." model not found") return end

			local Map = workspace.Map:FindFirstChildOfClass("Folder")

			local RootPart = Visual.HumanoidRootPart
			RootPart.Anchored = true
			RootPart.CFrame = Map.Path["1"].CFrame		

			Visual.Parent = workspace.Mobs

			DisplayedMobs[ID] = Visual
		end

Let me know if you need any further code like the script from the server etc.
Thank you.
PS (Ignore how they’re in the ground and just rotating randomly, I am trying to sort out this problem first)

the reason why the mobs “take turns to move” is that you didn’t loop through all of the mobs and tween them to their server position.

in your case, you can just render the mob if it isn’t stored inside DisplayedMobs. you can scrap the following lines:

local Visual = DisplayedMobs[ID]
if Visual ~= nil then
	local MoveTween = TweenService:Create(Visual.HumanoidRootPart, TweenInfo.new(MoveSpeed, Enum.EasingStyle.Linear), {CFrame = EnemyPosition})
	MoveTween:Play()
end

here’s how you should move the mobs (i don’t know the full code, im just going off based on the code you sent me):

--this is pseudo-code baby!
--feel free to tweak things if you'd like

local DisplayedMobs = {
     [mobID] = {
         Visual = --mob model
         Position = --the mob's position on the server
         --its okay to store the mob's stats inside their table
         --since the client is only going to read what the server has sent for said mob 
     }
}

local function updateMobs()
     --call this function inside your remote event function

     for mobId, mobData in pairs(DisplayedMobs) do
           local visual = mobData.Visual
           local goalPosition = mobData.Position
           
          coroutine.wrap(function()
             --we're using coroutines here to prevent mobs from "taking turns to move"
             --because MoveTween.Completed:Wait() yields the thread until the mob has successfully
             --moved to where they should be on the server

             local MoveTween = TweenService:Create(Visual.HumanoidRootPart, TweenInfo.new(MoveSpeed, Enum.EasingStyle.Linear), {CFrame = goalPosition})
		     MoveTween:Play()
             MoveTween.Completed:Wait()
          end)()
     end
end

let me know if you have any questions!

1 Like

It works likes a charm, but, I think sending the position on the server does not work, I did use a coroutine.wrap() but it still focuses on one mob. Here is the main code for it.

local Mob = {}

function Mob.Move(Enemy)
	if Enemy then
		local Map = workspace.Map:FindFirstChildOfClass("Folder")
		if Map then
			local ReachedEnd = false
			
			local Paths = Map:WaitForChild("Path")
			local EnemyModule = EnemyStats[Enemy.Name]
			
			local PosValue = Instance.new("CFrameValue")
			local ID_Result = HttpService:GenerateGUID(true)

			local EnemyInfo = {
				["HP"] = EnemyModule.Health,
				["MaxHP"] = EnemyModule.Health,
				["Node"] = 1,
				["Modifiers"] = EnemyModule.Modifiers,
				["Reward"] = EnemyModule.Reward,
				["Reverse"] = false,
				["Stunned"] = false,
				["Rotating"] = false,
				["Speed"] = EnemyModule.Speed,
				
				["Name"] = Enemy.Name,
				["Position"] = Map.Path["1"].CFrame,
			}
			
			local ClientEnemyInfo = {
				["Name"] = Enemy.Name,
				["Position"] = Vector2int16.new(math.floor(COORD_MULTIPLIER * EnemyInfo.Position.X + 0.5), math.floor(COORD_MULTIPLIER * EnemyInfo.Position.Y + 0.5)),
				["Speed"] = EnemyInfo.Speed,
				["Stunned"] = EnemyInfo.Stunned,
				["Node"] = EnemyInfo.Node,
				["ID"] = ID_Result,
			}
			
			local Connection
			local function onHeartbeat(DeltaTime)
				if (tick() >= NextStep) and ReachedEnd == false then
					NextStep = NextStep + INTERVAL
					
					EnemyInfo.Position = PosValue.Value
					
					local RequiredPosition = Vector2int16.new(math.floor(COORD_MULTIPLIER * EnemyInfo.Position.X + 0.5), math.floor(COORD_MULTIPLIER * EnemyInfo.Position.Z + 0.5))
					ClientEnemyInfo.Position = RequiredPosition
								
					PositionEnemyEvent:FireAllClients(ClientEnemyInfo)
				elseif ReachedEnd == true then
					
				end
			end
			Connection = RunService.Heartbeat:Connect(onHeartbeat)
			
			for Path = 2, #Map.Path:GetChildren() do
				local Distance = (Paths[EnemyInfo.Node].Position - Paths[Path].Position).Magnitude
				local TimeToWait = Distance*(MoveSpeed/EnemyInfo.Speed)
				
				EnemyInfo.Node = Path
				
				local NextNode = Paths[tostring(EnemyInfo.Node)]
				
				local MoveTween = TweenService:Create(PosValue, TweenInfo.new(TimeToWait, Enum.EasingStyle.Linear), {Value = NextNode.CFrame})
				MoveTween:Play()
				MoveTween.Completed:Wait()
			end
			
			ReachedEnd = true
		end
	end
end

function Mob.Spawn(Name, Quantity)
	if Name and Quantity then
		local Map = workspace.Map:FindFirstChildOfClass("Folder")
		if Map then
			local MobExists = Mobs:FindFirstChild(Name)
			if MobExists then
				for i = 1, Quantity do
					wait(SpawnCooldown)
					coroutine.wrap(Mob.Move)(MobExists)
				end
			end
		end
	end
end

return Mob

Again, thank you so much for your contribution, it really means a lot. I have been trying to make this work for about 4-5 days.

2 Likes

I have been testing and the problem seems to be on the server unfortunately.

The issue here is every time you move the one enemy you’re creating a heartbeat for that and only sending that mob’s data. Instead, you should have a table that stores all the enemies currently in the game and fire that table.

Edit: Didn’t realize this a month late, I was working on my game and I was figuring out how to move the enemy’s position on the server. I see you’re using a CFrameValue but I’m trying to do it with just tables, not having any luck, for some reason my brain just doesn’t know how to do it. Anyway, hope this helps if this is still the issue!

3 Likes

The way I do this is

When the client logs into the game I send a remote event telling them the current time() and you can use GetNetworkPing to improve accuracy

I then create 1 folder for each enemy in workspace and name the folder the same name as the model I want the client to use

I then save the time the enemy spawned into the folder using time() and there speed and there health
this can be done with Attributes or with NumberValues

now on the client side in a localscript i wait for the server to add folders that represent a enemy using ChildAdded and i use the folders name to pick the correct model to clone

and then the client uses the time and speed to work out how far the enemy has walked

local deltaTime = currentTime - enemySpawnTime
local distance = speed * deltaTime

and then i use that distance to workout where they are along the path and this is good because the server does not need to send events every 10 heartbeats so this method should use a very small amount of network and should allow you to have lots of enemy’s without lagging the game

11 Likes

This is a good method, though does it account for enemies that get effects like stunned, and reverse? Unless I’m overlooking this completely. Also instead of creating multiple folders wouldn’t it be better to use CollectionService and give them a tag whenever the Enemy is created. I would love to see the performance difference between this method and sending a remote every 10 heartbeats. I’m currently sending a remote event every 10 heartbeats and handling everything else on the client, the server is simply used for values.

If you wanted to handle stun and reverse what you need are propertys

time, distance and speed

When the enemy first gets spawned
time gets set to the current time()
distance is set to 0
and speed is set to let’s say 16

and if you want to stun or reverse then you
update the time to the current time()
And update the distance to the distance they where at when they got stunned
and the speed to 0 to make them stop moving or -16 to make them walk backwards

You could use the collection services if you wanted to

3 Likes

I will try to implement both you and 5uphi’s suggestions and I will notify you if I have it working. Thank you for your replies!

1 Like

Sorry for late reply, it works now! Unfortunately, I am not allowed to have 2 solutions so I have just marked one solution. Thank you so much!

Wouldn’t it be better if you open sourced a place showing this method off?

I will be making a video in the future explaining this method

1 Like

That’s amazing news hopefully that future is quite near :pray:

Is there a specific reason why you use Folders instead of FireAllClients()?
I know this is an old thread but I’m curious.

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.