This post is a continuation of Networking I (Pongbat)

In my previous post I explained the networking implementation in Pongbat. Last week I was testing this implementation and did encounter some issues at higher latencies.

In order to test at high latencies I made use of Apple’s Network Link Conditioner tool. At around 150 ms the implementation became problematic. The client would run too much out of sync with the server resulting in jerky gameplay. Further improvement was needed.

Initially I implemented server reconciliation as described in this post. Even though I got the whole mechanism eventually working, I was not satisfied with the result. The server code became much more complex and the experience on the client didn’t improve that much. Which is a shame, since I spent a lot of time getting this mechanism to work.

So after doing a bit more research, I came accross another approach that is confusingly also called server reconciliation, quote:

Another solution to the desynchronization issue, commonly used in conjunction with client-side prediction, is called server reconciliation. The client includes a sequence number in every input sent to the server, and keeps a local copy. When the server sends an authoritative update to a client, it includes the sequence number of the last processed input for that client. The client accepts the new state, and reapplies the inputs not yet processed by the server, completely eliminating visible desynchronization issues in most cases.

This approach is much easier to implement, puts much less strain on the server and the endresult (to me) even looks better on the client.

To implement this functionality I had to make the following changes:

The update(dt) function on the client now looks as such:

function client:update(dt)
	Peer.update(self, dt)

	-- don't perform any state updates if not connected
	if not self:isConnected() then return end

	-- every 2 seconds check average ping
	-- update jitter buffer size based on ping duration
	self._jitterBufferUpdateDelay = self._jitterBufferUpdateDelay - dt
	if self._jitterBufferUpdateDelay < 0 and #self._pingBuffer > 0 then
		self._jitterBufferUpdateDelay = 2.0

		local pingTotal = lume.reduce(self._pingBuffer, function(a, b) return a + b end)
		self._ping = pingTotal / #self._pingBuffer
		local bufferSize = math.ceil(self._ping / 1000 / TICK_RATE) + 1

		-- limit jitter buffer size to n frames
		bufferSize = math.min(bufferSize, CLIENT_JITTER_BUFFER_SIZE_MAX)

		self._jitterBuffer:resize(bufferSize)

		-- disable jitter buffer and high average ping values
		self._jitterBufferEnabled = (self._ping < 100)
	end

	-- get the ping of the last request and store in ping buffer
	-- the ping buffer is used to calculate average ping
	table.insert(self._pingBuffer, 1, self:getRoundTripTime())
	if #self._pingBuffer > 20 then
		table.remove(self._pingBuffer)
	end

	-- process player and cpu inputs
	for i, control in ipairs(self.controls) do
		control:update(dt, self.state)

		local input = control:getInput()

		-- get last input tick for current paddle
		local lastInputTick = self.state.paddles[input.paddleId]:getLastInputTick()

		-- add new local input to input buffer
		local inputBuffer = self._inputBuffers[input.paddleId]
		inputBuffer:enqueue(input)
		
		-- process all unprocessed inputs in input buffer
		-- and apply to current state
		if lastInputTick > 0 then
			for _, input in ipairs(inputBuffer:getItems()) do
				if input.tick > lastInputTick then
					local paddle = self.state.paddles[input.paddleId]:clone()
					paddle:applyInput(input.tick, input.move, input.attack)
					self.state.paddles[input.paddleId]:update(TICK_RATE)
					self.state.paddles[input.paddleId] = paddle
				end
			end
		end

		-- send new input to server
		self:send(NET_MESSAGE_INPUT, input)		

		-- reset input in control
		control:clearInput()
	end

	-- update local state for display
	self.state:update(dt)

	-- if buffer is empty, wait for buffer to refill
	-- but do nothing is jitter buffer is disabled
	if self._jitterBuffer:isEmpty() then 
		self._isBuffering = self._jitterBufferEnabled 
	end

	-- get next state from buffer, but keep current state
	-- if buffer is empty
	if (not self._isBuffering) then 
		local nextState = self._jitterBuffer:dequeue()
		self.state = nextState or self.state
	end	
end

As can be seen in the above listing, the jitter buffer was also modified. Now the client makes use of an adaptive jitter buffer. I keep track of the last 20 pings and calculate the average ping. Based on this ping the jitter buffer can increase or decrease in size. When ping is higher than 100 milliseconds, then I ignore the jitter buffer altogether. For high pings I don’t want to add more delay from the jitter buffer, in those situations it’s ok with me if the game becomes a bit more jerky every now and then.

Now one feature that I probably still should implement is a smoothing algorithm on the client, to smoothen the movement of entities between any two states.