plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
1f77028
— Peter John
2026-11-06T16:42:31+05:30
Docs: game docs for plain games, one scene style
- compiler/lsp/src/main.rs +0 -1
- docs/backlog.md +2 -0
- docs/game-scene-styles.md +0 -138
- docs/game.md +74 -50
- std/game/Input.plum +1 -1
compiler/lsp/src/main.rs
CHANGED
|
@@ -765,7 +765,6 @@ mod tests {
|
|
|
765
765
|
"docs/language.md",
|
|
766
766
|
"docs/std.md",
|
|
767
767
|
"docs/game.md",
|
|
768
|
-
"docs/game-scene-styles.md",
|
|
769
768
|
"docs/tools.md",
|
|
770
769
|
"docs/performance.md",
|
|
771
770
|
];
|
docs/backlog.md
CHANGED
|
@@ -53,6 +53,8 @@ Open work, in the order the user queued it, plus smaller items found during buil
|
|
|
53
53
|
|
|
54
54
|
## Smaller open items
|
|
55
55
|
|
|
56
|
+
- **Go-to-definition in the LSP is gone** (2026-11-06): it only ever handled inherited members, which went with inheritance. A real one is wanted once modules land.
|
|
57
|
+
- **Leaked `plum run --no-open` servers** (2026-11-06): a test using `compiler/game/tests/fixtures/host_fixture.plum` left servers running for hours. Not found; the likely cause is `serveStartsAndAnswersWithTheRightContentTypes` in `compiler/cli/tests/cli/game_web_test.rs`, which only kills its child on the success path (the other spawning tests use a kill-on-drop guard).
|
|
56
58
|
- **Locals named like functions, remaining cases** (found 2026-11-06, F-PUR-1 note, pre-existing, with or without fusion): in wasm-codegen, a flat local named like a free function traps or gives invalid wasm; a local in an embedded struct's `init` that captures a free call is silently wrong. The second goes away if inheritance is removed.
|
|
57
59
|
- **Pattern-shadowed parameter gives invalid wasm** (found 2026-11-06, pre-existing, with or without fusion): a parameter shadowed by a pattern and read in a sibling arm compiles to wasm that won't load. Details in the game engine run's note `F-T6-2`.
|
|
58
60
|
- **Closure self-call, still open** (`F-CLOSURE-1` fixed the typed-argument and arity checks; a self-call now errors in the checker when an argument does not fit a known parameter type). What stays: (a) a parameter with no declared type and no hint from a later call (`rec := |n| .. rec("x")` passed to `map`) has no type to check against; (b) `rec(None, [], n-1)` / `rec(Some(n), ..)` with untyped params still fail in codegen (invalid wasm or `type name 'Nil' is not yet supported as a value`), because the self-call's arguments are inferred with no expected type; checking them with the parameter as expected type fixes those but changes the wasm of programs that run today (e.g. a typed `Option[Int]` param called with `None`), so do it in a run that may re-baseline wasm; (c) a self-call from a nested closure (`outer := |n| inner := |m| outer(m-1) ..`) fails in codegen with `unknown function 'outer'`; (d) a typed declaration `f: fn(Int) -> Int := |n| ..` does not bind `f` inside its body; (e) a failed closure initialiser also reports a second `'rec' is not a function` at its first use.
|
docs/game-scene-styles.md
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
# Choosing a scene style
|
|
2
|
-
|
|
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
|
-
|
|
5
|
-
| Style | Where data lives | How it changes | Good for |
|
|
6
|
-
|---|---|---|---|
|
|
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 `render` 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, canvas: Canvas, m: Affine, alpha: Float) =
|
|
41
|
-
drawRectBox(canvas, m, alpha, self.width, self.height, self.color)
|
|
42
|
-
|
|
43
|
-
struct MyGame(Game(engine), stage: Stage) =
|
|
44
|
-
override fun update(self, dt: Float) =
|
|
45
|
-
self.stage.act(dt)
|
|
46
|
-
|
|
47
|
-
override fun draw(self) =
|
|
48
|
-
self.canvas.clear(Color.Black)
|
|
49
|
-
self.stage.draw(self.canvas)
|
|
50
|
-
|
|
51
|
-
fun main(io: Io) -> Game =
|
|
52
|
-
stage := Stage.create()
|
|
53
|
-
stage.root.add(Ball(x: 0.0, y: 40.0, speed: 30.0))
|
|
54
|
-
MyGame(engine: Engine.create(io), stage: stage)
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
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).
|
|
58
|
-
|
|
59
|
-
## Pure
|
|
60
|
-
|
|
61
|
-
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`.
|
|
62
|
-
|
|
63
|
-
```plum
|
|
64
|
-
import std/Int
|
|
65
|
-
import std/Float
|
|
66
|
-
import std/Str
|
|
67
|
-
import std/List
|
|
68
|
-
import std/Slice
|
|
69
|
-
import std/Option
|
|
70
|
-
import std/Io
|
|
71
|
-
import std/game/Game
|
|
72
|
-
import std/game/Vec2
|
|
73
|
-
import std/game/Color
|
|
74
|
-
import std/game/Input
|
|
75
|
-
import std/game/pure/Actions
|
|
76
|
-
import std/game/pure/PureScene
|
|
77
|
-
import std/game/pure/SceneInput
|
|
78
|
-
|
|
79
|
-
struct MyGame(Game(engine), mut stage: Stage) =
|
|
80
|
-
override fun update(self, dt: Float) =
|
|
81
|
-
self.stage = self.stage.frame(dt, self.input.events()).intoStage()
|
|
82
|
-
|
|
83
|
-
override fun draw(self) =
|
|
84
|
-
self.stage.draw(self.canvas)
|
|
85
|
-
|
|
86
|
-
fun main(io: Io) -> Game =
|
|
87
|
-
e := Engine.create(io)
|
|
88
|
-
sprite := Node.sprite(2, e.assets.texture("ball.png"), 100.0, 100.0, 48.0, 48.0)
|
|
89
|
-
spinning := Node(..sprite, actions: [Action.forever(Action.rotateBy(1.0, 1.0))].toSlice())
|
|
90
|
-
root := Node.group(1, 0.0, 0.0, 800.0, 600.0, [spinning].toSlice())
|
|
91
|
-
MyGame(engine: e, stage: Stage.create(root))
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
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).
|
|
95
|
-
|
|
96
|
-
## State and view
|
|
97
|
-
|
|
98
|
-
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`.
|
|
99
|
-
|
|
100
|
-
```plum
|
|
101
|
-
import std/Int
|
|
102
|
-
import std/Float
|
|
103
|
-
import std/Str
|
|
104
|
-
import std/List
|
|
105
|
-
import std/Slice
|
|
106
|
-
import std/Option
|
|
107
|
-
import std/Io
|
|
108
|
-
import std/game/Game
|
|
109
|
-
import std/game/Vec2
|
|
110
|
-
import std/game/Color
|
|
111
|
-
import std/game/functional/FunctionalScene
|
|
112
|
-
|
|
113
|
-
enum Msg = NoMsg
|
|
114
|
-
|
|
115
|
-
struct State(x: Float) =
|
|
116
|
-
fun update(self, dt: Float) -> State =
|
|
117
|
-
State(..self, x: self.x + 30.0 * dt)
|
|
118
|
-
|
|
119
|
-
fun render(self) -> SceneNode[Msg] =
|
|
120
|
-
RectShape(x: self.x, y: 40.0, w: 20.0, h: 20.0, color: Color.Red)
|
|
121
|
-
|
|
122
|
-
struct MyGame(Game(engine), mut state: State) =
|
|
123
|
-
override fun update(self, dt: Float) =
|
|
124
|
-
self.state = self.state.update(dt)
|
|
125
|
-
|
|
126
|
-
override fun draw(self) =
|
|
127
|
-
self.canvas.clear(Color.Black)
|
|
128
|
-
self.state.render().draw(self.canvas)
|
|
129
|
-
|
|
130
|
-
fun main(io: Io) -> Game =
|
|
131
|
-
MyGame(engine: Engine.create(io), state: State(x: 0.0))
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
`Anim` describes motion as a function of time, and `Ui` handles buttons and pointer clicks, turning them into your `Msg` values. `State` holds no host value, so you can test all of your rules without a browser. Full example: [`game_state_view.plum`](../examples/games/functional/game_state_view.plum).
|
|
135
|
-
|
|
136
|
-
## Compare them
|
|
137
|
-
|
|
138
|
-
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
|
@@ -7,14 +7,19 @@ Write a game in Plum and run it in the browser on Plum's own WebGPU renderer. It
|
|
|
7
7
|
## Try it
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
plum run examples/games/
|
|
10
|
+
plum run examples/games/orb_run.plum
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
This builds the game, starts a local server and opens your browser. Steer with the arrow keys or WASD, collect the orbs and dodge the mines. Press a key first, because browsers only start sound after you interact with the page.
|
|
14
14
|
|
|
15
15
|
## A minimal game
|
|
16
16
|
|
|
17
|
+
A game is any type with two methods, and `main` returns it.
|
|
18
|
+
|
|
19
|
+
- `update(self, io: Io, dt: Float) -> T` returns the next version of the game. `dt` is the length of the last frame in seconds.
|
|
20
|
+
- `draw(self, io: Io)` draws the current version.
|
|
21
|
+
|
|
17
|
-
|
|
22
|
+
Each frame the host calls `update`, and then `draw` on what `update` returned. Nothing is changed in place.
|
|
18
23
|
|
|
19
24
|
```plum
|
|
20
25
|
import std/Int
|
|
@@ -22,68 +27,58 @@ import std/Float
|
|
|
22
27
|
import std/Str
|
|
23
28
|
import std/Io
|
|
24
29
|
import std/game/Game
|
|
25
|
-
import std/game/Vec2
|
|
26
30
|
import std/game/Color
|
|
27
31
|
|
|
28
|
-
struct Ball(
|
|
32
|
+
struct Ball(x: Float, sprite: Texture) =
|
|
29
|
-
|
|
33
|
+
fun update(self, io: Io, dt: Float) -> Ball =
|
|
30
|
-
if
|
|
34
|
+
if io.isKeyDown(Key.Right)
|
|
31
|
-
|
|
35
|
+
Ball(x: self.x + 100.0 * dt, sprite: self.sprite)
|
|
36
|
+
else
|
|
37
|
+
self
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
fun draw(self, io: Io) =
|
|
34
|
-
|
|
40
|
+
io.clear(Color.Black)
|
|
35
|
-
self.
|
|
41
|
+
self.sprite.draw(io, self.x, 100.0)
|
|
36
42
|
|
|
37
|
-
fun main(io: Io) ->
|
|
43
|
+
fun main(io: Io) -> Ball =
|
|
38
|
-
e := Engine.create(io)
|
|
39
|
-
Ball(
|
|
44
|
+
Ball(x: 0.0, sprite: Texture.load(io, "ball.png"))
|
|
40
45
|
```
|
|
41
46
|
|
|
42
|
-
`main` runs once, after your assets have loaded. `update` and `draw` then run every frame.
|
|
43
|
-
|
|
44
|
-
`Engine.create(io)` builds the helpers your game uses, so you never pass `io` around in game code. Keep your rules in plain functions over plain data, like `steer(ship, dt)`. Those need no browser to test.
|
|
47
|
+
`main` runs once, after your assets have loaded. `update` and `draw` then run every frame. If `main` returns a type without these two methods, `plum run` runs it as an ordinary program instead.
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
Keep your rules in plain functions over plain data, like `steer(ship, dt)`. Those need no browser to test.
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
## What `io` gives you
|
|
49
52
|
|
|
50
|
-
|
|
53
|
+
Everything the host provides is a method of `io`. The full list is in [`std/Io.plum`](../std/Io.plum); the key codes and the `Texture` and `Sound` handles are in [`std/game/Game.plum`](../std/game/Game.plum).
|
|
51
54
|
|
|
52
55
|
| Group | Functions |
|
|
53
56
|
|---|---|
|
|
54
|
-
| Screen | `
|
|
57
|
+
| Screen | `screenWidth()`, `screenHeight()`, `frameTime()`, `clear(color)` |
|
|
55
58
|
| Shapes | `drawRect(x, y, w, h, c)`, `drawCircle(x, y, r, c)`, `drawLine(x1, y1, x2, y2, thickness, c)` |
|
|
56
59
|
| Text | `drawText(s, x, y, size, c)` |
|
|
57
|
-
| Images | `drawTexture(t, x, y)`, `drawTextureEx(t, sx, sy, sw, sh, x, y, w, h, rotation, flipX, tint)`, `textureWidth(t)`, `textureHeight(t)`, `setSmooth(t, smooth)` |
|
|
58
60
|
| Camera | `setCamera(x, y, scale)`, `resetCamera()` |
|
|
59
|
-
| Draw into an image | `createTexture(w, h)`, `renderTarget(t)`, `screenTarget()` |
|
|
60
61
|
| Blending | `blendMode(m)` with `Blend.Normal`, `Blend.Add`, `Blend.Multiply`, `Blend.Screen` |
|
|
62
|
+
| Keyboard | `isKeyDown(k)`, `isKeyPressed(k)`, with `Key.Space`, `Key.KeyA`, `Key.Up`, `Key.Enter`, ... |
|
|
63
|
+
| Mouse and touch | `mousePos()`, `isMouseDown(b)`, `isMousePressed(b)`, `mouseWheel()`, with `Mouse.Left`, `Mouse.Right`, `Mouse.Middle` |
|
|
64
|
+
| Random | `randomSeed()` |
|
|
61
65
|
|
|
62
|
-
|
|
66
|
+
Images and sounds are handles you load once, in `main`, and keep in your game. Do not load them every frame.
|
|
63
67
|
|
|
64
|
-
|
|
|
68
|
+
| Handle | Functions |
|
|
65
69
|
|---|---|
|
|
66
|
-
| `isKeyDown(k)`, `isKeyPressed(k)` | key state, with `Key.Space`, `Key.KeyA`, `Key.Up`, `Key.Enter`, ... |
|
|
67
|
-
| `
|
|
70
|
+
| `Texture.load(io, name)` | `draw(io, x, y)`, `width(io)`, `height(io)`, `setSmooth(io, smooth)` |
|
|
68
|
-
| `
|
|
71
|
+
| `Texture.create(io, w, h)` | a blank texture; `renderTarget(io)` draws into it and `Texture.screen(io)` goes back to the screen |
|
|
69
|
-
| `
|
|
72
|
+
| `Sound.load(io, name)` | `play(io)`, `loop(io)`, `stop(io)`, `setVolume(io, v)` |
|
|
73
|
+
| `loadFile(io, name)` | the file's text |
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
A missing file stops the game and names the file, so typos show up straight away. For a texture region, rotation or flip, use `io.drawTextureEx`.
|
|
72
76
|
|
|
73
|
-
|
|
77
|
+
Colors come from `Color.White`, `Color.Red` and so on, or `Color.rgb(r, g, b)`.
|
|
74
|
-
|
|
75
|
-
| Function | What it gives you |
|
|
76
|
-
|---|---|
|
|
77
|
-
| `texture(name)` | a `Texture` |
|
|
78
|
-
| `sound(name)` | a `Sound` |
|
|
79
|
-
| `file(name)` | the file's text |
|
|
80
|
-
| `exists(name)` | whether the file exists |
|
|
81
|
-
|
|
82
|
-
A missing file stops the game and names the file, so typos show up straight away. Look an asset up once, in `main`, and keep the `Texture`, rather than looking it up every frame.
|
|
83
78
|
|
|
84
|
-
|
|
79
|
+
### Input as events
|
|
85
80
|
|
|
86
|
-
|
|
81
|
+
`InputEvent.poll(io, prev)` in [`std/game/Input.plum`](../std/game/Input.plum) is the one place that reads input. It returns this frame's `InputEvent`s (`PointerDown`, `PointerUp`, `PointerMove`, `KeyDown`, `KeyUp`) and the keys now held. A game that feeds every event into its state reads nothing else, so a recorded list of events replays the game exactly.
|
|
87
82
|
|
|
88
83
|
## Assets
|
|
89
84
|
|
|
@@ -95,18 +90,47 @@ While `plum run` serves a game, it watches your code and assets. Save a file and
|
|
|
95
90
|
|
|
96
91
|
If the new code has a compile error, the old game keeps running and the error appears over it. Fix the error and the page reloads. The game restarts on each reload, so its state isn't kept. Turn this off with `--no-watch`.
|
|
97
92
|
|
|
98
|
-
##
|
|
93
|
+
## Scenes
|
|
94
|
+
|
|
95
|
+
For drawing more than a few shapes, build a picture and draw it. [`std/game/Scene.plum`](../std/game/Scene.plum) has one scene style: `SceneNode[M]` is a plain value, a tree of `Group`, `Sprite`, `Text`, `RectShape`, `CircleShape`, `LineShape`, `Filter` and `Clickable` nodes. `render(self)` builds a fresh tree from your state every frame, and `draw(io)` draws it. Nothing in the tree is retained or changed in place.
|
|
99
96
|
|
|
100
|
-
|
|
97
|
+
A `Clickable` carries a message of type `M`. `hitId(x, y)` tells you which one is under a point, and `msgOf(id)` gives its message, so clicks go through your state like any other event.
|
|
101
98
|
|
|
99
|
+
```plum
|
|
100
|
+
import std/Int
|
|
101
|
+
import std/Float
|
|
102
|
+
import std/Str
|
|
103
|
+
import std/Option
|
|
104
|
+
import std/List
|
|
105
|
+
import std/Io
|
|
106
|
+
import std/game/Game
|
|
107
|
+
import std/game/Color
|
|
108
|
+
import std/game/Scene
|
|
109
|
+
|
|
110
|
+
struct Counter(clicks: Int) =
|
|
102
|
-
|
|
111
|
+
fun update(self, io: Io, dt: Float) -> Counter =
|
|
112
|
+
if !io.isMousePressed(Mouse.Left)
|
|
113
|
+
return self
|
|
103
|
-
|
|
114
|
+
p := io.mousePos()
|
|
115
|
+
match self.render().hitId(p.x, p.y)
|
|
116
|
+
Some(id) => Counter(clicks: self.clicks + 1)
|
|
117
|
+
None => self
|
|
118
|
+
|
|
119
|
+
fun render(self) -> SceneNode[Int] =
|
|
120
|
+
Group(transform: Identity, children: [
|
|
104
|
-
|
|
121
|
+
Clickable(id: 1, msg: 1, child: RectShape(x: 20.0, y: 20.0, w: 100.0, h: 40.0, color: Color.Red)),
|
|
105
|
-
|
|
122
|
+
Text(text: "clicks {self.clicks}", x: 20.0, y: 80.0, size: 16.0, color: Color.White)
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
fun draw(self, io: Io) =
|
|
126
|
+
io.clear(Color.Black)
|
|
127
|
+
self.render().draw(io)
|
|
128
|
+
|
|
106
|
-
|
|
129
|
+
fun main(io: Io) -> Counter =
|
|
107
|
-
|
|
130
|
+
Counter(clicks: 0)
|
|
131
|
+
```
|
|
108
132
|
|
|
109
|
-
|
|
133
|
+
[`std/game/Ui.plum`](../std/game/Ui.plum) adds buttons on top of this, and [`std/game/Anim.plum`](../std/game/Anim.plum) adds animation by time. A full game written this way is [`orb_run.plum`](../examples/games/orb_run.plum); its header comment explains how it is put together.
|
|
110
134
|
|
|
111
135
|
## Testing a game
|
|
112
136
|
|
std/game/Input.plum
CHANGED
|
@@ -13,7 +13,7 @@ import std/List
|
|
|
13
13
|
# Player input shared by every scene style. `InputEvent.poll` is the only code that reads
|
|
14
14
|
# input from the host: a game that feeds every polled `InputEvent` into its state reads
|
|
15
15
|
# nothing else, so a recorded list of inputs replays the game exactly (see
|
|
16
|
-
# docs/game
|
|
16
|
+
# docs/game.md, "Input as events").
|
|
17
17
|
|
|
18
18
|
# One input: a pointer event in screen pixels, or a key going down or up.
|
|
19
19
|
enum InputEvent =
|