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 CL_EV_LIFECYCLE_REGISTER, with the function that receives it:

static void on_ask_ruby(cl_entity sender, cl_buf data, void* user) {
    (void)data;
    (void)user;
    // runs on the server when a client sends my_mod:ask_ruby
    cl_easy_give(sender, "my_mod:ruby", 1);
}

static void on_register(void* payload, void* user) {
    (void)payload;
    (void)user;
    cl_payload_desc d = CL_PAYLOAD_DESC_INIT;
    d.id = CL_STR("my_mod:ask_ruby");
    d.dir = CL_PAYLOAD_TO_SERVER;
    d.handler = on_ask_ruby;
    if (cl_payload_register(&d, NULL) != CL_OK) CL_LOGF(CL_LOG_ERROR, "ask_ruby channel failed");
}
Field Means
id The channel id. It must start with your mod id and a :, or registering fails with CL_E_PERMISSION_DENIED.
dir CL_PAYLOAD_TO_SERVER, CL_PAYLOAD_TO_CLIENT or CL_PAYLOAD_BOTH_WAYS (the default).
handler(sender, data, user) Receives the payload. sender is the player on the server, and a null handle on the client.
max_size The largest payload in bytes. The default is 32768.
user Your pointer, passed to the handler.

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 cl_buf. You create it, write values into it, send it, and release it:

cl_buf b = cl_buf_create(16);   // 16 bytes reserved; it grows if needed
cl_net_send_to_server(CL_STR("my_mod:ask_ruby"), b);
cl_buf_release(b);

Even an empty buffer is a useful message: “the player pressed the ruby key”. Sending copies the content, so releasing right after is fine.

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
cl_buf b = cl_buf_create(64);
cl_buf_write_str(b, CL_STR("my_mod:ruby"));
cl_buf_write_i32(b, 3);
cl_net_send_to_server(CL_STR("my_mod:ask_item"), b);
cl_buf_release(b);
// server handler
static void on_ask_item(cl_entity sender, cl_buf data, void* user) {
    (void)user;
    char item[64];
    size_t len = 0;
    int32_t count = 0;
    if (cl_buf_read_str(data, item, sizeof item, &len) != CL_OK) return;
    if (cl_buf_read_i32(data, &count) != CL_OK) return;
    CL_LOG("asked for %d x %s", count, item);
}
Write Read For
cl_buf_write_i32(b, n) cl_buf_read_i32(b, &n) Whole numbers
cl_buf_write_varint(b, n) cl_buf_read_varint(b, &n) Whole numbers, fewer bytes when small
cl_buf_write_i64(b, n) cl_buf_read_i64(b, &n) Very large whole numbers
cl_buf_write_f32(b, x) cl_buf_read_f32(b, &x) Decimal numbers
cl_buf_write_f64(b, x) cl_buf_read_f64(b, &x) Precise decimal numbers
cl_buf_write_str(b, s) cl_buf_read_str(b, buf, cap, &len) Strings
cl_buf_write_bytes(b, p, n) cl_buf_read_bytes(b, p, n, &got) Raw bytes, like a struct

cl_buf_remaining(b) tells you how many bytes are left to read. Reading past the end returns an error instead of a value, so check the results: a client may send fewer bytes than you expect. The data buffer in a handler is borrowed: read it, don’t release it.

Send from the server

On the server, send to one player:

cl_buf b = cl_buf_create(8);
cl_buf_write_i32(b, mana);
cl_net_send_to_player(player, CL_STR("my_mod:mana"), b);
cl_buf_release(b);

To reach everybody, loop over cl_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:

static void on_ask_rubies(cl_entity sender, cl_buf data, void* user) {
    (void)user;
    int32_t count = 0;
    if (cl_buf_read_i32(data, &count) != CL_OK) return;   // broken payload
    if (count < 1 || count > 3) return;                    // refuse silly amounts
    if (!cl_entity_is_valid(sender)) return;
    cl_easy_give(sender, "my_mod:ruby", count);
}

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 array index 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_easy.h>

static cl_keybind g_key;
static int32_t g_got = 0;
static char g_label[48] = "Rubies asked for: 0";

static void on_ask(cl_entity sender, cl_buf data, void* user) {   // server
    (void)data;
    (void)user;
    cl_easy_give(sender, "my_mod:ruby", 1);
    cl_buf reply = cl_buf_create(4);
    cl_buf_write_i32(reply, 1);
    cl_net_send_to_player(sender, CL_STR("my_mod:got_ruby"), reply);
    cl_buf_release(reply);
}

static void on_got(cl_entity sender, cl_buf data, void* user) {   // client
    (void)sender;
    (void)user;
    int32_t n = 0;
    if (cl_buf_read_i32(data, &n) != CL_OK) return;
    g_got += n;
    snprintf(g_label, sizeof g_label, "Rubies asked for: %d", (int)g_got);
}

static void on_register(void* payload, void* user) {
    (void)payload;
    (void)user;
    cl_payload_desc d = CL_PAYLOAD_DESC_INIT;
    d.id = CL_STR("my_mod:ask_ruby");
    d.dir = CL_PAYLOAD_TO_SERVER;
    d.handler = on_ask;
    cl_payload_register(&d, NULL);

    d.id = CL_STR("my_mod:got_ruby");
    d.dir = CL_PAYLOAD_TO_CLIENT;
    d.handler = on_got;
    cl_payload_register(&d, NULL);
}

static void on_client_tick(void* payload, void* user) {
    (void)payload;
    (void)user;
    while (cl_keybind_consume_click(g_key)) {
        cl_buf b = cl_buf_create(1);
        cl_net_send_to_server(CL_STR("my_mod:ask_ruby"), b);
        cl_buf_release(b);
    }
}

static void hud_render(cl_gui g, float partial_tick, void* user) {
    (void)partial_tick;
    (void)user;
    cl_gui_text(g, CL_STR_C(g_label), 4, 4, 0xFFE0115F, true);
}

static void on_client_setup(void* payload, void* user) {
    (void)payload;
    (void)user;
    cl_keybind_desc k = CL_KEYBIND_DESC_INIT;
    k.id = CL_STR("my_mod:ask_ruby");
    k.default_key = CL_STR("key.keyboard.r");
    k.category = CL_STR("key.categories.my_mod");
    cl_keybind_register(&k, &g_key);
    CL_ON(CL_EV_TICK_CLIENT_POST, on_client_tick);

    cl_hud_layer_desc h = CL_HUD_LAYER_DESC_INIT;
    h.id = CL_STR("my_mod:got");
    h.render = hud_render;
    cl_hud_layer_register(&h, NULL);
}

static cl_result mod_main(void) {
    cl_result r = CL_ON(CL_EV_LIFECYCLE_REGISTER, on_register);
    if (r == CL_OK) r = CL_ON(CL_EV_LIFECYCLE_CLIENT_SETUP, on_client_setup);
    return r;
}

CLIGHT_MOD_ENTRY(mod_main)

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 up to 64 players in a small table keyed by UUID.

Show a solution
typedef struct {
    cl_uuid who;
    int64_t last;
} ask_entry;

static ask_entry g_asks[64];
static int g_ask_count = 0;

static ask_entry* ask_entry_for(cl_uuid u) {
    for (int i = 0; i < g_ask_count; ++i) {
        if (g_asks[i].who.hi == u.hi && g_asks[i].who.lo == u.lo) return &g_asks[i];
    }
    if (g_ask_count == 64) return NULL;   // table full: refuse
    g_asks[g_ask_count].who = u;
    g_asks[g_ask_count].last = -1000000;
    return &g_asks[g_ask_count++];
}

static void on_ask(cl_entity sender, cl_buf data, void* user) {
    (void)data;
    (void)user;
    int64_t now = cl_level_game_time(cl_entity_level(sender));
    ask_entry* e = ask_entry_for(cl_entity_uuid(sender));
    if (!e || now - e->last < 100) return;   // 100 ticks = 5 seconds
    e->last = now;
    cl_easy_give(sender, "my_mod:ruby", 1);
}

The game time counts ticks and never goes backwards, so it is a reliable clock for cooldowns. A fixed-size array keeps the memory use predictable; for a big server you would also remove players in CL_EV_PLAYER_LEAVE.

Recap: register a channel with cl_payload_register in CL_EV_LIFECYCLE_REGISTER, write values into a cl_buf and read them back in the same order, send with cl_net_send_to_server or cl_net_send_to_player, release your buffers, and check every client request on the server.