ludic/LIFECYCLE-DESIGN.md
Orkuncakilkaya a38195128f feat(stdlib): namespaced standard library (issue #2)
Implement the bulk of the namespaced-stdlib proposal (workshopsoft/ludic#2):
156 namespace methods across Math, Text, List, Ease, Collide, World, Net,
Sys, Save, Mem, extended Screen, Color functions, extended Random, and Time.
All deterministic fixed-point; self-hosting (C-free bootstrap fixpoint holds).

Compiler (selfhost/):
- Math.*: sqrt/sin/cos/tan/atan2/asin/acos (fixed-point runtime prelude —
  bit-by-bit isqrt, 256-entry interpolated sine table, Ross atan2), plus
  hypot/dist/dist2/deg_to_rad/rad_to_deg/posmod/wrap/ping_pong/snapped/
  move_toward/smoothstep/lerp/remap/sign/floor/ceil/round.
- Text.* (complete): upper/lower/trim/repeat/pad, split/join/replace,
  and the libc-backed queries.
- List.* (complete): insert/remove_at/remove/sort plus the earlier ops.
- Ease.* (in/out/in_out/back/bounce) and Collide.* (rects/point_rect/
  circles/rect_circle).
- Phase 3: World/Net/Sys/Save namespaced over the bare builtins (byte-
  identical IR) and Mem.* (bytes/words/copy/fill/peek/poke).
- Screen.* extended (line/circle/fill_circle/triangle/fill_triangle via new
  runtime primitives; sprite/sprite_scaled aliases), Color.* functions,
  Random.* (value/int/sign), Time.* (frame/delta/elapsed/now — new
  game-loop frame counter).
- Fix a lexer bug: fixed-point literals with >4 fractional digits overflowed.

Docs & tooling:
- 129 new per-symbol doc pages; gen.py made data-driven (namespaces
  discovered from the docs, no hardcoded list); new check-impl.py enforces
  that every implemented namespace method / keyword / type / phase has a
  doc page, wired into `x test-tools`. Document the previously-undocumented
  keywords (break/continue/where/entry/new/public + and/or/not tokens).
- LSP: namespaced signature help (ns_method_sig) covering every namespace.

Tests: 12 new self-host/regression tests + a golden render for the drawing
primitives. All suites green (selfhost 21, regression 45, tools 29).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 00:26:19 +03:00

348 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Lifecycle events, expanded — a design doc
> **Status: LC0–LC1 shipped; LC2–LC6 are design.** The structural attach/detach
> pair and `@OnDetach` (§4, LC0), and reason-carrying `@OnDespawn` (§5, LC1), are
> implemented and tested ([`examples/detach.ludic`](examples/detach.ludic),
> [`examples/reason.ludic`](examples/reason.ludic), `bin/x test` checks). The
> extensions LC2–LC6 are research-informed proposals, not built. This document
> distills a survey of lifecycle models across seven systems (§3) into a roadmap
> for Ludic. §13 lists the open decisions.
---
## 1. Thesis
A game/ECS usually models lifetime as **create → destroy on a timeline**. A survey
of how other systems handle it — Unity (MonoBehaviour + DOTS), Unreal, Bevy,
flecs, EnTT, Godot, and non-game paradigms (actor model, declarative UI, RAII) —
shows that mature lifecycle designs model something richer than birth and death:
- **a reaction to a *reason*** — teardown that knows *why* it is ending (Unreal
`EndPlay(reason)`, Erlang `terminate(Reason)`, Akka `preRestart(reason, msg)`);
- **paired setup/teardown *keyed on dependencies*** — an update is teardown-then-
setup on a value change (React `useEffect`, Compose `DisposableEffect`);
- **a deterministic consequence of *scope / ownership*** — guaranteed, ordered,
single-shot teardown (C++/Rust RAII, DI scoped lifetimes);
- **an edge on *query membership*** — fire when data starts/stops matching a
composite condition (DOTS `OnStartRunning`, flecs `Monitor`).
Ludic's model is a good base: lifecycle hooks are `@`-annotations on handlers that
**desugar to ordinary code**, firing at fixed timeline moments, keeping the data
plain. This doc extends that base along the four axes above **without breaking the
desugars-to-code discipline** — every proposal lowers to plain branches and calls,
no hidden runtime.
**One structural advantage worth stating up front.** flecs and EnTT each carry
*two* lifecycle layers: a **memory** layer (ctor/dtor/move/copy — because C++
objects must be constructed and relocated as archetypes repack) and a **semantic**
layer (on_add/on_set/on_remove). Ludic's components are POD in packed `@S_`
arrays; there is nothing to construct, destruct, or move-relocate. **Ludic needs
only the semantic layer** — half the machinery, none of the "component isn't
movable" footguns. Keep it that way.
---
## 2. What Ludic has today
Seven hooks, each an annotation that desugars to a handler body at a timeline
moment ([LANGUAGE.md §Annotations](LANGUAGE.md)):
```
boot ─ @OnStart ─▶ spawn ─ @OnAttach(P), @OnSpawn(M) ─▶ … ─ @OnDetach(P)/@OnDespawn(M) ─▶ quit ─ @OnQuit
```
The lifecycle reads cleanest as a table of **paired setup/teardown** across five
scopes. Every cell is now filled — LC0 closed the one hole (`@OnDetach`):
| Scope | Setup | Teardown | Driven by |
|---|---|---|---|
| program | `@OnStart` | `@OnQuit` | boot / quit |
| entity | `@OnSpawn(M)` | `@OnDespawn(M)` | `spawn` / `despawn` |
| property (structural) | `@OnAttach(P)` | `@OnDetach(P)` ✅ | `attach` / `detach` |
| property (toggle) | `@OnEnable(P)` | `@OnDisable(P)` | `enable` / `disable` |
| scene | `on enter` | `on exit` | `become` |
Two things this table already gets right, which the survey flags as the frequent
mistakes to avoid:
- **The toggle pair is distinct from the structural pair.** Unity's clearest
lesson is separating the *repeatable* enable/disable cycle (pooling, pausing,
data kept) from the *once* create/destroy (data gone). Ludic has both, as
distinct verbs: `disable` pauses and keeps data; `detach` structurally removes
(a later `attach` re-seeds). This is exactly DOTS enableable-components vs
structural add/remove, and Bevy `disabled` vs `Remove`.
- **Hooks are typed annotations, not magic-named methods.** MonoBehaviour matches
`Awake`/`Update` by *string name* via reflection — a typo silently never runs.
Ludic's `@OnSpawn(Enemy)` is a checked reference; a wrong name is a compile
error. Preserve this.
What's missing is everything past "what happened": **why** it happened, **which
values changed**, **when composite conditions begin/end to hold**, and
**dependency-keyed** setup/teardown. That is the roadmap.
---
## 3. Research digest — the one idea to steal from each
| System | The transferable idea |
|---|---|
| **Unity MonoBehaviour** | Two-phase init with a global barrier (all `Awake` before any `Start`); repeatable enable-pair vs once create-pair. |
| **Unity DOTS** | *Data-driven activation*: `RequireForUpdate` + `OnStartRunning`/`OnStopRunning` — a system edge-triggers when its query starts/stops matching. Enableable components = cheap "logically off." |
| **Unreal** | *Reason-carrying teardown*: `EndPlay(EEndPlayReason)` — one teardown, branch on `Destroyed`/`LevelTransition`/`Quit`/…; forces enumerating every death path (no silent deaths). Provenance-tagged construction. |
| **Bevy** | Full structural event set Add/Insert/**Replace**/Remove/Despawn with strict order; **Replace exposes the old value before drop**. Hooks (type-level, singular, invariant) vs observers (plural, reactive). Declarative `before`/`after`/`chain` ordering. State `OnEnter`/`OnExit`/`OnTransition`. |
| **flecs** | `Monitor` observers fire on *composite query membership* start/stop. Events fire on **real transitions**, not every API call. Deferred-by-default with explicit sync points. |
| **EnTT** | `patch` as the *explicit mutation channel* that fires `on_update` (solves "raw writes are invisible"). Opt-in signals — zero cost when unused. |
| **Godot** | Tree membership *is* the lifecycle driver; enter top-down, **`_ready` bottom-up** (dependencies initialized first); `queue_free()` deferred safe-delete; `process_mode` pause inherited down the tree. |
| **Actor model (OTP/Akka)** | Lifecycle driven by *failure + supervision*: reason-carrying `terminate`, **restart as a state distinct from create/destroy** (stable identity, reset transient state), supervision trees, `code_change` = live state migration. |
| **Declarative UI (React/SwiftUI/Compose)** | *Paired setup/teardown keyed on a dependency list* — cleanup co-located with setup so it can't leak; an update **is** keyed teardown-then-setup; lifetime follows *identity*. |
| **RAII / Rust `Drop` / DI scopes** | *Scope = lifetime*: deterministic, reverse-construction-order, single-shot, no-resurrection teardown, guaranteed even on early exit; lifetime-mismatch checking (no long-lived thing holding a short-lived handle). |
Two recurring **footguns** the whole survey warns against, to design *out* of Ludic:
1. **Silent order-dependent reactivity.** Bevy's removal buffers are cleared at
end-of-frame, so a detector that runs before the mutator *misses removals
entirely*. If Ludic adds change/removal reactivity, make it either push-based
(fire at the mutation site — Ludic's natural style) or loudly order-checked.
2. **Invisible in-place writes.** flecs `on_set` and EnTT `on_update` don't fire
on a raw pointer write — you must call `modified()`/`patch`. Ludic can dodge
this entirely (see LC2): the compiler *sees* every write site.
---
## 4. LC0 — structural attach/detach + `@OnDetach` ✅ *shipped*
The one missing cell in §2's table. `attach P on e { overrides }` adds a property
to a **live** entity (seeding fields, firing `@OnAttach`); `detach P on e` removes
it (firing `@OnDetach`, which reads the outgoing value, before the has-flag
clears). Both fire only on a **real transition** (flecs/Bevy idempotent-add
semantics): re-attaching a present property or detaching an absent one is a no-op.
Lowering: `attach` guards on the has-flag and, when absent, reuses the existing
`emit_init_component` (seed + `@OnAttach`); `detach` guards on presence, clears the
flag, and fires `@OnDetach` with the property bound by name — the same binding the
`@OnDisable` path already uses. No new runtime; POD data stays in `@S_` storage.
See [`examples/detach.ludic`](examples/detach.ludic).
---
## 5. LC1 — reason-carrying teardown ✅ *shipped (`@OnDespawn`)*
The highest-conviction idea in the survey: it appears independently in Unreal
(`EndPlay`), Erlang (`terminate`), and Akka (`preRestart`), and Bevy has an open
issue asking for it. **Teardown should know *why*.** A destructor frequently needs
to branch — save on `Quit` but not on a scene swap, skip network cleanup when the
whole program is exiting.
`@OnDespawn` gains an optional bound **reason**:
```ludic
# doc-check: skip
# EndReason { Despawned, SceneExit, Quit } — the compiler owns this enum
@OnDespawn(Enemy, reason: r) handler Clean {
match r {
EndReason.Quit => {} # app closing — don't bother dropping loot
_ => drop_loot(Health.hp)
}
}
```
**What shipped.** The lowering is exactly the cheap desugars-to-code shape the
survey promises. The despawn hook compiles to `@on_despawn_<Model>(i32 %e, i32
%reason)`; when the hook writes `reason: r`, `r` is bound as an int local reading
`%reason`. Each teardown *site* passes a constant `EndReason`:
- `despawn e` passes `Despawned` (0) — an in-world death.
- **program shutdown** passes `Quit` (2): a generated `@L_despawn_all(reason)`
walks the live set at `done:` (before `@OnQuit`, matching the timeline) and
fires every survivor's `@OnDespawn`. This makes **"no silent deaths"** real —
an entity that outlives the run still gets its destructor, and can branch on
`Quit` to skip work that only matters mid-game. Emitted only when the program
has `@OnDespawn` hooks, so despawn-free programs are byte-for-byte unchanged.
- `SceneExit` (1) is reserved: a scene tearing down its owned entities
(SCENES-DESIGN E1) will pass it once scene-owned entities land.
`EndReason` is compiler-owned (resolved in `enum_ordinal`), so `EndReason.Quit`
works without a user declaration; a user enum of the same name still shadows it.
Backward-compatible: the `reason:` binding is optional, and `@OnDespawn` without
it is unchanged. `@OnDetach` and scene `on exit` do **not** yet take reasons
(§13.1). See [`examples/reason.ludic`](examples/reason.ludic).
---
## 6. LC2 — value-change hooks `@OnChange(P)` *(a compile-time win)*
Every reactive ECS wants "fire when a component's value changes" (flecs `on_set`,
EnTT `on_update`, Bevy `Changed<T>`), and every one hits the same footgun: a raw
in-place write is invisible, so you must route mutations through a special channel
(`modified()`, `patch`) or you miss changes.
**Ludic can sidestep the footgun because it is an AOT compiler that sees every
write site.** A field store `Health.hp = …` is a statement the compiler lowers; if
`Health` carries an `@OnChange`, the compiler can emit the hook call *right after
the store*. No dirty bits, no end-of-frame flush, no missed-write class of bugs —
the thing that is a runtime hazard everywhere else is resolved at compile time.
```ludic
# doc-check: skip
@OnChange(Health) handler Bar { hud_set_health(Health.hp) } # after any write to a Health field
```
Open question (§7): fire on *every* write (Bevy's `DerefMut` semantics — simple,
may over-fire) or guard with a value compare (fire only on actual change — needs
the old value, à la Bevy `Replace`). The compiler has the old value in hand at the
store site, so the value-compare form is feasible and is the more useful default.
---
## 7. LC3 — query-membership edges `@OnStartMatch` / `@OnStopMatch`
DOTS `OnStartRunning`/`OnStopRunning` and flecs `Monitor` fire when an entity
**starts or stops matching a composite query** — not a single component, but a
whole condition (`{Position, Velocity, moving}`). This is strictly more expressive
than per-property `@OnAttach`, which can't see "the entity now has *both* and is
alive." It's the natural ECS form of enter/exit.
```ludic
# doc-check: skip
@OnStartMatch(these: [Position, Velocity{dx != 0 or dy != 0}], on: Actor)
handler BeginMoving { play("footstep_loop.wav") }
@OnStopMatch(these: [Position, Velocity{dx != 0 or dy != 0}], on: Actor)
handler StopMoving { stop("footstep_loop.wav") }
```
Cost: unlike LC1/LC2 this needs runtime state — a per-entity shadow bit per
monitored query ("did it match last tick?"), checked once per frame, edge-
triggering the hook on a change. flecs does this by evaluating the query against
the entity's previous and current archetype. Ludic would keep a `@M_<query>` bit
array parallel to `@H_`. Medium cost; a genuinely differentiated feature.
---
## 8. LC4 — keyed effects (paired setup/teardown on a dependency list)
The declarative-UI headline, and the biggest reach. React `useEffect`, Compose
`DisposableEffect`, and SwiftUI `.task` all express: *while this thing exists (or
while key K holds), set up a resource; when it leaves or K changes, tear it down*
— with cleanup **co-located** with setup so it can't leak, and an *update* defined
as keyed teardown-then-setup. This collapses create/update/destroy into one
primitive.
```ludic
# doc-check: skip — sketch
@Effect(on: Enemy, keys: [Sprite.id]) handler Body {
let tex = image_load(Sprite.id)
dispose { image_drop(tex) } # runs on despawn OR when Sprite.id changes
}
```
Semantics: the setup runs on spawn (and whenever a listed key changes, after the
previous `dispose`), and `dispose` runs on despawn (and before each keyed re-run).
It unifies `@OnAttach`/`@OnDetach`/`@OnChange` into one leak-proof unit. Lowering
needs somewhere to stash the effect's captured teardown state and last key values
per entity — a per-effect side table, re-checked in a phase. Design only; the
syntax and storage model are open. This is where Ludic could feel genuinely modern
relative to every ECS surveyed (none of which have it).
---
## 9. LC5 — deferred structural changes with commit points
DOTS `EntityCommandBuffer`, flecs `defer_begin/end`, and Godot `queue_free()` all
make structural change **deferred with an explicit commit point**, so mutating
while iterating is safe and batched. Ludic's `spawn`/`despawn` are immediate today,
but *already* iteration-safe by a different route — matching is lazy per entity id
([LANGUAGE.md](LANGUAGE.md) "Matching is lazy, not snapshotted"), so despawning the
current entity is defined. A `defer { … }` block (or `despawn e at LateUpdate`)
that queues structural changes to a phase boundary would add batching and a single
predictable commit point, and is the prerequisite for safe parallel handlers
(the `reads`/`writes` scheduling in SCENES-DESIGN). Design only; lower priority
than LC1–LC3 because the immediate path is already safe.
---
## 10. LC6 — supervision, restart-as-a-state, live migration
The furthest-out cluster, from the actor model and OTP: lifecycle driven by
**failure**, not just create/destroy. Three ideas, all tied to Ludic's eventual
hot-reload / bytecode-VM roadmap rather than the near term:
- **Restart as a distinct state** between create and destroy — preserve an
entity's identity, reset its transient components, re-run setup (respawn,
hot-reload). Akka's "stable external ref, replaced internal state."
- **Supervision / failure escalation** — a subsystem owner declares a policy for
child faults (restart one / restart the group / escalate to reload the scene)
instead of defensive inline checks. Ludic has no failure model yet, so this
waits on one.
- **Live state migration** (`code_change`) — a hook that transforms an entity's
persistent state across a code/schema version, so hot-reload evolves data
instead of destroying it. Directly relevant to a self-hosting language.
---
## 11. Design principles distilled from the footguns
1. **No silent deaths.** Enumerate every teardown reason (LC1). If the compiler
must name the reason at each site, it can't forget a path.
2. **Fire on real transitions, not API calls.** Idempotent add/remove — LC0
already does this; keep it for every future hook.
3. **Keep "paused" and "gone" distinct.** `disable`/`enable` (data kept) vs
`detach`/`attach` (structural) — already true; don't let a future feature blur
them.
4. **Prefer compile-time resolution to runtime tracking.** LC2 turns the
universal "invisible write" footgun into a compile-time hook emission because
Ludic sees write sites. Reach for this wherever a runtime dirty-bit is the
obvious-but-worse option.
5. **If reactivity is order-dependent, make it loud.** Never silently drop events
at a frame boundary (Bevy's removal-buffer trap). Ludic's push-at-the-site
style avoids this by default.
6. **Deterministic teardown order.** When a scope tears down many things (a scene
unloading its owned entities — SCENES-DESIGN E1), define the order (reverse of
creation, RAII-style) rather than leaving it unspecified.
7. **Only the semantic layer.** POD components mean no ctor/dtor/move hooks. Don't
grow a memory-lifecycle layer Ludic doesn't need.
---
## 12. Suggested implementation order
- **LC0 — attach/detach + `@OnDetach`.** ✅ Done. Closes the structural pair.
- **LC1 — reason-carrying teardown.** ✅ Done for `@OnDespawn` (an `i32 %reason`
param + a constant at each site, plus a shutdown despawn-all for `Quit`).
`@OnDetach` / `on exit` reasons remain open (§13.1).
- **LC2 — `@OnChange(P)`.** Compile-time hook emission at write sites — a
Ludic-specific win over every ECS's invisible-write footgun. **Recommended next.**
- **LC3 — `@OnStartMatch`/`@OnStopMatch`.** First feature needing runtime shadow
state; the expressive ECS enter/exit.
- **LC4 — keyed effects.** The modern, leak-proof unification. Design first.
- **LC5 — deferred structural changes.** Batching + parallel-safety; the immediate
path is already iteration-safe, so lower urgency.
- **LC6 — supervision / restart / migration.** Waits on a failure model and the
hot-reload roadmap.
---
## 13. Open decisions
1. **Reason enum (LC1):** *resolved for `@OnDespawn`* — ships `Despawned`,
`SceneExit`, `Quit` as a compiler-owned `EndReason`, passed as an optional
`reason:` binding (not a separate annotation). Still open: `SceneExit` has no
firing site until scene-owned entities (SCENES-DESIGN E1); should `@OnDetach`
and scene `on exit` take reasons too, and if so with which reason values?
2. **`@OnChange` (LC2):** fire on every write (simple, over-fires) or only on an
actual value change (needs the old value at the store site)? Per-field or
whole-property granularity?
3. **Membership edges (LC3):** where do the shadow bits live, and is the check
per-frame or event-driven off attach/detach/spawn? Cost budget.
4. **Keyed effects (LC4):** syntax (`@Effect` annotation vs an `effect { … dispose
{ … } }` statement), and where per-entity teardown/key state is stored.
5. **Ordering:** none of this addresses intra-phase handler ordering (Bevy
`before`/`after`, flecs `DependsOn`). Worth a separate proposal; declarative
relational ordering over priority integers, per the survey.
---
*Companion to [LANGUAGE.md §Annotations](LANGUAGE.md) and
[SCENES-DESIGN.md](SCENES-DESIGN.md) (scene-owned entities and reasons intersect at
LC1/LC5). Supersedes nothing until the compiler work in §12 lands.*