LÖVE und PANZER - Real-time Networking

Posted on

I am well aware that this post is late. I was out of the province for a few weeks and hadn't the time to write. I rewrote the networking code several weeks ago and it works pretty well! Let me explain what I did.

So first off I want to explain my old networking code, and why it sucked. My first ever attempt at writing network code started a few years ago. What I ended up doing was serializing data every frame and sending it to the server. I serialized everything in a text-literal way, which is to say, I used a big ol' JSON string as my packet. Yeah. When I realize this was sending copious amounts of data over the network, I decided to try a "smarter" approach which was then copied over to LÖVE und PANZER. This approach was event-driven. Instead of sending data values every frame, I would send event flags such as "move=1" or "turn=-1" in a JSON string. On both server and client I would then interpolate moves based on which flags were active. This significantly reduced bandwidth but I was still sending JSON strings. Another hiccup was the server and client were never in parity. The server was authoritative so it was sending a force-sync command to the client every second or two with absolute data which would make objects on screen jump around a bit. After actually measuring the data in LÖVE und PANZER, I realized that I needed a whole new system that accounted for every byte in a packet to reduce network saturation. The solution I came up with was pretty interesting.

LÖVE sits on top of LuaJIT (among other things), so it has full access to LuaJIT's FFI module. FFI can be used to work with C data types and other nifty things that Lua proper does not have access to. A fellow lover who hangs out in the LÖVE IRC channel, LPGhatguy ended up helping me write a wrapper for FFI's cast to C string functionality that allowed me to easily convert Lua tables into C structs and back. This proved to be hugely useful. I could now create byte-precise packets to send back and forth.

local cdata = require "libs.cdata"

cdata:add_struct("player_create", [[
    typedef struct {
        uint8_t type;
        uint16_t id;
        uint8_t flags;
        int32_t model;
        int32_t decals;
        int32_t accessories;
        int32_t costumes;
        uint16_t hp;
        float turret;
        float cannon_x, cannon_y;
        float position_x, position_y, position_z;
        float orientation_x, orientation_y, orientation_z;
        float velocity_x, velocity_y, velocity_z;
        float scale_x, scale_y, scale_z;
        float acceleration;
        unsigned char name[64];
    } player_create;

When you need to send data to the server, you can simply serialize a table against a struct and pass that along. This also allows you to send incomplete data sets without issue. If you don't need to update a particular value, you can simply omit it and when the packet is deserialized the value will show up as 0 (so make sure to account for that).

local data = {
    type = 1,
    id   = 123,
    hp   = 50

local struct  = cdata:set_struct("player_create", data)
local encoded = cdata:encode(struct)
local decode = cdata:decode("player_create", data)
print(decode.id)    -- 123
print(decode.flags) -- 0

I have also opted to use a tick of 20Hz for sending immediate data such as position and orientation to the server, and a tick of 5Hz for sending other non-static data such as HP. This allows me to always be working with fresh data without over saturating the network. With Enet and IP's packet overhead, the average data rate is about 2KB/s per person. This scales exponentially as more people log in because the server needs to send more data to each person, based on every other person logged in. I have a solution to fix this that I have no yet implemented which is basically an area-of-effect for each player. If other players are outside of that area, you don't need to see their data. This is what most multiplayer games do.

Another fix I have yet to implement is reintroducing packet interpolation. As of right now, all players stutter around the map since their positions are being updated every few frames instead of every frame, but that's only a graphical issue. I are going to also need to take ping time into account when interpolating positions to ensure data parity between all clients and the server.

I haven't done extensive testing yet, but I feel that this networking code is far more robust than my previous attempts. With the aforementioned enhancements I plan to implement, I look forward to larger scale testing soon.