Event and override internals
Status: Implemented for event dispatch (zero cost without subscribers, priorities, filters, cancellation, mutable fields, WASM and Lua marshalling) and behaviour tables on mod content. Override chains on vanilla content and value events are Planned.
Events, behaviours and mixins explains what events, behaviours and mixins are. This page explains how events and behaviours work inside the engine, why they cost nothing when unused, and how to design a mod that coexists with others.
No patched machine code
Celestial is compiled C++. Patching its machine code would break with every engine build and make vanilla parity unverifiable. CLight offers published change points instead: events, behaviour tables, and named mixin targets the engine compiles into its own functions (Mixins). Because every point is known to the engine:
| Composition | Every change point orders its handlers by explicit priority, then load order |
| Stability | Event names, payload structs and behaviour tables are part of the versioned ABI (ABI stability and API versioning) |
| Threads | Every event and target documents its context |
| Sandbox | Lua and WASM mods use exactly the same points |
| Cost | An unused point is one load and one predicted branch |
Event dispatch
Each event type has a subscriber list and an “active” flag:
event block.break
├── active : atomic<u8> the fast-path test
└── list : [sub, sub, ...] sorted by priority; equal priorities keep subscription (= load) order
- With nothing subscribed, firing the event is
if (!active) skip;. - The firing thread builds the payload struct on its stack and walks a snapshot of the list. For each subscriber it skips: handlers after a cancellation (unless
CL_SUB_RECEIVE_CANCELLED), handlers whosefilterdoesn’t match the event’s filter key, handlers bound to another level, and handlers of disabled mods. - Lists are replaced copy-on-write when a mod subscribes or unsubscribes, so dispatch reads a stable snapshot without holding the lock. That is why
cl_event_subscribeworks in any context and takes effect “from the next dispatch”. CL_SUB_ONCEhandlers are unsubscribed right after their call.- If the event can’t be cancelled, a cancel written by a handler is cleared after dispatch.
- WASM: the payload is copied into the mod’s memory, the handler is called through the module’s function table, and the mut fields are copied back. Lua: the payload is exposed as a view on a reused userdata; easy fields (
event.block,event.item, …) are computed on access.
Behaviour tables
A mod block or item may carry a behaviour table (cl_block_behavior, cl_item_behavior). The engine copies the table at registration (up to its struct_size), so a later API version can append entries without breaking old mods.
- An entry left
NULLcosts nothing: the base block’s or item’s vanilla code runs. - An entry returns
CL_OVERRIDE_HANDLED(it wrote*out) orCL_OVERRIDE_CALL_ORIGINAL. random_tickis only scheduled for mod blocks that have the entry, with vanilla’s odds.- The C++ helpers (
clight::registry::block/item) fill the table with trampolines into the mod’s lambda table; Lua builds trampolines that call the newest function after a hot reload.
How handlers compose between mods
Handlers of one event are ordered by priority (lower first), then by subscription order, which follows the mod load order (dependencies first, ties by id). The order is the same on every machine and every run.
- Change values by small, composable steps (
event.amount = event.amount / 2), not by overwriting with an absolute value, unless that is your mod’s purpose. - Use
CL_PRIORITY_MONITORto observe the final value (logging, statistics); by convention, don’t change anything there. - Cancel only when your mod owns the decision; later handlers still see the event if they subscribed with
CL_SUB_RECEIVE_CANCELLED.
Client and server agreement
Some values are computed on both sides in vanilla (mining speed, movement). If your mod changes such a value on the server only, the client predicts the vanilla value and the player sees rubber-banding. Mod block properties (destroy_time, friction, speed_factor, jump_factor) are predicted by Celestial clients for exactly this reason. For your own changes through events and mixins, run the same code on both sides ("side": "both").
Requesting new change points
- Check Complete event list and Mixin targets.
- File a request naming the vanilla class and method it would mirror, the value you need to change, and the side and thread.
- New events and targets come in minor API versions. Probe for them at run time:
cl_event_type_lookup(CL_STR("..."), &t)returnsCL_E_NOT_FOUNDon an older game, andcl_core_hasanswers for functions and targets.
Change points that will not be added: protocol encoding, the scheduler and allocator internals, and the terrain mesher’s inner loop.
Planned
- Override chains on any content, including vanilla’s: several mods’ links per behaviour entry, ordered by priority and ending at vanilla, frozen with the registries so dispatch is one indirect call per link.
- Value events: the engine computes the vanilla value into
result, dispatches, and reads it back. - Tools:
/clight events <name>,/clight trace <event>, per-mod handler time.