plum

#treesitter#compiler#wasm

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

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


68edfc2 — Peter John 2026-11-04T19:52:18+05:30
docs: kids' games review plan (queued last) and Balloon Pop draft
docs/backlog.md CHANGED
@@ -13,6 +13,7 @@ Open work, in the order the user queued it, plus smaller items found during buil
13
13
  4. **FBIP: no heap mutation** — persistent std collections updated in place when unique, Koka-style `fip` functions as a checked in-place guarantee, a reference count used only as a uniqueness check if static proof falls short. Worklist = the `mut` fields left today (see below). (Decision record §9.)
14
14
  5. **One product type, `struct`** — after FBIP, merge `record` into an immutable `struct` with compiler-chosen boxing (optional hint, shown by `--explain`). (Decision record §9b.)
15
15
  6. **Deterministic games** — fixed timestep, input recording and replay, seeded `Random` in game state, state checksums; port the design from `../kam_macroquad` (`main.rs` tick accumulator, `game/replay.rs`). The `Io` capability makes recording and replaying host input straightforward. (Decision record §10.)
16
+ 7. **Kids' games (ages 4-7)** (user 2026-11-04: LAST, after all the refactoring is done) — review plan `docs/plans/2026-11-04-kids-games.review.md` (written, NOT approved; re-present for a fresh human approve). Seven functional-style games in `plum-examples/games/functional/kids/`: Balloon Pop (tap), Bunny Hop, Letter Bubbles, Caterpillar, Color Piano, Star Rocket (gentle Orb Run), Hoppy Frog (platformer); nothing can be lost. Draft `balloon_pop.plum` exists (untracked) and is blocked by the `List.length` bug below. Re-check the plan against the post-refactor APIs (explicit generics, `Io` primitive, crate paths) before approving.
16
17
 
17
18
  ## Planned, not yet placed in the queue
18
19
 
@@ -34,6 +35,7 @@ Open work, in the order the user queued it, plus smaller items found during buil
34
35
 
35
36
  ## Smaller open items
36
37
 
38
+ - **`List.length()` on a locally built list fails codegen** — `fun count() -> Int =` / `xs := List(1, 2)` / `xs.length()` gives `codegen: unknown method 'List.lengthFrom' (in 'count')`; the same call on a `List` parameter, or inside a `test` block, works. Also hit with a static `Hue.palette().length()`. Found drafting the kids' games; the user wants it fixed (with a regression test) before those games are built. Check whether the cleanup refactor already fixed it.
37
39
  - **Spread from a field path** — `Stage(..w.stage, x: 1)` is rejected; only a plain variable can be a spread source. Worked around with small helpers in the pure game. (language-restrictions T14)
38
40
  - **Fields declared inside `init` can't be `mut`** (`self.camX: Float = 0.0`) — such fields were moved into the record header. (T8)
39
41
  - **Benchmark harness can't pass `--no-fusion`** — `plum-game/web-test` compiles without flags, so fusion-off numbers can't be taken since the env var was removed. (T11)
docs/plans/2026-11-04-kids-games.review.md ADDED
@@ -0,0 +1,94 @@
1
+ # Kids' games (ages 4-7): review plan
2
+
3
+ ## Summary
4
+ Seven small example games for children aged 4 to 7, written in the functional style of Orb Run (one immutable game state, a list of messages that can happen to it, and a picture rebuilt every frame). Two are played by tapping or clicking, five with the keyboard, and two of those five are child-friendly versions of the ship game and of a platformer. None of them can be lost. There are no lives, no countdowns and no "game over", and mistakes get a gentle wiggle, never a penalty. Before the games, a compiler bug found while drafting them gets fixed: asking a list built inside a function for its length fails to compile.
5
+
6
+ ## What you will get
7
+ - A `kids` folder of functional-style games, each one file that runs with `plum game <file>`:
8
+ - **Balloon Pop** (tap): balloons float up. Tap one to pop it, and its colour's name floats up ("Red!"). Every 10 pops there is a confetti party.
9
+ - **Bunny Hop** (arrows or WASD): steer a bunny around a garden to collect carrots, and a flower grows for each one. There are no enemies.
10
+ - **Letter Bubbles** (letter keys): bubbles with big letters drift down. Press that letter to pop the bubble. It starts with a few easy letters and adds more over time.
11
+ - **Caterpillar** (arrows): a gentle snake. It eats leaves and grows, the edges wrap around, and it passes through itself, so it never dies.
12
+ - **Color Piano** (keys 1-4 or tap): four coloured pads that light up and play notes, plus a "copy me" tune that only grows when the child copies it right.
13
+ - **Star Rocket** (arrows, like Orb Run): fly a friendly rocket and collect stars. Clouds just bump you softly, and there are no mines and no lives.
14
+ - **Hoppy Frog** (arrows plus Space, a platformer): hop across lily pads and logs to catch flies. Falling in the water puts the frog back on the last pad with a splash.
15
+ - Big, bright shapes and big text, sized for small hands and early readers.
16
+ - Five new synthesized sounds (four piano notes and a cheer), made by the existing sound generator, with the current pop, bump and music sounds reused.
17
+ - Tests in every game for its rules, plus a check that each game compiles and runs frames without a browser.
18
+ - A short "Kids' games" entry in the README games section.
19
+ - The list-length compiler bug fixed, with a regression test.
20
+
21
+ ## What will be built
22
+ | Part | What it does | Size | Built by |
23
+ |---|---|---|---|
24
+ | List-length fix | Lets a list built inside a function report its length, with a regression test | M | a careful helper |
25
+ | New sounds | Adds four piano notes and a cheer to the sound generator and the asset folder | S | a fast helper |
26
+ | Balloon Pop | Finishes the existing draft (it currently hits the list bug) and its tests | S | a careful helper |
27
+ | Bunny Hop | Arrow-key garden walk that collects carrots and grows flowers | M | a careful helper |
28
+ | Letter Bubbles | Falling letter bubbles popped by pressing the matching key | M | a careful helper |
29
+ | Caterpillar | Grid-stepping caterpillar that grows, wraps at the edges and never dies | M | a careful helper |
30
+ | Color Piano | Four lit pads with notes and a growing "copy me" tune | M | a careful helper |
31
+ | Star Rocket | Orb Run's flying rules with stars, soft cloud bumps and no losing | M | a careful helper |
32
+ | Hoppy Frog | Gravity, jumping and landing on platforms, with a safe respawn from the water | L | a careful helper |
33
+ | Headless run check | Compiles each game and runs a few hundred frames with no browser | S | a fast helper |
34
+ | README entry | Lists the games and how to run them | S | a fast helper |
35
+
36
+ ## How it fits together
37
+ ```mermaid
38
+ flowchart LR
39
+ K[Keyboard and mouse] --> P[Input polling, once a frame]
40
+ P --> M["Messages: key, tap, tick"]
41
+ M --> S[Game state update]
42
+ S --> R[Picture rebuilt every frame]
43
+ R --> D[Browser renderer]
44
+ R -->|"what is under the tap"| S
45
+ S --> A[Sounds]
46
+ L[Shared functional scene library] --> R
47
+ ```
48
+
49
+ ## How it gets built
50
+ ```mermaid
51
+ flowchart TB
52
+ F[List-length fix] --> W2
53
+ N[New sounds] --> W2
54
+ subgraph W2["Wave 2, seven games in parallel"]
55
+ G1[Balloon Pop]
56
+ G2[Bunny Hop]
57
+ G3[Letter Bubbles]
58
+ G4[Caterpillar]
59
+ G5[Color Piano]
60
+ G6[Star Rocket]
61
+ G7[Hoppy Frog]
62
+ end
63
+ W2 --> H[Headless run check and README]
64
+ H --> V[Final review]
65
+ ```
66
+
67
+ ## Decisions you can change
68
+ - Where the games live: a `kids` folder inside the functional examples (default) | a top-level kids folder beside imperative, pure and functional.
69
+ - Shared code: each game is one self-contained file, as Orb Run is (default) | a small shared kids helper module for the camera and the host shell.
70
+ - How taps are read: pop as soon as the finger touches (default, because small children tap and do not click) | the usual press-and-release click.
71
+ - Platformer controls: arrows plus Space to jump (default) | arrows only, with Up to jump.
72
+ - Letter Bubbles letters: capital letters, starting with A to E and adding more every few pops (default) | the whole alphabet from the start.
73
+
74
+ ## Risks
75
+ - **The list fix touches the compiler, which the open compiler-cleanup run is also changing.** We keep the fix small and local, with a regression test, and leave rebasing to whichever run lands second.
76
+ - **More compiler gaps may turn up while writing the games.** Each one is noted in the backlog. A game works around it only when the fix would be large, and the workaround is called out in the final report.
77
+ - **Rectangles do not rotate in the functional renderer.** The games use circles, lines and upright rectangles, and the frog's and rocket's turning is shown with circles and lines.
78
+ - **Text width is only estimated, so centred text may sit slightly off.** Text is kept short and checked in the browser.
79
+ - **Hoppy Frog's collision is the hardest logic.** It gets the most tests: landing, walking off an edge, hitting a pad from below, and respawning.
80
+
81
+ ## Out of scope
82
+ - Images or sprite art drawn for these games (they use shapes, like the drafts).
83
+ - Saving progress or high scores, menus, a game picker, settings, and voice-over (spoken colour or letter names).
84
+ - Touch-specific gestures beyond a tap (no drag or swipe).
85
+ - Changes to the shared functional scene library, unless a game is truly blocked.
86
+
87
+ ## How we will know it works
88
+ - `plum test` passes for the whole kids folder and for the existing examples.
89
+ - The headless check runs every kids game for 300 frames with no error.
90
+ - In the browser, each game: Balloon Pop pops a balloon on tap. Bunny Hop collects carrots. Letter Bubbles pops on the right key and wiggles on a wrong one. Caterpillar grows and wraps. Color Piano plays and grows its tune. Star Rocket collects stars and survives clouds. Hoppy Frog jumps, lands and respawns.
91
+ - The list-length regression test fails before the fix and passes after it, and the whole compiler test suite still passes.
92
+
93
+ ## Rough time and cost
94
+ About 40 to 70 minutes and $15 to $30 (rough, until the machine plan is linted).
plum-examples/games/functional/kids/balloon_pop.plum ADDED
@@ -0,0 +1,405 @@
1
+ # Balloon Pop (`plum game plum-examples/games/functional/kids/balloon_pop.plum`): a
2
+ # game for small children. Balloons float up the sky; tap or click one to pop it.
3
+ # A popped balloon bursts into dots and its colour's name floats up ("Red!"), so the
4
+ # child hears the pop and sees the word. Every 10 pops a "Hooray!" party with
5
+ # confetti. There is no way to lose and no timer.
6
+ #
7
+ # Functional style, as in ../game_state_view.plum: `State` holds the whole game,
8
+ # `Msg` is everything that can happen to it, `update(msg)` returns the next `State`
9
+ # and `render()` returns a fresh `SceneNode[Msg]` picture every frame. Each balloon
10
+ # is a `Clickable` carrying its own `Pop(id)` message, so a pointer press finds the
11
+ # message under it in the picture (`hitId`, then `msgOf`) and sends it to `update`.
12
+ # A press pops at once: small children tap, they do not click.
13
+ module balloon_pop
14
+ import std/Str
15
+ import std/Int
16
+ import std/Float
17
+ import std/Slice
18
+ import std/List
19
+ import std/Math
20
+ import std/Random
21
+ import std/game/Vec2
22
+ import std/game/Color
23
+ import std/game/Game
24
+ import std/game/functional/FunctionalScene
25
+ import std/game/functional/Anim
26
+ import std/game/Input
27
+ import std/Io
28
+
29
+ primitive Rules =
30
+ ArenaW = 800.0
31
+ ArenaH = 600.0
32
+ Count = 6
33
+ Radius = 40.0
34
+ MinSpeed = 35.0
35
+ MaxSpeed = 70.0
36
+ PopTime = 0.6
37
+ PartyEvery = 10
38
+ PartyTime = 2.5
39
+
40
+ primitive Id =
41
+ Balloon = 100
42
+
43
+ # The balloon colours a child can name.
44
+ record Hue(name: Str, color: Color) =
45
+ fun palette() -> List[Hue] =
46
+ List(
47
+ Hue(name: "Red", color: Color.rgb(0.93, 0.2, 0.22)),
48
+ Hue(name: "Blue", color: Color.rgb(0.2, 0.45, 0.95)),
49
+ Hue(name: "Yellow", color: Color.rgb(1.0, 0.85, 0.15)),
50
+ Hue(name: "Green", color: Color.rgb(0.25, 0.75, 0.3)),
51
+ Hue(name: "Orange", color: Color.rgb(1.0, 0.55, 0.1)),
52
+ Hue(name: "Purple", color: Color.rgb(0.6, 0.3, 0.85)),
53
+ Hue(name: "Pink", color: Color.rgb(1.0, 0.5, 0.75))
54
+ )
55
+
56
+ fun of(i: Int) -> Hue =
57
+ hues := Hue.palette()
58
+ hues.at(i % hues.length())
59
+
60
+ fun count() -> Int =
61
+ Hue.palette().length()
62
+
63
+ # --- small pure helpers ---
64
+
65
+ fun nothing() -> SceneNode[Msg] =
66
+ Group(transform: Identity, children: Nil)
67
+
68
+ # Text centred on `cx` (the host font is about 0.6 of its size wide per letter).
69
+ fun centred(s: Str, cx: Float, y: Float, size: Float, color: Color) -> SceneNode[Msg] =
70
+ Text(text: s, x: cx - size * 0.3 * Float(s.length()), y: y, size: size, color: color)
71
+
72
+ # Grows in, then pulses gently.
73
+ fun partyAnim() -> Anim =
74
+ Seq(
75
+ steps: List(
76
+ ScaleTo(sx: 1.4, sy: 1.4, secs: 0.25),
77
+ Forever(anim: Seq(steps: List(ScaleTo(sx: 0.85, sy: 0.85, secs: 0.4), ScaleTo(sx: 1.0 / 0.85, sy: 1.0 / 0.85, secs: 0.4))))
78
+ )
79
+ )
80
+
81
+ # One balloon. `poppedAt` is the clock time it was popped, or negative while whole.
82
+ record Balloon(id: Int, x: Float, y: Float, speed: Float, hue: Int, phase: Float, poppedAt: Float) =
83
+
84
+ # A fresh balloon below the bottom edge, `depth` further down so they do not
85
+ # all arrive together.
86
+ fun spawn(id: Int, rng: Random, depth: Float) -> Spawned =
87
+ rx := rng.nextFloat()
88
+ rs := rx.rng.nextFloat()
89
+ rh := rs.rng.nextIntBelow(Hue.count())
90
+ x := 60.0 + rx.value * {Rules.ArenaW - 120.0}
91
+ speed := Rules.MinSpeed + rs.value * {Rules.MaxSpeed - Rules.MinSpeed}
92
+ b := Balloon(id: id, x: x, y: Rules.ArenaH + Rules.Radius + depth, speed: speed, hue: rh.value, phase: rx.value * Float.Tau, poppedAt: -1.0)
93
+ Spawned(balloon: b, rng: rh.rng)
94
+
95
+ fun popped(self) -> Bool =
96
+ self.poppedAt >= 0.0
97
+
98
+ # Where the balloon is drawn: it sways side to side as it rises.
99
+ fun swayX(self, clock: Float) -> Float =
100
+ self.x + 14.0 * Math.sin(clock * 1.3 + self.phase)
101
+
102
+ # Rises while whole; once popped it stays where it burst.
103
+ fun update(self, dt: Float) -> Balloon =
104
+ if self.popped() then self else Balloon(..self, y: self.y - self.speed * dt)
105
+
106
+ # Gone off the top, or popped long enough ago that the burst has faded.
107
+ fun done(self, clock: Float) -> Bool =
108
+ self.y < 0.0 - Rules.Radius * 3.0 || {self.popped() && clock - self.poppedAt > Rules.PopTime}
109
+
110
+ fun render(self, clock: Float) -> SceneNode[Msg] =
111
+ if self.popped() then self.renderBurst(clock - self.poppedAt) else self.renderWhole(clock)
112
+
113
+ fun renderWhole(self, clock: Float) -> SceneNode[Msg] =
114
+ r := Rules.Radius
115
+ c := Hue.of(self.hue).color
116
+ Clickable(
117
+ id: self.id,
118
+ msg: Pop(id: self.id),
119
+ child: Group(
120
+ transform: Translate(dx: self.swayX(clock), dy: self.y),
121
+ children: List(
122
+ LineShape(x1: 0.0, y1: r, x2: 6.0 * Math.sin(clock * 3.0 + self.phase), y2: r * 2.6, thickness: 2.0, color: Color.rgb(0.35, 0.35, 0.35)),
123
+ CircleShape(cx: 0.0, cy: r + 2.0, r: 6.0, color: c),
124
+ CircleShape(cx: 0.0, cy: 0.0, r: r, color: c),
125
+ CircleShape(cx: 0.0 - r * 0.35, cy: 0.0 - r * 0.4, r: r * 0.22, color: Color.White.withAlpha(0.55))
126
+ )
127
+ )
128
+ )
129
+
130
+ # Eight dots fly out and fade, and the colour's name floats up.
131
+ fun renderBurst(self, age: Float) -> SceneNode[Msg] =
132
+ t := {age / Rules.PopTime}.min(1.0)
133
+ fade := 1.0 - t
134
+ hue := Hue.of(self.hue)
135
+ reach := 20.0 + 70.0 * t
136
+ Group(
137
+ transform: Translate(dx: self.x, dy: self.y),
138
+ children: Cons(
139
+ head: centred("{hue.name}!", 0.0, -20.0 - 60.0 * t, 40.0, Color.rgb(0.15, 0.15, 0.3).withAlpha(fade)),
140
+ tail: burstDots(0, reach, hue.color.withAlpha(fade))
141
+ )
142
+ )
143
+
144
+ fun burstDots(i: Int, reach: Float, c: Color) -> List[SceneNode[Msg]] =
145
+ if i >= 8
146
+ Nil
147
+ else
148
+ a := Float(i) * Float.Tau / 8.0
149
+ Cons(head: CircleShape(cx: Math.cos(a) * reach, cy: Math.sin(a) * reach, r: 8.0, color: c), tail: burstDots(i + 1, reach, c))
150
+
151
+ record Spawned(balloon: Balloon, rng: Random)
152
+
153
+ # What can happen to the game.
154
+ enum Msg =
155
+ | Tick(dt: Float)
156
+ | Resize(w: Float, h: Float)
157
+ | Event(input: Input)
158
+ | Pop(id: Int)
159
+
160
+ record State(balloons: List[Balloon], rng: Random, pops: Int, clock: Float, partyAt: Float, sw: Float, sh: Float) =
161
+
162
+ fun start(seed: Int, sw: Float, sh: Float) -> State =
163
+ State(balloons: Nil, rng: Random.seeded(seed), pops: 0, clock: 0.0, partyAt: -100.0, sw: sw, sh: sh).fill(0)
164
+
165
+ # Balloons `i` and up, spread out below the screen.
166
+ fun fill(self, i: Int) -> State =
167
+ if i >= Rules.Count
168
+ return self
169
+ sp := Balloon.spawn(Id.Balloon + i, self.rng, Float(i) * 110.0 - Rules.ArenaH * 0.6)
170
+ State(..self, balloons: self.balloons.append(List(sp.balloon)), rng: sp.rng).fill(i + 1)
171
+
172
+ fun update(self, msg: Msg) -> State = match msg
173
+ Tick(dt) => self.tick(dt)
174
+ Resize(w, h) => State(..self, sw: w, sh: h)
175
+ Event(input) => self.onInput(input)
176
+ Pop(id) => self.pop(id)
177
+
178
+ fun tick(self, dt: Float) -> State =
179
+ moved := State(..self, clock: self.clock + dt, balloons: self.balloons.map(|b| b.update(dt)))
180
+ moved.balloons.fold(moved, |s, b| s.renew(b))
181
+
182
+ # A balloon that is gone comes back at the bottom with a new colour.
183
+ fun renew(self, b: Balloon) -> State =
184
+ if !b.done(self.clock)
185
+ return self
186
+ sp := Balloon.spawn(b.id, self.rng, 0.0)
187
+ State(..self, balloons: self.balloons.map(|x| if x.id == b.id then sp.balloon else x), rng: sp.rng)
188
+
189
+ fun pop(self, id: Int) -> State =
190
+ hit := self.balloons.fold(false, |found, b| found || {b.id == id && !b.popped()})
191
+ if !hit
192
+ return self
193
+ pops := self.pops + 1
194
+ State(
195
+ ..self,
196
+ pops: pops,
197
+ partyAt: if pops % Rules.PartyEvery == 0 then self.clock else self.partyAt,
198
+ balloons: self.balloons.map(|b| if b.id == id then Balloon(..b, x: b.swayX(self.clock), poppedAt: self.clock) else b)
199
+ )
200
+
201
+ fun partying(self) -> Bool =
202
+ self.clock - self.partyAt < Rules.PartyTime
203
+
204
+ # A press pops the balloon under it; everything else is ignored.
205
+ fun onInput(self, e: Input) -> State = match e
206
+ PointerDown(x, y) => self.pressAt(x, y)
207
+ _ => self
208
+
209
+ fun pressAt(self, x: Float, y: Float) -> State =
210
+ view := self.render()
211
+ id := view.hitId(x, y) ?: return self
212
+ msg := view.msgOf(id) ?: return self
213
+ self.update(msg)
214
+
215
+ # --- render ---
216
+
217
+ fun zoom(self) -> Float =
218
+ {self.sw / Rules.ArenaW}.min(self.sh / Rules.ArenaH)
219
+
220
+ fun offsetX(self) -> Float =
221
+ {self.sw - self.zoom() * Rules.ArenaW} / 2.0
222
+
223
+ fun offsetY(self) -> Float =
224
+ {self.sh - self.zoom() * Rules.ArenaH} / 2.0
225
+
226
+ fun render(self) -> SceneNode[Msg] =
227
+ z := self.zoom()
228
+ Group(
229
+ transform: Compose(first: Translate(dx: self.offsetX(), dy: self.offsetY()), then: Scale(sx: z, sy: z)),
230
+ children: List(
231
+ renderSky(self.clock),
232
+ Group(transform: Identity, children: self.balloons.map(|b| b.render(self.clock))),
233
+ self.renderHud(),
234
+ self.renderParty()
235
+ )
236
+ )
237
+
238
+ fun renderHud(self) -> SceneNode[Msg] =
239
+ Group(
240
+ transform: Identity,
241
+ children: List(
242
+ RectShape(x: 16.0, y: 14.0, w: 170.0, h: 56.0, color: Color.White.withAlpha(0.7)),
243
+ CircleShape(cx: 46.0, cy: 42.0, r: 18.0, color: Hue.of(0).color),
244
+ Text(text: "{self.pops}", x: 80.0, y: 20.0, size: 44.0, color: Color.rgb(0.15, 0.15, 0.3))
245
+ )
246
+ )
247
+
248
+ fun renderParty(self) -> SceneNode[Msg] =
249
+ if !self.partying()
250
+ return nothing()
251
+ age := self.clock - self.partyAt
252
+ p := Anim.at(partyAnim(), age)
253
+ Group(
254
+ transform: Identity,
255
+ children: List(
256
+ Group(transform: Identity, children: confetti(0, age)),
257
+ Group(
258
+ transform: Compose(first: Translate(dx: Rules.ArenaW / 2.0, dy: 250.0), then: Scale(sx: p.scaleX, sy: p.scaleY)),
259
+ children: List(
260
+ centred("Hooray!", 0.0, -40.0, 72.0, Color.rgb(0.95, 0.3, 0.5)),
261
+ centred("{self.pops} balloons!", 0.0, 40.0, 40.0, Color.rgb(0.15, 0.15, 0.3))
262
+ )
263
+ )
264
+ )
265
+ )
266
+
267
+ # 30 confetti squares falling from the top, `age` seconds into the party.
268
+ fun confetti(i: Int, age: Float) -> List[SceneNode[Msg]] =
269
+ if i >= 30
270
+ Nil
271
+ else
272
+ x := Float({i * 7919} % 800)
273
+ y := Float({i * 104729} % 200) - 200.0 + age * {180.0 + Float(i % 5) * 40.0}
274
+ wob := 10.0 * Math.sin(age * 4.0 + Float(i))
275
+ Cons(head: RectShape(x: x + wob, y: y, w: 12.0, h: 12.0, color: Hue.of(i).color), tail: confetti(i + 1, age))
276
+
277
+ # Sky, two drifting clouds and the grass.
278
+ fun renderSky(clock: Float) -> SceneNode[Msg] =
279
+ Group(
280
+ transform: Identity,
281
+ children: List(
282
+ RectShape(x: 0.0, y: 0.0, w: Rules.ArenaW, h: Rules.ArenaH, color: Color.rgb(0.55, 0.8, 1.0)),
283
+ RectShape(x: 0.0, y: 0.0, w: Rules.ArenaW, h: 200.0, color: Color.rgb(0.45, 0.72, 0.98)),
284
+ CircleShape(cx: 690.0, cy: 90.0, r: 46.0, color: Color.rgb(1.0, 0.9, 0.35)),
285
+ cloud(wrapX(120.0 + clock * 12.0), 130.0),
286
+ cloud(wrapX(520.0 + clock * 8.0), 220.0),
287
+ RectShape(x: 0.0, y: Rules.ArenaH - 50.0, w: Rules.ArenaW, h: 50.0, color: Color.rgb(0.4, 0.78, 0.35))
288
+ )
289
+ )
290
+
291
+ fun wrapX(x: Float) -> Float =
292
+ span := Rules.ArenaW + 200.0
293
+ x - span * Float(Int(x / span)) - 100.0
294
+
295
+ fun cloud(x: Float, y: Float) -> SceneNode[Msg] =
296
+ white := Color.White.withAlpha(0.9)
297
+ Group(
298
+ transform: Translate(dx: x, dy: y),
299
+ children: List(
300
+ CircleShape(cx: -30.0, cy: 8.0, r: 24.0, color: white),
301
+ CircleShape(cx: 0.0, cy: 0.0, r: 32.0, color: white),
302
+ CircleShape(cx: 32.0, cy: 8.0, r: 24.0, color: white)
303
+ )
304
+ )
305
+
306
+ # --- the host shell ---
307
+
308
+ record World(mut state: State, mut held: Held, popSound: Int, partySound: Int, musicSound: Int)
309
+
310
+ export fun init(io: Io) -> World =
311
+ w := World(
312
+ state: State.start(io.randomSeed(), Rules.ArenaW, Rules.ArenaH),
313
+ held: Held.none(),
314
+ popSound: io.soundLoad("pickup.wav"),
315
+ partySound: io.soundLoad("gameover.wav"),
316
+ musicSound: io.soundLoad("music.wav")
317
+ )
318
+ io.soundLoop(w.musicSound, 0.2)
319
+ w
320
+
321
+ # Polls the host once, sends every input to the state, then one tick of time;
322
+ # plays a pop for each new pop and a fanfare when a party starts.
323
+ export fun update(io: Io, w: World, dt: Float) =
324
+ polled := Input.poll(io, w.held)
325
+ w.held = polled.held
326
+ s := w.state.update(Resize(w: io.screenWidth(), h: io.screenHeight()))
327
+ before := s
328
+ for i := range polled.inputs.length()
329
+ s = s.update(Event(input: polled.inputs.at(i)))
330
+ if s.pops > before.pops
331
+ io.soundPlay(w.popSound, 0.9)
332
+ if s.partyAt != before.partyAt
333
+ io.soundPlay(w.partySound, 0.6)
334
+ w.state = s.update(Tick(dt: dt))
335
+
336
+ export fun draw(io: Io, w: World) =
337
+ io.clear(Color.rgb(0.55, 0.8, 1.0))
338
+ w.state.render().draw(io)
339
+
340
+ # --- tests ---
341
+
342
+ fun newState() -> State =
343
+ State.start(3, Rules.ArenaW, Rules.ArenaH)
344
+
345
+ # A state whose first balloon is still and in the middle of the screen.
346
+ fun withCentred(s: State) -> State =
347
+ State(..s, balloons: s.balloons.map(|b| if b.id == Id.Balloon then Balloon(..b, x: 400.0, y: 300.0, speed: 0.0, phase: 0.0) else b))
348
+
349
+ fun first(s: State) -> Balloon =
350
+ s.balloons.at(0)
351
+
352
+ test "a new game has its balloons below the screen and no pops"
353
+ s := newState()
354
+ assert s.balloons.length() == Rules.Count
355
+ assert s.pops == 0
356
+ assert s.balloons.fold(true, |ok, b| ok && b.y > Rules.ArenaH * 0.4)
357
+
358
+ test "balloons rise"
359
+ s := newState()
360
+ t := s.update(Tick(dt: 1.0))
361
+ assert first(t).y < first(s).y
362
+ assert first(s).y == newState().balloons.at(0).y
363
+
364
+ test "pressing a balloon pops it and counts"
365
+ s := withCentred(newState())
366
+ t := s.update(Event(input: PointerDown(x: 400.0, y: 300.0)))
367
+ assert t.pops == 1
368
+ assert first(t).popped()
369
+ again := t.update(Event(input: PointerDown(x: 400.0, y: 300.0)))
370
+ assert again.pops == 1
371
+
372
+ test "pressing the sky does nothing"
373
+ s := withCentred(newState())
374
+ t := s.update(Event(input: PointerDown(x: 50.0, y: 560.0)))
375
+ assert t.pops == 0
376
+
377
+ test "a popped balloon comes back at the bottom"
378
+ s := withCentred(newState()).update(Pop(id: Id.Balloon))
379
+ t := s.update(Tick(dt: Rules.PopTime + 0.1))
380
+ assert !first(t).popped()
381
+ assert first(t).y > Rules.ArenaH
382
+
383
+ test "every tenth pop starts a party that ends"
384
+ s := newState()
385
+ for i := range 9
386
+ s = State(..s, pops: s.pops + 1)
387
+ assert !s.partying()
388
+ t := withCentred(s).update(Pop(id: Id.Balloon))
389
+ assert t.pops == 10
390
+ assert t.partying()
391
+ u := t.update(Tick(dt: Rules.PartyTime + 0.1))
392
+ assert !u.partying()
393
+
394
+ test "the press is found through the camera"
395
+ s := withCentred(newState()).update(Resize(w: 1600.0, h: 600.0))
396
+ miss := s.update(Event(input: PointerDown(x: 400.0, y: 300.0)))
397
+ assert miss.pops == 0
398
+ hit := s.update(Event(input: PointerDown(x: 800.0, y: 300.0)))
399
+ assert hit.pops == 1
400
+
401
+ test "the same seed gives the same balloons"
402
+ a := State.start(9, Rules.ArenaW, Rules.ArenaH)
403
+ b := State.start(9, Rules.ArenaW, Rules.ArenaH)
404
+ assert first(a).x == first(b).x
405
+ assert first(a).hue == first(b).hue