Last time I posted progress on Pongbat there were still quite a few issues with the core gameplay. In general some aspects of the game just were buggy or didn’t respond well. These problematic issues were especially noticable in multiplayer games. To resolve these issues I’ve worked on several improvements in the game that I want to discuss in this post.
The following functionality was added to the game:
- Fixed Timestep
- Jitter Buffer
- Client-Side Prediction
- Instant Input Feedback
Together these improvements now guarantee a smooth multiplayer experience, at least over wifi networks.
All of the improvements have been based on topics from the Gaffer on Games website and I will add relevant links. In this post I mainly want to explain my specific implementation of these functionalities.
Fixed Timestep
This was the simplest of functions to add. The fixed timestep was required since sometimes the game would get a big spike in frames and the simulation would run extremely fast for a short while. For the player this is not a fun experience, as it becomes much harder for the player to respond.
The current implementation is as follows (simplified):
local game = Class {} -- based on hump.class
local TICK_RATE = 1/60 -- simulation runs on 60 fps
function game:init()
Scene.init(self)
end
function game:enter(previous, server, client)
Scene.enter(self, previous)
self.server = server -- if we're the host we have a server
self.client = client
self.time = 0
end
function game:update(dt)
Scene.update(self, dt)
self.time = self.time + dt
while self.time >= TICK_RATE do
self.time = self.time - TICK_RATE
-- update server if we're the host
if self.server ~= nil then self.server:update(TICK_RATE) end
-- update client state always
self.client:update(TICK_RATE)
end
end
function game:draw()
Scene.draw(self)
-- game state is stored in client, so only render if we have a state
if self.client.state == nil then return end
-- all rendering code here ...
end
return game
In LÖVE 2D the rendering code is already separated from the simulation update code, so we only need to make sure the simulation updates correctly. The while
loop waits until enough time is passed to advance a frame. If somehow a lot of time has passed, advance the simulation multiple ticks at once.
This implementation is based on the “Free the physics” implementation from Glenn’s article Fix Your Timestep!.
Jitter Buffer
The jitter buffer is currenly very basic and will probably need to be improved a bit later on.
Currently the buffer class implementation is as follows:
local buffer = Class {} -- based on hump.class
function buffer:init(size, sort)
assert(size ~= nil, "a size is required to create a buffer")
self._size = math.max(size or DEFAULT_BUFFER_SIZE, 1)
self._items = {}
self._sort = sort or function(left, right) return true end
end
-- remove last item from buffer
function buffer:dequeue()
return table.remove(self._items)
end
-- remove all items from buffer
function buffer:clear()
self._items = {}
end
function buffer:enqueue(item
-- always add item to buffer, even if full
table.insert(self._items, item)
-- sort items in buffer based on sort function in constructor
table.sort(self._items, self._sort)
-- if buffer overflows, remove last item
if #self._items > self._size then table.remove(self._items) end
end
function buffer:isFull()
return #self._items == self._size
end
function buffer:isEmpty()
return #self._items == 0
end
return buffer
In the constructor a size is defined as well as a sorting function. When we add a new item to the buffer, the following happens:
- An new item is added to the internal array.
- The array is sorted using the sort function.
- If the buffer overflows, remove the last item from the array.
The buffer is used by the network client as follows (simplified):
local CLIENT_BUFFER_SIZE = 5
local client = Class { __includes = Peer } -- based on hump.class
function client:onUpdate(state)
self.buffer:enqueue(state)
-- wait for buffer to be full, before starting updates
if self.buffer:isFull() then self.isUpdating = true end
end
function client:init(host, port, controls)
Peer.init(self, host, port, PEER_MODE_CLIENT)
self.state = State()
self.isUpdating = false
self.buffer = Buffer(CLIENT_BUFFER_SIZE, function(state1, state2)
-- sort based on tick value
return state1.tick > state2.tick
end)
self:registerMessageHandler(NET_MESSAGE_UPDATE, function(state)
-- server sends state updates which are passed to client:onUpdate(...)
self:onUpdate(state)
end)
end
function client:update(dt)
Peer.update(self, dt)
self.state:update(dt)
-- if buffer is empty, wait for buffer to refill
if self.buffer:isEmpty() then self.isUpdating = false end
-- if buffer is not empty, proceed to next state by removing from the buffer
if self.isUpdating == true then self.state = self.buffer:dequeue() end
end
return client
The client contains a boolean value for isUpdating
. If the buffer becomes empty, isUpdating
will become false. When isUpdating
is false, we wait for buffer to be full again before getting the next state from the buffer.
The buffer does introduce a slight lag, but this is not really noticable when the buffer has a small size and the user will get a smoother, more predictable gameplay.
In the future I might need to make the buffer adaptive, based on ping value.
Glenn discusses the jitter buffer in the article State Synchronization.
Client-Side Prediction
This was the most complex function to implement, but mainly due to the way I implemented the game state and entities previously. In my previous version the client would get a simplified state with simplified entities from the server. As a result, the methods used on the server for updating state were not available on the client.
In order to allow the client full access of all the state methods I needed to serialize the state and entity classes. I was concerned that sending full classes over sockets would cause a big data increase, but this fear seemed to be ungrounded.
In order to send the full classes I made use of bitser, which was a dependency I already used for the networking library sock.lua. Making the classes serializable proved to be trivial:
function love.load(args)
-- bitser knows how to serialize hump.class objects
bitser.registerClass('State', State)
bitser.registerClass('Ball', Ball)
bitser.registerClass('Paddle', Beam)
-- ...
-- in order to serialze hump.vector, we need to provide the metatable
bitser.registerClass('Vector', getmetatable(Vector()), nil, setmetatable)
-- don't send metatables over network connection
bitser.includeMetatables = false
-- more init code here ...
end
Now updating state on the client would become just a matter of calling state:update(dt)
when needed. Now state transitions on the client would be exactly the same as on the server if the tick and delta times are equal.
Combined with the jitter buffer, client-side prediction works well. If the game doesn’t receive a state for some time and the buffer is empty, the client can still update the state. When the client later receives a valid state from the server, the local state is replaced.
Glenn discusses client-side prediction in the article What Every Programmer Needs To Know About Networking.
Instant Input Feedback
In the previous version a user would input some key (e.g. move up) and the client would send the input to the server. The server would then process the feedback and send the result back to the client. Finally the client would render the new state. For local games this approach would be fine, but there would be a noticable lag when connecting with a server over wifi.
As such I made a small change in the client. The client now immediately updates the state with the user input, prior to sending the input to the server. If the delay is small enough, results will be mostly the same and the state should be synchronized again once the server has processed the input and sent back to the client. With low pings, this approach seems fine, but could use additional improvement at higher ping values.
The code to update the local state with user input is as follows:
function client:update(dt)
Peer.update(self, dt)
-- apply user input to state
for _, control in ipairs(self.controls) do
control:update(dt, self.state)
local paddleId = control:getPaddleId()
local input = control:getInput()
-- send moves up and down
self:send(NET_MESSAGE_MOVE, { paddleId = paddleId, direction = input.direction })
self.state:paddleMove(paddleId, input.direction)
-- send attacks
if input.attack == true then
self:send(NET_MESSAGE_ATTACK, { paddleId = paddleId })
self.state:paddleAttack(paddleId)
end
control:clearInput()
end
-- update rest of state (e.g. ball positions, power-ups, ...)
self.state:update(dt)
-- buffer handling ...
end
I hope to test the networking code more extensively in the coming week. If needed, I will add additional improvements. I do hope the current implementation will be sufficient for the initial release.
Conclusion
There are a many techniques a developer can implement to provide a smooth experience for multiplayer games. Depending on the complexity of a game, just implementing a few techniques might already provide a smooth enough experience for most players for low latency situations.
In the next article I will describe improvements in Pongbat to better deal with high latency situations.
Further reading: Networking II (Pongbat)