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
- Player and CPU input controllers now keep track of the
tickfor 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.
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.