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 local IP address such as 127.0.0.1 on an arbitrary port like 14285. 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 endIf your home network is configured differently, 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
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 0010The 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 NehabWhat Every Programmer Needs To Know About Game Networking by Gaffer