Trying to make a server sided movement validation

Hello there!

I’ve been trying to make a server sided movement validation system for this game i’m making (and I’m fairly a beginner when it comes to anti cheats), if you’re a familiar with Battle Cats then you will understand what i’m trying to do.

Due to the chaotic nature of this game, I wanted to handle hitboxes on the client for performance.

Since the troops move in a straight line it makes the synchronization fairly simple, this is what i achieved so far

  • Both the client and server run the same movement logic starting from a shared startTime.
  • The client sends its position to the server every 0.2 seconds, which the server compares against its internal simulation.
  • On average, this gives extremely tight sync, with only ~0.001 studs of difference.

Everything works fine until a troop stops moving to attack (e.g. detects a target in range) but to keep in mind:

  • The server tracks all troops and checks for interactions using Magnitude and range values every frame.
  • When a troop stops, both client and server start an attack cycle, respecting foreswing, backswing, and total cooldown timing using separate threads.
  • After each attack (exactly after the backswing), the troop resumes movement.

Here an example of the issue

Despite both sides following the same logic, each time the troop stops and resumes, the client becomes slightly more desynced from the server.
The more times it stops and resumes, the worse the desync becomes.

Here’s the part of the code that handles the server side simulation, there are some band-aid solutions for a few minimal things and an overall mess I have to improve so don’t mind that too much.

function StartTroopValidation(player: Player, id: number, guid: string, startingPos: Vector3, troopType: string, startTime: number)
	local data = troopData[troopType][id]
	local position = startingPos
	local direction = (troopType == "Enemy") and Vector3.new(1, 0, 0) or Vector3.new(-1, 0, 0)

    -- initialize troop for simulation
	local troopInfo = {
		TotalElapsedTime = os.clock() - startTime,
		StartingPos = startingPos,
		Position = startingPos,
		Health = data.Stats.Health,
		Data = data,
		Type = troopType,
		Direction = direction,
		OnCooldown = false,
		OnBackswing = false
	}

	reportedTroops[player] = reportedTroops[player] or {}
	reportedTroops[player][guid] = troopInfo

	local conn

	conn = RunService.Heartbeat:Connect(function(deltaTime: number)
		local troopInfo = reportedTroops[player] and reportedTroops[player][guid]

		if not troopInfo then
			conn:Disconnect()
			return
		end

		if troopInfo.OnBackswing then return end

		local detected = false

        -- checks for troops ahead
		for otherId, other in pairs(reportedTroops[player]) do
			if otherId ~= guid and other.Type ~= troopInfo.Type then
				local distance = math.abs(troopInfo.Position.X - other.Position.X)

				if distance <= troopInfo.Data.Stats.Range then
					detected = true
					break
				end
			end
		end


		if detected then

			if not troopInfo.OnCooldown then

				local attackCycle = troopInfo.Data.AttackCycle

              --thread to handle the attack cycle if it detects another troop
				task.spawn(function()
					task.wait(attackCycle.Foreswing)

					if not reportedTroops[player] or not reportedTroops[player][guid] then return end
					reportedTroops[player][guid].OnBackswing = true

					task.wait(attackCycle.Backswing)

					if reportedTroops[player] and reportedTroops[player][guid] then

						reportedTroops[player][guid].OnCooldown = true
						reportedTroops[player][guid].OnBackswing = false

						local TBA = math.max(attackCycle.TBA, attackCycle.Foreswing + attackCycle.Backswing)
						local cooldownDuration = TBA - (attackCycle.Foreswing + attackCycle.Backswing)

						task.delay(cooldownDuration, function()
							if reportedTroops[player] and reportedTroops[player][guid] then
								reportedTroops[player][guid].OnCooldown = false
							end
						end)
					end
				end)
			end
		else
			troopInfo.TotalElapsedTime += deltaTime

			troopInfo.Position = troopInfo.StartingPos + troopInfo.Direction * troopInfo.Data.Stats.Speed * troopInfo.TotalElapsedTime
		end

		reportedTroops[player][guid] = troopInfo
	end)
end

So questions

  1. Is there something I might be missing or doing wrong when pausing/resuming movement?
  2. Has anyone attempted a similar server-authoritative validation system for movement, especially in unit-based games like this?
  3. Is there a more reliable approach for syncing these state transitions (stop/resume/attack) accurately?

Any advice or shared experience would be extremely helpful!

Thanks in advance :pray:

1 Like

Just wait for server authority update and AuroraScript
imo not worth time making system that may be outdated tommorow?

Could you elaborate on these? and why would a system like this get outdated?

I finally solved the desync problem and it turns out the issue was something I completely overlooked, and it wasn’t mentioned earlier in the post.

basically

self:_DamageTarget(target)

That function internally uses a RemoteFunction to validate damage with the server.
It yields the thread and pauses execution until the server replies.

so all i had to do was to make it a separated thread in order to not have interruption in the main one

task.spawn(function()
	self:_DamageTarget(target)
end)

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