Clash Royale Pathfinding Issue

I need to make the pathfinding replicate to the client efficiently, and I want the enemies to die when they’re supposed to. (i’m not very sure what is the issue is exactly)

Just like in clash royale, there are red and blue teams that attack eachother. This is marked on the front face of the green parts.

Issues:

  • the pathfinding may be a little broken
  • it has trouble replicating to the client after 3+ entities are summoned, and it just cant find the target summoned entity on the client. It’s probably because the server and client don’t sync.

I’ve tried many things, such as making a numeric ID system to help sync the server and client, and
only submitting tables to the client once per unreliableremote event per frame.

Server EntityService
local module = {}

-- RELIANT ON A CUSTOM "NETWORKSERVICE"; IF YOU USE OTHER METHODS FOR NETWORKING THEN PLEASE EDIT

--- utils ---
local RunService = game:GetService('RunService')

-- reliances (feel free to edit) --
local ModuleLoader = require(game:GetService('ReplicatedStorage').ModuleLoader)

local NetworkService = ModuleLoader:GetService('NetworkService')

local Remotes = {
	AddEntity = NetworkService:GetUnreliableRemoteEvent('AddEntity'),
	RotateEntities = NetworkService:GetUnreliableRemoteEvent('RotateEntities'),
	RemoveEntities = NetworkService:GetUnreliableRemoteEvent('RemoveEntities'),
	MoveEntities =  NetworkService:GetUnreliableRemoteEvent('MoveEntities'),
	UpdateEntityHealth =  NetworkService:GetUnreliableRemoteEvent('UpdateEntityHealth'),
}
-----------------------------------

local types = require(script.Types)

type Entity = types.Entity
type SummonEntityResult = types.SummonEntityResult

local EntityTemplates = require(script.EntityTemplates)

_G.CurrentEntities = {}

local TotalEntitiesSummoned = 0
-----------------------
local config = {
	DefaultEntityTemplate = EntityTemplates.Goblin,
	ArenaFloorHeight = .5, -- y	
	Bounds = Vector3.new(15, 20, 18),
}

--- helpers ---
local function assertwarn(condition: any?, warning: string): boolean
	if not condition then
		warn(warning)
		return false
	end
	return true
end

local function WithinBounds(Position: Vector3, Bounds: Vector3): boolean
	assert(typeof(Position) == 'Vector3', 'Position not given or invalid.')
	assert(typeof(Bounds) == 'Vector3', 'Bounds not given or invalid.')

	return math.abs(Position.X) < Bounds.X 
		and math.abs(Position.Y) < Bounds.Y 
		and math.abs(Position.Z) < Bounds.Z
end

local function IsTableEmpty(Table: {any?}): boolean
	for _ in pairs(Table) do
		return false
	end
	
	return true
end

local function CollideEntities(entity1: Entity, entity2: Entity)
	local dist = (entity1.Position - entity2.Position).Magnitude
	local minDist = entity1.EntitySizeRadius + entity2.EntitySizeRadius

	if dist < minDist and dist > 0 then
		local dir = (entity1.Position - entity2.Position).Unit
		local overlap = minDist - dist

		entity1.Position = entity1.Position + dir * (overlap * 0.5)
		entity2.Position = entity2.Position - dir * (overlap * 0.5)
	end
end

local function ExecuteEntityBehaviour(deltaTime: number)
	local entitiesToMove = {}
	local entitiesToEliminate = {}
	local entitiesToRotate = {}
	local entitiesToUpdateHealth = {}
	
	for i, entity in pairs(_G.CurrentEntities) do
		local closestEnemy: Entity? = nil
		local closestDist = math.huge

		for _, otherEntity in pairs(_G.CurrentEntities) do
			if not otherEntity or otherEntity.Health <= 0 or otherEntity.Id == entity.Id then continue end
			if otherEntity and otherEntity.Position then
				CollideEntities(entity, otherEntity)
				if otherEntity.BlueTeam == not entity.BlueTeam then
					local dist = (otherEntity.Position - entity.Position).Magnitude
					if dist < closestDist then
						closestDist = dist
						closestEnemy = otherEntity
					end
				end
			end
		end

		if not entity.Deployed or not closestEnemy then continue end
		
		local direction = closestEnemy.Position - entity.Position
		
		local yRotation = math.atan2(-direction.X, -direction.Z)
		entitiesToRotate[entity.Id] = yRotation

		if closestDist <= entity.Range then
			entity:Attack(closestEnemy)
			entitiesToUpdateHealth[closestEnemy.Id] = {CurrentHealth = closestEnemy.Health, MaxHealth = closestEnemy.MaxHealth}

			if closestEnemy.Health <= 0 then
				local id = closestEnemy.Id
				entitiesToEliminate[id] = true
				entitiesToUpdateHealth[id] = {CurrentHealth = 0, MaxHealth = closestEnemy.MaxHealth}
			end
		elseif assertwarn(WithinBounds(entity.Position, config.Bounds), 'Target position out of bounds.') then

			entity:Step(direction, closestDist, deltaTime)
			entitiesToMove[entity.Id] = entity.Position
		end
	end
	
	for id, _ in pairs(entitiesToEliminate) do
		local entity = _G.CurrentEntities[id]
		if entity then
			entity:Eliminate()
		end
	end
	
	-- only uploading if table has values
	if not IsTableEmpty(entitiesToMove) then Remotes.MoveEntities:FireAllClients(entitiesToMove) end 
	if not IsTableEmpty(entitiesToEliminate) then Remotes.RemoveEntities:FireAllClients(entitiesToEliminate) end
	if not IsTableEmpty(entitiesToRotate) then Remotes.RotateEntities:FireAllClients(entitiesToRotate) end
	if not IsTableEmpty(entitiesToUpdateHealth) then Remotes.UpdateEntityHealth:FireAllClients(entitiesToUpdateHealth) end
	
end
-----------------
-------------
function module:SummonEntity(EntityName: string, Position: Vector3, BlueTeam: boolean?): (boolean, string? | SummonEntityResult?)
	return pcall(function()
		assert(typeof(EntityName) == 'string', 'Entity name not given or invalid.')
		assert(typeof(Position) == 'Vector3', 'Position not given or invalid.')
		assert(WithinBounds(Position, config.Bounds), 'Position is not within bounds.')

		local FoundEntity = EntityTemplates[EntityName] or assert('Entity template not found.')

		local NewEntity: Entity = setmetatable(table.clone(FoundEntity), getmetatable(FoundEntity))
		NewEntity.BlueTeam = BlueTeam ~= nil and BlueTeam or config.DefaultEntityTemplate.BlueTeam
		NewEntity.Position = Position + Vector3.new(0, config.ArenaFloorHeight + NewEntity.EntityHeight/2, 0)

		TotalEntitiesSummoned += 1

		NewEntity.Id = TotalEntitiesSummoned
		
		_G.CurrentEntities[NewEntity.Id] = NewEntity

		NewEntity:Deploy()
		Remotes.AddEntity:FireAllClients(NewEntity.Name, NewEntity.Position, NewEntity.Id, NewEntity.BlueTeam)

		return {Entity = NewEntity, TablePosition = #_G.CurrentEntities}
	end)
end

function module:DiscardEntity(TablePosition: number): (boolean, string?)
	return pcall(function()
		assert(typeof(TablePosition) == 'number', 'Entity table position not given or invalid.')

		_G.CurrentEntities[TablePosition] = nil
	end)
end

function module:GetAllEntities(): {Entity?}
	return _G.CurrentEntities
end

local initizalized = false
function module:Init(): (boolean, string?)
	if initizalized then return true end

	initizalized = true

	task.wait(1)

	local debounce = false
	return pcall(function()
		RunService.Heartbeat:Connect(function(deltaTime)
			if debounce then return end
			debounce = true

			ExecuteEntityBehaviour(deltaTime)

			debounce = false
		end)
	end)
end

return module
Client ClashRoyaleEffectHandler
local module = {}
if game:GetService('RunService'):IsServer() then return {} end

-- utils --
local initizalized = false

local ReplicatedStorage = game:GetService('ReplicatedStorage')

-- reliances (feel free to edit) --
local ModuleLoader = require(ReplicatedStorage.ModuleLoader)
local NetworkService = ModuleLoader:GetService('NetworkService')

local Remotes = {
	AddEntity = NetworkService:GetUnreliableRemoteEvent('AddEntity'),
	RotateEntities = NetworkService:GetUnreliableRemoteEvent('RotateEntities'),
	RemoveEntities = NetworkService:GetUnreliableRemoteEvent('RemoveEntities'),
	MoveEntities =  NetworkService:GetUnreliableRemoteEvent('MoveEntities'),
	UpdateEntityHealth =  NetworkService:GetUnreliableRemoteEvent('UpdateEntityHealth'),
}
-------------

-- helpers --
local function getEntityModelByName(EntityName: string): BasePart? | Model?
	assert(typeof(EntityName) == 'string', 'Entity name not gven or invalid.')

	return ReplicatedStorage.ClashRoyaleEffects.Entities:FindFirstChild(EntityName)
end

local function createFolder(Name: string?, Parent: Instance?): (string?, Instance?) -> Folder
	local newFolder = Instance.new('Folder')
	newFolder.Name = Name or 'Folder'
	newFolder.Parent = Parent or workspace
	return newFolder
end
-----------

local ClashRoyaleWorkspace = workspace:WaitForChild('ClashRoyaleWorkspace') or createFolder('ClashRoyaleWorkspace', workspace)
local EffectsFolder = ClashRoyaleWorkspace:WaitForChild('Effects') or createFolder('Effects', ClashRoyaleWorkspace)
local EntitiesFolder = EffectsFolder:WaitForChild('Entities') or createFolder('Entities', EffectsFolder)
---------
local CurrentEntities = {}

function module:Init(): (boolean, string?)
	if initizalized then return end
	initizalized = true
	return pcall(function()
		Remotes.AddEntity.OnClientEvent:Connect(function(EntityName: string, Position: Vector3, EntityId: number, BlueTeam: boolean?)
			assert(typeof(EntityName) == 'string', 'Entity name not gven or invalid.')
			assert(typeof(EntityId) == 'number', 'Entity ID not gven or invalid.')
			assert(typeof(Position) == 'Vector3', 'Entity position not gven or invalid.')

			local newEntity = getEntityModelByName(EntityName):Clone()
			CurrentEntities[EntityId] = newEntity

			newEntity.Parent = EntitiesFolder
			newEntity.Position = Position

			local Color = BlueTeam and Color3.new(0,0,1) or Color3.new(1,0,0)  

			newEntity.SurfaceGui.Frame.BackgroundColor3 = Color

			local HealthBar = ReplicatedStorage.ClashRoyaleEffects.Other:FindFirstChild('HealthBar')
			if HealthBar then
				local NewBar = HealthBar:Clone()
				NewBar.Parent = newEntity
				NewBar.Adornee = newEntity
				NewBar.Health.BackgroundColor3 = Color
			end
		end)

		Remotes.MoveEntities.OnClientEvent:Connect(function(IdTable: {Vector3})
			for EntityId, Position in pairs(IdTable) do
				assert(typeof(Position) == 'Vector3', 'Entity position not gven or invalid.')

				local entity = CurrentEntities[EntityId]
				if entity then
					entity.Position = Position
				else
					warn('Entity with Entity ID: '..tostring(EntityId)..' could not be found.')
				end
			end
		end)

		Remotes.RotateEntities.OnClientEvent:Connect(function(IdTable: {number})
			for EntityId, YRotation in pairs(IdTable) do
				assert(typeof(YRotation) == 'number', 'Entity rotation not gven or invalid.')

				local entity: Part = CurrentEntities[EntityId]
				if entity then
					entity.CFrame = CFrame.new(entity.CFrame.Position) * CFrame.Angles(0, YRotation, 0)
				else
					warn('Entity with Entity ID: '..tostring(EntityId)..' could not be found.')
				end
			end
		end)

		Remotes.RemoveEntities.OnClientEvent:Connect(function(IdTable: {boolean})
			for EntityId, boolean in pairs(IdTable) do
				local entity: Part = CurrentEntities[EntityId]
				if entity and boolean then
					entity:Destroy()
				else
					warn('Entity with Entity ID: '..tostring(EntityId)..' could not be found.')
				end
			end
		end)

		Remotes.UpdateEntityHealth.OnClientEvent:Connect(function(IdTable: {{CurrentHealth: number, MaxHealth: number}})
			for EntityId, InfoTable in pairs(IdTable) do
				assert(typeof(InfoTable.CurrentHealth) == 'number', 'Current health not gven or invalid.')
				assert(typeof(InfoTable.MaxHealth) == 'number', 'Max health not gven or invalid.')

				local entity: Part = CurrentEntities[EntityId]
				if entity then
					local HealthBar = entity:FindFirstChild('HealthBar')
					if HealthBar then
						HealthBar.Enabled = true
						HealthBar.Health.Size = UDim2.new(InfoTable.CurrentHealth/InfoTable.MaxHealth, 0, 1)
					end
				else
					warn('Entity with Entity ID: '..tostring(EntityId)..' could not be found.')
				end
			end
		end)
	end)
end

return module

If you need any extra information, please let me know.

If you have any tips for me to improve my code, please share it! (All help will be appreciated)

1 Like

I am not sure about the pathfinding error, but the reason your entities can’t be found is due to sparse arrays. Remotes cannot send a table with numeric keys if they are non sequential. For example, sending table with keys [1] and [3] would convert them to [“1”], [“3”] before sending. In your client code, you handle most remotes with an IdTable parameter which takes ids as keys, however, the server converts the ids to strings before sending, except for entities 1 and 2 since they are sequential and start at 1. For how to fix it, I can think of 2 options:

  1. Convert the ids to strings before adding in AddEntities.
  2. Convert the ids in IdTable parameter to numbers before indexing your entities.

Also, if you want to read more on this here is a similar devforum post about this. https://devforum.roblox.com/t/losing-table-data-when-firing-event/3256682

1 Like

I applied this fix on the client side, and it started working! I never knew about this very subtle phenomenon so thanks.

Also, for the server sided logic, it was best that the entity object’s methods handled the death logic, and not handled on the EcecuteEntityBehaviour function. This is because i was checking for the enemy’s health to be 0 or below before the attack has even damaged the enemy.

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