Threading model
Status: Partly implemented. Every callback runs in a known context (
MAIN,TICK,RENDERorANY) and one mod’s code never runs on two threads at once. Worker jobs, parallel callbacks, world snapshots and network-thread listeners are Planned (the enum valuesASYNC,PARALLELandNETare reserved for them).
Celestial is heavily multithreaded: dimensions tick in parallel, meshing and worldgen run on a job system, and the network has its own threads. CLight hides none of that, but it gives each mod a simple promise: your callbacks are called one at a time, in a context that says what you may touch.
The contexts
| Context | Runs on | Typical callbacks |
|---|---|---|
MAIN |
The main thread during startup and shutdown | lifecycle.* events, registration, server.command_register |
TICK |
The server tick thread (server events), or the client tick (client events) | Gameplay events, block and item behaviours, scheduled tasks, commands, payload handlers, menu clicks |
RENDER |
The client’s frame | HUD layers, screen render callbacks, client.key, client.screen_open |
ANY |
Wherever it is called from | Functions with no thread rule (text, stacks, buffers, config, sounds on the client) |
Every function in the generated tables and every event in Complete event list states its context. cl_current_context() (Lua cl.mod.current_context(), C++ clight::mods::current_context()) tells you the current one.
Rule 1: change the world on TICK
World changes (cl_level_set_block, cl_player_give, cl_entity_teleport, …) belong in TICK callbacks: event handlers, scheduled tasks, commands. From a RENDER callback (a HUD layer, a screen), don’t change the world; send a payload to the server or schedule a task instead.
To do something later, schedule it on the tick:
-- 20 ticks = one second
cl.sched.run_server(20, function() player:send_message("One second later") end)
clight::after(20, [player] { player.send_message("One second later"); });
cl_sched_run_server(20, later_fn, user, NULL);
Rule 2: one callback at a time
The engine holds a per-mod lock while it runs your code, so two of your callbacks never run at the same time, even when two levels tick in parallel. You don’t need locks in your own mod. The price is that a slow callback holds up whoever called it: keep handlers short, and split long work into steps with the scheduler (or, in Lua, cl.spawn and cl.sleep_ticks).
Rule 3: the frame never waits
RENDER callbacks run inside the frame. Do the expensive part on the tick and only draw in the frame. The examples count items once per client tick (tick.client_post) and draw the cached number in the HUD layer.
Time limits
| Runtime | Limit |
|---|---|
| Lua | 50 ms per callback by default. A callback that runs longer is stopped with an error. |
| WebAssembly | A deadline per call (epoch interruption). A call that runs too long traps. |
A Lua mod is turned off for the session after ten errors within a minute; a WebAssembly mod after ten traps, or at once when a call runs past its deadline.
Language runtimes and threads
| Runtime | Notes |
|---|---|
| Lua | One VM per mod; coroutines (cl.spawn, cl.sleep_ticks) for flows that wait across ticks |
| WebAssembly | One instance per mod. wasm32-wasip1 without threads. |
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Changing the world from a HUD layer or screen | Nothing happens, or the server overwrites it | Send a payload, or schedule a task |
| A long loop in one handler | The game hitches; the call is stopped | Split it with the scheduler |
Keeping a cl_entity after the player left |
CL_E_STALE_HANDLE |
Check cl_entity_is_valid first |
Keeping a cl_gui or cl_cmd_ctx after the callback |
Invalid handle | Use them only inside the callback that received them |