Repository-cleanup / DX pass folding three tracker items into one coherent change, verified green end to end (`bin/x test` 49/0, `bin/x selfhost-test` 29/0, `bin/x test-tools` 29/0). #28 — curate & categorise examples/ - 42 flat entries regrouped into intent-revealing subdirs: games/, rendering/, ecs/, events/, networking/, lang/, library/ (was lib/). - chronorift dir-vs-file duplication resolved: the entry file and its import modules now live together under games/chronorift(.ludic). - Every path reference updated repo-wide (test runner, editor-tool drivers, docs/site, design docs). - New examples/README.md indexes the whole set with run commands. - Showcase examples without a self-asserting entry (hello, events, net_rt) now get a compile-only rot guard in `bin/x test`, so nothing here rots silently. #30 — text-diffable golden baseline - The 4 binary selfhost/golden/*.ppm blobs are replaced by a single selfhost/golden/renders.sha256 manifest (SHA-256 per render). Hashes are byte-identical to the old PPMs, so the baseline is unchanged — only its form. - game_case now compares framebuffer hashes; a regression shows as a changed hex line in review, not "binary files differ". - New `bin/x golden` regenerates the manifest deliberately (review with `git diff selfhost/golden/renders.sha256`). #27 — PPM & asset handling - Headless renders now write build/out.ppm, never the repo root; `x app`, `x clean`, messaging and .gitignore updated to match. Nothing is written to the working root any more. - Redundant local Kenney .zip archives removed (the art ships extracted; .gitignore already excludes *.zip). CC0 License.txt files retained. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
41 KiB
Events & modding, expanded — a design doc
Status: EV0 fully shipped; EV1 (spawn/despawn), EV2 (first cut) and EV3 shipped; EV4–EV7 are design. Implemented, self-hosted to the C-free fixpoint, and each a
bin/x testcheck:
EV0 —
event/@On/emitlowered to@ev_<E>dispatch (compile-time listeners), plus the foreign C ABI (ludic_on_<E>, the%Ev_<E>payload struct, a fixed-capacity listener array), proven by a C mod intests/mod_c/mod.cbindingexamples/mod_host.ludic. Byte-identical when no event is declared. (examples/events/events.ludic)EV1 — public events across the whole architecture, every scope shipped: program (
@Public @OnStart/@OnQuit→program_start/program_quit,examples/events/program_events.ludic); models (@Public @OnSpawn/@OnDespawn→model_<M>_spawn/_despawn,examples/events/promote.ludic); properties (@Public @OnAttach/@OnDetach/@OnEnable/@OnDisable→prop_<P>_attachetc.,examples/events/prop_events.ludic); scenes (apublicscene →scene_<S>_enter/_exit,examples/events/scene_events.ludic); and layers (apubliclayer +enable/disable layer L→layer_<L>_show/_hide,examples/events/layer_events.ludic) — which also landed SCENES E2 layer toggle (@LE_<L>flag gating a layer's handlers).EV2 / EV2b — the world table: the reflection ABI, generated from the compile-time schema, so a mod reads, writes, scans, identifies, and creates entity state by name without compiling against the game.
ludic_prop_id/ludic_field_id/ludic_get/ludic_set/ludic_has(read/write —world_mod.c);ludic_entity_count/ludic_kind/ludic_model_id(scan and identify —world_scan.c);ludic_spawn(model_id)(create, reusing the compiler's own spawn lowering —world_spawn.c);get/setaddress each field by its real struct offset, correct forint/fixed/byte/ptrand mixed layouts (world_mixed.c); and iterate (ludic_query_next,world_query.c). Emitted only for an ECS program that declares events, so event-free games stay byte-exact. The world table is complete: read, write, scan, identify, create, iterate.EV3 —
cancellableevents, thecancelverb, andemit E(…)as an expression returning the veto flag. (examples/events/cancel.ludic)EV5 — leak-proof scoped listeners:
ludic_off_<E>(token)(explicit unregister; dispatch skips tombstoned slots),ludic_on_entity_<E>(entity, cb)(entity-scoped), and a generatedludic_sweep_entitycalled fromdespawnthat nulls every listener the dying entity owned — a listener can't leak past its entity. Proven bytests/mod_c/scoped_mod.c.EV6 — re-entrant
emitis depth-bounded (@ev_depthvsEV_DEPTH_CAP): a listener may emit another event, but an event cycle traps as an early return instead of hanging the frame. Dispatch order was already deterministic (array, registration order). Proven byexamples/events/recurse.ludic.EV7 (schema opening) — a mod defines a brand-new component at runtime:
ludic_register_prop(name, nfields)mallocs flat[MAX_ENT × nfields × i32]storage + a has-flag array and returns a prop id past the compile-time range;ludic_attach_dyn/ludic_detach_dyntoggle it on an entity;get/set/has/prop_idfall through to the dynamic registry for ids ≥ the compile-time count. A mod adds entirely new data to entities by name, with per-entity isolation. Proven bytests/mod_c/world_dyn.c. (EV7's other half — networking's local/remote event split — has no substrate in Ludic yet.)Still design: EV4 (the scripting-shim bridge — deferred to keep the suite interpreter-free) and EV7 networking. This is a companion to LIFECYCLE-DESIGN.md and SCENES-DESIGN.md. Where those docs extend Ludic's internal, compile-time lifecycle, this one proposes the external, runtime layer that turns those same lifecycle moments into a public event surface — the foundation a game can hand to mods written in Ludic, JS/TS, Lua, or anything with a C ABI. It distills a survey of modding and event systems (§3) into a phased roadmap (EV0–EV7, §12–§13). §14 lists the open decisions.
1. Thesis
Ludic already has a lifecycle. @OnSpawn(Enemy), @OnDetach(Sprite), scene
on enter, @OnDespawn(M, reason: r) — every one is a compile-time,
closed-world, zero-cost hook that desugars to a direct call at a fixed site.
That is the right design for the game author, who is compiled together with the
game. It is exactly the wrong design for a mod author, who is not.
A modding event system is the mirror image of the lifecycle layer along three axes:
| Lifecycle hooks (today) | Modding events (this doc) | |
|---|---|---|
| World | closed — all handlers known at compile time | open — mods add listeners after compilation |
| Binding | static — a checked symbol, a direct call | dynamic — registered at load, dispatched at runtime |
| Language | in-language — Ludic, compiled together | cross-language — JS/TS/Lua/native over an ABI |
The instinct would be to build a second, parallel system. The design that keeps
Ludic's discipline builds one system seen from two sides. A lifecycle hook is a
private view of a moment; a public event is the same moment exposed across the
ABI. The author promotes a hook to an event; the compiler keeps its zero-cost
direct calls and emits one guarded bus_emit at the very same site. Nothing
exposed → nothing emitted → goldens stay byte-identical, exactly like has_ecs
and the g_ondespawn shutdown walk.
The Luanti dividend. The gap analysis (LUANTI-ROADMAP.md) found that ~57k of
Luanti's lines exist only to bridge C++ and Lua, and that its mod predicates are
runtime strings it must re-interpret every call. Ludic pays neither tax. The
reflection surface a mod needs — "what properties exist, what fields, at what
offsets" — is a compile-time fact; the compiler can generate the bridge
instead of a human hand-writing 57k lines, and it is always in sync with the game
it describes. A mod itself written in Ludic and compiled to a shared library binds
that surface with zero marshalling; a Lua mod binds the same surface through
its FFI. One ABI, every language.
2. What Ludic has today, and why it can't reach a mod
The lifecycle table from LIFECYCLE-DESIGN.md §2, every cell filled, every cell a zero-cost desugar:
| Scope | Setup hook | Teardown hook | Fire site the compiler already owns |
|---|---|---|---|
| program | @OnStart |
@OnQuit |
boot / shutdown |
| entity | @OnSpawn(M) |
@OnDespawn(M, reason) |
spawn / despawn / shutdown-walk |
| property (structural) | @OnAttach(P) |
@OnDetach(P) |
attach / detach |
| property (toggle) | @OnEnable(P) |
@OnDisable(P) |
enable / disable |
| scene | on enter |
on exit |
become (and push/pop, SCENES E3) |
Two more fire sites are proposed but unbuilt, and both are natural events:
@OnChange(P) (LC2 — a value-change hook the compiler can emit right after every
write site) and @OnStartMatch/@OnStopMatch (LC3 — query-membership edges).
Every one of these is a place the compiler already writes a call. The problem is purely that the call is closed: its targets are fixed at compile time, so a mod loaded at runtime has no way to be one of them. The entire job of this doc is to add, at each of these sites, an opt-in second exit to an open runtime list — without touching the closed path's cost when no one opts in.
What a mod additionally needs, that no hook provides:
- a stable name for each event that survives recompilation (a mod compiled against v1 must still bind in v1.1);
- a way to read and write game state it did not compile against (the world table, §9);
- a way to veto or rewrite an action before it commits, not just observe it after (cancellable events, §8);
- a loader — mods enable, disable, and unload, and their listeners must vanish cleanly when they do (§10).
3. Research digest — the one idea to steal from each
The lifecycle doc surveyed engines for internal lifecycle. This surveys systems for their modding and event surface — how untrusted, separately-authored code plugs into a running game.
| System | The transferable idea |
|---|---|
| Bukkit / Spigot (Minecraft) | The canonical cancellable event: Cancellable.setCancelled(true) vetoes the action; EventPriority orders listeners; @EventHandler(ignoreCancelled=true) opts out of already-vetoed events. Events are classes, checked at bind time — not strings. |
| Fabric (Minecraft) | Event<T> backed by an invoker over a plain array of callbacks — deterministic registration order, no reflection at dispatch, phases for ordering. The closest existing design to what Ludic wants: fast, ordered, array-backed. |
| Factorio | Deterministic modded events for multiplayer lockstep: script.on_event(defines.events.X), numeric event ids, raise_event for custom events, filtered subscriptions. Proof that a heavily-modded game can still replay bit-for-bit. |
| Minetest / Luanti | register_on_* + a string-keyed global (minetest.*) world API. The thing to beat: its predicates are runtime strings, and its C++↔Lua bridge is 57k hand-written lines. |
| Godot | Signals as a first-class language construct: signal hurt(amount), emit_signal, connect. Decoupled, per-object, declared where the data lives. |
| DOM events | The two-phase dispatch vocabulary: capture → target → bubble, preventDefault (veto the default action) vs stopPropagation (halt the chain), and passive listeners that promise not to cancel (so dispatch can skip the veto check). |
Node EventEmitter |
The dead-simple baseline on/emit — and its footguns: untyped string names (a typo silently never fires) and listener leaks (a listener on a dead object keeps it alive). Design both out. |
| flecs / Bevy observers | ECS-native reactive events: an event targeted at an entity, observers that fire on component add/set/remove, deferred so mutation-during-iteration is safe. The correct shape for an ECS. |
Blender bpy.app.handlers |
Named application-level handler lists a script appends to, with a persistent flag controlling survival across file loads — the "engine lifecycle exposed to scripts" model, and the lesson that survival scope must be explicit. |
| Roblox | BindableEvent (local) vs RemoteEvent (across the network boundary) — the same event abstraction, one flag deciding whether it crosses a trust/latency boundary. Relevant the day Ludic has networking. |
Five footguns the survey warns against, to design out of Ludic from the start:
- Untyped string events. Node/DOM let any string be an event; a typo never fires and never errors. Ludic's core events are compiler-checked symbols; only genuinely-dynamic mod-defined events use interned strings, and those must be registered before use (§6), so an unknown name is a load-time error, not a silent no-op.
- Listener leaks. A listener bound to an entity that despawns must die with it. Ludic ties listener lifetime to the scope it names (§10) — entity-scoped listeners are swept by the same despawn walk that already runs.
- Nondeterministic dispatch order. Hash-map iteration over listeners breaks replay and save-load. Ludic dispatches in a defined order (priority, then registration order) so a modded game stays deterministic — a hard constraint, not a nicety, given Ludic's deterministic-by-design rng and byte-identical goldens.
- Re-entrancy / mutate-during-dispatch. A listener that emits another event, or despawns the entity mid-dispatch, is the flecs "command during iteration" hazard. Ludic defers structural changes made inside dispatch to the next sync point (ties to LIFECYCLE LC5), and bounds re-entrant emit depth.
- Cancellation ambiguity. If two listeners disagree, who wins? Ludic's rule
(§8): one veto wins and is sticky; later listeners see the cancelled state
and, unless they opted into
ignoreCancelled, are skipped.
4. The two layers, named
To talk about this precisely the doc fixes two words:
- A hook is the existing compile-time construct: an
@-annotation or scene clause that desugars to a direct call. Closed, zero-cost, author-only. Unchanged. - An event is the new runtime construct: a named, ABI-visible moment that any registered listener — in any language — may observe or (if cancellable) veto.
An event is fed by a hook site. Promoting is additive: the hook keeps firing its compile-time listeners as direct calls; the event is an extra, guarded emission at the same site. Author code never pays for the bus it doesn't expose, and mod code never sees a hook it wasn't given.
5. EV0 — the event bus core
The minimum viable layer: declare an event, emit it, and have both in-language and foreign listeners receive it — with zero cost when a program declares no events.
Declaring a custom event. A first-class declaration, mirroring property:
# doc-check: skip — sketch
event PlayerHurt { entity: int, amount: int } # a payload is a flat POD record
event WaveCleared { } # payloads may be empty
Emitting. A statement, mirroring spawn/emit_signal:
# doc-check: skip — sketch
emit PlayerHurt(entity: e, amount: dmg)
Listening in-language (author code, or a native Ludic mod) reuses the
annotation channel, mirroring @OnSpawn:
# doc-check: skip — sketch
@On(PlayerHurt) handler FlashRed { hud_flash(0xFF0000) }
Listening across the ABI (a JS/TS/Lua mod) goes through the stable C ABI:
/* the entire foreign-facing event ABI — four functions */
uint32_t ludic_event_id(const char *name); /* intern → stable id */
uint32_t ludic_on(uint32_t event, int32_t prio, ludic_cb cb, void *ctx);
void ludic_off(uint32_t token);
void ludic_emit(uint32_t event, void *payload); /* mod-raised events */
/* cb: void (*)(void *ctx, void *payload) — payload is the flat POD record */
Lowering — the discipline holds. An exposed event's emit site becomes:
; emit PlayerHurt(entity: e, amount: dmg) lowers to:
1. build the payload record on the stack (POD, no heap)
2. call each compile-time @On(PlayerHurt) handler directly ; zero-cost path
3. if g_listeners[EV_PlayerHurt].count != 0: ; one branch
loop the runtime listener list, calling each cb(ctx, &payload)
- A program that declares no
eventemits none of this. Ahas_eventsflag (exactly likehas_ecs,g_ondespawn) gates the whole subsystem; a game with no public events is byte-for-byte identical to today. This is the non-negotiable invariant every phase preserves. - The compile-time
@Onhandlers are direct calls appended to the site — a native listener costs the same as a lifecycle hook. Only foreign listeners walk the runtime list, and an event with zero foreign listeners is a single count check. - The runtime list is a compiler-owned, fixed-capacity buffer per event
(like the scene stack in SCENES E3) — not heap, not a hash map.
ludic_onis an index bump;ludic_offtombstones a slot. Deterministic order falls out of the array (§7 of SCENES' "no dispatch tables" spirit, honestly bent — see §11).
6. EV1 — promoting hooks to events (the taxonomy)
Custom events (EV0) cover author-raised signals. The lifecycle events —
spawn, despawn, attach, scene enter — should not require the author to hand-write
an emit in every @OnSpawn. Instead, a hook is promoted with one annotation:
# doc-check: skip — sketch
@Public @OnSpawn(Enemy) handler Init { Health.hp = Health.max }
# now firing this hook ALSO emits the public event model.Enemy.spawn
@Public on a lifecycle hook tells the compiler to add the guarded bus_emit at
that hook's existing site, with a generated payload built from what the hook
already binds (the entity id, the model/property fields, the EndReason). The
result is a uniform event namespace across the whole architecture — precisely the
"events for properties, models, scenes, layers, game" the request asks for:
| Scope | Public event name | Payload | Fed by |
|---|---|---|---|
| program | program.start / program.quit |
{} |
@OnStart / @OnQuit |
| phase | phase.<Name>.pre / .post |
{ frame } |
the phase scheduler |
| model | model.<M>.spawn / .despawn |
{ entity, reason? } |
@OnSpawn / @OnDespawn |
| property (structural) | prop.<P>.attach / .detach |
{ entity, <fields> } |
@OnAttach / @OnDetach |
| property (toggle) | prop.<P>.enable / .disable |
{ entity } |
@OnEnable / @OnDisable |
| property (value) | prop.<P>.change |
{ entity, field, old, new } |
@OnChange (LC2) |
| query (membership) | query.<Q>.enter / .exit |
{ entity } |
@OnStartMatch/@OnStopMatch (LC3) |
| scene | scene.<S>.enter / .exit / .push / .pop |
{} |
on enter/on exit, push/pop |
| layer | layer.<L>.show / .hide |
{} |
layer toggle (SCENES E2) |
- Names are stable strings, ids are fast integers.
model.Enemy.spawnis the public contract; the compiler assigns it a numeric id and registers the mapping in a generated init. A mod compiled against the string binds by id at load — so reordering declarations doesn't break a shipped mod (unlike raw decl-order numbering, which is fine for the closed scene machine but wrong for an open ABI). - Opt-in per hook, not global. Only
@Publichooks emit. A game exposes the slice of its lifecycle it wants moddable and pays for nothing else. @Publiccomposes with everything. A@Public @OnDespawn(Enemy, reason: r)emitsmodel.Enemy.despawnwith theEndReasonin the payload — mods can tell a scene-exit death from a real one, the LC1 dividend extended to the mod boundary.
7. EV2 — the world table (reflection for mods)
The user's "game table": the stable, versioned surface a mod uses to read and
write game state it never compiled against. Minetest's minetest.*, Factorio's
game.*, but generated rather than hand-written.
Because Ludic's data is packed POD in @S_ arrays whose layout the compiler knows
exactly, the compiler can emit a schema (property id → field ids → offset +
type) plus a small accessor ABI over it:
/* the world table — reflection + mutation over the live ECS */
uint32_t ludic_prop_id(const char *name); /* "Health" → id */
uint32_t ludic_field_id(uint32_t prop, const char *name); /* ("Health","hp")→id */
int64_t ludic_get(int32_t entity, uint32_t prop, uint32_t field);
void ludic_set(int32_t entity, uint32_t prop, uint32_t field, int64_t v);
bool ludic_has(int32_t entity, uint32_t prop);
int32_t ludic_spawn(uint32_t model); /* → entity */
void ludic_despawn(int32_t entity);
uint32_t ludic_query(uint32_t *props, int n); /* → iterator handle */
int32_t ludic_query_next(uint32_t iter); /* → entity or -1 */
- Generated from the compile-time schema, so it never drifts. Add a field to
Health, recompile, and the schema updates; a mod that asked for("Health","hp")still resolves. This is the entire Luanti bridge, minus the hand-written 57k lines and minus the runtime-string re-interpretation. ludic_setrespects the semantic layer. Writing a field routes through the same path a native write does, so@OnChange/prop.change(LC2) fires for a mod's write exactly as for the author's — mods can't silently corrupt invariants that hooks are meant to maintain.- Mods can register content, within limits. A mod may
ludic_onexisting events andludic_emitcustom ones; defining a newproperty/modelis a harder call (it needs storage the closed@S_arrays didn't reserve). The pragmatic first cut: models and properties are closed (author-defined), and mods extend behavior (listeners, custom events, world reads/writes) but not the schema. Opening the schema to mods is EV-late (§13, open decision 4).
8. EV3 — cancellable and mutable events
Observation alone (Node, Blender) can't stop a mod from turning damage off — the modding headline is that a listener runs before the action and can veto or rewrite it. Events split into two kinds, distinguished at declaration:
- notifications — fired after the fact, observe-only, can't change anything.
Cheap, un-ordered-safe, the default.
model.Enemy.spawnafter the spawn. - decisions — fired before the action, listeners may cancel it or
mutate the payload; the caller reads the verdict and branches. Marked
cancellable(BukkitCancellable, DOMpreventDefault).
# doc-check: skip — sketch
event cancellable BeforeHurt { entity: int, amount: int } # a decision event
# an author (or native mod) listener that halves fire damage and vetoes lethal hits:
@On(BeforeHurt, prio: 100) handler Armor {
BeforeHurt.amount = BeforeHurt.amount / 2 # mutate the payload…
if BeforeHurt.amount >= Health.hp { cancel } # …or veto the whole action
}
# the fire site consults the verdict:
let dmg = emit? BeforeHurt(entity: e, amount: raw) # emit? returns the (maybe-mutated) payload
if !cancelled(dmg) { Health.hp -= dmg.amount }
Rules, chosen from the survey to remove the ambiguity footgun:
- Priority, then registration order.
prio:(default 0) orders listeners high-to-low; ties break by registration order. Deterministic, replay-safe. - One veto wins and is sticky. Once a listener calls
cancel, the event is cancelled for the rest of the chain; later listeners still run (so they can react to the cancellation) unless declaredignoreCancelled, which skips them. stopPropagationis separate fromcancel. DOM's distinction:cancelvetoes the action,haltstops the chain. Keep both; they answer different questions.- Passive listeners. A listener declared
@On(E, passive)promises not to cancel or mutate — the dispatcher can call it after the decision is settled, and a foreign listener that lies is a load-time capability error (§10), not a mid-frame surprise. - Mutation is bounded to the payload. A decision listener rewrites the payload record, never arbitrary world state, so the caller's branch is the only place the change takes effect — no spooky action at a distance.
9. EV4 — the mod ABI & the language-agnostic bridge
"Agnostic JS/TS/Lua or their own" resolves cleanly once EV0–EV3 exist, because the contract is the C ABI, not any one language. Two mod tiers bind the same four event functions (§5) and the same world table (§7):
Tier 1 — native mods (Ludic → shared library). A mod is a .ludic file
compiled to a .dylib/.so/.wasm with extern fn bindings
(LANGUAGE.md §Functions & FFI). It binds the ABI with zero
marshalling — payloads are the same POD records the host builds — and its @On
handlers can even be inlined by the same compiler if the mod is compiled with the
game. This is the tier Luanti can't offer and the one that makes Ludic's modding
fast: a compiled predicate where Luanti has a re-interpreted string.
Tier 2 — scripted mods (JS/TS/Lua/…). The game embeds a scripting runtime (QuickJS, Lua, Wasm) and registers a thin per-language shim that:
- calls
ludic_event_id("model.Enemy.spawn")once at load to resolve the id; - calls
ludic_on(id, prio, trampoline, script_fn)wheretrampolineis a single C function that marshals the POD payload into the script runtime's values and invokesscript_fn; - exposes the world table (§7) as idiomatic bindings (
world.get(e, "Health", "hp")in Lua,world.get(e, "Health", "hp")in TS).
The host writes one trampoline per language, not one per event — the schema (§7) drives the marshalling generically. A Lua mod and a TS mod differ only in their shim; the game core is identical. This is the structural win the Luanti gap analysis pointed at: the bridge cost is O(languages), not O(events × languages) hand-written, because the schema is generated.
┌─────────────── the stable C ABI ───────────────┐
Ludic game core ──────┤ ludic_on / ludic_emit / ludic_get / ludic_set ├────── generated schema
(emits at hook sites) └────────────────────┬───────────────────────────┘ (prop→field→offset)
│
┌────────────────────────────────┼────────────────────────────────┐
│ │ │
Tier 1: native mod Tier 2: Lua shim Tier 2: JS/TS shim
(.dylib, zero marshalling) (one trampoline) (one trampoline)
10. EV5 — mod lifecycle, scoping & leak-proofing
A mod is not eternal; it loads, enables, disables, and unloads, and its listeners must vanish with it — the Node listener-leak footgun, solved structurally.
- Every registration returns a token (
ludic_on → token), and a mod's tokens are tracked under its mod handle. Unloading a mod callsludic_offon all of them at once — a mod can't leak a listener past its own life. - Listeners may be scoped to a game object.
ludic_on_entity(entity, …)binds a listener that the existing despawn walk sweeps when that entity dies — the same@L_despawn_allloop LC1 already emits, extended to drop entity-scoped listeners. An entity-scoped listener on a dead entity is impossible by construction, not by discipline. - Scene-scoped listeners ride SCENES E1: a listener registered while a scene is
active is dropped by that scene's synthesized
on exit, alongside its owned entities. Overlay push/pop (SCENES E3) scopes listeners to the overlay's life. - Survival is explicit (Blender's
persistentlesson): a listener is program-, mod-, scene-, or entity-scoped, chosen at registration. There is no implicit "lives forever" — the default is the narrowest scope that makes sense (mod), and wider survival is opt-in and visible. - Capabilities gate what a scripted mod may touch (§14, open decision 6). A mod
manifest declares the events and world-table properties it needs; the loader
grants ids only for those. A mod that never asked for
Healthcannotludic_setit — an untrusted-code boundary the closed lifecycle layer never needed but an open mod ABI must have.
11. EV6 — determinism, re-entrancy & the one honest compromise
Ludic is deterministic by design — deterministic rng, byte-identical PPM goldens, save-load of the whole World. A modding layer is the classic place that determinism goes to die (hash-ordered listeners, mods reading wall-clock, emit storms). Holding the line is a feature, and the same one that makes Factorio's modded multiplayer lockstep-correct.
- Dispatch order is total and defined — priority, then registration order, over an array, never a hash map. Two mods loaded in the same order dispatch in the same order on every machine.
- Emit is synchronous by default, deferred on demand.
emit Eruns listeners now (push-at-the-site, Ludic's natural style — the LIFECYCLE footgun-1 fix). Structural changes a listener requests (spawn/despawn/attach) defer to the next sync point (LIFECYCLE LC5'sdefer), so mutate-during-dispatch is safe and batched. Re-entrantemitinside a listener is allowed but depth-bounded (a compile-time cap, trap on overflow) so an event cycle can't hang a frame. - Foreign listeners are the determinism boundary. A native (Tier 1) listener is as deterministic as any handler. A scripted (Tier 2) listener is only as deterministic as the script — so the sandbox (§10) can deny nondeterministic capabilities (wall-clock, unseeded rng, filesystem) to a mod that must stay in a deterministic session (multiplayer, replays). Single-player mods can opt out.
The one honest compromise. SCENES-DESIGN's principle is "no dispatch tables —
the active-scene path is a register read and a static branch." The runtime
listener list is a dispatch table, walked at runtime. This doc owns that: it is
the deliberate, opt-in exception, justified because open-world extension is the
entire point of a mod ABI and cannot be resolved at compile time by definition.
The mitigations keep it honest — it is (a) gated behind has_events so unused it
costs nothing, (b) an array not a hash map so it stays deterministic, (c) fed by
compile-time-checked names so the closed side stays typed, and (d) reached only
after the zero-cost direct calls to compile-time @On handlers. Ludic pays for a
dispatch table exactly when, and only when, a game chooses to be moddable.
12. Lowering summary
Everything above reduces to constructs Ludic already has or honestly-scoped additions to them:
| Construct | Lowers to |
|---|---|
event E { … } |
a generated payload record type + a reserved event id + a has_events bump |
emit E(…) |
build POD payload · direct-call each @On(E) handler · if count: walk runtime list |
@On(E) handler |
a compile-time listener: a direct call appended to E's emit site (zero-cost) |
@Public @OnX(…) |
the existing hook's site, plus a guarded bus_emit of a payload built from the hook's bindings |
| public event name | a stable string interned to an integer id in a generated registry init |
| the runtime listener list | a compiler-owned fixed-capacity array per event; ludic_on = index bump, ludic_off = tombstone |
| the world table | a generated schema (prop→field→offset/type) + accessor ABI over the live @S_ arrays |
cancellable / cancel |
a verdict field on the payload; the emit site branches on it |
| entity/scene-scoped listener | dropped by the existing despawn walk / synthesized on exit (LC1 / SCENES E1) |
| deferred structural change in a listener | LIFECYCLE LC5's defer queue, flushed at the sync point |
No heap for native payloads, no hash map, no per-event hand-written bridge. The active game path is unchanged unless it opts in; the opt-in cost is one branch per exposed event plus the listeners a mod actually registers.
13. Design principles distilled
- One system, two sides. A public event is a lifecycle hook seen from across the ABI. Don't build a parallel event runtime; promote the sites you already have.
- Opt-in or invisible. No
event, no@Public→ byte-identical goldens.has_eventsgates the world the wayhas_ecsgates the ECS. - Closed stays typed; only the open edge is dynamic. Core events are compiler-checked symbols; string names exist only at the genuinely-runtime mod boundary, and even there must be registered (no silent typos).
- Generated bridge, never hand-written. The world table and payload marshalling come from the compile-time schema, so they never drift and cost O(languages), not O(events × languages). This is the Luanti dividend — spend it.
- Deterministic dispatch is a feature. Array order, not hash order; deny nondeterministic capabilities to mods in deterministic sessions. Modded replay and modded multiplayer depend on it.
- Lifetime follows scope, explicitly. Every listener names its scope (program/mod/scene/entity); the existing teardown walks sweep it. No implicit immortality, no leaks.
- One ABI, every language. The C ABI is the contract. Native mods bind it with zero marshalling; scripted mods bind it through one trampoline per language. Ludic never blesses a single scripting language.
- Only the semantic layer, still. Mods observe and decide; they do not get ctor/dtor/move hooks Ludic doesn't have. POD in, POD out.
14. Suggested implementation order
Each phase is independently shippable and testable, matching how the repo phases work (and how LIFECYCLE/SCENES sequence).
- EV0 — the bus core. ✅ Compile-time half shipped.
event/emit/@Onwith theg_events-gated zero-cost lowering: an event compiles to a@ev_<E>function whose body is its listeners in declaration order (payload bound by name as params), andemit E(…)is a direct call. Verified byte-identical for event-free programs, self-hosted to the C-free fixpoint. Still open in EV0: the foreign C ABI (ludic_on/ludic_emit) and its runtime listener array, so a mod in another language can join the same dispatch. Implementation notes: ASTN_EVENT/S_EMIT;parse_event+@Onannotation +emitstatement (guarded by an identifier-lookahead so a bareemit(...)call still parses); registriesg_events/g_onlisten(emit_core);emit_event_fns(emit_game);emit_emit(emit_stmt).examples/events/events.ludicis abin/x testcheck. - EV1 —
@Publichook promotion. ✅ All scopes shipped.@Publicon a lifecycle hook fires a public event at that hook's site (payload: entity, plusEndReasonfor despawn);find_event(name)doubles as the "is this hook public?" gate. Covered: program (@OnStart/@OnQuit→program_start/_quit), models (@OnSpawn/@OnDespawn), properties (@OnAttach/@OnDetach/@OnEnable/@OnDisable→prop_<P>_…). Scenes and layers use apublicblock modifier instead of an annotation:scene_<S>_enter/_exitat the synthesized scene functions, andlayer_<L>_show/_hideat the layer-toggle site. Building layer events also delivered SCENES E2 layer toggle:enable/disable layer Lflips an@LE_<L>flag that gates that layer's handlers, emitted only for toggled layers so untouched scene programs stay byte-identical. - EV2 / EV2b — the world table. ✅ Read/write/scan/identify/create shipped.
The generated reflection ABI (§7), dispatching a runtime prop/model id to the
right
@S_/@H_/@L_kindstorage: read/write (prop_id/field_id/get/set/has), scan/identify (entity_count/kind/model_id), and create (spawn(model_id), which reuses the compiler's own spawn lowering — defaults,@OnSpawn, and the spawn event).get/setaddress each field by its real struct offset (constant struct GEP), correct forint/fixed/byte/ptrfields and mixed layouts alike. Emitted only for an ECS program that declares events (gated onhas_ecs() && g_events), so event-free games are byte-identical. Aludic_query_next(prop, from)cursor iterates live entities that have a property. The world table is complete: read, write, scan, identify, create, iterate. - EV3 — cancellable events. ✅ Shipped.
event cancellable E, thecancelverb, andemit E(…)as an expression yielding the veto flag; the flag is a trailing field of%Ev_<E>, so a foreign listener vetoes by setting it. Priority ordering andignoreCancelled/halt(§8) remain open. The modding headline — observation becomes control. - EV4 — the scripting bridge. One reference shim (Lua or QuickJS) over the ABI, proving the O(languages) claim end to end.
- EV5 — mod lifecycle & scoping. ✅ Shipped. A parallel owner array
@evO_<E>(-1 = program-scoped, ≥0 = owning entity);ludic_on_<E>andludic_on_entity_<E>register with the right owner;ludic_off_<E>(token)tombstones a slot to null and dispatch skips null slots;ludic_sweep_entity, called fromemit_despawnwhen the program has events, nulls every listener a despawning entity owned. Scene-scoped listeners (drop onon exit) remain the same shape applied at the scene teardown — a follow-on. - EV6 — determinism & re-entrancy. ✅ Depth bound shipped.
@ev_depthincrements on each@ev_<E>entry and decrements on exit; pastEV_DEPTH_CAP(32) a dispatch returns immediately (a cancellable event returns "not cancelled"), so an event cycle can't hang. Dispatch order was already deterministic (array, registration order). Still design: deferred structural changes at a sync point (LC5) and capability gating for deterministic sessions. - EV7 — schema-opening & networking. ✅ Schema-opening shipped. A mod defines
a new component at runtime:
ludic_register_prop(name, nfields)allocates flat[MAX_ENT × nfields × i32]storage + a has-flag array (capacity 32 dynamic components) and returns a prop id past the compile-time range;ludic_attach_dyn/ludic_detach_dyntoggle presence;get/set/has/prop_idfall through to the dynamic registry for a prop id ≥ the compile-time component count. Per-entity storage is isolated (world_dyn.c). This is the first genuinely dynamic@S_storage — a deliberate departure from the closed dense arrays, so it lives entirely behind the ABI (the game's own components stay static and byte-identical). Dynamic components use integer fields addressed by index (no field-name schema). Still design: the local/remote event split (Roblox's lesson) waits on Ludic having a networking substrate.
EV0–EV1 deliver "the whole architecture emits public events." EV2–EV3 are where a mod becomes able to change the game. EV4 proves the language-agnostic claim. EV5–EV7 are hardening and reach.
15. Open decisions
emitverb & payload identity. Isemit E(…)the only spelling, or does asignal-style per-property declaration (Godot) read better for the common case? Are payloads always fresh POD records, or can an emit borrow an existing property in place (cheaper, but aliases live storage)?@Publicgranularity. Per-hook (proposed), per-model (@Public model Enemy), or a program-level "expose all lifecycle" switch for prototyping? Does@Publicbelong on the hook or on themodel/property/sceneit concerns?- Name scheme stability. Dotted strings (
model.Enemy.spawn) interned to ids — confirmed. Open: are ids stable across recompiles of the same source (needed for save-compatibility of a listener table), and how does a renamed model migrate a shipped mod? - Schema opening (EV2/EV7). Do mods stay behavior-only (listeners + custom
events + world reads/writes over author-defined schema), or can a mod define new
property/model? The latter needs dynamic@S_storage — a real departure from the closed dense arrays (LUDIC_MAX_ENT 1024). Probably EV7. - Cancellation surface. Keep
cancel(veto action) andhalt(stop chain) distinct (DOM), or collapse to one? IsignoreCancelledper-listener or a priority-band convention? - Sandbox model. Capability manifest per mod (proposed) — at what granularity (per event? per property? per world-table verb)? What is denied by default in a deterministic session, and who declares a session deterministic?
- Re-entrancy bound. Compile-time constant emit-depth cap (trap on overflow), or a runtime budget? What is the default depth, and is an event cycle a warning or an error?
- Scripting runtime, in or out of scope. Does Ludic ship an embedded runtime (QuickJS/Lua) as a blessed default, or only the ABI and reference shims, leaving the runtime to the game? (Bias: ship the ABI + one reference shim; bless no language.)
Companion to LIFECYCLE-DESIGN.md (the hook sites this layer
promotes) and SCENES-DESIGN.md (scene/layer/overlay events and
scoped-listener teardown). Grounded in the Luanti gap analysis (LUANTI-ROADMAP.md):
the generated bridge is how Ludic avoids the 57k-line C++↔Lua tax. Supersedes
nothing until the compiler work in §12 lands.