Skip to content

Payloads and buffers

Lesson 10.1

The client draws and listens to keys; the server owns the world. When a key press should change the world, or the server wants to update a HUD, the two halves have to talk. They do it with payloads: small messages with your own name and content.

Register a channel

A payload travels on a channel with an id in your mod’s namespace. Register it in on_register, with the lambda that receives it:

class AskRubyMod final : public clight::Mod {
public:
    void on_register() override {
        auto ch = clight::net::on_payload("my_mod:ask_ruby", [](clight::Entity sender, const clight::Buf&) {
            // runs on the server when a client sends my_mod:ask_ruby
            sender.give("my_mod:ruby");
        }, {.dir = CL_PAYLOAD_TO_SERVER});
        if (!ch) clight::log::error("ask_ruby channel failed");
    }
};

clight::net::on_payload(id, handler, options) registers the channel:

Part Means
id The channel id. It must start with your mod id and a :, or registering fails with CL_E_PERMISSION_DENIED.
handler(sender, data) Receives the payload. sender is the player on the server, and an empty handle on the client. A handler that doesn’t need the sender can take just (const clight::Buf& data).
.dir CL_PAYLOAD_TO_SERVER, CL_PAYLOAD_TO_CLIENT or CL_PAYLOAD_BOTH_WAYS (the default).
.max_size The largest payload in bytes. The default is 32768.

The handler is a lambda and may capture what it needs ([this] for the mod). It lives as long as the mod.

Your mod is loaded once per game, so one registration covers both sides. The handler runs in the game’s tick, like an event handler, so it can give items and place blocks on the server, or update HUD values on the client.

Buffers

The content goes into a buffer, a clight::Buf. You create it, write values into it, and send it:

clight::Buf b = clight::net::buf_create(16);   // 16 bytes reserved; it grows if needed
clight::net::send_to_server("my_mod:ask_ruby", b);

Even an empty buffer is a useful message: “the player pressed the ruby key”. Sending copies the content, and clight::Buf releases itself at the end of the scope. A clight::Buf can’t be copied (the compiler says so), so functions take it as clight::Buf& or const clight::Buf& (see Handles and strings).

Write and read in the same order

A buffer is a row of values. The sender writes them, the receiver reads them in exactly the same order:

// client
clight::Buf b = clight::net::buf_create(64);
b.write_str("my_mod:ruby");
b.write_i32(3);
clight::net::send_to_server("my_mod:ask_item", b);
// server handler
clight::net::on_payload("my_mod:ask_item", [](clight::Entity, const clight::Buf& data) {
    auto item = data.read_str();
    auto count = data.read_i32();
    if (!item || !count) return;   // a broken payload
    clight::log::info("asked for {} x {}", *count, *item);
}, {.dir = CL_PAYLOAD_TO_SERVER});

The data buffer in a handler is borrowed: it belongs to the game and is only valid during the call. Read it, and copy out what you need to keep.

Write Read For
b.write_i32(n) data.read_i32() Whole numbers
b.write_varint(n) data.read_varint() Whole numbers, fewer bytes when small
b.write_i64(n) data.read_i64() Very large whole numbers
b.write_f32(x) data.read_f32() Decimal numbers
b.write_f64(x) data.read_f64() Precise decimal numbers
b.write_str(s) data.read_str() Strings
b.write_bytes(p, n) data.read_bytes(p, n) Raw bytes, like a struct

Every read returns an Expected. data.remaining() tells you how many bytes are left. Reading past the end gives an error instead of a value, so check the results: a client may send fewer bytes than you expect.

Send from the server

On the server, send to one player:

void send_mana(clight::Entity player, int32_t mana) {
    clight::Buf b = clight::net::buf_create(8);
    b.write_i32(mana);
    player.net_send_to_player("my_mod:mana", b);
}

To reach everybody, loop over clight::entity::server_players(). A payload from the client to the server can be at most 32767 bytes; larger ones fail with CL_E_LIMIT.

Never trust the client

A payload from the client is a request, not an order. Anybody can change their client and send anything. So the server checks every request:

clight::net::on_payload("my_mod:ask_rubies", [](clight::Entity p, const clight::Buf& data) {
    auto count = data.read_i32();
    if (!count) return;                       // broken payload
    if (*count < 1 || *count > 3) return;     // refuse silly amounts
    if (!p.is_valid()) return;
    p.give("my_mod:ruby", *count);
}, {.dir = CL_PAYLOAD_TO_SERVER});

Ask yourself for every payload: what if somebody sends this a thousand times, or with a huge number? Limit amounts, add cooldowns, and check that the player is allowed to do the thing. Never use a number from the client as an index into a std::vector without checking it first.

Both sides need the mod

Payloads only arrive where your mod is installed. A vanilla server ignores them, and players without your mod don’t get them. A mod that relies on payloads should say "sync": "required" in its manifest.

A complete round trip

A key on the client asks the server for a ruby, and the server answers, so the HUD can count:

#include <clight.hpp>

class RoundTripMod final : public clight::Mod {
public:
    void on_register() override {
        clight::net::on_payload("my_mod:ask_ruby", [](clight::Entity p, const clight::Buf&) {   // server
            p.give("my_mod:ruby");
            clight::Buf reply = clight::net::buf_create(4);
            reply.write_i32(1);
            p.net_send_to_player("my_mod:got_ruby", reply);
        }, {.dir = CL_PAYLOAD_TO_SERVER});

        clight::net::on_payload("my_mod:got_ruby", [this](const clight::Buf& data) {   // client
            got_ += data.read_i32().value_or(0);
            label_ = "Rubies asked for: " + std::to_string(got_);
        }, {.dir = CL_PAYLOAD_TO_CLIENT});
    }

    void on_client_setup() override {
        clight::keybind::add("my_mod:ask_ruby", "key.keyboard.r", [] {
            clight::Buf b = clight::net::buf_create(1);
            clight::net::send_to_server("my_mod:ask_ruby", b);
        }, {.category = "key.categories.my_mod"});

        clight::hud::add_layer("my_mod:got", [this](clight::Gui g, float) {
            g.text(label_, 4, 4, 0xFFE0115F, true);
        });
    }

private:
    int32_t got_ = 0;
    std::string label_ = "Rubies asked for: 0";
};

CLIGHT_MOD(RoundTripMod)

The client handler takes only the data: on the client there is no sender. In singleplayer both halves run in one game, but the messages still take the same path, so what works there works on a server too.

Try it

Add a cooldown to the round trip: the server ignores my_mod:ask_ruby from a player who asked less than 5 seconds ago. Keep the last time per player in a std::map, keyed by UUID.

Show a solution
using PlayerKey = std::pair<uint64_t, uint64_t>;

class CooldownMod final : public clight::Mod {
public:
    void on_register() override {
        clight::net::on_payload("my_mod:ask_ruby", [this](clight::Entity p, const clight::Buf&) {
            cl_uuid u = p.uuid();
            int64_t now = p.level().game_time();

            auto [it, first_time] = last_ask_.try_emplace(PlayerKey{u.hi, u.lo}, now);
            if (!first_time) {
                if (now - it->second < 100) return;   // 100 ticks = 5 seconds
                it->second = now;
            }
            p.give("my_mod:ruby");
        }, {.dir = CL_PAYLOAD_TO_SERVER});

        clight::on<clight::ev::PlayerLeave>([this](cl_ev_player& e) {
            cl_uuid u = clight::Entity(e.player).uuid();
            last_ask_.erase(PlayerKey{u.hi, u.lo});
        }).detach();
    }

private:
    std::map<PlayerKey, int64_t> last_ask_;
};

try_emplace adds the player with the current time if they aren’t in the map yet, and tells you whether it did. The game time counts ticks and never goes backwards, so it is a reliable clock for cooldowns. Removing players in ev::PlayerLeave keeps the map from growing forever on a busy server.

Recap: register a channel with clight::net::on_payload(id, lambda) in on_register, write values into a clight::Buf and read them back in the same order with data.read_*(), send with clight::net::send_to_server or player.net_send_to_player, and check every client request on the server.