LÖVE und PANZER - Smooth Real-time Networking

Posted on

With the new networking structure in place, making everything look nice is important. In my last post, I explained the idea behind how our network code works. In this post, I will explain the details that made everything come together.

First off, a quick recap. We are sending data from the client to the server on a 20Hz frequency, whch means the client is sending data 20 times each second, or roughly once every three frames. The server grabs this data and relays it on to all other clients. Ignoring latency for a moment, that means that each client is receiving new position data every third frame. Between these updates, the other tanks will just sit on the map and then jump to their new position. This is really ugly. To solve that problem, we did two things:

  1. Introduce velocity
  2. Use velocity to move the tank in lieu of data

When the player pushes certain buttons on their gamepad or keyboard, (in this case, left-axis-y and W/S, respectively), those inputs are encoded as movement velocity and sent along with the current position so that the server and other clients know how fast you are moving. Coupled with rotational velocity (left-axis-x, A/D), others can know where you are, and where you will be in the next frame.

typedef struct {
	uint16_t id;
	float turret;
	float turret_velocity;
	float position_x,     position_y,     position_z;
	float orientation_x,  orientation_y,  orientation_z;
	float velocity_x,     velocity_y,     velocity_z;
	float rot_velocity_x, rot_velocity_y, rot_velocity_z;
	float acceleration;
} player_update_f;
for _, player in pairs(self.players) do
	player.position      = player.position      + player.velocity * dt
	player.orientation.x = player.orientation.x + player.rot_velocity.x  * dt
	player.orientation.y = player.orientation.y + player.rot_velocity.y  * dt
	player.orientation.z = player.orientation.z + player.rot_velocity.z  * dt
	player.turret        = player.turret        + player.turret_velocity * dt
	player.direction     = player.orientation:orientation_to_direction()
end

There is a bit of a problem, though. This data does not account for latency. While this results in smooth visuals, if there is any deviation in latency, you are still going to see tanks jumping around when you get a new position update. On top of that, the positions you receive could be tens or even hundreds of milliseconds out of date. The tanks on your screen is several frames behind where it actually is, so when you aim to shoot, you may be way off. Your shot sent to the server will be a miss when you see it hit dead-on. To fix theese problems, we need to do two things:

  1. Account for latency
  2. Add visual smoothing

To account for latency, we need to grab our most recent ping and apply it to the data we receive. The server also does this so every step of the way, data is being adjusted for latency, leaving the final data as close to true as possible.

function ClientManager:player_update_f(id, update)
	if id ~= self.id then
		local peer = self.client.connection.peer
		local ping = peer:round_trip_time() / 1000 / 2

		update.position      = update.position      + update.velocity * ping
		update.orientation.x = update.orientation.x + update.rot_velocity.x  * ping
		update.orientation.y = update.orientation.y + update.rot_velocity.y  * ping
		update.orientation.z = update.orientation.z + update.rot_velocity.z  * ping
		update.turret        = update.turret        + update.turret_velocity * ping
    end
end

To add smoothing, we need to create separate variables to hold the actual position and use a linear interpolation (lerp) function to speed up or slow down the velocity of the tank enough to ease it into the position it should actually be at without being visibly noticable. In my example below I add 10% of the distance between where we are and where we should be on top of the velocity. These distances are generally very small and not noticable. In the case of severe lag where a player has stopped moving but other players do not get this notification for several hundred or thousand milliseconds, we may see some rubber banding.

function ClientManager:player_update_f(id, update)
	if id ~= self.id then
		local peer = self.client.connection.peer
		local ping = peer:round_trip_time() / 1000 / 2

		update.position      = update.position      + update.velocity * ping
		update.orientation.x = update.orientation.x + update.rot_velocity.x  * ping
		update.orientation.y = update.orientation.y + update.rot_velocity.y  * ping
		update.orientation.z = update.orientation.z + update.rot_velocity.z  * ping
		update.turret        = update.turret        + update.turret_velocity * ping

		self.players[id]                  = self.players[id]       or {}
		self.players[id].real_turret      = update.turret          or self.players[id].real_turret
		self.players[id].real_position    = update.position        or self.players[id].real_position
		self.players[id].real_orientation = update.orientation     or self.players[id].real_orientation
		self.players[id].velocity         = update.velocity        or self.players[id].velocity
		self.players[id].rot_velocity     = update.rot_velocity    or self.players[id].rot_velocity
		self.players[id].turret_velocity  = update.turret_velocity or self.players[id].turret_velocity
		self.players[id].acceleration     = update.acceleration    or self.players[id].acceleration
	end
end
for _, player in pairs(self.players) do
	player.position      = player.position      + player.velocity * dt
	player.orientation.x = player.orientation.x + player.rot_velocity.x  * dt
	player.orientation.y = player.orientation.y + player.rot_velocity.y  * dt
	player.orientation.z = player.orientation.z + player.rot_velocity.z  * dt
	player.turret        = player.turret        + player.turret_velocity * dt

	if player.id ~= self.id then
		local adjust = 0.1
		player.position    = player.position:lerp(player.real_position, adjust)
		player.orientation = player.orientation:lerp(player.real_orientation, adjust)
		player.turret      = player.turret + adjust * (player.real_turret - player.turret)
	end

	player.direction = player.orientation:orientation_to_direction()
end

The end result is very smooth movement, very smooth rotation, and latency-compensated positioning that will make the game look great, feel great, and work accurately in real-time battles.