Networking using UDP

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 the client uses "setpeername" instead of "setsockname". Once the client connects to a server with "setpeername", he accepts data exclusively from that server using the "receive" function. This is done in loop because there could be several queued datagrams.

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)
  repeat
    local data = udp:receive()
    if data then
      table.insert(history, 1, data)
    end
  until data
end

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

If we want to run this script over the Internet, things get more complicated. Most home routers will usually reassign the ports in order to share one Internet connection among several devices. Consequently, the addresses and ports that we are working with are only valid within our home network.

Peer-to-peer

In P2P multiplayer, all participants communicate with each other directly. P2P works best with a small number of players who don't have incentive to cheat. Let's start with a simple 2-player match.

Connection

First, we need to find a peer on the network who wants to play. For testing purposes we will use a localhost address such as 127.0.0.1 on an arbitrary port like 14285. IP addresses in the range between 127.0.0.0 to 127.255.255.255 are considered "loopback addresses". Loopback addresses work entirely on the localhost without transmitting any information over the network. Loopback addresses are ideal when we want to exchange information between two applications on the same device. By running a second instance of the script, it detects that the same IP address and port are already occupied. 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")
local ip = "127.0.0.1"
local port = 14285
-- host or join locally
local udp = socket.udp()
udp:settimeout(0)
udp:setsockname(ip, port)
if not udp:getsockname() then
  -- join
  udp:setpeername(ip, port)
  udp:send("join")
  connected = true
end
To run this script on your local area network, you may need to find your local IP address. Luckily, your local network IP address can be found using "socket.dns.toip":
local ip = socket.dns.toip("localhost")
If you have trouble establishing a connection, it's good to check which ports are currently "open" on your machine. On Windows, this is done using the NETSTAT utility:
netstat -a -n
Running the script over a public network like the Internet is considerably more difficult. Many people today access the internet using routers where one public IP address is often shared between multiple devices. Each device connected to your home router is assigned its own private IP address that is not accessible publicly. In short, not every device using the Internet has its own public IP address. Routers perform network address translation (NAT) to connect devices on your home network with the outside world.

Before our P2P game can begin, the first peer 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 need to exchange basic input data with 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 contained 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 packed 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 have the complete input history of each player.

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

Simulation

We begin form the same initial state, with both agents in predefined starting positions. The game needs to be deterministic so that the same input should always produce identical results. Determinism is difficult to achieve, especially between platforms. In Lua, we have to be particularly careful with functions like "pairs" which can subtly lead to unpredictable results.

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 the buffered input.

Download the source code (p2p.lua)

References

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