Skip to content

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 setup once (installs the SDK’s wasi-sdk), then clight new cpp my_mod.
  • clight build compiles every .cpp under src/ with -std=c++23 -fno-exceptions -fvisibility=hidden for wasm32-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 toolchain clight-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. ItemStack and Text release in their destructor; copying adds a reference. Buf has no retain in the C ABI, so it is move-only: pass Buf& / const Buf& or std::move it.
  • 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 static that outlives the mod; reset it in on_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::on returns 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 with clight::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::string and views all work.
  • Functions that return text return std::string (inside Expected).
  • clight::log::info("{} players", n) (debug, warn, error) formats with std::format.

Not in the wrapper

  • Coroutines. There is no co_await scheduler; use clight::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_request with a callback, API 1.20: Filesystem and HTTP); an awaitable wrapper is not part of clight.hpp.
  • Typed registries and descriptor classes. Use the C descriptor structs with the clight::registry helpers.
  • Exceptions. Off in every CLight build.

Performance tips

  • The wrapper’s own callbacks avoid std::function except 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; check Expected where 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).