plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
e38cbb1
— Peter John
2026-11-05T23:55:13+05:30
plans: FBIP review plan, game engine refactor
docs/plans/2026-11-05-fbip.review.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# FBIP: no heap mutation: review plan
|
|
2
|
+
|
|
3
|
+
**Status:** waiting for your approval. Nothing is built until you say so.
|
|
4
|
+
**Background:** decision record §9 and §9b, backlog item 4.
|
|
5
|
+
|
|
6
|
+
## Summary
|
|
7
|
+
|
|
8
|
+
Today a `mut` field changes a value in place, and anyone else holding that value sees the change. FBIP ("functional but in-place") removes that. Every update returns a new value, and the compiler reuses the old memory when it can prove nobody else holds it. You get the simple reasoning of immutable data at the speed of mutation.
|
|
9
|
+
|
|
10
|
+
Plum already does this for one case: `T(..x, f: v)` writes in place when `x` is proven unique. FBIP extends that to collections and turns "hopefully in place" into a checked guarantee.
|
|
11
|
+
|
|
12
|
+
## What you will get
|
|
13
|
+
|
|
14
|
+
- `xs.set(i, v)` and `xs.push(v)` return the new collection, written in place when the old one is unique.
|
|
15
|
+
- `fip fun`: a function the compiler guarantees runs in place. If it can't prove that, you get a compile error, rather than a silent copy every frame.
|
|
16
|
+
- No aliasing surprises: nothing can change a value you hold.
|
|
17
|
+
- Snapshots and replay become almost free, which directly helps deterministic simulation and level 3 hot reload.
|
|
18
|
+
- A `--no-fbip` switch, as you asked, so it can be turned off and compared.
|
|
19
|
+
|
|
20
|
+
## What will be built
|
|
21
|
+
|
|
22
|
+
| Part | What it does | Size |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| Baseline | Benchmark before any change, on the games and on collection loops | S |
|
|
25
|
+
| Stronger uniqueness proof | Today it covers locals and parameters used in a spread. It must follow a collection through calls, fields of fresh values, loops and `x = x.push(v)` | L |
|
|
26
|
+
| In-place array writes | `Array.set` and friends return the array, writing in place when unique and copying when not | M |
|
|
27
|
+
| `fip fun` | The checked guarantee, with an error naming the spot that broke it | M |
|
|
28
|
+
| Persistent `Slice`, `Buffer`, `Deque`, `Map` | The first four worklist types stop using `mut`. Every call site in `std` and the examples changes from `xs.push(v)` to `xs = xs.push(v)` | L |
|
|
29
|
+
| Parsers and animation | `JsonParser`, `Action`, and the actors in the pure game | M |
|
|
30
|
+
| Game shells and imperative scene | See decision 1 | M to L |
|
|
31
|
+
| Remove `mut` | Only if everything above lands (decision 3) | S |
|
|
32
|
+
|
|
33
|
+
Each row is its own build, ends green, and is compared to the baseline.
|
|
34
|
+
|
|
35
|
+
## Decisions for you
|
|
36
|
+
|
|
37
|
+
**1. Does the imperative style stay? This is the big one.**
|
|
38
|
+
The new `Game` struct has `update(self, dt)` that changes `self`, and the imperative scene is mutable by design. "No heap mutation" contradicts both.
|
|
39
|
+
- **A: keep `mut` for shells and the imperative scene (my recommendation).** FBIP covers `std` collections and pure game code. `mut` stays as a clearly marked exception.
|
|
40
|
+
- **B: remove it everywhere.** `update(self, dt) -> Self` everywhere. The imperative scene goes away or becomes a thin layer. Purer, but it conflicts with the libGDX style you just chose.
|
|
41
|
+
|
|
42
|
+
**2. When to run it.** You said FBIP waits for the benchmark. I recommend keeping that: the benchmark tells us what FBIP needs to beat. Phases 4 to 6 haven't been benchmarked either.
|
|
43
|
+
|
|
44
|
+
**3. Removing `mut` as a language feature.** Follows from 1. With A it stays. With B it goes.
|
|
45
|
+
|
|
46
|
+
**4. Static proof only, or a runtime fallback?** Wasm GC has no reference counts. Koka checks a count at runtime; Plum would prove uniqueness at compile time.
|
|
47
|
+
- **Static first (my recommendation),** adding a small count only if the games show the proof falls short.
|
|
48
|
+
- A count everywhere is simpler to get right, but adds a field to each object and a cost to each update.
|
|
49
|
+
|
|
50
|
+
**5. Visible ownership (from Pony).** The backlog suggests showing uniqueness in types, like Pony's `iso`/`val`. It makes errors clearer but adds concepts to a language you've been simplifying. **I'd leave it out,** and let `fip` carry the guarantee.
|
|
51
|
+
|
|
52
|
+
## Risks
|
|
53
|
+
|
|
54
|
+
- **A silent copy every frame.** This is the main risk: an array write the proof can't handle becomes a copy, and a game gets slow with no error. `fip` catches it where it's used, and `--explain-fusion` will report copies.
|
|
55
|
+
- **The proof may fall short on real code.** Collections stored in struct fields are the hard case, and they're everywhere in games. It's today's explicit gap ("field assignments are not yet marked"). If this fails, we need the fallback in decision 4.
|
|
56
|
+
- **A large mechanical change.** `xs.push(v)` becomes `xs = xs.push(v)` at every call site, and forgetting it is caught by the existing "unused result" error. That helps a lot.
|
|
57
|
+
- **Slower compiles.** Each new analysis costs time, and hot reload relies on fast compiles.
|
|
58
|
+
|
|
59
|
+
## Out of scope
|
|
60
|
+
|
|
61
|
+
Merging anything else into `struct` (already done), the `Io` split, and reference counting for memory management.
|
|
62
|
+
|
|
63
|
+
## How we will know it works
|
|
64
|
+
|
|
65
|
+
- All tests pass, and example behaviour is unchanged. Wasm bytes will change here, so the check is behaviour rather than bytes.
|
|
66
|
+
- On the game benchmarks, frame time is no worse than the baseline, and allocations per frame don't go up.
|
|
67
|
+
- `fip` versions of the core loops compile, and a deliberately broken one gives a clear error.
|
|
68
|
+
- `--no-fbip` works and gives the same program results.
|
|
69
|
+
|
|
70
|
+
## Rough time and cost
|
|
71
|
+
|
|
72
|
+
Large, the biggest item left in the queue. About 8 to 12 tasks, likely several hours of agent time, because the proof work needs careful review. The baseline and stronger proof come first, and they tell us early whether the rest is viable. We can stop there if it isn't.
|
docs/plans/2026-11-05-game-engine.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Game engine refactor: Game struct, main as the entry, plum run
|
|
2
|
+
|
|
3
|
+
**Design:** agreed with the user 2026-11-05. A game is a struct embedding `Game`, with abstract `update(self, dt)` and `draw(self)`, and libGDX-style properties `self.input`, `self.canvas`, `self.audio`, `self.assets`, `self.clock`, and no `self.io` in game code. `fun main(io: Io) -> Game` is the one entry point. `plum game` goes away: `plum run` serves and opens a game, and `plum build` writes wasm and HTML for it. Level 1 hot reload: watch, recompile, reload, with an error overlay.
|
|
4
|
+
**Goal:** the design above, with every example game and scene library ported, no language change, and no real slowdown on stress scenes.
|
|
5
|
+
**Architecture:** std/game, compiler/game (host, bridge, web-test), compiler/cli, compiler/driver, compiler/checker, examples/games.
|
|
6
|
+
**Tech stack:** Plum, Rust 2021, JavaScript.
|
|
7
|
+
**Detail:** contracts
|
|
8
|
+
**Target-min:** 120
|
|
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`, the executables that build reports (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). Sweep `target/debug/plum test std examples tests` (774 before this plan). `compiler/game` and `compiler/game/web-test` are standalone crates: `cargo test` is blocked there too, so build with `cargo build --tests --manifest-path` and run their test executables.
|
|
13
|
+
- Wasm check: `bash .superlite/wasm-tree.sh check`. Files outside `examples/games/` must stay byte-identical. Files under `examples/games/` will change. Never change the baseline.
|
|
14
|
+
- Design (coordinator ruling), all in `std/game/Game.plum` plus `std/game/Input.plum`: `struct Texture(id: Int)`, `struct Sound(id: Int)` (flat); `Assets` with name-to-id maps and get-or-load `texture(name)`, `sound(name)`, crashing and naming a missing file, plus `file(name)`; `Canvas` for clear, shapes, text, textures, camera, blend, render target and screen size; `Audio` for play, loop, stop, volume; `Clock` with `time`, `frame`, `tick(dt)`; `Input` (the struct) for keys, mouse and pointer, with held-key state and `events() -> Slice[InputEvent]`; `enum Input` in `std/game/Input` renamed `InputEvent`; `Engine` bundling them, `Engine.create(io)`; `struct Game(engine: Engine)` whose `init` copies the parts to `self.input`, `self.canvas`, `self.audio`, `self.assets`, `self.clock`, with `abstract fun update(self, dt: Float)` and `abstract fun draw(self)`. Direct `io.drawRect` stays available: `Io` is unchanged.
|
|
15
|
+
- The `Io` externs in `std/Io.plum` do not change names, so the JS bridge and the headless Backend keep their import lists.
|
|
16
|
+
|
|
17
|
+
## Task graph
|
|
18
|
+
```mermaid
|
|
19
|
+
flowchart LR
|
|
20
|
+
T1[baseline + std/game API]
|
|
21
|
+
T2[main entry: host + bridge]
|
|
22
|
+
T3[plum run and build]
|
|
23
|
+
T4[hot reload level 1]
|
|
24
|
+
T5[imperative library + games]
|
|
25
|
+
T6[functional library + game]
|
|
26
|
+
T7[pure library + game]
|
|
27
|
+
T8[fixtures + remove old entry]
|
|
28
|
+
T9[docs]
|
|
29
|
+
T10[verify]
|
|
30
|
+
%% taskgraph
|
|
31
|
+
T1 --> T2
|
|
32
|
+
T2 --> T3
|
|
33
|
+
T3 --> T4
|
|
34
|
+
T2 --> T5
|
|
35
|
+
T5 --> T6
|
|
36
|
+
T6 --> T7
|
|
37
|
+
T7 --> T8
|
|
38
|
+
T4 --> T8
|
|
39
|
+
T4 --> T9
|
|
40
|
+
T8 --> T9
|
|
41
|
+
T9 --> T10
|
|
42
|
+
```
|
|
43
|
+
Ruling: host and entry first, with old `init`/`update`/`draw` exports still working so ports stay green one at a time; the old entry goes in T8. T3 and T4 touch the CLI and server while T5 to T7 touch Plum, so they can overlap - cost: about 2 hours.
|
|
44
|
+
|
|
45
|
+
## Package A: API and entry
|
|
46
|
+
**Probe entry:** run=plum test std examples tests
|
|
47
|
+
|
|
48
|
+
### T1 baseline and std/game API · Complexity 5 · Est. context 55k
|
|
49
|
+
**Owns:** std/game/Game.plum, std/game/Input.plum, std/game/imperative/*.plum, std/game/pure/*.plum, std/game/functional/*.plum, examples/games/**/*.plum, compiler/game/web-test/fixtures/*.plum, docs/plans/2026-11-05-game-engine.results.md
|
|
50
|
+
**Components:** 2
|
|
51
|
+
**LOC owned:** 250
|
|
52
|
+
**Tests:** fast
|
|
53
|
+
**Produces:** `std/game/Game.plum` provides `Game`, `Engine`, `Canvas`, `Audio`, `Assets`, `Clock`, `Texture`, `Sound`; `std/game/Input.plum` provides struct `Input` and `enum InputEvent` (renamed from `Input` at every use)
|
|
54
|
+
e.g. a test in `std/game/Game.plum` builds a small `Game` subtype and calls `update`; `plum test std` passes
|
|
55
|
+
**Notes:** FIRST record the baseline: release `plum-web-test --measure 5 --json` for stress_scene, stress_world, stress_pure_scene, stress_state_view into the results doc, headless, same machine. Then the API. Keep the existing `export fun init` games working unchanged. Add tests with `test` blocks that don't call the host.
|
|
56
|
+
**Test code:** `plum test std examples tests`; wasm check.
|
|
57
|
+
|
|
58
|
+
### T2 main entry in host and bridge · Complexity 5 · Est. context 55k
|
|
59
|
+
**Owns:** std/game/Game.plum, compiler/game/src/*.rs, compiler/game/web/*, compiler/game/tests/**, compiler/checker/src/*.rs, compiler/driver/src/*.rs, compiler/wasm-codegen/src/*.rs, compiler/checker/tests/**/*.rs, compiler/wasm-codegen/tests/wasm_codegen/**/*.rs
|
|
60
|
+
**Components:** 2
|
|
61
|
+
**LOC owned:** 200
|
|
62
|
+
**Tests:** fast
|
|
63
|
+
**Consumes:** `std/game/Game.plum` provides `Game`, `Engine`, `Canvas`, `Audio`, `Assets`, `Clock`, `Texture`, `Sound`; `std/game/Input.plum` provides struct `Input` and `enum InputEvent` (renamed from `Input` at every use) (T1)
|
|
64
|
+
**Produces:** `fun main(io: Io) -> Game` (or a subtype) runs as a game in the browser bridge and headless host; std's exports tick the clock and dispatch `update` and `draw`; compile output says whether a program is a game (`isGame`); `main` taking `Io` and returning a `Game` type-checks; old `init` exports still work
|
|
65
|
+
e.g. `compiler/game/tests/fixtures` gains a fixture with `fun main(io: Io) -> Game`, and `host_test` drives frames through it
|
|
66
|
+
**Notes:** Check first: are `export fun`s in imported std files emitted, and only when needed? A CLI program importing std/game must not export them or trip the host. If exports from std can't work simply, the compiler generates them when `main` returns a `Game`; say which you chose. No wasm change for non-game programs.
|
|
67
|
+
**Test code:** all binaries; game crate tests; a web-test run on the new fixture; wasm check.
|
|
68
|
+
|
|
69
|
+
### T3 plum run and plum build · Complexity 4 · Est. context 45k
|
|
70
|
+
**Owns:** compiler/cli/src/*.rs, compiler/cli/tests/cli/*.rs, compiler/game/web-test/src/*.rs, compiler/game/web-test/tests/*.rs, Makefile.toml
|
|
71
|
+
**Components:** 1
|
|
72
|
+
**LOC owned:** 150
|
|
73
|
+
**Tests:** fast
|
|
74
|
+
**Consumes:** `fun main(io: Io) -> Game` (or a subtype) runs as a game in the browser bridge and headless host; std's exports tick the clock and dispatch `update` and `draw`; compile output says whether a program is a game (`isGame`); `main` taking `Io` and returning a `Game` type-checks; old `init` exports still work (T2)
|
|
75
|
+
**Produces:** `plum run` serves a game and opens the browser, or runs a CLI app; `plum build` writes wasm and HTML for a game (`-o`, default `dist`), or a native executable for a CLI app; `--assets`, `--port`, `--no-open` on `plum run`; a `Result` return from `main` gives exit code 0 or 1 and prints the error variant's name; `plum game` removed
|
|
76
|
+
e.g. `plum run examples/games/imperative/game.plum` prints `serving http://...`; `plum run examples/basics.plum` runs it
|
|
77
|
+
**Notes:** Games with old `init` exports also count as games until T8. Check what an `Int` return does today and keep it. Don't break `plum test`. The web-test tool and Makefile move off `plum game`.
|
|
78
|
+
**Test code:** cli tests for each dispatch case; all binaries.
|
|
79
|
+
|
|
80
|
+
### T4 hot reload level 1 · Complexity 4 · Est. context 45k
|
|
81
|
+
**Owns:** compiler/cli/src/*.rs, compiler/cli/tests/cli/*.rs, compiler/game/web/*
|
|
82
|
+
**Components:** 2
|
|
83
|
+
**LOC owned:** 180
|
|
84
|
+
**Tests:** fast
|
|
85
|
+
**Consumes:** `plum run` serves a game and opens the browser, or runs a CLI app; `plum build` writes wasm and HTML for a game (`-o`, default `dist`), or a native executable for a CLI app; `--assets`, `--port`, `--no-open` on `plum run`; a `Result` return from `main` gives exit code 0 or 1 and prints the error variant's name; `plum game` removed (T3)
|
|
86
|
+
**Produces:** `plum run` on a game watches the source file, its imports and the asset folder, recompiles on a change, and reloads the page; a compile error shows as an overlay with the old game still running; `--no-watch` turns it off; a static `plum build` site has none of this
|
|
87
|
+
e.g. saving a game file with a type error shows the error on the page; fixing it reloads
|
|
88
|
+
**Notes:** Poll mtimes rather than adding a dependency. Server-sent events to the page. Assets served fresh. Log each rebuild's time. No state is kept across reloads, by design: replay comes later with deterministic simulation.
|
|
89
|
+
**Test code:** a web-test or cli test: change a served file, see the new wasm served; a broken file keeps the old wasm and serves the error.
|
|
90
|
+
|
|
91
|
+
## Package B: port
|
|
92
|
+
**Probe entry:** run=plum test std examples tests
|
|
93
|
+
|
|
94
|
+
### T5 imperative library and games · Complexity 5 · Est. context 55k
|
|
95
|
+
**Owns:** std/game/imperative/*.plum, examples/games/imperative/*.plum
|
|
96
|
+
**Components:** 2
|
|
97
|
+
**LOC owned:** 200
|
|
98
|
+
**Tests:** fast
|
|
99
|
+
**Consumes:** `fun main(io: Io) -> Game` (or a subtype) runs as a game in the browser bridge and headless host; std's exports tick the clock and dispatch `update` and `draw`; compile output says whether a program is a game (`isGame`); `main` taking `Io` and returning a `Game` type-checks; old `init` exports still work (T2)
|
|
100
|
+
**Produces:** `Actor.draw` and `Stage.draw` take `Canvas`; imperative games are structs embedding `Game` with `fun main(io: Io) -> Game`
|
|
101
|
+
e.g. `game.plum` has no `export fun`, and plays the same
|
|
102
|
+
**Test code:** `plum test std examples tests`; web-test on each game.
|
|
103
|
+
|
|
104
|
+
### T6 functional library and game · Complexity 4 · Est. context 45k
|
|
105
|
+
**Owns:** std/game/functional/*.plum, examples/games/functional/*.plum
|
|
106
|
+
**Components:** 2
|
|
107
|
+
**LOC owned:** 120
|
|
108
|
+
**Tests:** fast
|
|
109
|
+
**Consumes:** `Actor.draw` and `Stage.draw` take `Canvas`; imperative games are structs embedding `Game` with `fun main(io: Io) -> Game` (T5)
|
|
110
|
+
**Produces:** `SceneNode.draw` takes `Canvas`; the functional game has `State` untouched by host types, with a thin `Game` struct over it
|
|
111
|
+
e.g. `State.update` and `render` still take no host value, and their tests still need no browser
|
|
112
|
+
**Notes:** Check fusion still removes the scene: `state_view_draw_has_no_scenenode_alloc` must stay green.
|
|
113
|
+
**Test code:** sweep; fusion alloc test; web-test on the game.
|
|
114
|
+
|
|
115
|
+
### T7 pure library and game · Complexity 4 · Est. context 45k
|
|
116
|
+
**Owns:** std/game/pure/*.plum, examples/games/pure/*.plum
|
|
117
|
+
**Components:** 2
|
|
118
|
+
**LOC owned:** 120
|
|
119
|
+
**Tests:** fast
|
|
120
|
+
**Consumes:** `SceneNode.draw` takes `Canvas`; the functional game has `State` untouched by host types, with a thin `Game` struct over it (T6)
|
|
121
|
+
**Produces:** pure `Node` and `Stage` draw take `Canvas`; the pure game embeds `Game`
|
|
122
|
+
**Test code:** sweep; web-test on the game.
|
|
123
|
+
|
|
124
|
+
### T8 fixtures and remove the old entry · Complexity 3 · Est. context 40k
|
|
125
|
+
**Owns:** compiler/game/web-test/fixtures/*.plum, compiler/game/tests/**, compiler/game/src/*.rs, compiler/game/web/*, compiler/cli/src/*.rs, compiler/cli/tests/cli/*.rs, std/game/Game.plum
|
|
126
|
+
**Components:** 1
|
|
127
|
+
**LOC owned:** 120
|
|
128
|
+
**Tests:** fast
|
|
129
|
+
**Consumes:** pure `Node` and `Stage` draw take `Canvas`; the pure game embeds `Game` (T7)
|
|
130
|
+
**Produces:** every fixture uses `main`; host, bridge and CLI drop the old `init` entry; the stress comparison against the T1 baseline is in the results doc
|
|
131
|
+
e.g. a module with only `export fun init` is no longer treated as a game
|
|
132
|
+
**Notes:** Compare with the T1 baseline. A stress fixture going over 10% slower is a finding: report which, and whether the wrappers inlined. Fix it if it's simple, for example by having the fixture's hot loop call through fewer layers; don't quietly accept it. Check `../kam_plum` for code using the old entry and report it; don't edit it.
|
|
133
|
+
**Test code:** all binaries; game crate; web-test tests; stress numbers.
|
|
134
|
+
|
|
135
|
+
## Package C: docs and verify
|
|
136
|
+
**Probe entry:** run=plum test std examples tests
|
|
137
|
+
|
|
138
|
+
### T9 docs · Complexity 2 · Est. context 25k
|
|
139
|
+
**Owns:** README.md, docs/game.md, docs/game-scene-styles.md, docs/tools.md, docs/language.md, compiler/game/web-test/README.md, compiler/lsp/src/main.rs
|
|
140
|
+
**Components:** 1
|
|
141
|
+
**LOC owned:** 120
|
|
142
|
+
**Tests:** fast
|
|
143
|
+
**Consumes:** `plum run` on a game watches the source file, its imports and the asset folder, recompiles on a change, and reloads the page; a compile error shows as an overlay with the old game still running; `--no-watch` turns it off; a static `plum build` site has none of this (T4)
|
|
144
|
+
**Consumes:** every fixture uses `main`; host, bridge and CLI drop the old `init` entry; the stress comparison against the T1 baseline is in the results doc (T8)
|
|
145
|
+
**Produces:** docs describe `Game`, `main`, `plum run`, `plum build`, and hot reload, and every example compiles
|
|
146
|
+
**Notes:** Same plain style as the current docs. `plum game` is gone from all of them. Keep examples compiling.
|
|
147
|
+
**Test code:** `docSnippetsHaveNoSyntaxDiagnostics`; compile each example.
|
|
148
|
+
|
|
149
|
+
### T10 verify · Complexity 2 · Est. context 30k
|
|
150
|
+
**Owns:** docs/plans/2026-11-05-game-engine.results.md
|
|
151
|
+
**Components:** 1
|
|
152
|
+
**LOC owned:** 30
|
|
153
|
+
**Tests:** slow
|
|
154
|
+
**Consumes:** docs describe `Game`, `main`, `plum run`, `plum build`, and hot reload, and every example compiles (T9)
|
|
155
|
+
**Produces:** results
|
|
156
|
+
**Notes:** Workspace build; all binaries; game and web-test crates; sweep; wasm check (only `examples/games/` differs); browser on every game and fixture; stress numbers. Report red, don't fix.
|
|
157
|
+
**Test code:** results doc.
|
|
158
|
+
|
|
159
|
+
## Review Focus
|
|
160
|
+
- A: state - a game's clock ticks once per frame; assets cache by name; input events are polled once per frame.
|
|
161
|
+
- B: state - games play the same; rules that took no host value still take none.
|
|
162
|
+
- C: encoding - a `plum run` error page escapes compiler output.
|