UDP in Lua with LuaSocket

SL:MP and SNET both started with LuaSocket and UDP. Here's the basic API on a working example: a LuaJIT server and a MoonLoader client exchanging player positions.

Author: Published: Updated: 4 min read

SL:MP began in August 2020 with a simple question: could we sync players in GTA: San Andreas with nothing but LuaSocket on top of SA:MP? We could. From there the protocol went from compressed JSON to serialized Lua tables and finally to the binary SL:NET over UDP. Later I split the networking part out into a separate library, SNET. Below is that foundation: plain LuaSocket and UDP, nothing on top.

Why UDP

TCP guarantees delivery and order. For a game that's more of a drawback: if one segment is lost, everything behind it waits for the retransmission. A player position that's half a second late is useless, and it holds up the fresh data too.

UDP sends datagrams as they are. Each one arrives whole or not at all. The trade-offs:

  • packets get lost, and nobody tells you;
  • they arrive in a different order than they were sent;
  • sometimes they're duplicated.

For a stream of positions that's tolerable: the next packet comes 100 ms later and replaces the lost one. For a chat message or a dialog choice it isn't.

A LuaJIT server

The core LuaSocket UDP calls: socket.udp() creates a socket, setsockname binds it to an address and port, receivefrom reads a datagram along with the sender's address, and sendto sends to a specific address. The server below accepts packets from clients and relays each one to everyone else:

local socket = require("socket")
local udp = assert(socket.udp())
assert(udp:setsockname("*", 7777))
udp:settimeout(0)
local clients = {}
while true do
  local data, ip, port = udp:receivefrom()
  if data then
    local key = ip .. ":" .. port
    clients[key] = { ip = ip, port = port, seen = socket.gettime() }
    for k, c in pairs(clients) do
      if k ~= key then
        udp:sendto(key .. " " .. data, c.ip, c.port)
      end
    end
  else
    -- queue is empty: drop silent clients and yield the CPU
    local now = socket.gettime()
    for k, c in pairs(clients) do
      if now - c.seen > 10 then
        clients[k] = nil
      end
    end
    socket.sleep(0.01)
  end
end

"*" in setsockname means all local interfaces. UDP has no connections, so a client is identified by its IP and port pair, and a disconnect has to be detected by silence: here, 10 seconds without packets.

With a timeout of 0, receivefrom returns nil, "timeout" when the queue is empty. While packets keep coming, the loop drains them without pausing, and socket.sleep only kicks in when there's nothing to read.

A MoonLoader client

The client talks to a single server, so setpeername is more convenient: after it, send and receive work without an address. Unlike sendto, setpeername also accepts a host name.

local socket = require("socket")
local udp = assert(socket.udp())
udp:settimeout(0)
assert(udp:setpeername("127.0.0.1", 7777))
local function send_position()
  local x, y, z = getCharCoordinates(PLAYER_PED)
  udp:send(string.format("%.2f %.2f %.2f", x, y, z))
end
local function read_packets()
  while true do
    local data = udp:receive()
    if not data then break end
    local id, x, y, z = data:match("^(%S+) (%S+) (%S+) (%S+)$")
    if id then
      print(id, tonumber(x), tonumber(y), tonumber(z))
    end
  end
end
function main()
  local last_send = 0
  while true do
    wait(0)
    read_packets()
    local now = socket.gettime()
    if now - last_send >= 0.1 then
      send_position()
      last_send = now
    end
  end
end
function onScriptTerminate(script, quit_game)
  if script == thisScript() then
    udp:close()
  end
end

wait(0) hands control back to the game until the next frame, so the script polls the socket once per frame without freezing GTA. Sending is capped at ten packets per second: sending the position every frame at 60 FPS is extra traffic with no visible benefit. In a real multiplayer, the print would be replaced by moving another player's character.

Ordering and loss

The simplest protection against reordered packets is a sequence number. The sender increments a counter and puts it in each packet; the receiver drops anything older than the last one it accepted:

local last_seq = {}
local function accept(id, seq)
  if last_seq[id] and seq <= last_seq[id] then
    return false
  end
  last_seq[id] = seq
  return true
end

For positions that's enough: a late packet is simply thrown away. Important events are harder. They have to be acknowledged and resent until the acknowledgement arrives, without holding up the position stream. On top of that, the text format in the example is wasteful: numbers as strings take several times more bytes than in binary. In SL:MP we eventually settled on the binary SL:NET with its own bitstream.

Size is another constraint. A typical Ethernet MTU is 1500 bytes, and a larger datagram gets split into IP fragments. If any fragment is lost, the whole datagram is gone, so a big packet is lost noticeably more often than a small one. Keep game packets within a few hundred bytes, and split large data (the player list on join, dialog contents) into chunks or send it through a mechanism with acknowledgements.

What SNET adds

SNET handles exactly these problems on top of the same LuaSocket and UDP. It has delivery confirmation for important packets and priorities: some packets go out best effort, others must arrive. The library itself puts no limit on the number of clients. SNET runs on LuaJIT under Windows and Linux and in MoonLoader, with the same protocol on both sides. The community ported it to Node.js (node-snet), and there was an experimental Go port too.

If you're building your own networking mod in Lua, starting with plain LuaSocket is worth it if only to see which problems a library solves for you. The code here follows my Lua style guide, and to see all of this in a finished multiplayer, try running an SL:MP server.

Frequently asked questions

Why does sendto fail when I pass a host name?

In LuaSocket, sendto only accepts an IP address: host names aren't resolved there for performance reasons. Either resolve the IP up front with socket.dns.toip(), or use setpeername, which does accept host names.

Why settimeout(0) instead of reading the socket in a separate thread?

Lua has no threads in the usual sense, and MoonLoader runs scripts as coroutines inside the game loop. A blocking receive would stall the whole script until a packet arrives. With a timeout of 0 the call returns nil and "timeout" immediately when there's nothing to read.

How is a UDP socket with setpeername different from a regular one?

After setpeername the socket is "connected": send and receive work without an address, and datagrams from other addresses are dropped. That's handy for a client talking to one server; a server with many clients needs an unconnected socket with sendto and receivefrom.