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:
- Paddle entities now include a
lastInputTick
property. - Player and CPU input controllers now keep track of the
tick
for input. This tick is sent to server. - All inputs send to the server are stored in an input buffer on the client.
- When the client retrieves a state from the server, the client:
- While next input in inputbuffer has tick greater than state tick.
- Apply the input to the game state.
- Repeat.
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.