Skip to content

C (the C ABI)

Status: Implemented. C mods compile to WebAssembly (wasm32-wasip1, the SDK’s wasi-sdk, clight build); there is no native DLL tier. The entry is clight_mod_main(void) through CLIGHT_MOD_ENTRY(fn). Every cl_* function is a WASM import from module clight; callbacks are indices into the module’s exported function table. clight_easy.h adds one-line helpers.

clight.h is the API. Every other binding is generated on top of it (The binding generator and CLight IDL). This page covers the rules the C ABI follows and how a C mod should use it; C++ and Lua follow the same ownership, threading and error rules with friendlier syntax. Function tables: generated/. The C course on the website teaches the same material step by step.

Setup

  • C17 (-std=c17, from clight-sdk/wasm/flags.txt). The headers also compile as C++.
  • clight setup once, then clight new c my_mod and clight build. CMake is optional (clight_add_mod, toolchain clight-sdk/cmake/wasi-sdk.cmake).
/* src/main.c */
#include <clight.h>

static void on_break(void* payload, void* user) {
    cl_ev_block_break* e = (cl_ev_block_break*)payload;
    char name[17];
    size_t n = 0;
    (void)user;
    if (cl_player_name(e->player, name, sizeof name, &n) != CL_OK) return;
    CL_LOGF(CL_LOG_INFO, "%.*s mined diamond ore at %d %d %d", (int)n, name, e->pos.x, e->pos.y, e->pos.z);
}

static cl_result mod_main(void) {
    cl_subscribe_opts o = CL_SUBSCRIBE_OPTS_INIT;
    o.filter = cl_ident_intern(CL_STR("minecraft:diamond_ore"));   /* only called for diamond ore */
    return cl_event_subscribe(CL_EV_BLOCK_BREAK, &o, on_break, NULL, NULL);
}

CLIGHT_MOD_ENTRY(mod_main)

CLIGHT_MOD_ENTRY defines the exports clight_mod_api_version, clight_mod_main, clight_guest_alloc, clight_guest_free (and clight_mixins when the module declares CL_MIXINs). Add CLIGHT_MOD_UNLOAD(fn) for an unload function (Mod and lifecycle), and CLIGHT_MOD_HOT_STATE(var) (or CLIGHT_MOD_SAVE_STATE / CLIGHT_MOD_RESTORE_STATE) to keep state through a hot reload (Hot reload).

Easy helpers: clight_easy.h

#include <clight_easy.h> (it includes clight.h) for helpers that take C strings and ids and release their temporary stacks and texts:

#include <clight_easy.h>

static void on_join(void* payload, void* user) {
    cl_entity player = CL_EVENT(cl_ev_player, payload)->player;
    char name[64];
    (void)user;
    cl_easy_player_name(player, name, sizeof name);
    cl_easy_give(player, "my_mod:ruby", 1);
    cl_easy_send_messagef(player, "Welcome, %s!", name);
}

static cl_result mod_main(void) { return CL_ON(CL_EV_PLAYER_JOIN, on_join); }
CLIGHT_MOD_ENTRY(mod_main)
Helper Does
CL_EVENT(type, payload) Casts the handler’s payload: CL_EVENT(cl_ev_block_break, payload)->pos
CL_ON(event, fn) cl_event_subscribe with the default options
CL_LOG(fmt, ...) printf-style info log (CL_LOGF(level, ...) for other levels)
cl_easy_give(player, id, count) Gives items; CL_E_NOT_FOUND for an unknown id
cl_easy_take(player, id, count), cl_easy_count(player, id), cl_easy_consume_held(player, count) Inventory helpers (consume: nothing used up in creative)
cl_easy_send_message, cl_easy_send_messagef, cl_easy_send_action_bar, cl_easy_broadcast Plain C strings (the f form is printf-style, up to 511 bytes)
cl_easy_send_title(player, title, subtitle) A title with vanilla’s times (NULL subtitle for none)
cl_easy_show_message(text) A chat line only this client sees
cl_easy_player_name(player, buf, cap) Writes the name ("" for non-players), returns its length
cl_easy_set_block(level, pos, id) The block’s default state, updating neighbours and clients
cl_easy_arg_str(ctx, name, buf, cap), cl_easy_arg_int(ctx, name, fallback), cl_easy_arg_entity(ctx, name) Command arguments
cl_easy_stack_is(stack, id) cl_stack_is with a C string

How calls reach the engine

Each function in clight.h is declared as a WebAssembly import from module clight with the same name (cl_level_set_block). The engine binds the imports when it instantiates the module, so a mod has no link-time dependency on Celestial, and it knows which mod is calling without a parameter. New functions are only appended (ABI stability and API versioning); cl_core_has("name") tells you whether the running game has one.

Memory and ownership

Rule Detail
Strings in cl_str slices (CL_STR("lit"), CL_STR_C(c_string)). The engine copies anything it keeps.
Strings out Caller buffers with the two-call length pattern (below)
Handles Plain values. Only cl_item_stack, cl_text and cl_buf are owned
Event payloads Borrowed, valid during the handler. Copy what you need.
Descriptor structs Read during the call. Function pointers, behaviour tables and user pointers inside them must stay valid for the mod’s lifetime (static functions and data).
Allocations Your memory is your linear memory (malloc, capped by limits.memory_mb). The engine never frees yours.

Owned values in practice

One release per owned value you received:

cl_item_stack held = cl_player_inventory_get(player, cl_player_selected_slot(player));   /* owned */
if (cl_stack_is(held, CL_STR("my_mod:ruby"))) { /* ... */ }
cl_stack_release(held);

cl_text t = cl_text_literal(CL_STR("Hi"));
cl_text bang = cl_text_literal(CL_STR("!"));
cl_text_append(t, bang);          /* append takes its own reference to the child... */
cl_text_release(bang);            /* ...so release yours */
cl_player_send_message(player, t);
cl_text_release(t);

Values you receive inside a payload or callback (e->tool, ctx->stack) are borrowed. If you keep one past the callback, take your own reference first (cl_stack_retain, cl_text_retain).

Error handling

Fallible functions return cl_result (CL_OK = 0, negative on failure, Error codes). Results go out through pointer parameters.

#define TRY(x) do { cl_result r_ = (x); if (r_ != CL_OK) { CL_LOGF(CL_LOG_ERROR, "%s failed: %d", #x, (int)r_); return r_; } } while (0)

static cl_result mod_main(void) {
    TRY(CL_ON(CL_EV_LIFECYCLE_REGISTER, on_register));
    TRY(CL_ON(CL_EV_PLAYER_JOIN, on_join));
    return CL_OK;
}

For value-returning getters, check cl_last_error() when the default value is ambiguous:

float hp = cl_living_health(e);
if (hp == 0.0f && cl_last_error() == CL_E_STALE_HANDLE) { /* the entity is gone */ }

cl_last_error_message(buf, cap, &n) returns a readable explanation of the last failure on this thread.

Error Usually means Handle it?
CL_E_STALE_HANDLE The object went away between ticks Yes. Normal in gameplay code: drop the work.
CL_E_NOT_FOUND An unknown id Yes, when the id comes from a player or config
CL_E_UNSUPPORTED A feature this game doesn’t have yet Yes. Fall back.
CL_E_WRONG_PHASE, CL_E_WRONG_SIDE, CL_E_WRONG_THREAD A bug in your mod No. Fix the code.
CL_E_PERMISSION_DENIED A capability the player didn’t allow, or registering outside your namespace Declare it, or fall back

A non-CL_OK return from the entry function disables your mod with the error shown on the Mods screen. Handlers return void, so an error inside a handler is logged and the handler simply does nothing.

Strings and arrays

cl_str is a pointer plus a length, UTF-8 and not necessarily NUL-terminated:

cl_str a = CL_STR("minecraft:stone");     /* literal */
cl_str b = CL_STR_C(some_c_string);       /* from a NUL-terminated string */
printf("%.*s\n", (int)a.len, a.ptr);      /* print without assuming a NUL */

Strings the engine returns are written into your buffer; a too small buffer returns CL_E_BUFFER_TOO_SMALL with the needed length in *out_len:

char small[64]; size_t n = 0;
if (cl_player_name(p, small, sizeof small, &n) == CL_E_BUFFER_TOO_SMALL) {
    char* big = malloc(n + 1);
    cl_player_name(p, big, n + 1, &n);
    /* ... */
    free(big);
}

Player names fit in 17 bytes and identifiers in 256, so buffers of those sizes never need the second call. Functions returning cl_str directly (cl_mod_id(), cl_event_name()) point at engine-owned memory that lives for the whole process.

Functions that return several values use (T* out, size_t cap, size_t* out_count); *out_count is the total available, so n > cap means “call again with a bigger array”:

cl_entity players[64]; size_t n = 0;
cl_server_players(players, 64, &n);
for (size_t i = 0; i < (n < 64 ? n : 64); i++) { /* ... */ }

The C API has no container types. Key your own tables by plain values (cl_ident, raw ids, cl_uuid), not by handle values: handle slots are reused after the object goes away.

Callbacks and events

All C callbacks receive void* user as their last parameter. Point it at your state:

typedef struct alarm_state { int32_t breaks; } alarm_state;
static alarm_state g_state;
static cl_sub g_sub;

cl_event_subscribe(CL_EV_BLOCK_BREAK, &o, on_break, &g_state, &g_sub);
  • Event handlers are void (*)(void* payload, void* user); cast the payload with CL_EVENT(type, payload).
  • Write only the fields marked mut (see the generated event list). Cancel with cl_event_cancel(&e->header).
  • Keep the cl_sub to unsubscribe later. Everything is released when the mod unloads.
  • Behaviour callbacks return cl_override (CL_OVERRIDE_HANDLED after writing *out, or CL_OVERRIDE_CALL_ORIGINAL) (Events, behaviours and mixins).
  • A mod’s callbacks never run on two threads at once, so plain globals need no locks.

Scheduling

C has no coroutines. Schedule follow-up work on the tick:

static void later(void* user) { cl_easy_broadcast("Five seconds have passed."); (void)user; }
/* ... */
cl_sched_run_server(100, later, NULL, NULL);     /* 100 ticks = 5 seconds */

cl_sched_repeat_server repeats; cl_task_cancel stops a task. Worker jobs are planned (The job system).

Files (WASI)

fopen and friends work inside the sandbox: the mod’s folder is mounted read-only at /, and mods/<id>/data/ read/write at /data. stdout and stderr go to the log. Sockets and other paths are not available.

FILE* f = fopen("/data/scores.txt", "a");
if (f) { fprintf(f, "%s %d\n", name, score); fclose(f); }

Performance tips

  • Intern identifiers once (cl_ident_intern at setup) for filters and compares.
  • Use event filters on frequent events, so your handler isn’t called for other blocks or entities.
  • Each API call crosses the sandbox boundary; keep hot loops inside your own code and call the API for results.
  • Keep handler state in plain structs; a disabled feature should cost one if.

More: Performance rules.

Debugging

Dev builds (clight build without --release) keep debug info, so a trap is logged with function names. Log with CL_LOG or printf. Debugger attach, strict-mode call logging and leak reports are planned (Debugging your first mod).

WASM specifics

  • Target wasm32-wasip1, built as a reactor module (-mexec-model=reactor) with its function table exported.
  • malloc works in your linear memory, capped by limits.memory_mb (default 256 MB).
  • A call that runs past its deadline traps and is stopped.
  • See WASM toolchains (C, C++ and others).