Interpolating enemies w/ sporadic updates

Problem statement

Given an enemy with a walkspeed of ‘n’, that receives position updates around every 1/10 of a second, how would you interpolate the enemy to move seamlessly? The critical part of this statement is the ‘around every 1/10 of a second’. This number is variable because it could take 1/10 of a second to receive an update or it could be a lot longer than that (due to the nature of task scheduling and network traffic/network speed).

Scenario

The current situation that I’m running into is that I have a bunch of enemies that are represented on the server simply as { position = Vector2 }. Every frame, an enemy’s position is updated and every 1/10 of a second, position updates are sent to clients. Given the problem’s context, we know that the time between packets will not always be the same. I’m running into a problem where enemies stutter while waiting for the next packet. I’ve tried researching online and checking out devforum posts, but none of them can quite fix the issue that I have.

Attempt #1

My first attempt consists of “rubber-banding”-- continuing an enemy’s movement, and once a new update is received, set the start_position back to the server’s start position immediately. Due to the nature of the updates, this looks terrible and is not at all the experience I want for my players.

-- enemy lerping
-- extra code that is irrelevant to the problem has been removed
function EnemyRender:Step(dt)
	local moved = false
	
	-- update model's physical position
	self.alpha += dt * SETTINGS.ServerUpdateFrequency
	self.position = self.start_position:Lerp(
		self.end_position,
		self.alpha
	)
end

-- receive an update from the server
function EnemyRender:UpdatePosition(packet)
	local target = packet[1]
	target = Vector3.new(target.X, 3, target.Y)
	self.start_position = self.target_position
	self.target_position = target
	self.alpha = 0
end

Attempt #2

My second attempt consists of using a spring to ‘fix’ the start position. I’ll use the enemy’s current position on the client as a start and “spring” it back to the actual start position on the server. While not entirely mimicking the server’s movement, it gets a very close fit. This solution is extremely close to what I’m trying to accomplish, but small errors accumulate over time, which cause very large inconsistencies (ESPECIALLY when changing directions).

-- update position
function EnemyRender:Step(dt)
	-- update position
	self.dt += dt
	
	local direction = self.target_position - self.start_position.p
	if direction.Magnitude > 10^-16 then
		local velocity = direction.Unit * self.enemy_info.walkspeed
		self.position = self.start_position.p + velocity * self.dt
	end
end

-- receive an update from the server
function EnemyRender:UpdatePosition(packet)
	local target = packet[1]
	target = Vector3.new(target.X, 3, target.Y)
	self.start_position.p = self.position
	self.start_position.t = self.target_position
	self.target_position = target
	self.dt = 0
end

I feel like there’s a simple solution to this problem that I’m just not thinking of. If anybody has had experience with this, I’d really appreciate some guidance. If you need any more information, please feel free to ask.

I think it’s going to be very hard to show where the enemy is accurately with the delay, so I would show where the enemy is going on the client.

You could use octrees for variable timesteps. That way the enemies that are close to a player can update every 1/100th of a second, and the enemies that are far every 1 second.

in my entity movement system for my tower defense game, i clamp the entity’s speed times the delta time and divided it by the distance between the entity’s destination and its current position.

so something like this:

local speed = 35
local deltaTime = 0.235 -- just as an example

local randomX, randomZ = math.random(-20, 20), math.random(-20, 20)

local startPosition = Vector3.zero
local finishPosition = Vector3.new(randomX, 0, randomZ)

local distance = (finishPosition - startPosition).Magnitude

local alpha = math.clamp(speed * deltaTime / distance, 0, 1)

local positionNow = startPosition:Lerp(finishPosition, alpha)

the only caveat with this though is that the entity can sometimes briefly stop for a couple frames after reaching its destination, and then resumes its course.
(at least from my testings, i’m unsure about your situation)