Skip to content

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 whose filter doesn’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_subscribe works in any context and takes effect “from the next dispatch”.
  • CL_SUB_ONCE handlers 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 NULL costs nothing: the base block’s or item’s vanilla code runs.
  • An entry returns CL_OVERRIDE_HANDLED (it wrote *out) or CL_OVERRIDE_CALL_ORIGINAL.
  • random_tick is 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_MONITOR to 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

  1. Check Complete event list and Mixin targets.
  2. File a request naming the vanilla class and method it would mirror, the value you need to change, and the side and thread.
  3. New events and targets come in minor API versions. Probe for them at run time: cl_event_type_lookup(CL_STR("..."), &t) returns CL_E_NOT_FOUND on an older game, and cl_core_has answers 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.