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:

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:

  1. An new item is added to the internal array.
  2. The array is sorted using the sort function.
  3. 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)