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>
16 KiB
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, abin/x testcheck). 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) specs:
- Exactly one scene active; the
startscene 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 exitare lifecycle hooks (not phases).become Nameruns the old scene'son exit, switches, runs the newon 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
- 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.)
- 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.
- 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. - 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)
# 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
machinestates —Titleis0,Overworldis1. The active scene lives in one implicit register (__scene). This makessceneamachinethe compiler writes for you, which is the right mental model and the right lowering. - A layer handler may not use phase
Start.Startruns once at boot, before any scene is entered; scene setup goes inon 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.
# 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 atspawntime while a scene is active, plus a generateddespawn-by-tag in the synthesizedon 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 howdisablekeeps field data. - Composes with
@OnDespawn(Model): the destructor hook fires for each scene-owned entity as it's torn down, sodrop_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:
-
Per-layer toggle.
disable Hud/enable Hudflips one flag; the layer's handlers stop running and drawing. This isdisable Handlergeneralized to a named group — same one-flag-flip cost. -
Pause vs. tear-down. A layer can keep drawing while its update handlers are suspended:
# 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'sUpdatehandlers suspend but itsRenderhandler still paints the frozen world behind the menu. Today that requires aif !pausedguard in every update handler; with layers it's structural. -
Layer lifecycle hooks.
on show/on hideper layer, mirroring sceneon enter/on exit, for the toggle points. (Naming TBD — could fold into the@OnEnable/@OnDisableannotations, 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.
# 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 NamerunsName'son enterand makes it the top scene without running the base scene'son exit.popruns the overlay'son exitand returns to whatever was beneath.- Update belongs to the top of the stack; render walks the whole stack bottom
to top. So
Pause'sMenulayer draws overOverworld's frozenWorldandHud. This is the default that makes pause menus "just work." An overlay that should let the layer beneath keep updating opts in withpush 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/popare an index bump and anon enter/on exitcall. Depth overflow is a compile-time or trap decision (§9). becomestill exists and still means "swap the base scene" (fullon exit→on enter, stack cleared).push/popare 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).
# 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:
# 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
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:
# 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). 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/becomelowered to the implicitmachine; the active scene is snapshotted per phase so exactly one scene's layers dispatch in any phase.examples/scenes.ludiccompiles, runs, and is checked bybin/x test. This is the floor everything else builds on. - S1 —
@OnEnter/@OnExitannotation 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 Lflips an@LE_<L>flag that gates the layer's handlers (emitted only for toggled layers, so untouched scene programs stay byte-identical), and apubliclayer fireslayer_<L>_show/_hide— seeexamples/layer_events.ludic. Still open: the pause half (keep drawing whileUpdatehandlers suspend) andon show/on hideblocks. - 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/writesscheduling (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
- 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.) - Stack depth: compile-time cap with an error on overflow, or a runtime trap? What capacity (8? configurable)?
passthroughgranularity: does a passthrough overlay let all lower layers update, or can it name which phases fall through?- Layer hook naming:
on show/on hide, or reuse@OnEnable/@OnDisable? - Scene-local storage sharing: union non-coexisting scenes automatically, or require an explicit opt-in so the sharing is visible in source?
- Global handlers and overlays: do globals run once per frame regardless of stack depth (proposed: yes), or per active scene?
becomefrom 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" and the ordering
sketch in examples/scenes.ludic. Supersedes nothing
until the compiler work in §10 lands.