Skip to content

Handles and strings

Lesson 4.2

Your mod runs in a sandbox, so it can’t hold pointers to the game’s objects. Instead, the API gives you small numbers that stand for them: handles. The wrapper puts each kind of handle into a small class, and it turns the C API’s strings into std::string_view and std::string. This lesson explains both, and the few rules that come with them.

Handles

A player, a world, an item stack, a text: each one is a handle, a tiny struct with one number inside. In C they are cl_entity, cl_level, cl_item_stack, cl_text. The wrapper has a class for each:

clight::Entity player;   // a player or another entity (wraps cl_entity)
clight::Level level;     // a world: overworld, Nether, End (wraps cl_level)
clight::ItemStack stack; // an item stack (wraps cl_item_stack)
clight::Text text;       // a chat text with colours (wraps cl_text)

They convert both ways by themselves. An event gives you a cl_entity, and clight::Entity player = e.player; wraps it. A C function that wants a cl_entity takes a clight::Entity as it is. player.raw() gives you the C handle when you want to be explicit.

You never look inside a handle. You call methods on it, or pass it to API functions:

cl_vec3 pos = player.pos();
clight::log::info("at {:.1f} {:.1f} {:.1f}", pos.x, pos.y, pos.z);

A handle can be empty, which means “nothing”. Every wrapper class turns into false when it is:

clight::Entity attacker = e.attacker;
if (!attacker) {
    return;   // nobody did it: a fall, lava, ...
}

In C code you will see CL_IS_NULL(handle) for the same check.

Handles are different types, so the compiler stops you from passing a world where a player is expected.

Handles can go stale

A handle stays valid as long as the thing it stands for exists. When a player leaves or an entity dies, its handle goes stale: functions called with it fail with CL_E_STALE_HANDLE or return an empty value, but nothing crashes. Before using a handle you kept for a while, check it:

if (saved_player.is_valid()) {
    saved_player.send_message("Still here!");
}

Owned handles: the wrapper releases them

Some functions create something and hand it to you: a text, an item stack, a buffer. In C you must give it back with a release function when you are done, like free after malloc. In C++, the wrapper does it for you. clight::Text, clight::ItemStack and clight::Buf release their handle in their destructor, when they go out of scope:

{
    clight::Text t = clight::literal("Hello!");   // created: t owns it
    t.color(0x55FF55);
    player.send_message(t);
}   // t is destroyed here, and the text is released

This pattern is called RAII: the object owns the resource for exactly as long as the object exists. You can’t forget a release, and an early return can’t skip one.

Class Made by Copying it
clight::Text clight::literal, clight::text::translatable Both copies share the text (the game counts the references).
clight::ItemStack clight::item, player.inventory_get(slot), menu.get_item(slot) Both copies share the stack.
clight::Buf clight::net::buf_create Not allowed: the compiler stops it. Pass it by reference, or move it with std::move.

A buffer has one owner. clight::Buf copy = b; doesn’t compile (“call to deleted constructor”), so pass buffers as clight::Buf& or const clight::Buf&, and hand one over with std::move if you have to.

Everything else (players, levels, handles in event payloads, the data buffer a payload handler gets) is borrowed: you use it, and nobody releases it. The borrowed classes like clight::Entity and clight::Level are just a number inside; copy them as much as you like.

If you ever hold an owned raw C handle, clight::Text::adopt(raw) puts it into a class that will release it. Never adopt a borrowed handle: the wrapper would release something that isn’t yours.

Strings into the API: std::string_view

Every wrapper function that takes text takes a std::string_view, so all of these work:

player.send_message("A literal");
std::string greeting = "Hello, " + player.name();
player.send_message(greeting);
std::string_view id = "minecraft:stone";
player.give(id);

Underneath, the C API passes strings as a pointer and a length, in a small struct called cl_str. You only meet cl_str when you fill in C structs (descriptors, see Items) or read strings from events. Two ways make one:

cl_str a = CL_STR("minecraft:stone");   // from a string literal
cl_str b = clight::s(id);               // from any std::string_view (or std::string)

A cl_str doesn’t own its text: it points at yours. So the text must still exist when the game reads it. clight::s("literal") and clight::s(some_constant) are always fine. clight::s(player.name()) inside a single call is fine too, but storing it and using it later is not, because the temporary std::string is gone by then.

Strings from the game

A cl_str you get from the game (for example a chat message in an event) is not \0-terminated, and it is only valid during your handler. clight::sv turns it into a std::string_view:

std::string_view said = clight::sv(e.message);
clight::log::info("said: {}", said);
if (said == "hello") { /* ... */ }

To keep it for later, copy it into a std::string: std::string kept{clight::sv(e.message)};.

Functions that give you a string return a std::string. In C, those take a buffer, its size and a length pointer, and you call them twice when you don’t know the size. The wrapper does that dance for you:

auto dim = player.level().dimension();   // "minecraft:overworld"
if (dim) {
    clight::log::info("dimension: {}", *dim);
}

dimension() doesn’t return the string directly. It returns a clight::Expected<std::string>: the string, or the reason it couldn’t get one. if (dim) checks which, and *dim is the string. Errors and Expected explains it. The easy helper player.name() is the exception: it returns the name directly, or "" when there is none.

Lists work the same way. clight::entity::server_players() gives you a std::vector of every player online:

if (auto players = clight::entity::server_players()) {
    for (clight::Entity p : *players) {
        p.send_message("Server restart in 5 minutes.");
    }
}

if (auto players = ...) declares the variable and checks it in one line: the body only runs when the list could be read.

Ids and names

Items, blocks and many other things have an id like "minecraft:stone". Most wrapper functions take the id directly. Some C functions want a number the game looks up once:

if (auto stone = clight::registry::block_lookup("minecraft:stone")) {
    cl_block_state s = clight::registry::block_default_state(*stone);
    level.set_block(pos, s);
}

You can keep such numbers in members or static variables and look them up only once. An id without a namespace means minecraft:.

Try it

Write a function void log_player(clight::Entity player) that logs a line like Alex at 12.5 64.0 -30.2 in minecraft:overworld. Use position() and dimension(), which can fail, and log nothing if one of them does.

Show a solution
void log_player(clight::Entity player) {
    auto pos = player.position();
    auto dim = player.level().dimension();
    if (!pos || !dim) return;

    clight::log::info("{} at {:.1f} {:.1f} {:.1f} in {}", player.name(), pos->x, pos->y, pos->z, *dim);
}

pos is an Expected<cl_vec3>, so its fields are reached with ->, like through a pointer. *dim is the std::string inside. {:.1f} formats a number with one decimal.

Recap: handles stand for game objects and the wrapper’s classes turn false when empty; Text, ItemStack and Buf release themselves (a Buf can only be moved); strings go in as std::string_view, come back as std::string, and clight::sv reads the game’s cl_str.