plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


b883e37 — Peter John 2026-11-05T22:39:59+05:30
Docs: a short scene style guide, move internal notes out of the way
docs/backlog.md CHANGED
@@ -35,7 +35,7 @@ Open work, in the order the user queued it, plus smaller items found during buil
35
35
  - **One way to set up a struct.** Drop `fun init(self)` in favour of field defaults + ordinary constructor functions. `init` is what forces the Boxed layout and the EmbedInit machinery in `ctors.rs`. Main user: `std/game/imperative/Scene.plum`.
36
36
  - **From Pony:** (1) a two-level ownership model (unique + mutable vs shared + immutable, like Pony's `iso`/`val` with `consume`) as the visible face of FBIP, replacing `mut` fields — take into the FBIP plan; (2) no ambient authority: no globals, a function without `Io` provably does no IO — what deterministic sim needs; (3) Pony partial functions (`?`, no error value) argue for `Option` + `?` as the common path and `Result` only when the error value matters. Not copied: union types, structural interfaces, six capabilities.
37
37
 
38
- - **Self-hosting groundwork** (user 2026-11-04: plan now, build later) — `docs/plans/2026-11-04-self-hosting-groundwork.review.md`, design in `docs/self-hosting.md`. All host I/O onto `Io` (`Os.plum` deleted, `io.now()`, `io.httpGet`), then a Plum lexer, a winnow-style combinator library with chumsky-style errors, an AST and a parser in `compiler/` that parses every `.plum` file. AST comparison with the Rust parser and new host calls (args, exit, env, stderr, binary files) wait for cleanup phase 2a. The `Io` move should land before the `Io`-primitive plan above.
38
+ - **Self-hosting groundwork** (user 2026-11-04: plan now, build later) — `docs/plans/2026-11-04-self-hosting-groundwork.review.md`, design in `docs/plans/self-hosting.md`. All host I/O onto `Io` (`Os.plum` deleted, `io.now()`, `io.httpGet`), then a Plum lexer, a winnow-style combinator library with chumsky-style errors, an AST and a parser in `compiler/` that parses every `.plum` file. AST comparison with the Rust parser and new host calls (args, exit, env, stderr, binary files) wait for cleanup phase 2a. The `Io` move should land before the `Io`-primitive plan above.
39
39
 
40
40
  ## FBIP worklist: `mut` fields left after language-restrictions
41
41
 
@@ -74,4 +74,4 @@ Open work, in the order the user queued it, plus smaller items found during buil
74
74
  - **Retire PureScene?** — after the component-style functional game, decide whether `std/game/pure` + `games/pure` stay. (Decision record §4b)
75
75
  - **`adder(1)(2)`** (calling a returned closure directly) is not supported. (functional-gaps plan)
76
76
  - **Fusion past a closure-spec copy on `Nil` miscompiles** — in `drive.rs` `quiet`, resolving `Nil.fold$c0(..)` to `List.fold$c0` (read-only) lets a value move past it, and codegen then fails with `unknown method 'List.fold$c0'` (5 `closure_spec_test` cases). `typeNameMethodKey` skips `$` copies until this is fixed.
77
- - **Input model** — steps 1-3 built 2026-11-04 (typed `Msg` events for the functional style only); see [game-scene-styles.md](game-scene-styles.md) section 6.
77
+ - **Input model** — steps 1-3 built 2026-11-04 (typed `Msg` events for the functional style only); see [game-scene-styles.md](decisions/2026-11-game-scene-styles-analysis.md) section 6.
docs/decisions/2026-11-game-scene-styles-analysis.md ADDED
@@ -0,0 +1,136 @@
1
+ # Game scene styles
2
+
3
+ > Internal design notes, kept for history. Some of this describes features that have since changed (`trait`, `own`). For the current guide see [Choosing a scene style](../game-scene-styles.md).
4
+
5
+ Plum ships three ways to structure a game's scene. This page compares them, maps them to engines people know, says how far each is from deterministic simulation, and proposes a better input model (section 6, not built yet).
6
+
7
+ Decisions behind the styles are in [decisions/2026-11-language-and-scene-decisions.md](2026-11-language-and-scene-decisions.md).
8
+
9
+ ## 1. The three styles
10
+
11
+ | | Imperative | Pure | Functional |
12
+ |---|---|---|---|
13
+ | Std modules | `std/game/imperative/Scene` | `std/game/pure/PureScene`, `SceneInput`, `Actions` | `std/game/functional/FunctionalScene`, `Ui`, `Anim` |
14
+ | Example | `games/imperative/game_scene.plum` | `games/pure/game_pure_scene.plum` | `games/functional/game_state_view.plum` |
15
+ | Scene tree | Kept across frames, changed in place | Kept across frames, each update returns the next tree (in place when the compiler proves it unique) | Rebuilt from `State` every frame, then thrown away |
16
+ | Where game data lives | In actors (`record Walker(Actor, mut speed: Float)`) | In nodes (`Node` fields, `actions`, `Custom(actor)`) | In your own `State` and per-kind records (`Ship`, `Orb`); the tree holds none |
17
+ | Per-frame entry | `stage.act(dt)`, `stage.draw(io)` | `stage.frame(dt, inputs)`, `stage.draw(io)` | `state.update(msg)`, `state.render().draw(io)` |
18
+ | Behaviour | Override `act`/`draw`/`hit` on the `Actor` trait | `Actor` trait (`act`, `draw`) in a `Custom` node, plus timed `Action`s | Methods on your records; nodes have none |
19
+ | Finding a node | Object references (`same(a, b)`) | By id or path: `root.findPath(id)`, `root.modify(id, f)` | Not needed: change `State`, the next `render()` shows it |
20
+ | Animation | Code in `act` | `Action.forever(Action.rotateBy(..))`, stepped each frame | `Anim.at(anim, t)`, a function of time; nothing is stepped |
21
+ | Children | `Slice` of actors | `Slice[Node]` | `List[SceneNode]` |
22
+
23
+ ### Main API (after the 2026-11-04 method refactor)
24
+
25
+ - **Pure:** `Node.group/sprite/label/button/custom(..)` build nodes. `node.update(dt)`, `node.drawTree(io, m, tint)`, `node.findHit(m, p)`, `node.findPath(id)`, `node.modify(id, f)`, `node.route(path, depth, e)` and `node.onPointer(e)` work on them. `Stage.create(root)`, `stage.frame(dt, inputs)`, `stage.draw(io)` and `Input.poll(io, wasDown)` run a frame. `Action.moveBy/rotateBy/delay/sequence/parallel/forever(..)` and `action.step(pose, dt)` drive animations.
26
+ - **Functional:** `SceneNode[M]` is an enum over the game's message type `M` (`Group`, `Filter`, `Sprite`, `Text`, `RectShape`, `CircleShape`, `LineShape`, `Clickable`). `node.draw(io)`, `node.bounds()`, `node.hitPath(x, y)` and `node.hitId(x, y)` use it. `Transform.matrix()`, `Effect.tint(alpha)` and `Rect` methods are the helpers. `Ui.step(ui, input, hitId)` and `Ui.button(..)` handle UI.
27
+
28
+ ## 2. Engine analogies
29
+
30
+ | Plum style | Closest engine model | Notes |
31
+ |---|---|---|
32
+ | Imperative | **Flame** (Flutter's game engine), libGDX **Scene2D** | `Stage` ~ `FlameGame`, `Actor` ~ `PositionComponent`, `act(dt)` ~ `update(dt)`, `draw` ~ `render(canvas)`, `add`/`remove`/`zIndex` ~ `add`/`remove`/`priority`, `hit` ~ `containsLocalPoint`. The names come from Scene2D; the model is the same as Flame's. |
33
+ | Pure | None directly | An immutable Flame/Scene2D: the same component tree, updated by returning copies. Closest to Redux applied to a scene graph. Flutter never has you update its retained tree; the framework owns it. |
34
+ | Functional | **Elm**, and **Flutter's widget layer** | `render()` ~ `build()`, `SceneNode` ~ immutable widgets. Flutter also keeps an Element tree (diffed, holds local `State`) and a RenderObject tree (layout, paint); Plum has neither. Nothing is retained, so nothing is diffed: every frame is redrawn, and the compiler fuses `render` into `draw` so the tree is never built. There is no local widget state (everything is in `State`), no keys and no constraint layout (positions are absolute). |
35
+
36
+ ## 3. Pure vs functional
37
+
38
+ Both avoid mutation in game logic. The difference is **where the state lives**:
39
+
40
+ - In **pure**, the tree *is* the game state. Changing the game means finding a node (`findPath`, `modify`) and returning a new tree. Nodes carry their own behaviour (`Actor`) and animations (`actions`), and pointer input changes nodes (a button's `pressed`/`over`).
41
+ - In **functional**, the tree is a *picture of* the game state. Changing the game means changing `State`; the next `render()` reflects it. Hover and press state lives in `UiState` inside `State`, and a click on a `Clickable(id, msg, ..)` becomes the game message `msg`.
42
+
43
+ Cost: pure updates run in place when the compiler proves uniqueness. Functional describes the whole frame each time, but fusion removes the tree (see `fusion_alloc_test.rs`, `state_view_draw_has_no_scenenode_alloc`).
44
+
45
+ ## 4. Determinism
46
+
47
+ **Goal:** the same seed and the same inputs produce the same game, bit for bit, on every machine. This enables replays, lockstep multiplayer, rollback and desync checks (planned: [backlog](backlog.md), "deterministic sim").
48
+
49
+ ### Already in place (all three styles)
50
+
51
+ - **Seeded randomness.** Every game uses a `Random` value threaded through its state (`Random.seeded(seed)`), never a host call per draw.
52
+ - **Math in Plum.** `Math.sin/cos/sqrt/hypot` are written in Plum (`std/Math.plum`), not imported from the browser, so they don't vary between JS engines.
53
+ - **wasm floats.** wasm `f64` arithmetic is IEEE 754 and gives the same results everywhere (only NaN bit patterns can differ).
54
+ - **Deterministic maps.** `Map` uses a fixed FNV-1a hash, so iteration order depends only on the insert sequence.
55
+
56
+ ### Still missing (all three styles)
57
+
58
+ 1. **Fixed timestep.** `plum_bridge.js` passes the real frame time (`dt = (now - last) / 1000`) to `update`. The simulation must instead advance in fixed steps (for example 1/60 s), with the shell accumulating real time and running 0..n steps per frame.
59
+ 2. **Recorded seed.** `init` calls `io.randomSeed()`; a replay must record and supply that seed.
60
+ 3. **Recorded input.** Done for input: `Input.poll` is the only input read, and the games feed every polled `Input` into their state (section 5). Recording is a matter of saving each step's `Polled.inputs`.
61
+ 4. **Host values in state.** The functional game stores the screen size (`sw`, `sh`) and gets `Resize` messages; these must be in the input log too, or kept out of the simulation.
62
+ 5. **Checksums.** A per-step hash of the simulation state, to detect desyncs.
63
+
64
+ ### How hard each style is
65
+
66
+ 1. **Functional: easiest.** All game state is one immutable `State`, and everything that happens arrives as a `Msg` (`Tick`, `Resize`, `Restart`, `UiEv`). A replay is the seed plus the `Msg` list, applied with `State.update` in order. Rollback is keeping an old `State` (nothing changes it). A checksum hashes `State`. Only the `World` shell, which turns keyboard and mouse into messages, touches `io`.
67
+ 2. **Pure: moderate.** Updates are pure too, but state is split between the `Stage` tree and the `World` fields, and the tree is updated in place, so a snapshot needs an explicit deep copy. Inputs already arrive as a list per frame (`Input.poll`), which is easy to record.
68
+ 3. **Imperative: hardest.** State is a mutable object graph: snapshots and checksums need a deep walk, and any code may read `io`. Replay still works if every step's inputs are recorded and nothing else reads the host.
69
+
70
+ **Recommendation:** build deterministic simulation on the functional style first. Its `Msg` stream is already the replay log.
71
+
72
+ ## 5. Input
73
+
74
+ Steps 1 and 2 of the proposal below are built (2026-11-04).
75
+
76
+ - **One `Input` type** (`std/game/Input`): `PointerDown(x, y) | PointerUp(x, y) | PointerMove(x, y) | KeyDown(key) | KeyUp(key)`. `Input.poll(io, held) -> Polled(inputs, held)` is the only code that reads input from `io`. It compares the host's mouse button and every `Key` code with what was held after the last poll. `Held` (`isDown(key)`, `after(input)`) tracks held keys from the inputs, so games never call `io.isKeyDown`. `input.position()`, `input.through(m)` and `input.isPointer()` are helpers.
77
+ - **One `UiKind`/`UiEvent`**, also in `std/game/Input`, used by both `PureScene` and `Ui`.
78
+
79
+ | | Imperative | Pure | Functional |
80
+ |---|---|---|---|
81
+ | Pointer | `stage.input(e)`: the topmost actor under the pointer, then each parent, until one's `onInput` returns true | `stage.input(e)` / `stage.frame(dt, inputs)`: hit path, then `node.route` child first; `Button` reacts, `Custom` asks `actor.onInput` | `state.update(Event(input))` -> `render().hitId(x, y)` -> `Ui.step` -> `UiEvent`s |
82
+ | Keyboard | A press gives the actor the focus; keys go to it, then its parents | A press gives the node the focus; keys go to it (then its ancestors); keys no node consumes come back in `StageHandled.keys` | `Event(KeyDown/KeyUp)` updates `State.held`; the game reads `held.isDown(..)` |
83
+ | Capture (press, drag off, release) | none | yes | yes |
84
+ | Events | `onInput -> Bool` (consumed) | `UiEvent(id, kind)`, `Handled.consumed` | `UiEvent(id, kind)` |
85
+
86
+ The example games and fixtures poll once per frame and feed every input in. Nothing else reads input from the host.
87
+
88
+ In the functional style, clicks arrive as the game's own messages (step 3). The pure and imperative styles still identify UI elements by integer id.
89
+
90
+ ## 6. Proposal: one input model with `onInput`
91
+
92
+ The sketch `scene_graph.pfl` (repo root, not tracked) designs input for a scene2d-style pure scene:
93
+
94
+ ```
95
+ trait Actor<Msg> {
96
+ fn on_input(self: own Self, input: Input) -> (own Self, List<Msg>) = (self, [])
97
+ fn route(self: own Self, path: Path, input: Input) -> (own Self, List<Msg>)
98
+ }
99
+ class Button<Msg> { .., pressed: Bool, on_click: Msg }
100
+ enum Input { PointerDown(Vec2), PointerUp(Vec2), PointerMove(Vec2), Key(Int) }
101
+ ```
102
+
103
+ Its ideas:
104
+ - one `Input` type that includes keys;
105
+ - an overridable `on_input` on every actor;
106
+ - bubbling along the hit path, child first and then each parent, with messages flowing back up;
107
+ - **typed messages**: a button carries the game's own `Msg` value.
108
+
109
+ ### Step 1: one shared `Input` type, keys included (built)
110
+
111
+ `std/game/Input.plum`: see section 5. Gain: one recorded input list per step (determinism item 3), one vocabulary across styles.
112
+
113
+ ### Step 2: `onInput` for game actors (built)
114
+
115
+ - **Pure:** `trait Actor` has `fun onInput(self, n: Node, e: Input, over: Bool) -> Handled` (default: unchanged, nothing raised, not consumed). `Node.onInput` handles `Button` itself and asks a `Custom` node's actor. `Handled` has `consumed: Bool`; `route` skips the ancestors of a node that consumed the input. Only groups have children, so for pointers this matters once groups react; for keys it decides what the game receives.
116
+ - **Imperative:** `trait Actor` has `fun onInput(self, e: Input) -> Bool` (e in the actor's coordinates; true = consumed). `stage.input(e) -> Bool` bubbles from the hit actor through `parent`. A press sets `stage.focus`; keys bubble from the focused actor.
117
+ - **Functional:** no methods on nodes (they are data). Inputs reach `State.update` as `Event(input)` messages.
118
+
119
+ ### Step 3: typed messages (built for the functional style)
120
+
121
+ - `SceneNode[M]` is generic over the game's message type. `Clickable(id, msg: M, child)` replaces `Tagged(id, child)`. `node.msgOf(id)` reads a clickable's message.
122
+ - `Ui.button(id, msg, label, ..)` builds one. `Ui.messages(view, events)` turns the Clicks that `Ui.step` raised into the game's own messages, read from the view the pointer was on.
123
+ - The game declares `RestartClicked` in its `Msg` and handles it in `update` like any other message. The `UiEv` wrapper and the `e.id == Id.Restart && e.kind == Click` check are gone. The id stays only to identify the button for hover and press state (`Ui` needs identity, and `==` on records is identity).
124
+ - Cost: every scene signature says `SceneNode[Msg]`, and a local built from a bare constructor needs an annotation (`x: SceneNode[Msg] := Group(..)`); passing it straight to its use (`Filter(child: Group(..))`) avoids that, and also keeps fusion. Helpers that do not mention `M` (`hitLeaf`, `hitPrepend`, `nearLine`) are top-level functions, because a static method of a generic type must mention its parameter. Fusion of `render().draw(io)` still holds (`fusion_alloc_test`); allocations in the exempt panel/animation functions went from 15 to 12.
125
+ - Found on the way: TRMC hoisted a list cell's head into an untyped temporary, so `Cons(head: Full(..), ..)` in a function returning `List[Box[Int]]` gave the temporary a `Box` with its own parameter left open, and produced invalid wasm. Fixed in `trmc.rs` (the temporary takes the field's concrete type); regression test `trmc_list_of_generic_enum` in `examples/recursion.plum`.
126
+ - Pure and imperative styles keep integer ids (generic traits, `Actor[Msg]`, are not supported).
127
+
128
+ ### Next
129
+
130
+ 1. Decide whether PureScene stays (backlog: "Retire PureScene?").
131
+
132
+ ### Open questions
133
+
134
+ - Should pointer events reach a node's parents after a child consumes them (Flame: no, the DOM: yes unless stopped)?
135
+ - Should `onInput` see the event in local coordinates (as `onPointer` does today) or in world coordinates?
136
+ - Do held-key sets belong in `UiState` (shared) or in each game's state?
docs/{imperative-vs-functional.md → decisions/2026-11-imperative-vs-functional.md} RENAMED
@@ -1,8 +1,10 @@
1
1
  # Imperative vs functional games in Plum
2
2
 
3
+ > Internal design notes, kept for history. Some of this describes features that have since changed (`trait`, `own`). For the current guide see [Choosing a scene style](../game-scene-styles.md).
4
+
3
5
  Plum ships three ways to build a game's scene: **imperative** (a mutable scene graph, Flame/Scene2D style), **functional** (an immutable `State`, a `render()` that builds a picture of it each frame, Elm style) and **pure** (a retained scene tree that every update returns anew). This page compares them one aspect at a time and collects what has been measured or discussed. It is a comparison, not a decision: the open choices are in the last two sections.
4
6
 
5
- Related pages: [game-scene-styles.md](game-scene-styles.md) (API tables, engine analogies, input model), [decisions/2026-11-language-and-scene-decisions.md](decisions/2026-11-language-and-scene-decisions.md) (why the styles exist), [backlog.md](backlog.md) (deterministic games, FBIP, "One way to build a game").
7
+ Related pages: [game-scene-styles analysis](2026-11-game-scene-styles-analysis.md) (API tables, engine analogies, input model), [decisions/2026-11-language-and-scene-decisions.md](2026-11-language-and-scene-decisions.md) (why the styles exist), [backlog.md](../backlog.md) (deterministic games, FBIP, "One way to build a game").
6
8
 
7
9
  Conventions: "measured" means a number from a file cited next to it; "proposal" and "undecided" mark things nobody has built or chosen. Timings are ms per frame on an Apple M3 Pro, headed Chromium (WebGPU), `--uncapped --measure 10`, unless a row says otherwise. Differences under about 5% are noise.
8
10
 
@@ -45,7 +47,7 @@ Functional cost, stated in the backlog: deep nested updates, and events returned
45
47
  - **Functional.** `state.render().draw(io)`. `SceneNode.draw` is structural recursion that passes the parent matrix and alpha down; nothing is retained, so every frame is redrawn. The supercompiler fuses `render` into `draw`, so the tree is never built (test `state_view_draw_has_no_scenenode_alloc` in `compiler/wasm-codegen/tests/wasm_codegen/fusion_alloc_test.rs`). The one exception is the game-over overlay, whose `Filter` reads its child twice.
46
48
  - **Pure.** `node.drawTree(io, m, tint)` walks the retained tree. Custom actors draw through `Actor.draw`.
47
49
 
48
- Notes from the discussion ([decisions §5](decisions/2026-11-language-and-scene-decisions.md)): the functional picture already follows the textbook design (recursive ADT, matrix passed down, top-down hit paths, assets by handle). Structural sharing pays off only for parts that do not change (UI, terrain); where everything moves, rebuilding costs about the same and is simpler. Folding the tree into a flat command list is redundant because the host already batches draws. Zippers are not needed, since code edits `State` and rebuilds. Static subtree reuse is deferred (backlog).
50
+ Notes from the discussion ([decisions §5](2026-11-language-and-scene-decisions.md)): the functional picture already follows the textbook design (recursive ADT, matrix passed down, top-down hit paths, assets by handle). Structural sharing pays off only for parts that do not change (UI, terrain); where everything moves, rebuilding costs about the same and is simpler. Folding the tree into a flat command list is redundant because the host already batches draws. Zippers are not needed, since code edits `State` and rebuilds. Static subtree reuse is deferred (backlog).
49
51
 
50
52
  ## Input handling
51
53
 
@@ -62,7 +64,7 @@ Typed messages exist only in the functional style: generic traits (`Actor[Msg]`)
62
64
 
63
65
  ## Sprite animation
64
66
 
65
- Source: the 2026-11-05 notes in [backlog.md](backlog.md) ("One way to build a game"); `std/game/functional/Anim.plum`.
67
+ Source: the 2026-11-05 notes in [backlog.md](../backlog.md) ("One way to build a game"); `std/game/functional/Anim.plum`.
66
68
 
67
69
  - **Imperative.** The sprite mutates `frame` and `timer` in `act`, so it must be advanced exactly once per tick per sprite. It drifts if a tick is skipped or repeated, and a rewind has to restore the mutated fields.
68
70
  - **Functional.** Playback is a function of time. A sprite stores only `clip` and `startedAt` and computes the frame in `draw`: `(tick - startedAt) / ticksPerFrame % frames`. Nothing is mutated per tick, there is no drift, and replay or rewind is free. `Anim` is plain data (`MoveBy | RotateBy | ScaleTo | Delay | Seq | Par | Forever`) and `Anim.at(anim, t)` returns the pose `t` seconds in; `Forever` repeats using `t - floor(t/lap)*lap`.
@@ -92,7 +94,7 @@ The examples play sounds by diffing state around an update. Returning effects as
92
94
 
93
95
  ## Determinism, replay and rollback
94
96
 
95
- Determinism comes from what may enter `update`, not from the style. Rules for all styles (design notes in [backlog.md](backlog.md), item "Deterministic games"; these are proposals, only some are in place):
97
+ Determinism comes from what may enter `update`, not from the style. Rules for all styles (design notes in [backlog.md](../backlog.md), item "Deterministic games"; these are proposals, only some are in place):
96
98
 
97
99
  1. **Fixed timestep.** The host accumulates wall time and calls `update` with a constant `dt`; `draw` may interpolate. Today `plum_bridge.js` passes the real frame time, so this is not in place.
98
100
  2. **Input as a per-tick `InputFrame` value.** Passed in, never polled inside `update`; a replay is the list of frames. Today every example calls `Input.poll(io, ..)` inside the shell's `update(io, w, dt)`. Recording would save each step's `Polled.inputs`.
@@ -102,7 +104,7 @@ Determinism comes from what may enter `update`, not from the style. Rules for al
102
104
  6. **Floats.** wasm `f64` is IEEE 754 and gives the same results in every engine; do not hash NaN bits. `Math.sin/cos/sqrt/hypot` are written in Plum (`std/Math.plum`), not imported from the browser.
103
105
  7. **A per-tick state checksum.** Hash the state each tick and compare runs. Not built.
104
106
 
105
- Other gaps from [game-scene-styles.md](game-scene-styles.md) section 4: host values in state (the functional game keeps `sw`, `sh` and gets `Resize` messages, so they must be in the input log or out of the simulation).
107
+ Other gaps from [game-scene-styles analysis](2026-11-game-scene-styles-analysis.md) section 4: host values in state (the functional game keeps `sw`, `sh` and gets `Resize` messages, so they must be in the input log or out of the simulation).
106
108
 
107
109
  How each style stands under those rules:
108
110
 
@@ -235,5 +237,5 @@ Trade-off summary: embedding `Game` suits the imperative style (`update` returns
235
237
  - **Events as data.** Return an event list from `update` (functional) instead of diffing state in the shell to play sounds. Not built.
236
238
  - **Lens sugar** for deep functional updates, and spread from a field path (`Stage(..w.stage, x: 1)`). Proposals only.
237
239
  - **Static subtree reuse** in functional scenes, deferred by the user as a later optimisation.
238
- - **Input open questions** ([game-scene-styles.md](game-scene-styles.md)): whether a consumed pointer event still reaches parents, local vs world coordinates in `onInput`, and whether held keys live in `UiState` or each game's state.
240
+ - **Input open questions** ([game-scene-styles analysis](2026-11-game-scene-styles-analysis.md)): whether a consumed pointer event still reaches parents, local vs world coordinates in `onInput`, and whether held keys live in `UiState` or each game's state.
239
241
  - **Kids' games** (last in the queue) are functional-style; they would be the first games written after the choice.
docs/decisions/2026-11-language-and-scene-decisions.md CHANGED
@@ -15,7 +15,7 @@ A record of the design decisions taken in the 2026-11-03/04 sessions, with the r
15
15
  - `plum-std/game/imperative/`: the mutable `Scene` (moved 2026-11-04, user).
16
16
  - `plum-std/game/pure/`: PureScene, SceneTree, SceneInput, Actions (pure updates, in place via `own` today).
17
17
  - `plum-std/game/functional/`: Picture, Anim, Ui (state/view).
18
- - **Update 2026-11-04 (user):** `Picture` was renamed `FunctionalScene` and `SceneTree` was merged into `PureScene`; their free functions became methods on `SceneNode`/`Node`/`Action` etc. See [game-scene-styles.md](../game-scene-styles.md).
18
+ - **Update 2026-11-04 (user):** `Picture` was renamed `FunctionalScene` and `SceneTree` was merged into `PureScene`; their free functions became methods on `SceneNode`/`Node`/`Action` etc. See [game-scene-styles.md](2026-11-game-scene-styles-analysis.md).
19
19
  - `plum-examples/games/imperative/`, `games/pure/` and `games/functional/` for their example games (the mutable games moved too, 2026-11-04). PureScene is imperative under an ownership discipline (`act(own n)` edits a retained tree in place); only the functional component style is functional. `plum game` finds assets in the nearest parent `assets/` folder.
20
20
 
21
21
  - **Examples (user, 2026-11-04):** examples consolidated from 34 files to about 20 topical ones (every test kept), the mutable `game_loop` moved to `games/imperative/`, and the shared game assets moved to `plum-examples/games/assets/`.
docs/game-scene-styles.md CHANGED
@@ -1,134 +1,142 @@
1
- # Game scene styles
1
+ # Choosing a scene style
2
2
 
3
- Plum ships three ways to structure a game's scene. This page compares them, maps them to engines people know, says how far each is from deterministic simulation, and proposes a better input model (section 6, not built yet).
3
+ Plum ships three scene libraries. Each is a different answer to one question: where does your game's data live, and who changes it?
4
4
 
5
- Decisions behind the styles are in [decisions/2026-11-language-and-scene-decisions.md](decisions/2026-11-language-and-scene-decisions.md).
6
-
7
- ## 1. The three styles
8
-
9
- | | Imperative | Pure | Functional |
5
+ | Style | Where data lives | How it changes | Good for |
10
6
  |---|---|---|---|
11
- | Std modules | `std/game/imperative/Scene` | `std/game/pure/PureScene`, `SceneInput`, `Actions` | `std/game/functional/FunctionalScene`, `Ui`, `Anim` |
12
- | Example | `games/imperative/game_scene.plum` | `games/pure/game_pure_scene.plum` | `games/functional/game_state_view.plum` |
13
- | Scene tree | Kept across frames, changed in place | Kept across frames, each update returns the next tree (in place when the compiler proves it unique) | Rebuilt from `State` every frame, then thrown away |
14
- | Where game data lives | In actors (`record Walker(Actor, mut speed: Float)`) | In nodes (`Node` fields, `actions`, `Custom(actor)`) | In your own `State` and per-kind records (`Ship`, `Orb`); the tree holds none |
15
- | Per-frame entry | `stage.act(dt)`, `stage.draw(io)` | `stage.frame(dt, inputs)`, `stage.draw(io)` | `state.update(msg)`, `state.render().draw(io)` |
16
- | Behaviour | Override `act`/`draw`/`hit` on the `Actor` trait | `Actor` trait (`act`, `draw`) in a `Custom` node, plus timed `Action`s | Methods on your records; nodes have none |
17
- | Finding a node | Object references (`same(a, b)`) | By id or path: `root.findPath(id)`, `root.modify(id, f)` | Not needed: change `State`, the next `render()` shows it |
18
- | Animation | Code in `act` | `Action.forever(Action.rotateBy(..))`, stepped each frame | `Anim.at(anim, t)`, a function of time; nothing is stepped |
19
- | Children | `Slice` of actors | `Slice[Node]` | `List[SceneNode]` |
20
-
21
- ### Main API (after the 2026-11-04 method refactor)
22
-
23
- - **Pure:** `Node.group/sprite/label/button/custom(..)` build nodes. `node.update(dt)`, `node.drawTree(io, m, tint)`, `node.findHit(m, p)`, `node.findPath(id)`, `node.modify(id, f)`, `node.route(path, depth, e)` and `node.onPointer(e)` work on them. `Stage.create(root)`, `stage.frame(dt, inputs)`, `stage.draw(io)` and `Input.poll(io, wasDown)` run a frame. `Action.moveBy/rotateBy/delay/sequence/parallel/forever(..)` and `action.step(pose, dt)` drive animations.
24
- - **Functional:** `SceneNode[M]` is an enum over the game's message type `M` (`Group`, `Filter`, `Sprite`, `Text`, `RectShape`, `CircleShape`, `LineShape`, `Clickable`). `node.draw(io)`, `node.bounds()`, `node.hitPath(x, y)` and `node.hitId(x, y)` use it. `Transform.matrix()`, `Effect.tint(alpha)` and `Rect` methods are the helpers. `Ui.step(ui, input, hitId)` and `Ui.button(..)` handle UI.
25
-
26
- ## 2. Engine analogies
27
-
28
- | Plum style | Closest engine model | Notes |
29
- |---|---|---|
30
- | Imperative | **Flame** (Flutter's game engine), libGDX **Scene2D** | `Stage` ~ `FlameGame`, `Actor` ~ `PositionComponent`, `act(dt)` ~ `update(dt)`, `draw` ~ `render(canvas)`, `add`/`remove`/`zIndex` ~ `add`/`remove`/`priority`, `hit` ~ `containsLocalPoint`. The names come from Scene2D; the model is the same as Flame's. |
31
- | Pure | None directly | An immutable Flame/Scene2D: the same component tree, updated by returning copies. Closest to Redux applied to a scene graph. Flutter never has you update its retained tree; the framework owns it. |
32
- | Functional | **Elm**, and **Flutter's widget layer** | `render()` ~ `build()`, `SceneNode` ~ immutable widgets. Flutter also keeps an Element tree (diffed, holds local `State`) and a RenderObject tree (layout, paint); Plum has neither. Nothing is retained, so nothing is diffed: every frame is redrawn, and the compiler fuses `render` into `draw` so the tree is never built. There is no local widget state (everything is in `State`), no keys and no constraint layout (positions are absolute). |
33
-
34
- ## 3. Pure vs functional
35
-
36
- Both avoid mutation in game logic. The difference is **where the state lives**:
37
-
38
- - In **pure**, the tree *is* the game state. Changing the game means finding a node (`findPath`, `modify`) and returning a new tree. Nodes carry their own behaviour (`Actor`) and animations (`actions`), and pointer input changes nodes (a button's `pressed`/`over`).
39
- - In **functional**, the tree is a *picture of* the game state. Changing the game means changing `State`; the next `render()` reflects it. Hover and press state lives in `UiState` inside `State`, and a click on a `Clickable(id, msg, ..)` becomes the game message `msg`.
40
-
41
- Cost: pure updates run in place when the compiler proves uniqueness. Functional describes the whole frame each time, but fusion removes the tree (see `fusion_alloc_test.rs`, `state_view_draw_has_no_scenenode_alloc`).
42
-
43
- ## 4. Determinism
44
-
45
- **Goal:** the same seed and the same inputs produce the same game, bit for bit, on every machine. This enables replays, lockstep multiplayer, rollback and desync checks (planned: [backlog](backlog.md), "deterministic sim").
46
-
47
- ### Already in place (all three styles)
48
-
49
- - **Seeded randomness.** Every game uses a `Random` value threaded through its state (`Random.seeded(seed)`), never a host call per draw.
50
- - **Math in Plum.** `Math.sin/cos/sqrt/hypot` are written in Plum (`std/Math.plum`), not imported from the browser, so they don't vary between JS engines.
51
- - **wasm floats.** wasm `f64` arithmetic is IEEE 754 and gives the same results everywhere (only NaN bit patterns can differ).
52
- - **Deterministic maps.** `Map` uses a fixed FNV-1a hash, so iteration order depends only on the insert sequence.
53
-
54
- ### Still missing (all three styles)
7
+ | [Imperative](#imperative) | In objects in a tree | You change them in place | UI, and objects with their own behaviour |
8
+ | [Pure](#pure) | In a tree of nodes | Each update returns the next tree | Timed animations, and rules you can test without a browser |
9
+ | [State and view](#state-and-view) | In plain data, with no tree kept | `update` returns new data, and `view` draws it | Games that are simple to reason about |
10
+
11
+ Not sure? Start with **state and view** for a small game, or **imperative** if you're used to engines with objects and scene graphs. For a very small arcade game you may not need one at all: see [drawing directly](game.md#a-minimal-game).
12
+
13
+ ## Imperative
14
+
15
+ A tree of actors, each with its own `act` and `draw`. Moving a parent moves everything inside it. Library: `std/game/imperative/Scene`.
16
+
17
+ ```plum
18
+ import std/Int
19
+ import std/Float
20
+ import std/Str
21
+ import std/Slice
22
+ import std/Option
23
+ import std/Io
24
+ import std/game/Game
25
+ import std/game/Vec2
26
+ import std/game/Color
27
+ import std/game/Affine
28
+ import std/game/Input
29
+ import std/game/imperative/Scene
30
+
31
+ struct Ball(Actor(x, y), speed: Float) =
32
+ fun init(self) =
33
+ self.width = 20.0
34
+ self.height = 20.0
35
+ self.color = Color.Red
36
+
37
+ override fun act(self, dt: Float) =
38
+ self.x = self.x + self.speed * dt
39
+
40
+ override fun draw(self, io: Io, m: Affine, alpha: Float) =
41
+ drawRectBox(io, m, alpha, self.width, self.height, self.color)
42
+
43
+ struct World(stage: Stage)
44
+
45
+ export fun init(io: Io) -> World =
46
+ stage := Stage.create()
47
+ stage.root.add(Ball(x: 0.0, y: 40.0, speed: 30.0))
48
+ World(stage: stage)
49
+
50
+ export fun update(io: Io, w: World, dt: Float) =
51
+ w.stage.act(dt)
52
+
53
+ export fun draw(io: Io, w: World) =
54
+ io.clear(Color.Black)
55
+ w.stage.draw(io)
56
+ ```
55
57
 
56
- 1. **Fixed timestep.** `plum_bridge.js` passes the real frame time (`dt = (now - last) / 1000`) to `update`. The simulation must instead advance in fixed steps (for example 1/60 s), with the shell accumulating real time and running 0..n steps per frame.
57
- 2. **Recorded seed.** `init` calls `io.randomSeed()`; a replay must record and supply that seed.
58
- 3. **Recorded input.** Done for input: `Input.poll` is the only input read, and the games feed every polled `Input` into their state (section 5). Recording is a matter of saving each step's `Polled.inputs`.
59
- 4. **Host values in state.** The functional game stores the screen size (`sw`, `sh`) and gets `Resize` messages; these must be in the input log too, or kept out of the simulation.
60
- 5. **Checksums.** A per-step hash of the simulation state, to detect desyncs.
58
+ Useful extras: `toFront` and `toBack` for draw order, `sortChildren` to sort by depth each frame, and `setCullingArea` to skip what's off screen. Full example: [`game_scene.plum`](../examples/games/imperative/game_scene.plum).
59
+
60
+ ## Pure
61
+
62
+ Also a tree, but an update never changes a node it was given: it returns the next one. The compiler reuses memory where it can, so this doesn't mean copying everything. Timed animations are plain values called actions. Library: `std/game/pure`.
63
+
64
+ ```plum
65
+ import std/Int
66
+ import std/Float
67
+ import std/Str
68
+ import std/List
69
+ import std/Slice
70
+ import std/Option
71
+ import std/Io
72
+ import std/game/Game
73
+ import std/game/Vec2
74
+ import std/game/Color
75
+ import std/game/Input
76
+ import std/game/pure/Actions
77
+ import std/game/pure/PureScene
78
+ import std/game/pure/SceneInput
79
+
80
+ struct World(mut stage: Stage, mut held: Held)
81
+
82
+ export fun init(io: Io) -> World =
83
+ sprite := Node.sprite(2, 0, 100.0, 100.0, 48.0, 48.0)
84
+ spinning := Node(..sprite, actions: [Action.forever(Action.rotateBy(1.0, 1.0))].toSlice())
85
+ root := Node.group(1, 0.0, 0.0, 800.0, 600.0, [spinning].toSlice())
86
+ World(stage: Stage.create(root), held: Held.none())
87
+
88
+ export fun update(io: Io, w: World, dt: Float) =
89
+ polled := Input.poll(io, w.held)
90
+ w.held = polled.held
91
+ w.stage = w.stage.frame(dt, polled.inputs).intoStage()
92
+
93
+ export fun draw(io: Io, w: World) =
94
+ w.stage.draw(io)
95
+ ```
61
96
 
62
- ### How hard each style is
97
+ Find a node by id with `findPath` and change it with `modify`. Full example: [`game_pure_scene.plum`](../examples/games/pure/game_pure_scene.plum).
63
98
 
64
- 1. **Functional: easiest.** All game state is one immutable `State`, and everything that happens arrives as a `Msg` (`Tick`, `Resize`, `Restart`, `UiEv`). A replay is the seed plus the `Msg` list, applied with `State.update` in order. Rollback is keeping an old `State` (nothing changes it). A checksum hashes `State`. Only the `World` shell, which turns keyboard and mouse into messages, touches `io`.
65
- 2. **Pure: moderate.** Updates are pure too, but state is split between the `Stage` tree and the `World` fields, and the tree is updated in place, so a snapshot needs an explicit deep copy. Inputs already arrive as a list per frame (`Input.poll`), which is easy to record.
66
- 3. **Imperative: hardest.** State is a mutable object graph: snapshots and checksums need a deep walk, and any code may read `io`. Replay still works if every step's inputs are recorded and nothing else reads the host.
99
+ ## State and view
67
100
 
68
- **Recommendation:** build deterministic simulation on the functional style first. Its `Msg` stream is already the replay log.
101
+ Your game is plain data. `update` returns the next state, and `render` describes what to draw as a value. Nothing is kept between frames, so there's no tree to keep in sync. Library: `std/game/functional`.
69
102
 
70
- ## 5. Input
103
+ ```plum
104
+ import std/Int
105
+ import std/Float
106
+ import std/Str
107
+ import std/List
108
+ import std/Slice
109
+ import std/Option
110
+ import std/Io
111
+ import std/game/Game
112
+ import std/game/Vec2
113
+ import std/game/Color
114
+ import std/game/functional/FunctionalScene
71
115
 
72
- Steps 1 and 2 of the proposal below are built (2026-11-04).
116
+ enum Msg = NoMsg
73
117
 
74
- - **One `Input` type** (`std/game/Input`): `PointerDown(x, y) | PointerUp(x, y) | PointerMove(x, y) | KeyDown(key) | KeyUp(key)`. `Input.poll(io, held) -> Polled(inputs, held)` is the only code that reads input from `io`. It compares the host's mouse button and every `Key` code with what was held after the last poll. `Held` (`isDown(key)`, `after(input)`) tracks held keys from the inputs, so games never call `io.isKeyDown`. `input.position()`, `input.through(m)` and `input.isPointer()` are helpers.
75
- - **One `UiKind`/`UiEvent`**, also in `std/game/Input`, used by both `PureScene` and `Ui`.
118
+ struct State(x: Float) =
119
+ fun update(self, dt: Float) -> State =
120
+ State(..self, x: self.x + 30.0 * dt)
76
121
 
77
- | | Imperative | Pure | Functional |
78
- |---|---|---|---|
79
- | Pointer | `stage.input(e)`: the topmost actor under the pointer, then each parent, until one's `onInput` returns true | `stage.input(e)` / `stage.frame(dt, inputs)`: hit path, then `node.route` child first; `Button` reacts, `Custom` asks `actor.onInput` | `state.update(Event(input))` -> `render().hitId(x, y)` -> `Ui.step` -> `UiEvent`s |
80
- | Keyboard | A press gives the actor the focus; keys go to it, then its parents | A press gives the node the focus; keys go to it (then its ancestors); keys no node consumes come back in `StageHandled.keys` | `Event(KeyDown/KeyUp)` updates `State.held`; the game reads `held.isDown(..)` |
81
- | Capture (press, drag off, release) | none | yes | yes |
82
- | Events | `onInput -> Bool` (consumed) | `UiEvent(id, kind)`, `Handled.consumed` | `UiEvent(id, kind)` |
122
+ fun render(self) -> SceneNode[Msg] =
123
+ RectShape(x: self.x, y: 40.0, w: 20.0, h: 20.0, color: Color.Red)
83
124
 
84
- The example games and fixtures poll once per frame and feed every input in. Nothing else reads input from the host.
125
+ struct World(mut state: State)
85
126
 
86
- In the functional style, clicks arrive as the game's own messages (step 3). The pure and imperative styles still identify UI elements by integer id.
127
+ export fun init(io: Io) -> World =
128
+ World(state: State(x: 0.0))
87
129
 
88
- ## 6. Proposal: one input model with `onInput`
130
+ export fun update(io: Io, w: World, dt: Float) =
131
+ w.state = w.state.update(dt)
89
132
 
90
- The sketch `scene_graph.pfl` (repo root, not tracked) designs input for a scene2d-style pure scene:
133
+ export fun draw(io: Io, w: World) =
91
-
92
- ```
93
- trait Actor<Msg> {
134
+ io.clear(Color.Black)
94
- fn on_input(self: own Self, input: Input) -> (own Self, List<Msg>) = (self, [])
95
- fn route(self: own Self, path: Path, input: Input) -> (own Self, List<Msg>)
96
- }
97
- class Button<Msg> { .., pressed: Bool, on_click: Msg }
135
+ w.state.render().draw(io)
98
- enum Input { PointerDown(Vec2), PointerUp(Vec2), PointerMove(Vec2), Key(Int) }
99
136
  ```
100
137
 
101
- Its ideas:
102
- - one `Input` type that includes keys;
103
- - an overridable `on_input` on every actor;
104
- - bubbling along the hit path, child first and then each parent, with messages flowing back up;
105
- - **typed messages**: a button carries the game's own `Msg` value.
106
-
107
- ### Step 1: one shared `Input` type, keys included (built)
108
-
109
- `std/game/Input.plum`: see section 5. Gain: one recorded input list per step (determinism item 3), one vocabulary across styles.
110
-
111
- ### Step 2: `onInput` for game actors (built)
112
-
113
- - **Pure:** `trait Actor` has `fun onInput(self, n: Node, e: Input, over: Bool) -> Handled` (default: unchanged, nothing raised, not consumed). `Node.onInput` handles `Button` itself and asks a `Custom` node's actor. `Handled` has `consumed: Bool`; `route` skips the ancestors of a node that consumed the input. Only groups have children, so for pointers this matters once groups react; for keys it decides what the game receives.
114
- - **Imperative:** `trait Actor` has `fun onInput(self, e: Input) -> Bool` (e in the actor's coordinates; true = consumed). `stage.input(e) -> Bool` bubbles from the hit actor through `parent`. A press sets `stage.focus`; keys bubble from the focused actor.
115
- - **Functional:** no methods on nodes (they are data). Inputs reach `State.update` as `Event(input)` messages.
116
-
117
- ### Step 3: typed messages (built for the functional style)
118
-
119
- - `SceneNode[M]` is generic over the game's message type. `Clickable(id, msg: M, child)` replaces `Tagged(id, child)`. `node.msgOf(id)` reads a clickable's message.
120
- - `Ui.button(id, msg, label, ..)` builds one. `Ui.messages(view, events)` turns the Clicks that `Ui.step` raised into the game's own messages, read from the view the pointer was on.
121
- - The game declares `RestartClicked` in its `Msg` and handles it in `update` like any other message. The `UiEv` wrapper and the `e.id == Id.Restart && e.kind == Click` check are gone. The id stays only to identify the button for hover and press state (`Ui` needs identity, and `==` on records is identity).
122
- - Cost: every scene signature says `SceneNode[Msg]`, and a local built from a bare constructor needs an annotation (`x: SceneNode[Msg] := Group(..)`); passing it straight to its use (`Filter(child: Group(..))`) avoids that, and also keeps fusion. Helpers that do not mention `M` (`hitLeaf`, `hitPrepend`, `nearLine`) are top-level functions, because a static method of a generic type must mention its parameter. Fusion of `render().draw(io)` still holds (`fusion_alloc_test`); allocations in the exempt panel/animation functions went from 15 to 12.
123
- - Found on the way: TRMC hoisted a list cell's head into an untyped temporary, so `Cons(head: Full(..), ..)` in a function returning `List[Box[Int]]` gave the temporary a `Box` with its own parameter left open, and produced invalid wasm. Fixed in `trmc.rs` (the temporary takes the field's concrete type); regression test `trmc_list_of_generic_enum` in `examples/recursion.plum`.
124
- - Pure and imperative styles keep integer ids (generic traits, `Actor[Msg]`, are not supported).
125
-
126
- ### Next
127
-
128
- 1. Decide whether PureScene stays (backlog: "Retire PureScene?").
138
+ `Anim` describes motion as a function of time, and `Ui` handles buttons and pointer clicks, turning them into your `Msg` values. Because `update` never receives `io`, you can test all of your rules without a browser. Full example: [`game_state_view.plum`](../examples/games/functional/game_state_view.plum).
129
139
 
130
- ### Open questions
140
+ ## Compare them
131
141
 
132
- - Should pointer events reach a node's parents after a child consumes them (Flame: no, the DOM: yes unless stopped)?
133
- - Should `onInput` see the event in local coordinates (as `onPointer` does today) or in world coordinates?
134
- - Do held-key sets belong in `UiState` (shared) or in each game's state?
142
+ The imperative, pure and state-and-view examples are the same game, Orb Run, written three ways. [`game.plum`](../examples/games/imperative/game.plum) is the same game drawn directly, with no scene library. Read them side by side to see how each one feels.
docs/game.md CHANGED
@@ -76,7 +76,7 @@ Drawing directly is enough for small games. For bigger ones, Plum includes three
76
76
  | **Pure scene** | A tree where each update returns the next version | rules you can test without a browser | [`game_pure_scene.plum`](../examples/games/pure/game_pure_scene.plum) |
77
77
  | **State and view** | Plain data, and a `view` function draws it each frame | games that are simple to reason about | [`game_state_view.plum`](../examples/games/functional/game_state_view.plum) |
78
78
 
79
- These four examples play the same kind of game, so you can compare them side by side. [Game scene styles](game-scene-styles.md) goes deeper into the differences, and [imperative vs functional](imperative-vs-functional.md) compares the code.
79
+ These four examples are the same game, so you can compare them side by side. [Choosing a scene style](game-scene-styles.md) shows a small program in each.
80
80
 
81
81
  ## Testing a game
82
82
 
docs/plans/2026-11-04-self-hosting-groundwork.review.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Self-hosting groundwork: review plan
2
2
 
3
- Design: [../self-hosting.md](../self-hosting.md). Nothing is built until you approve.
3
+ Design: [self-hosting.md](self-hosting.md). Nothing is built until you approve.
4
4
 
5
5
  ## Summary
6
6
  The first steps toward a Plum compiler written in Plum. Every host call moves onto the `Io` capability (`Os.plum`
docs/plans/2026-11-05-compiler-cleanup-phase5-6.md ADDED
@@ -0,0 +1,149 @@
1
+ # Compiler cleanup, phases 5 and 6: file splits, type kinds, tests in Plum
2
+
3
+ **Review plan:** docs/plans/2026-11-04-compiler-cleanup.review.md (phase 5 rows "File splits", "Enum kinds"; phase 6 "Tests in Plum")
4
+ **Goal:** the checker and codegen are split out of their 4.6k and 7.9k line files into modules with clear jobs; the 949-line `compileSourceUnits` is broken into its stages; one kind field replaces the struct-only flags on `ast::Enum`; tests that only run a program and compare the answer become Plum `test` blocks. No language change; example wasm byte-identical. User 2026-11-05: "Let phase 5 and 6 proceed".
5
+ **Architecture:** compiler/checker, compiler/wasm-codegen, compiler/core, compiler/driver, compiler/lsp, new compiler/tests (Plum tests).
6
+ **Tech stack:** Rust 2021, Plum.
7
+ **Detail:** contracts
8
+ **Target-min:** 90
9
+
10
+ ## Global constraints
11
+ - Work on `master` in the main checkout, default shared `target/`. Don't commit. Never run `plum format` on real files. No `sed -i -E`.
12
+ - No `cargo test` (hook): `cargo build --workspace --tests`, then run ALL 14 test binaries directly from `target/debug/deps`, executable files only, skipping `.rlib`/`.rmeta`: plum_checker, checker, plum, cli, plum_core, core, plum_driver, driver_tests, plum_host, plum_lsp, plum_test_support, plum_wasm_codegen, wasm_codegen, tree_sitter_plum. `plum` and `plum_lsp` each also have a non-test binary: run the one that prints a test result line. Report per-binary counts. Sweep `target/debug/plum test std examples` (662 before this plan).
13
+ - Byte-identical wasm: `bash .superlite/wasm-tree.sh check` must say identical (25 files). Never change the baseline.
14
+ - docs/ and README.md and the game engine (`std/game`, `std/Io.plum`) are out of scope: the user is still discussing them. Do not touch them, except T9's results doc.
15
+ - Each task ends with workspace build zero Rust warnings (tree-sitter C warnings are pre-existing), all binaries, sweep, wasm check, except that T1 and T2 run in parallel in separate crates and only build and test their own crate plus plum-cli.
16
+
17
+ ## Task graph
18
+ ```mermaid
19
+ flowchart LR
20
+ T1[checker split]
21
+ T2[codegen split]
22
+ T3[codegen driver stages]
23
+ T4[type kinds]
24
+ T5[verify phase 5]
25
+ T6[Plum test home + tail-call proof]
26
+ T7[move wasm_codegen run tests]
27
+ T8[move other run tests]
28
+ T9[final verify]
29
+ %% taskgraph
30
+ T1 --> T4
31
+ T2 --> T3
32
+ T3 --> T4
33
+ T4 --> T5
34
+ T5 --> T6
35
+ T6 --> T7
36
+ T6 --> T8
37
+ T7 --> T9
38
+ T8 --> T9
39
+ ```
40
+ Ruling: T1 and T2 are in separate crates so they run in parallel; T7 and T8 touch separate test folders so they run in parallel; the rest is a chain because each edits what the last produced - cost: about 90 min.
41
+
42
+ ## Package A: file splits
43
+ **Probe entry:** run=plum test std examples
44
+
45
+ ### T1 checker split · Complexity 4 · Est. context 50k
46
+ **Owns:** compiler/checker/src/*.rs
47
+ **Components:** 1
48
+ **LOC owned:** 150
49
+ **Tests:** fast
50
+ **Model:** sonnet
51
+ **Produces:** `compiler/checker/src/lib.rs` under 500 lines: public API and module list; about 13 modules, each one job
52
+ e.g. `wc -l compiler/checker/src/*.rs` -> no file over 1000 lines except the core inference module, which is named in the report if over
53
+ **Notes:** Pure moves: no logic change, no renames of public items, tests stay green unchanged. Suggested modules follow what is there: tables and recording, unification and binds, type conversion, declarations and prepass, generics and instantiation, statements, expressions, calls, patterns, closures and hints, resolution errors. Choose by reading the code; report the final list. `pub(crate)` as needed, nothing newly `pub`.
54
+ **Test code:** checker binaries and sweep; wasm check; `git diff --stat` shows moves with no net logic.
55
+
56
+ ### T2 codegen split · Complexity 5 · Est. context 60k
57
+ **Owns:** compiler/wasm-codegen/src/*.rs
58
+ **Components:** 1
59
+ **LOC owned:** 200
60
+ **Tests:** fast
61
+ **Model:** sonnet
62
+ **Produces:** `compiler/wasm-codegen/src/lib.rs` under 500 lines plus `compileSourceUnits`; about 11 modules, each one job
63
+ e.g. `wc -l compiler/wasm-codegen/src/*.rs` -> no file over 1500 lines
64
+ **Notes:** Pure moves. Suggested modules: GC type registry, wasm module builder, local layout, expressions, calls, closures, patterns and match, statements, generics units, host glue and exports, builtins. Keep `compileEntryTests` working. Line comments like `// ---- units` mark natural seams.
65
+ **Test code:** plum_wasm_codegen and wasm_codegen binaries; sweep; wasm check.
66
+
67
+ ### T3 codegen driver stages · Complexity 5 · Est. context 55k
68
+ **Owns:** compiler/wasm-codegen/src/*.rs
69
+ **Components:** 1
70
+ **LOC owned:** 200
71
+ **Tests:** fast
72
+ **Consumes:** `compiler/wasm-codegen/src/lib.rs` under 500 lines plus `compileSourceUnits`; about 11 modules, each one job (T2)
73
+ **Produces:** `compileSourceUnits` is a short list of named stage calls, under 80 lines, with state passed in a struct
74
+ e.g. stages such as collect units, collect concrete generics, discover closures, declare types, compile bodies, emit
75
+ **Notes:** Stage order is unchanged; output is byte-identical, including the order of types and functions. A stage's state goes in fields of one struct rather than long parameter lists. Stop where a seam needs logic changes, and report it.
76
+ **Test code:** all binaries; sweep; wasm check.
77
+
78
+ ### T4 type kinds · Complexity 5 · Est. context 55k
79
+ **Owns:** compiler/core/src/*.rs, compiler/checker/src/*.rs, compiler/wasm-codegen/src/*.rs, compiler/lsp/src/*.rs, compiler/driver/src/*.rs, compiler/core/tests/core/*.rs, compiler/checker/tests/**/*.rs, compiler/wasm-codegen/tests/wasm_codegen/**/*.rs
80
+ **Components:** 1
81
+ **LOC owned:** 180
82
+ **Tests:** fast
83
+ **Consumes:** `compiler/checker/src/lib.rs` under 500 lines: public API and module list; about 13 modules, each one job (T1)
84
+ **Consumes:** `compileSourceUnits` is a short list of named stage calls, under 80 lines, with state passed in a struct (T3)
85
+ **Produces:** `ast::Enum.kind: TypeKind` where `TypeKind` is `Enum` or `Struct(StructInfo { forceBoxed, embeds, embedFields, base })`, replacing the separate fields; `Fn.is_test` replaces `__test_` name-prefix checks; `core/src/unions.rs` renamed to say what it now does
86
+ e.g. `grep -rn 'isStruct\|__test_' compiler/*/src` -> only the name generation for test fns, if a name is still generated
87
+ **Notes:** The review plan's record, trait and value flags no longer exist because those features were removed. Only what's above remains, so this is smaller than planned. A small `e.isStruct()` helper is fine. Don't merge `embeds.rs` and `ctors.rs` unless it's plainly simpler; say which you chose. Other generated names (`__new`, `__init_`, `__for_`) stay.
88
+ **Test code:** all binaries; sweep; wasm check.
89
+
90
+ ### T5 verify phase 5 · Complexity 2 · Est. context 25k
91
+ **Owns:** docs/plans/2026-11-05-compiler-cleanup-phase5-6.results.md
92
+ **Components:** 1
93
+ **LOC owned:** 20
94
+ **Tests:** slow
95
+ **Consumes:** `ast::Enum.kind: TypeKind` where `TypeKind` is `Enum` or `Struct(StructInfo { forceBoxed, embeds, embedFields, base })`, replacing the separate fields; `Fn.is_test` replaces `__test_` name-prefix checks; `core/src/unions.rs` renamed to say what it now does (T4)
96
+ **Produces:** phase 5 results section
97
+ **Notes:** Workspace build; all 14 binaries with counts; sweep; wasm; browser (stress_scene + one game per style via compiler/game/web-test); `wc -l` of both crates before and after. Report red, don't fix.
98
+ **Test code:** results doc section.
99
+
100
+ ## Package B: tests in Plum
101
+ **Probe entry:** run=plum test std examples compiler/tests
102
+
103
+ ### T6 Plum test home and tail-call proof · Complexity 3 · Est. context 35k
104
+ **Owns:** compiler/tests/**/*.plum, compiler/test-support/src/*.rs
105
+ **Components:** 1
106
+ **LOC owned:** 60
107
+ **Tests:** fast
108
+ **Consumes:** phase 5 results section (T5)
109
+ **Produces:** `compiler/tests/` holding `.plum` test files, swept by `target/debug/plum test std examples compiler/tests`; a test proving `plum test` runs tail calls like a compiled program
110
+ e.g. `test "deep tail recursion"` running a 200000-step self-tail call, a method tail call and a closure tail call under `plum test`
111
+ **Notes:** The review plan gates phase 6 on this. If tail calls behave differently under `plum test`, stop: reply BLOCKED, with no tests moved. `--no-fusion` should be swept too, if the moved tests are to keep exercising that path; say whether.
112
+ **Test code:** the new test passes; sweep with the new folder.
113
+
114
+ ### T7 move wasm_codegen run tests · Complexity 4 · Est. context 50k
115
+ **Owns:** compiler/wasm-codegen/tests/wasm_codegen/**/*.rs, compiler/tests/codegen/*.plum
116
+ **Components:** 1
117
+ **LOC owned:** 200
118
+ **Tests:** fast
119
+ **Consumes:** `compiler/tests/` holding `.plum` test files, swept by `target/debug/plum test std examples compiler/tests`; a test proving `plum test` runs tail calls like a compiled program (T6)
120
+ **Produces:** wasm_codegen tests that only run a program and compare its answer are `.plum` tests; each moved test keeps its assertion
121
+ e.g. `assert_eq!(runInt(..., "fun main() -> Int = 40 + 2"), 42)` becomes `test "..."` with `assert` in a `.plum` file
122
+ **Notes:** Move ONLY tests that compile, run and compare. Keep in Rust: anything asserting a compile error, wasm contents, validation, traps, panics, fusion decisions, or host calls. Test that a moved test can fail: break one assert temporarily to see `plum test` report it. Group by feature, not one file per test. Report counts moved and kept.
123
+ **Test code:** `plum test` on the folder; wasm_codegen binary; sweep.
124
+
125
+ ### T8 move other run tests · Complexity 3 · Est. context 40k
126
+ **Owns:** compiler/core/tests/core/*.rs, compiler/checker/tests/**/*.rs, compiler/cli/tests/cli/*.rs, compiler/tests/core/*.plum
127
+ **Components:** 1
128
+ **LOC owned:** 120
129
+ **Tests:** fast
130
+ **Consumes:** `compiler/tests/` holding `.plum` test files, swept by `target/debug/plum test std examples compiler/tests`; a test proving `plum test` runs tail calls like a compiled program (T6)
131
+ **Produces:** run-and-compare tests in core, checker and cli are `.plum` tests; each moved test keeps its assertion
132
+ e.g. core `closure_spec_test` cases that run a program and compare its answer
133
+ **Notes:** Same rule as T7. A core test that also asserts a pass's output stays in Rust. Report counts moved and kept.
134
+ **Test code:** `plum test` on the folder; core, checker, cli binaries; sweep.
135
+
136
+ ### T9 final verify · Complexity 2 · Est. context 30k
137
+ **Owns:** docs/plans/2026-11-05-compiler-cleanup-phase5-6.results.md
138
+ **Components:** 1
139
+ **LOC owned:** 20
140
+ **Tests:** slow
141
+ **Consumes:** wasm_codegen tests that only run a program and compare its answer are `.plum` tests; each moved test keeps its assertion (T7)
142
+ **Consumes:** run-and-compare tests in core, checker and cli are `.plum` tests; each moved test keeps its assertion (T8)
143
+ **Produces:** phases 5 and 6 results
144
+ **Notes:** Everything in T5 again, plus the sweep with compiler/tests and the count of tests moved. Total assertions must not drop: compare Rust tests removed against Plum tests added. Report red, don't fix.
145
+ **Test code:** results doc.
146
+
147
+ ## Review Focus
148
+ - A: state - pure moves keep behaviour; the driver's stage order and output order are unchanged.
149
+ - B: state - a moved test keeps its assertion's strength and can actually fail.
docs/{self-hosting.md → plans/self-hosting.md} RENAMED
@@ -1,7 +1,7 @@
1
1
  # Self-hosting Plum
2
2
 
3
3
  Status: design, 2026-11-04. The first slices are planned in
4
- [plans/2026-11-04-self-hosting-groundwork.review.md](plans/2026-11-04-self-hosting-groundwork.review.md); nothing here is built yet.
4
+ [plans/2026-11-04-self-hosting-groundwork.review.md](2026-11-04-self-hosting-groundwork.review.md); nothing here is built yet.
5
5
 
6
6
  Goal: the lexer, parser, checker, compiler passes, wasm emitter and CLI written in Plum, compiled by a Rust "stage 0"
7
7
  compiler until the Plum compiler can compile itself.