C++ (clight.hpp)
Status: Implemented. C++ mods compile to WebAssembly with exceptions off. The wrapper has RAII owned values,
clight::Expected<T>results,try_variants of the easy helpers, capturing lambdas for every callback (freed when the registration ends) and typed mixins. Coroutines, job and HTTP awaitables, typed registries and descriptor classes are not part of the wrapper.
clight.hpp is a header-only C++ wrapper over the C API. It is generated from clight.idl together with clight.h, so the two never disagree. It only calls C functions, so a C++ mod has exactly the C ABI’s stability, and no C++ type ever crosses the boundary. Function tables with every C++ name: generated/. The C++ course on the website teaches the same material step by step.
Setup
clight setuponce (installs the SDK’s wasi-sdk), thenclight new cpp my_mod.clight buildcompiles every.cppundersrc/with-std=c++23 -fno-exceptions -fvisibility=hiddenforwasm32-wasip1(clight-sdk/wasm/flags.txt). RTTI isn’t needed by the wrapper.- CMake is optional:
clight_add_mod(my_mod SOURCES src/main.cpp)with the toolchainclight-sdk/cmake/wasi-sdk.cmake.
#include <clight.hpp> // includes clight.h: the whole C API is available too
The mod class
class MyMod final : public clight::Mod {
public:
void on_construct() override; // the mod was loaded: subscribe to events here
void on_register() override; // lifecycle.register: blocks, items, payload channels
void on_client_setup() override; // keybinds, HUD layers
};
CLIGHT_MOD(MyMod)
The other virtuals: on_registries_frozen, on_common_setup, on_server_setup, on_server_starting, on_server_stopped, on_unload. CLIGHT_MOD (in exactly one .cpp) defines the exports, creates one object, and deletes it (then frees every lambda the wrapper still keeps) when the mod unloads. Members of your class are a good home for state.
Core types
| C | C++ | Notes |
|---|---|---|
cl_result |
clight::Expected<T> |
std::expected<T, cl_result> when the standard library has it, otherwise a small class with the same members |
cl_str |
std::string_view in, std::string out |
clight::s(view) → cl_str, clight::sv(str) → std::string_view |
| borrowed handles | clight::Entity, Level, CmdCtx, Gui, Screen, Widget, Menu, Keybind, Config, Task, HudLayer, RmlDoc, Sound, CmdNode |
One number inside; copy freely. if (!e) tests for null; e.raw() gives the C handle; they convert to the C type by themselves |
| owned values | clight::ItemStack, clight::Text (copy = retain), clight::Buf (move-only) |
Released in the destructor |
cl_sub, cl_mixin |
clight::Subscription, clight::mixin::Handle |
RAII and [[nodiscard]]: unsubscribe or remove on destruction; .detach() keeps them |
| descriptors, enums, payloads | the C structs and constants (cl_block_desc, CL_SOUND_BLOCKS, cl_ev_block_break) |
Start descriptors from their _INIT macro |
Errors: Expected, no exceptions
Exceptions are off, so failures come back as values:
auto dim = player.level().dimension(); // Expected<std::string>
if (!dim) {
clight::log::warn("no dimension: {}", int(dim.error())); // error() is the cl_result
return;
}
clight::log::info("in {}", *dim);
int32_t n = c.arg_i32("amount").value_or(1); // a default instead of a check
| Use | When |
|---|---|
if (r) { use *r } / if (!r) { r.error() } |
Failures you expect: stale handles, unknown ids, wrong side |
r.value_or(x) |
A sensible default exists |
Expected<void> |
Calls that only succeed or fail (teleport, set_health) |
Don’t call .value() or *r on a failed result: without exceptions that traps the call (the mod’s callback is stopped and logged).
The easy layer and try_ variants
For the everyday jobs the wrapper takes plain strings and ids and returns simple values:
clight::on<clight::ev::PlayerJoin>([](cl_ev_player& e) {
clight::Entity player = e.player;
player.give("my_mod:ruby"); // count defaults to 1; false for an unknown id
player.send_message("Welcome, " + player.name() + "!"); // name() is "" for non-players
}).detach();
| Easy form | Full form |
|---|---|
player.name() → std::string ("" on error) |
player.try_name() → Expected<std::string> |
player.give(std::string_view id, int32_t count = 1) → bool |
player.give(cl_item_stack) → Expected<void> |
player.send_message(std::string_view), send_action_bar(...) → bool |
the cl_text overloads → Expected<void> |
player.send_title(title, subtitle = {}, fade_in = -1, stay = -1, fade_out = -1) → bool |
send_title(cl_text, cl_text, ...) |
player.pos() → cl_vec3 ({0,0,0} when stale) |
player.position() → Expected<cl_vec3> |
player.consume_held(n = 1), take(id, n = 1), count_item(id) → int32_t |
inventory_consume(slot, n), inventory_shrink, inventory_set |
level.set_block(pos, "minecraft:stone") → bool |
level.set_block(pos, state, flags = CL_SET_ALL) → Expected<void> |
clight::item(id, n) → ItemStack, clight::literal(s) → Text, clight::broadcast(s) → bool |
cl_stack_create_by_name, cl_text_literal, cl_server_broadcast |
ctx.reply(s), ctx.send_success(s, broadcast = false), ctx.send_failure(s) → bool |
the cl_text overloads |
The bool forms have no [[nodiscard]]: ignoring the result is fine.
Memory and ownership
- Owned values are RAII.
ItemStackandTextrelease in their destructor; copying adds a reference.Bufhas no retain in the C ABI, so it is move-only: passBuf&/const Buf&orstd::moveit. - Borrowed handles need no cleanup. They can go stale: calls on a stale handle return an error (or the documented default);
e.is_valid()checks first. - Event payloads are borrowed views valid during the handler. Copy strings out (
std::string{clight::sv(e.message)}) if you keep them. clight::Text::adopt(raw)/ItemStack::adopt(raw)take ownership of a raw owned handle; never adopt a borrowed one.- Never keep an owned value in a
staticthat outlives the mod; reset it inon_unload.
Lambdas and callbacks
Lambdas (capturing or not) work everywhere the C API takes a function and a void* user. The wrapper keeps each lambda in a per-mod table and gives the engine an opaque id (slot and generation), never a pointer into the mod’s memory. A lambda is destroyed, with its captures, when its registration ends:
| Helper | The lambda is freed |
|---|---|
clight::on<Event>(fn, {.priority, .flags, .filter, .level}) |
when the Subscription is reset or destroyed, after a CL_SUB_ONCE handler ran, or on unload (.detach()) |
clight::after(ticks, fn), clight::after_client(ticks, fn) |
after it ran, or on task.cancel() |
clight::every(period, fn, delay = period) |
on task.cancel() (also from inside: fn(clight::Task self)) |
clight::cmd::add(name, fn), CmdNode::executes(fn) |
when the same command is added again (/reload), or on unload |
clight::hud::add_layer(id, fn, {.above, .below, .visible}) |
on layer.remove(), or on unload |
clight::keybind::add(id, key, fn, {.category, .label, .context}), keybind.on_press(fn) |
on unload (or when the on_press subscription is reset) |
clight::net::on_payload(id, fn(Entity sender, const Buf& data), {.dir, .max_size}) |
on unload |
clight::screen::open(ScreenSpec{...}), screen.button/checkbox/slider/text_field(..., fn) |
when the screen closes (widgets also when init runs again after a resize) |
clight::menu::open(player, MenuSpec{...}) |
when the menu closes |
clight::registry::item(desc, {.use}), registry::block(desc, {.use_without_item, .attack, .random_tick}) |
on unload |
clight::mixin::inject<T>(at, fn) |
when the mixin::Handle is reset or destroyed |
A call the engine had already queued for a removed lambda does nothing. A lambda that removes itself while it runs is destroyed after it returns. One mod’s callbacks never run on two threads at once (the engine’s per-mod lock), so captured state needs no locks.
clight::after(20, [player] { player.send_message("One second later"); });
clight::keybind::add("my_mod:zoom", "key.keyboard.z", [this] { zoom_ = !zoom_; });
auto sub = clight::on<clight::ev::BlockBreak>([this](cl_ev_block_break& e) { ++broken_; }); // keep `sub` as a member
clight::onreturns a[[nodiscard]]Subscription: dropping it unsubscribes at once, and the compiler warns. Store it, or call.detach().- Callbacks that report a count (commands) may return
void(= 1). Widget, keybind and timer callbacks may leave out their parameter. - The event payload comes by reference with its exact type (
cl_ev_block_break&);[](auto& e)works too. Cancel withclight::cancel(e.header); write mut fields directly (e.xp = 0).
Mixins
// T is the target's traits type from the generated mixin header (one per target)
auto h = clight::mixin::inject<T>(clight::at::head, [](auto& ci) {
// ci.args(), ci.cancel(), ci.set_return(...)
}); // Expected<mixin::Handle>: keep the handle, or detach() it
clight::at::head, ret, wrap, overwrite, point("name"), value("name") and call("name") choose where; Opts{.priority, .flags, .filter, .before, .after} order it among other mods. The game’s targets arrive in batches (cl_core_has("mixin:<name>") tells you which exist): Mixin targets.
Static declarations use CL_MIXIN(target, at, fn), and clight::accessor<V>("name").get(self) / .set(self, v) read and write accessor fields. Targets, sites and the Ci frame: Mixins.
Strings and formatting
- Every wrapper function that takes text takes a
std::string_view: literals,std::stringand views all work. - Functions that return text return
std::string(insideExpected). clight::log::info("{} players", n)(debug,warn,error) formats withstd::format.
Not in the wrapper
- Coroutines. There is no
co_awaitscheduler; useclight::after/clight::every, or split work into steps. - Jobs. Worker jobs are planned for the whole API (The job system). HTTP works through the C functions (
cl_http_requestwith a callback, API 1.20: Filesystem and HTTP); an awaitable wrapper is not part ofclight.hpp. - Typed registries and descriptor classes. Use the C descriptor structs with the
clight::registryhelpers. - Exceptions. Off in every CLight build.
Performance tips
- The wrapper’s own callbacks avoid
std::functionexcept where a spec struct stores optional callbacks (screens, menus); keep heavy work out of per-frame callbacks. - Count and compute on the tick, draw on the frame (HUD layers, screen
render). - Prefer
bool-returning easy calls in fire-and-forget code; checkExpectedwhere failure matters.
Debugging
Build without --release for function names in trap reports, log with clight::log, and use printf / std::cout if you like (they go to the log). Debugger attach is planned (Debugging your first mod).