ludic/SCENES-DESIGN.md
Orkuncakilkaya bca8f126fc Networking N2–N6, and a fully C-free toolchain
Implement the rest of NETWORKING-DESIGN.md (N2–N6) and eliminate every
`.c` file from the repo. clang remains only the LLVM-IR assembler; no C
is compiled anywhere.

Networking (selfhost/emit_net.ludic + parser/emit changes):
- N2 @Sync: per-model serialize/apply + by-kind dispatchers; POD-scalar
  compile error and empty-participation warning; selective replication.
- N3 @Owned: @L_owner array + owner/set_owner/is_owner; owners snapshot.
- N4 @ToServer/@ToClients remote events: framed net_send + net_pump re-emit.
- N5 @Server/@Predicted role guards + drivable sim (tick_fixed/tick_render,
  entry-owns-the-loop).
- Built-in loopback transport so multiplayer runs with zero foreign code;
  extern fn net_send/net_poll still overrides it for a real socket.
- N6 blessed runtime (examples/net_rt.ludic) + end-to-end demo (net_demo).
- Fix: llty("entity") is now i32 (entities are i32 handles), so let e = self().

C elimination:
- Networking + foreign-mod-ABI tests rewritten as self-contained pure-Ludic
  programs (examples/net_*, world_*, mod_events, scoped); tests/ removed.
- Reflection ABI exposed to Ludic as world_* builtins (Ludic-to-Ludic modding).
- Formatter rewritten C→Ludic: tools/ludic-tools/fmt.ludic.
- Language server rewritten C→Ludic: tools/ludic-tools/lsp.ludic (lexer, index
  parser, cross-file workspace resolver, JSON, all LSP handlers).
- Obsolete migrate_*.c codemods deleted; ludic_syntax.h kept as vocabulary data.

Suites: ./test.sh 44/44, ./tools/test-tools.sh 28/28 (LSP 42/42), fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 15:08:23 +03:00

354 lines
16 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.

# Scenes, expanded — a design doc
> **Status: S0 shipped; S1–S6 are design.** The base construct — `scene` /
> `layer` / `on enter` / `on exit` / `become`, lowered to the implicit machine of
> §3 and §9 — is implemented and tested ([`examples/scenes.ludic`](examples/scenes.ludic),
> a `test.sh` check). The extensions in §4–§8 (scene-owned entities, richer
> layers, the overlay stack, scene-local state, transition parameters) are still
> design targets. This document reaches deliberately past the thin sketch so we
> can decide the shape before building each one. §11 lists the open decisions.
---
## 1. Where we are
A Ludic program is almost always several mutually-exclusive states — a title
screen, the overworld, a battle, a pause menu. Two ways to write that exist in
the language today, and a third is sketched:
| Approach | Status | Cost |
|---|---|---|
| Mode register consulted at the top of every handler (`if reg(R_MODE) == …`) | works | a guard re-read per handler per frame; state is a magic number; nothing scopes to it |
| `machine`/`state`/`become` over a register | works | dispatch on the register each frame; still one flat register, no per-state handlers or lifecycle |
| `scene`/`layer`/`on enter`/`on exit` | **sketch only** | — |
The sketch ([`examples/scenes.ludic`](examples/scenes.ludic)) specs:
- Exactly **one scene active**; the `start` scene runs first.
- A scene's handlers run only while it is active; handlers outside any scene are
global.
- **Layers group handlers; declaration order is draw order** — within a phase,
globals first, then the active scene's layers in written order.
- `on enter` / `on exit` are lifecycle hooks (not phases).
- `become Name` runs the old scene's `on exit`, switches, runs the new `on enter`
— two direct calls and a store, no dispatch table.
That's a good spine. The problem is it's specced as **sugar over a mode
register**: it tidies the syntax but adds little the register didn't already
have. The compiler knows *much* more at a scene boundary than a register does,
and this doc is about spending that knowledge.
---
## 2. Design principles
1. **The scene boundary is a compile-time fact — use it.** The set of handlers,
layers, and owned state for each scene is known statically. Transitions should
be direct calls and a single store, never a table walk. (The sketch already
promises this; the extensions must preserve it.)
2. **Structure, not registers.** Anything you'd track with a hand-managed
register alongside the mode — which entities belong to this state, which layers
are drawn, what's paused — should be expressible *as* scene structure and
enforced by the compiler.
3. **Reuse the machinery we already have.** Layers pausing, scenes tearing down
their entities, and hooks firing are all expressible in terms of
`enable`/`disable` (cheap flag flips), `despawn`, and the lifecycle-hook
lowering. Scenes should *compose* those, not introduce a parallel runtime.
4. **One active-scene path stays hot; overlays are the exception, not the rule.**
The common case (one full-screen scene at a time) must lower to the cheapest
possible dispatch. Richer shapes (a pause menu over a frozen world) are opt-in
and pay only for what they use.
---
## 3. Core model (firmed up from the sketch)
```ludic
# doc-check: skip — illustrative
scene Title start {
on enter { ui_open(UI_Menu) }
on exit { ui_visible(UI_Menu, 0) }
layer Main {
handler Choose phase Update {
if ui_clicked(UI_NewGame) { become Overworld }
}
}
}
scene Overworld {
on enter { spawn_party() }
layer World { handler Move phase Update { … } }
layer Hud { handler Draw phase Render { … } }
}
```
Unchanged from the sketch, made precise:
- **Scenes number themselves by declaration order**, exactly like `machine`
states — `Title` is `0`, `Overworld` is `1`. The active scene lives in one
implicit register (`__scene`). This makes `scene` a `machine` the compiler
writes for you, which is the right mental model and the right lowering.
- **A layer handler may not use phase `Start`.** `Start` runs once at boot,
before any scene is entered; scene setup goes in `on enter`.
- **Global handlers still run every frame**, before any scene's layers, in every
phase. A scene's layers run only while it is active.
Everything below is new.
---
## 4. Extension E1 — scene-owned entities (scoped lifetime)
The single biggest thing a mode register cannot do: **own the entities that only
make sense in this state, and tear them down automatically on exit.** Today a
battle scene spawns combatants in `on enter` and must remember to despawn every
one in `on exit` — miss one and it leaks into the overworld.
Proposal: entities spawned *by a scene's handlers or `on enter`* are tagged with
that scene, and `on exit` despawns them by default.
```ludic
# doc-check: skip
scene Battle {
on enter { spawn Foe; spawn Foe; spawn Foe } # tagged @Battle
# on exit: implicit `despawn all @Battle` — no manual cleanup
layer World { handler Fight phase Update { … } }
}
```
- Implemented as an implicit **scene tag** (a `{Battle}`-style kind bit) added at
`spawn` time while a scene is active, plus a generated `despawn`-by-tag in the
synthesized `on exit`. Reuses the existing tag-filter and despawn-hook
machinery — no new runtime.
- **Opt out** for entities that should outlive the scene: `spawn Foe persist` (or
spawn it from a global handler). Persisted entities keep their data across the
transition, matching how `disable` keeps field data.
- Composes with `@OnDespawn(Model)`: the destructor hook fires for each
scene-owned entity as it's torn down, so `drop_loot`-style cleanup still runs.
**Open:** does a re-`become Battle` get fresh entities (fresh tag generation) or
resume the old ones? Default: fresh. See §9.
---
## 5. Extension E2 — layers are more than draw order
The sketch uses layers only to order `Render`. Layers are the natural unit for
three more things, all built on the existing `enable`/`disable` flag flips:
1. **Per-layer toggle.** `disable Hud` / `enable Hud` flips one flag; the layer's
handlers stop running and drawing. This is `disable Handler` generalized to a
named group — same one-flag-flip cost.
2. **Pause vs. tear-down.** A layer can keep drawing while its *update* handlers
are suspended:
```ludic
# doc-check: skip
scene Overworld {
layer World { handler Move phase Update { … } handler Draw phase Render { … } }
layer Hud { handler DrawHud phase Render { … } }
}
```
When a pause menu opens over the Overworld (see E3), `World`'s `Update`
handlers suspend but its `Render` handler still paints the frozen world behind
the menu. Today that requires a `if !paused` guard in every update handler;
with layers it's structural.
3. **Layer lifecycle hooks.** `on show` / `on hide` per layer, mirroring scene
`on enter`/`on exit`, for the toggle points. (Naming TBD — could fold into the
`@OnEnable`/`@OnDisable` annotations, which already exist for properties.)
---
## 6. Extension E3 — the scene *stack* (the headline)
The sketch says "exactly one scene is active." That's the right default and the
wrong constraint. The states a mode register handles *worst* are the ones that
**overlay without replacing**: a pause menu over live gameplay, a dialog box, an
inventory screen, a confirmation prompt. With one register you either lose the
underlying state or hand-roll a "previous mode" variable and restore it.
Proposal: keep "one *base* scene," but allow scenes to be **pushed as overlays**.
```ludic
# doc-check: skip
scene Overworld {
layer World { handler Move phase Update { … } handler Draw phase Render { … } }
layer Hud { handler DrawHud phase Render { … } }
on enter { … }
handler PauseKey phase Input { if pressed(KEY_ESC) { push Pause } }
}
scene Pause overlay { # `overlay` = pushed, not swapped
on enter { dim_backdrop() }
layer Menu {
handler Nav phase Update {
if pressed(KEY_ESC) { pop } # back to Overworld, untouched
}
handler Draw phase Render { ui_render() }
}
}
```
- `push Name` runs `Name`'s `on enter` and makes it the top scene **without**
running the base scene's `on exit`. `pop` runs the overlay's `on exit` and
returns to whatever was beneath.
- **Update belongs to the top of the stack; render walks the whole stack bottom
to top.** So `Pause`'s `Menu` layer draws over `Overworld`'s frozen `World` and
`Hud`. This is the default that makes pause menus "just work." An overlay that
should let the layer beneath keep updating opts in with `push Name passthrough`.
- **The stack is a small fixed-capacity array of scene ids** (say 8) in a
compiler-owned buffer — not heap, not a linked structure. `push`/`pop` are an
index bump and an `on enter`/`on exit` call. Depth overflow is a compile-time
or trap decision (§9).
- `become` still exists and still means "swap the base scene" (full `on exit` →
`on enter`, stack cleared). `push`/`pop` are the overlay verbs. Keeping the two
distinct is what lets the common single-scene path stay a single register.
This is the extension that turns `scene` from "nicer mode register" into
something with no clean equivalent in the register world.
---
## 7. Extension E4 — scene-local state
A scene almost always has state that exists only while it's active — a battle's
turn counter, a menu's cursor index. Today that's a global register that other
scenes could stomp. Proposal: **`var` / `const` declared inside a `scene` is
scoped to it**, storage shared across scenes that are never simultaneously active
(the compiler can overlap their storage since only one base scene runs at a
time — an arena-per-scene, or a union).
```ludic
# doc-check: skip
scene Battle {
var turn = 0 # visible only inside Battle; reset by `on enter` if desired
layer World { handler Step phase Update { turn += 1 } }
}
```
- Reads/writes lower to a fixed offset in the scene's state block, no register
indirection.
- Overlay scenes (E3) that *can* be live simultaneously with their base cannot
share storage — the compiler keeps their blocks distinct. Base scenes that
never coexist share.
---
## 8. Extension E5 — parameterized transitions, and the reserved annotations
**Parameters on transitions.** `become`/`push` can carry arguments that the
target's `on enter` binds — so a battle knows which foes, a dialog knows which
line:
```ludic
# doc-check: skip
scene Battle {
on enter (foe_kind: int, count: int) { for i in 0 .. count { spawn_foe(foe_kind) } }
}
# elsewhere:
become Battle(FOE_GOBLIN, 3)
```
Lowers to argument stores into the scene's state block (E4) immediately before
the `on enter` call. No variadic runtime; the arity is checked at compile time.
**The already-reserved annotation form.** [LANGUAGE.md:374](LANGUAGE.md:374)
reserves `@OnEnter` / `@OnExit` as handler annotations "waiting on scene support."
This doc adopts them as the annotation spelling of `on enter` / `on exit`,
mirroring how `@OnStart` is the annotation form of `phase Start`:
```ludic
# doc-check: skip
@OnEnter(Battle) handler Setup { … } # == Battle's `on enter`
@OnExit(Battle) handler Teardown { … }
```
Both spellings desugar to the same synthesized scene-lifecycle function; a scene
may use either, not both, for a given hook.
**`reads`/`writes` + scenes (forward-looking).** The `reads`/`writes` clauses are
parsed but unconsumed ([LANGUAGE.md:717](LANGUAGE.md:717)). Once an analysis pass
exists, a scene's layers declare which state they touch, and the scheduler can run
independent layers of the active scene in parallel within a phase — the scene
boundary gives the pass a natural scope to reason about. Noted as a destination,
not part of the first cut.
---
## 9. Lowering summary
Everything above reduces to existing runtime concepts:
| Construct | Lowers to |
|---|---|
| active base scene | one implicit register `__scene`, states numbered by decl order — literally a compiler-written `machine` |
| `become Name` | `on exit` call · `set __scene` · `on enter` call (two direct calls + store, as the sketch promises) |
| scene layers in a phase | the phase scheduler, after global handlers, dispatches on `__scene` to that scene's layer handlers in declaration order |
| `push`/`pop` (E3) | fixed-capacity scene-id array + index; render walks it, update reads its top |
| scene-owned entities (E1) | implicit kind tag at `spawn`; generated `despawn`-by-tag in synthesized `on exit`; reuses despawn hooks |
| layer toggle / pause (E2) | the same one-flag-flip as `disable Handler`, keyed per layer |
| scene-local `var` (E4) | fixed offsets in a per-scene state block; non-coexisting scenes share storage |
| transition args (E5) | arg stores into the state block before the `on enter` call |
| `@OnEnter`/`@OnExit` (E5) | the same synthesized lifecycle functions as `on enter`/`on exit` |
No heap, no dispatch tables, no new allocator. The active-scene path is a
register read and a static branch; the stack adds a small array only for programs
that push overlays.
---
## 10. Suggested implementation phases
Each is independently shippable and testable, matching how the repo phases work.
- **S0 — parse & lower the sketch.** ✅ **Done.** `scene`/`layer`/`on enter`/`on
exit`/`become` lowered to the implicit `machine`; the active scene is
snapshotted per phase so exactly one scene's layers dispatch in any phase.
[`examples/scenes.ludic`](examples/scenes.ludic) compiles, runs, and is checked
by `test.sh`. This is the floor everything else builds on.
- **S1 — `@OnEnter`/`@OnExit` annotation form** (E5, cheap once S0 exists).
- **S2 — layer toggle & pause** (E2) on top of the existing `enable`/`disable`.
✅ *Toggle shipped* (via EVENTS-DESIGN EV1 layers): `enable layer L` / `disable
layer L` flips an `@LE_<L>` flag that gates the layer's handlers (emitted only
for toggled layers, so untouched scene programs stay byte-identical), and a
`public` layer fires `layer_<L>_show`/`_hide` — see
[`examples/layer_events.ludic`](examples/layer_events.ludic). Still open: the
*pause* half (keep drawing while `Update` handlers suspend) and `on show`/`on
hide` blocks.
- **S3 — the scene stack** (E3): `push`/`pop`/`overlay`/`passthrough`. The big one.
- **S4 — scene-owned entities** (E1) and **scene-local state** (E4).
- **S5 — transition parameters** (E5).
- **S6 (later) — `reads`/`writes` scheduling** (E5), gated on the analysis pass.
S0–S1 deliver the sketch as promised; S2–S3 are where the "great potential"
actually lands; S4–S5 are ergonomics; S6 is a performance destination.
---
## 11. Open decisions
1. **Re-entering a scene:** fresh entities/state, or resume? (Default proposed:
`become` = fresh, `push`/`pop` = the pushed scene is fresh each push, the base
underneath is untouched.)
2. **Stack depth:** compile-time cap with an error on overflow, or a runtime trap?
What capacity (8? configurable)?
3. **`passthrough` granularity:** does a passthrough overlay let *all* lower
layers update, or can it name which phases fall through?
4. **Layer hook naming:** `on show`/`on hide`, or reuse `@OnEnable`/`@OnDisable`?
5. **Scene-local storage sharing:** union non-coexisting scenes automatically, or
require an explicit opt-in so the sharing is visible in source?
6. **Global handlers and overlays:** do globals run once per frame regardless of
stack depth (proposed: yes), or per active scene?
7. **`become` from inside an overlay:** does it clear the stack (proposed: yes) or
is it an error while overlays are pushed?
---
*Companion to [LANGUAGE.md §"Scenes & layers"](LANGUAGE.md) and the ordering
sketch in [`examples/scenes.ludic`](examples/scenes.ludic). Supersedes nothing
until the compiler work in §10 lands.*