Skip to content

Custom packets (payloads)

Status: Implemented for payload channels with byte buffers in both directions. Typed codecs, can_send checks, group sends and configuration-phase payloads are Planned.

Goal: the server keeps a “mana” value per player and syncs it to the client for a HUD bar; the client asks the server to “cast” with a key. The server validates every request.

Language: Lua, with C++ at the end. APIs: Networking.

1. Register both channels (both sides)

A "side": "both" mod runs the same script on the client and the server, so one registration covers both ends:

local mana = {}          -- server: by player UUID
local hud_mana = nil     -- client: the last value the server sent

cl.on("lifecycle.register", function()
  -- server -> client: the current mana
  cl.net.payload_register{ id = "my_mod:mana", dir = "to_client", handler = function(sender, data)
    hud_mana = data:read_i32()
  end }

  -- client -> server: a cast request (the spell id)
  cl.net.payload_register{ id = "my_mod:cast", dir = "to_server", max_size = 64, handler = function(sender, data)
    local spell = data:read_str()
    local m = mana[sender.uuid] or 0
    if spell ~= "spark" or m < 10 then return end         -- never trust the client
    mana[sender.uuid] = m - 10
    sender:send_action_bar("Spark!")
    local out = cl.buf.create(4)
    out:write_i32(mana[sender.uuid])
    sender:send_to_player("my_mod:mana", out)
  end }
end)

The handler’s sender is the player on the server and nil on the client.

2. Send when the value changes

local function sync(p)
  local b = cl.buf.create(4)
  b:write_i32(mana[p.uuid] or 0)
  p:send_to_player("my_mod:mana", b)
end

cl.on("player.join", function(event)
  mana[event.player.uuid] = 50
  sync(event.player)
end)

Send on change, not every tick.

3. Client to server

local key
cl.on("lifecycle.client_setup", function()
  key = cl.keybind.register{ id = "my_mod:cast", default_key = "key.keyboard.g", label = "Cast spark" }
end)
cl.on("tick.client_post", function()
  if not key then return end
  while key:consume_click() do
    local b = cl.buf.create(16)
    b:write_str("spark")
    cl.net.send_to_server("my_mod:cast", b)
  end
end)

cl.hud.register_layer{ id = "mana", visible = function() return hud_mana ~= nil end,
  render = function(g) g:text("Mana: " .. hud_mana, 4, 4, 0x5599FF, true) end }

4. Vanilla clients

A vanilla client ignores the mod’s channels. Sending to such a player does nothing visible, so the server half keeps working; just don’t rely on the client half being there. (Checking first with a can_send function is planned.)

5. Singleplayer

In singleplayer the client and the integrated server are the same process. Payloads still go through the same path, so the code above works unchanged.

6. C++

void on_register() override {
    clight::net::on_payload("my_mod:mana", [this](const clight::Buf& data) {
        hud_mana_ = data.read_i32().value_or(0);
    }, {.dir = CL_PAYLOAD_TO_CLIENT});
    clight::net::on_payload("my_mod:cast", [](clight::Entity sender, const clight::Buf& data) {
        auto spell = data.read_str();
        if (!spell || *spell != "spark") return;
        sender.send_action_bar("Spark!");
    }, {.dir = CL_PAYLOAD_TO_SERVER, .max_size = 64});
}

Checklist

  • [ ] Both channels register on both sides (in lifecycle.register).
  • [ ] The server checks every value a client sends.
  • [ ] Payloads stay small (max_size) and are sent on change.