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 isclight_mod_main(void)throughCLIGHT_MOD_ENTRY(fn). Everycl_*function is a WASM import from moduleclight; callbacks are indices into the module’s exported function table.clight_easy.hadds 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, fromclight-sdk/wasm/flags.txt). The headers also compile as C++. clight setuponce, thenclight new c my_modandclight build. CMake is optional (clight_add_mod, toolchainclight-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 withCL_EVENT(type, payload). - Write only the fields marked mut (see the generated event list). Cancel with
cl_event_cancel(&e->header). - Keep the
cl_subto unsubscribe later. Everything is released when the mod unloads. - Behaviour callbacks return
cl_override(CL_OVERRIDE_HANDLEDafter writing*out, orCL_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_internat 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. mallocworks in your linear memory, capped bylimits.memory_mb(default 256 MB).- A call that runs past its deadline traps and is stopped.
- See WASM toolchains (C, C++ and others).