Skip to content

Threading model

Status: Partly implemented. Every callback runs in a known context (MAIN, TICK, RENDER or ANY) 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 values ASYNC, PARALLEL and NET are 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