2DEngine

Networking using UDP

Contents

  1. Introduction
  2. Chat
    1. Server
    2. Client
  3. Peer-to-peer
    1. Connection
    2. Communication
    3. Simulation
    4. Source code

Introduction

This is going to be a very brief introduction to LuaSocket, a generic networking library for Lua. In particular, we will be looking at UDP (User Datagram Protocol). UDP is different than TCP in a number of ways. Generally speaking, UDP is simpler and more lightweight.

Chat

Let's start by creating a minimal chat application. We will setup a server and a client scripts. This will allow us to run two or more instances of Love2D and pass messages between them. All of this will be achieved using the LuaSocket module.

Server

The server script creates a UDP object at port 14285. By setting its address to "*" the server will be able to communicate with multiple clients. Each client is identified using a unique string, based on his IP address and occupied port. Note that the "timeout" interval is set to 0 in order to avoid synchronous blocking. The server iterates incoming messages (datagrams) and re-broadcasts them to all clients.

socket = require("socket")
-- bind to all local interfaces 
local udp = socket.udp()
udp:settimeout(0)
udp:setsockname("*", 14285)

local clients = {}

function love.update()
  while true do
    -- receive
    local data, ip, port = udp:receivefrom()
    if data == nil then
      break
    end
    -- manage clients
    local uid = ip..":"..port
    if not clients[uid] then
      clients[uid] = { ip = ip, port = port }
    end
    -- re-broadcast
    for _, c in pairs(clients) do
      udp:sendto(uid..":"..data, c.ip, c.port)
    end
  end
end

function love.draw()
  love.graphics.print("Clients:", 0, 0)
  local i = 0
  for uid in pairs(clients) do
    i = i + 1
    love.graphics.print(uid, 0, i*16)
  end
end

Since the server could be communicating with several clients, it's important to note the "sendto" function. It is similar to "send" except that "sendto" requires the explicit IP address and port of the receiving client.

Client

The client script basically collects all text input and sends it to the server. Note that instead of "setsockname", we are using "setpeername" in order to connect through "localhost" at port 14285. If we want to run the script over the Internet, we could use an actual IP address instead of "localhost".

local socket = require("socket")

-- connect to server
local udp = socket.udp()
udp:settimeout(0)
udp:setpeername("localhost", 14285)

-- send messages
local input = {}
function love.textinput(text)
  table.insert(input, text)
end

function love.keypressed(key)
  if key == "backspace" then
    table.remove(input)
  elseif key == "return" then
    udp:send(table.concat(input))
    input = {}
  end
end

-- receive messages
local history = {}
function love.update(dt)
  while true do
    local data = udp:receive()
    if data == nil then
      break
    end
    table.insert(history, 1, data)
  end
end

function love.draw()
  love.graphics.print(table.concat(input), 0, 0)
  love.graphics.print(table.concat(history, "\n"), 0, 16)
end

Note that once the client has connected to a server via "setpeername", he accepts data exclusively from that server using the "receive" function. This is done in loop because there could be several queued datagrams.

Peer-to-peer

In P2P multiplayer, all participants communicate with each other directly. P2P works best with a small number of players who have little incentive to cheat. Let's start with a simple "player versus player" or PVP match.

Connection

First, we need to find a peer on the network who wants to play. For testing purposes we will use a local IP address such as 127.0.0.1 on an arbitrary port like 14285. By starting a second instance of our game, the script will detect that the same address and port are already occupied by somebody else. We send our peer the "join" message so that he knows that we are looking for a match.
local connected = false
local socket = require("socket")
-- host or join locally
local udp = socket.udp()
udp:settimeout(0)
udp:setsockname("127.0.0.1", 14285)
if not udp:getsockname() then
  -- join
  udp:setpeername("127.0.0.1", 14285)
  udp:send("join")
  connected = true
end
Before our P2P game can begin, the first peer or host needs to receive that "join" message. This message also contains the IP and port of the second peer which up to that point are unbeknown. Once both peers have connected via "setpeername" we are ready to start!
function love.update(dt)
  if not connected then
    -- wait for somebody to join
    repeat
      local data, ip, port = udp:receivefrom()
      if data == "join" then
        udp:setpeername(ip, port)
        connected = true
        break
      end
    until not data
  else

Communication

The two peers only have to send basic input data to each other. This is more efficient than trying to sync the positions and state of every object in the game world. Essentially, we are making a two-player game where one of the controllers is managed over the network. Each message will be composed of two numbers:
132843 0010
The first number in this messages is the tick counter which serves as a time stamp. The second number is the state of our keyboard (we only need to track four buttons). In fact, we can exclude certain combinations like the up and down keys being pressed at the same time. The more observant readers may notice that these messages could be pack into binary for improved performance.

local keys = { "up", "down", "left", "right" }
...
-- store and send local input
local state = {}
for i, key in ipairs(keys) do
  state[i] = love.keyboard.isDown(key) and 1 or 0
end
local msg = table.concat(state)
udp:send(tick.." "..msg)
player[tick] = state

Any messages arriving over the network are also parsed and stored. This way we can keep the complete input history of every player.

repeat
  local data = udp:receive()
  if data then
    -- parse and store peer input
    local stamp, params = data:match("(%d+)%s(.+)")
    stamp = tonumber(stamp)
    if params then
      local state = { params:match("(%d)(%d)(%d)(%d)") }
      opponent[stamp] = state
    end
    -- track the last received message
    last = math.max(last, stamp)
  end
until not data

Simulation

Next, we have to figure out how to handle the stored controller data in order to produce a consistent result between players. We begin form the same initial state, with both agent in predefined starting positions. The game needs to be deterministic so that the same input should always produce identical results. Determinism is not easy to achieve. For example, we have to be particularly careful with functions like "pairs" which can subtly produce unpredictable results. There may be inconsistencies differences between platforms too.

repeat
  local data = udp:receive()
  if data then
    ... parse and store peer input
  end
until not data

accum = accum + dt
while accum > int do
  accum = accum - int
  tick = tick + 1
  ... store and send local input
end

for i = sync, last do
  if not player[i] or not opponent[i] then
    break
  end
  sync = i
  for _, agent in pairs(agents) do
    local state = agent[i]
    assert(state, i)
    local vx, vy = 0, 0
    if state[1] == "1" then
      vy = -25
    elseif state[2] == "1" then
      vy = 25
    end
    if state[3] == "1" then
      vx = -25
    elseif state[4] == "1" then
      vx = 25
    end
    agent.x = agent.x + vx*int
    agent.y = agent.y + vy*int
  end
end

Before reacting to any input we have to make sure that everyone is on the same page. Both peers must be running using a fixed time step. This is achieved using an accumulator and a constant update interval. Messages are indexed based on their corresponding time stamp so the order of their arrival is irrelevant. Basically, the simulation moves forward depending on how much input was buffered.

Download the source code (p2p.lua)

References

LuaSocket by Diego Nehab
What Every Programmer Needs To Know About Game Networking by Gaffer