wanderland 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. wanderland-0.1.0/.gitignore +31 -0
  2. wanderland-0.1.0/CLAUDE.md +60 -0
  3. wanderland-0.1.0/LICENSE +21 -0
  4. wanderland-0.1.0/PKG-INFO +380 -0
  5. wanderland-0.1.0/README.md +343 -0
  6. wanderland-0.1.0/example.py +154 -0
  7. wanderland-0.1.0/js/board.js +207 -0
  8. wanderland-0.1.0/js/characters/character.js +103 -0
  9. wanderland-0.1.0/js/characters/index.js +25 -0
  10. wanderland-0.1.0/js/characters/mossball.js +390 -0
  11. wanderland-0.1.0/js/characters/rover.js +264 -0
  12. wanderland-0.1.0/js/easing.js +31 -0
  13. wanderland-0.1.0/js/hud.js +73 -0
  14. wanderland-0.1.0/js/index.js +81 -0
  15. wanderland-0.1.0/js/objects.js +153 -0
  16. wanderland-0.1.0/js/palette.js +30 -0
  17. wanderland-0.1.0/js/scene.js +67 -0
  18. wanderland-0.1.0/js/viewer.js +373 -0
  19. wanderland-0.1.0/package-lock.json +554 -0
  20. wanderland-0.1.0/package.json +17 -0
  21. wanderland-0.1.0/pyproject.toml +69 -0
  22. wanderland-0.1.0/rl_playground.py +233 -0
  23. wanderland-0.1.0/src/wanderland/__init__.py +69 -0
  24. wanderland-0.1.0/src/wanderland/actions.py +267 -0
  25. wanderland-0.1.0/src/wanderland/commands.py +97 -0
  26. wanderland-0.1.0/src/wanderland/generate.py +83 -0
  27. wanderland-0.1.0/src/wanderland/puzzles.py +482 -0
  28. wanderland-0.1.0/src/wanderland/render.py +207 -0
  29. wanderland-0.1.0/src/wanderland/solve.py +81 -0
  30. wanderland-0.1.0/src/wanderland/static/index.css +136 -0
  31. wanderland-0.1.0/src/wanderland/static/index.js +4114 -0
  32. wanderland-0.1.0/src/wanderland/world.py +429 -0
  33. wanderland-0.1.0/test/browser_test.mjs +99 -0
  34. wanderland-0.1.0/test/expr_entry.js +7 -0
  35. wanderland-0.1.0/test/fixture.json +387 -0
  36. wanderland-0.1.0/test/fixture_collect2.json +1 -0
  37. wanderland-0.1.0/test/fixture_hover.json +1 -0
  38. wanderland-0.1.0/test/fixture_lava.json +1 -0
  39. wanderland-0.1.0/test/fixture_nogoal.json +292 -0
  40. wanderland-0.1.0/test/fixture_teeter.json +1 -0
  41. wanderland-0.1.0/test/harness.html +115 -0
  42. wanderland-0.1.0/test/lr_init.json +1 -0
  43. wanderland-0.1.0/test/lr_solve.json +1 -0
  44. wanderland-0.1.0/test/screenshot.mjs +35 -0
  45. wanderland-0.1.0/test/shot.html +41 -0
  46. wanderland-0.1.0/test/shot_characters.mjs +29 -0
  47. wanderland-0.1.0/test/shot_expr.html +4 -0
  48. wanderland-0.1.0/test/shot_expr.mjs +19 -0
  49. wanderland-0.1.0/test/shot_gem.mjs +37 -0
  50. wanderland-0.1.0/test/shot_lava.mjs +21 -0
  51. wanderland-0.1.0/test/shot_lr.mjs +15 -0
  52. wanderland-0.1.0/test/shot_notebook.mjs +20 -0
  53. wanderland-0.1.0/test/shot_return.mjs +41 -0
  54. wanderland-0.1.0/test/shot_teeter.mjs +21 -0
  55. wanderland-0.1.0/test/shot_widget.mjs +35 -0
  56. wanderland-0.1.0/test/test_sim.py +243 -0
@@ -0,0 +1,31 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ venv/
6
+ *.egg-info/
7
+ .eggs/
8
+ build/
9
+ dist/
10
+
11
+ # marimo
12
+ __marimo__/
13
+
14
+ # Node
15
+ node_modules/
16
+
17
+ # Test artifacts (regenerable screenshots + bundled test entry)
18
+ test/*.png
19
+ test/expr_bundle.js
20
+
21
+ # OS / editor
22
+ .DS_Store
23
+ *.swp
24
+ .idea/
25
+ .vscode/
26
+
27
+ # internal planning (not tracked)
28
+ todo.md
29
+
30
+ # Local-only saved layouts (not shipped)
31
+ layouts/
@@ -0,0 +1,60 @@
1
+ # Wanderland — working notes for Claude Code
2
+
3
+ Interactive low-poly 3D coding playground built as an **anywidget** for **marimo**: write
4
+ Python commands, watch a character act them out in a tile/grid world. Dual purpose —
5
+ teaching coding, and evaluating agents/LLMs on grid-world tasks. Default character: **Mo**.
6
+
7
+ ## Architecture (don't break these)
8
+
9
+ - **Python is the authoritative simulator; the frontend is a deterministic replay of a
10
+ precomputed timeline.** All game logic lives in `src/wanderland/`. The frontend (`js/`)
11
+ holds no game logic — it just animates the timeline. Results are available synchronously
12
+ in Python (work headless).
13
+ - **The timeline event vocabulary is the versioned Python↔JS contract.** Add a verb by
14
+ registering a handler in `actions.py` — never grow an `if/elif` in `world.py::_simulate`.
15
+ - **Every world declares an explicit, required action space** (no default, no canonical
16
+ bundle). Recording a verb outside it raises.
17
+ - **Characters are a pure frontend concern** — the contract is `js/characters/character.js`;
18
+ Mo (`mossball.js`) and Rover (`rover.js`) implement it. Adding a character never touches
19
+ Python.
20
+
21
+ ## Layout
22
+
23
+ - `src/wanderland/`: `actions.py` (verb registry + sim semantics), `commands.py` (recorded
24
+ verbs), `puzzles.py` (Puzzle + objects + `from_ascii`/`from_dict`/`to_prompt` + built-ins),
25
+ `generate.py` (`random_room`), `solve.py` (BFS oracle), `world.py` (the AnyWidget; `act()`),
26
+ `static/` (built bundle).
27
+ - `js/`: `index.js` (entry), `viewer.js` (orchestrator), `scene.js`, `board.js` + `objects.js`
28
+ (world render), `characters/`, `hud.js`, `palette.js`, `easing.js`.
29
+ - Notebooks: `example.py` (education), `rl_playground.py` (agent/RL). Tests in `test/`.
30
+
31
+ ## Build / test
32
+
33
+ - **After editing anything in `js/`, rebuild the bundle:** `npm run build` (esbuild →
34
+ `src/wanderland/static/index.js`). The committed bundle is what ships; source edits do
35
+ nothing until rebuilt.
36
+ - Python sim tests (headless, fast): `.venv/bin/python test/test_sim.py`
37
+ - Frontend render test (headless, software WebGL): `node test/browser_test.mjs`
38
+ - Characters end-to-end: `node test/shot_characters.mjs`
39
+ - Notebooks: smoke-test with `.venv/bin/python -m marimo export html <nb>.py -o /tmp/x.html`.
40
+ - Editable install lives in `.venv` (`uv pip install -e ".[dev]"`).
41
+
42
+ ## Conventions
43
+
44
+ - **No Claude attribution** in commits or PRs (no `Co-Authored-By`, no "Generated with…").
45
+ - **Neutral language** — no external framework or paper names; keep it a generic grid-world
46
+ playground.
47
+ - `todo.md` (gitignored) and `layouts/` (untracked) are local-only — don't commit them.
48
+
49
+ ## Model / gotchas
50
+
51
+ - Gems come in two kinds: non-blocking `g` (walk onto it, then `collect_gem`; scores) and
52
+ blocking `G` (`pickup` from the front; carried). Keys/balls/boxes are carry-slot inventory
53
+ (limit one). Doors are tiles with open/closed/locked state; a locked door needs a matching
54
+ key (kept). Box opens to its contents.
55
+ - `World.load()` is **idempotent** (re-loading the same program is a no-op) so a reactive
56
+ marimo re-run after a finished run doesn't reset the scene. Keep that.
57
+ - A blocked move carries `blocked_by` → the character distinguishes (edge = teeter, near
58
+ fall; wall/door/object = bonk).
59
+ - In `World.act(actions, ...)` the `actions` param shadows the `actions` module — alias it
60
+ (`from . import actions as actions_mod`) if you need the registry there.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Konstantin Taletskiy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,380 @@
1
+ Metadata-Version: 2.4
2
+ Name: wanderland
3
+ Version: 0.1.0
4
+ Summary: An interactive low-poly 3D coding playground (anywidget) for marimo: write Python commands and watch Mo the Mossball act them out.
5
+ Project-URL: Homepage, https://github.com/ktaletsk/wanderland
6
+ Project-URL: Repository, https://github.com/ktaletsk/wanderland
7
+ Project-URL: Issues, https://github.com/ktaletsk/wanderland/issues
8
+ Author: Konstantin Taletskiy
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: anywidget,education,gridworld,learn-to-code,marimo,reinforcement-learning,three.js,widget
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: Jupyter
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Education
16
+ Classifier: Intended Audience :: Science/Research
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Education
25
+ Classifier: Topic :: Multimedia :: Graphics
26
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: anywidget>=0.9.13
29
+ Requires-Dist: traitlets>=5.9
30
+ Provides-Extra: dev
31
+ Requires-Dist: marimo>=0.9; extra == 'dev'
32
+ Requires-Dist: numpy>=1.21; extra == 'dev'
33
+ Requires-Dist: watchfiles>=0.21; extra == 'dev'
34
+ Provides-Extra: rl
35
+ Requires-Dist: numpy>=1.21; extra == 'rl'
36
+ Description-Content-Type: text/markdown
37
+
38
+ <h1>
39
+ <p align="center">
40
+ 🌱
41
+ <br>Wanderland
42
+ </p>
43
+ </h1>
44
+
45
+ An interactive low-poly **3D coding playground** as an [anywidget](https://anywidget.dev),
46
+ built for Python notebooks. Write simple Python commands and watch a charming
47
+ little character — **Mo the Mossball** — animate through a stylized world, collecting
48
+ gems and reaching goals.
49
+
50
+ It captures the joy of a learn-to-code playground — *write code, watch the character act
51
+ it out* — with an original character, original art, and a small Python command API,
52
+ running entirely inside a reactive marimo notebook.
53
+
54
+ The same engine doubles as a substrate for **agent / LLM evaluation**: worlds follow
55
+ classic grid-world mechanics (keys, doors, boxes, an explicit action space) and render to
56
+ text prompts you can hand a model — so the identical world is both a kids' puzzle and a
57
+ gridworld benchmark.
58
+
59
+ ```python
60
+ # --- cell: create + show the world (it has its own ▶ Run My Code button) ---
61
+ import marimo as mo
62
+ import wanderland as mp
63
+ from wanderland import move_forward, turn_right, collect_gem
64
+
65
+ world = mo.ui.anywidget(mp.World(mp.puzzles.gem_path()))
66
+ world # renders the 3D scene + the in-scene Run button
67
+
68
+ # --- cell: your program (editing this just *loads* it; Mo doesn't move yet) ---
69
+ # gem_path's gems are non-blocking: walk Mo onto each one, then collect_gem().
70
+ def solution():
71
+ move_forward(); move_forward(); move_forward(); collect_gem() # first gem
72
+ turn_right(); move_forward(); move_forward()
73
+ turn_right(); move_forward(); move_forward(); move_forward(); collect_gem() # second gem
74
+ turn_right(); turn_right(); move_forward(); move_forward(); move_forward()
75
+
76
+ # --- cell: hand the program to the widget (reactive; recomputes on edit) ---
77
+ world.load(solution)
78
+ # ...now press ▶ Run My Code in the scene to animate it once.
79
+
80
+ # --- cell: read the outcome back in Python (synchronous, after load) ---
81
+ world.success # True
82
+ world.gems_collected # 2
83
+ world.reached_goal # True
84
+ ```
85
+
86
+ The **▶ Run My Code** button lives inside the widget. Editing the program reloads its
87
+ timeline silently; Mo only moves when you press Run, and then he stays at his final pose.
88
+
89
+ ---
90
+
91
+ ## Install & run
92
+
93
+ ```bash
94
+ pip install wanderland # or: uv add wanderland
95
+ ```
96
+
97
+ The published package ships the prebuilt 3D frontend — **no Node required**. It works in
98
+ any notebook that supports [anywidget](https://anywidget.dev) (marimo, Jupyter). Open the
99
+ example notebook to play:
100
+
101
+ ```bash
102
+ uv run marimo edit example.py # the teaching playground
103
+ uv run marimo edit rl_playground.py # the agent / LLM-evaluation notebook
104
+ ```
105
+
106
+ You'll see Mo standing in a warm low-poly world; running a program animates him through
107
+ your commands step by step. Drag to orbit the camera. For headless / agent use, nothing
108
+ needs a browser — `World.act(...)`, `solve(...)`, and `render(...)` all return synchronously
109
+ (add `pip install "wanderland[rl]"` for the numpy image observation).
110
+
111
+ <details>
112
+ <summary>Develop from source (rebuild the frontend)</summary>
113
+
114
+ Requires Python ≥ 3.10 and Node ≥ 18.
115
+
116
+ ```bash
117
+ npm install && npm run build # build the 3D bundle -> src/wanderland/static/index.js
118
+ uv venv && uv pip install -e ".[dev]"
119
+ uv run marimo edit example.py
120
+ ```
121
+ </details>
122
+
123
+ ---
124
+
125
+ ## Writing Mo programs
126
+
127
+ A **program** is an ordinary Python function. Inside it you call commands in the order
128
+ you want them to happen:
129
+
130
+ | command | what it does |
131
+ |---|---|
132
+ | `move_forward()` | step one tile in the direction faced |
133
+ | `turn_left()` / `turn_right()` | rotate 90° — turning is **egocentric** (relative to the current heading) |
134
+ | `pickup()` | take the object in the cell **faced** (a key/ball/box, or a blocking gem) into your hand — carry limit one. You don't move. |
135
+ | `drop()` | drop the carried object onto the empty floor cell faced |
136
+ | `toggle()` | open/close the door faced (a locked door opens with a matching-color key, which you keep); open a box to reveal its contents |
137
+ | `collect_gem()` | collect the **non-blocking** gem on the tile you're standing on (walk on, then collect; scores, not carried) |
138
+ | `move_backward()` | step back without turning — **free-play only**, off the canonical action set |
139
+
140
+ Interaction is always on the cell you **face**, standing adjacent — you never walk onto
141
+ a blocking object. A blocked move is animated by *why* it failed: Mo teeters at the brink
142
+ of the world's edge (a near-fall), and bonks off a wall, door, or object.
143
+
144
+ ### Action space
145
+
146
+ Every world declares the **exact set of verbs it permits** — explicitly, with no default
147
+ and no canonical bundle. That declared set *is* the action space you'd hand an agent, and
148
+ it's enforced: calling a verb the world didn't list raises (so a measured run can't
149
+ silently use the wrong vocabulary — `move_backward` is rejected unless the world lists it).
150
+
151
+ ```python
152
+ world.action_space # ('move_forward', 'turn_left', 'turn_right', 'pickup', 'drop', 'toggle')
153
+ world.actions_doc # [{'name': 'pickup', 'doc': '...'}, ...] — ready for an LLM prompt
154
+ ```
155
+
156
+ ### Running a program
157
+
158
+ Three ways to drive Mo, depending on who pulls the trigger:
159
+
160
+ - **`world.load(solution)`** — the recommended notebook flow. Captures the commands,
161
+ simulates them, and hands the timeline to the widget **without playing**. The user
162
+ presses the widget's own **▶ Run My Code** button to animate it once; Mo stays at his
163
+ final pose. Editing the program reloads silently.
164
+ - **`world.run(solution)`** — captures *and plays immediately* (no button). Handy for
165
+ programmatic or headless use; returns the result dict.
166
+ - **`@world.program`** — decorator form of `run()`; plays whenever the defining cell
167
+ re-executes.
168
+
169
+ All three capture the command sequence and simulate it in Python (the source of truth);
170
+ `world.success` and friends are available synchronously regardless of which you use. The
171
+ example notebook uses `load()` + the in-scene button.
172
+
173
+ ### Reading the outcome
174
+
175
+ Because the simulation runs in Python, results are available **synchronously** right
176
+ after the program runs (and work even without a browser):
177
+
178
+ ```python
179
+ world.success # all (non-blocking) gems collected AND goal reached
180
+ world.gems_collected # int
181
+ world.total_gems # int
182
+ world.reached_goal # bool
183
+ world.result # the full dict: final pose, what's carried, ...
184
+ ```
185
+
186
+ For *reactive* readback, read `world.value` (or `world.state`) in another cell — the
187
+ frontend writes a playback report there when the animation finishes, which re-runs
188
+ dependent marimo cells.
189
+
190
+ ### Defining your own world
191
+
192
+ ```python
193
+ from wanderland import from_ascii, World
194
+
195
+ puzzle = from_ascii("Locked Room", """
196
+ > . # .
197
+ . . Ly .
198
+ Ky . # O
199
+ """, actions=("move_forward", "turn_left", "turn_right", "pickup", "toggle"))
200
+
201
+ world = World(puzzle)
202
+ ```
203
+
204
+ Each cell is one whitespace-separated token; the top row is north, columns go east:
205
+
206
+ | token | meaning |
207
+ |---|---|
208
+ | `^ > v <` | start tile **and** the agent's facing (N/E/S/W) — `S` also works with `heading=` |
209
+ | `.` `#` `~` `!` `O` | floor · **wall** · water (impassable) · **lava** (walkable but deadly) · goal |
210
+ | `g` / `G` | non-blocking gem (walk on, then `collect_gem()`) / blocking gem (`pickup()` from the front) |
211
+ | `Kc` `Bc` `Xc` | key / ball / box of color `c` (`r g b p y e`) — `Xc:obj` gives a box hidden contents |
212
+ | `Dc` `Lc` | closed / locked door of color `c` |
213
+
214
+ `actions=` is **required**. Built-in worlds live in `mp.puzzles` (`first_steps`,
215
+ `gem_path`, `spiral`, `locked_room`).
216
+
217
+ > **Rendering:** floor, water, walls, gems, and the colored objects (keys, balls,
218
+ > boxes, doors) all render in 3D, and `pickup`/`drop`/`toggle` animate — the carried
219
+ > item floats above the character, doors unlock and swing open, boxes open to their
220
+ > contents. (Box contents stay hidden in the agent prompt; see below.)
221
+
222
+ ### For agents & RL
223
+
224
+ Wanderland doubles as a substrate for **agent / LLM evaluation** on grid-world tasks.
225
+ The research notebook is **`rl_playground.py`** (`uv run marimo edit rl_playground.py`).
226
+
227
+ **Show the agent the world — three encodings of the same state:**
228
+
229
+ ```python
230
+ print(world.to_prompt("structured")) # text: the canonical, measured input
231
+ print(world.to_prompt("ascii")) # text: a grid picture
232
+ world.render().save("obs.png") # a 2D top-down image (stdlib PNG, no browser)
233
+ world.render().to_numpy() # (H, W, 3) uint8 for vision models (needs numpy)
234
+ world.action_space, world.actions_doc # the verbs you hand the agent (+ docs)
235
+ ```
236
+
237
+ `structured` is an explicit header + a coordinate-tagged object list (usually a stronger
238
+ prompt for an LLM than a raw grid picture); `ascii` and `render()` are picture views. All
239
+ **hide box contents until toggled**, so you can show the identical world three ways and
240
+ measure how much the encoding alone moves success rate. Example `structured`:
241
+
242
+ ```
243
+ World: Locked Room (4 wide x 3 tall; x east, y south, origin top-left)
244
+ You are at (0,0) facing east, carrying nothing.
245
+ Actions: move_forward, turn_left, turn_right, pickup, toggle
246
+ Goal: step onto (3,2).
247
+ Objects (box contents are hidden until opened):
248
+ - locked yellow door at (2,1)
249
+ - yellow key at (0,2)
250
+ Walls: (2,0), (2,2)
251
+ ```
252
+
253
+ **Run the agent's output, generate worlds, and verify with the oracle:**
254
+
255
+ ```python
256
+ from wanderland import World, random_room, solve, from_json, to_json
257
+
258
+ puzzle = random_room(seed=7, gems=1) # a reproducible, solvable grid room
259
+ env = World(puzzle)
260
+ env.act(["turn_left", "move_forward", ...]) # Wanderland executes a list of action names
261
+ env.success, env.result # authoritative + synchronous (no browser needed)
262
+ env.replay(trace) # animate a trajectory scored by ANOTHER executor
263
+ # (a list of {action, pos, dir, carrying} steps)
264
+
265
+ plan = solve(puzzle) # BFS oracle: a shortest solving plan (baseline)
266
+ spec = to_json(puzzle); from_json(spec) # save / load a world as JSON
267
+ ```
268
+
269
+ `act()` validates each name against the action space (unknown/out-of-space verbs raise, so a
270
+ measured run can't cheat). `replay()` renders an *external* trace without re-simulating it —
271
+ a `move_forward` that didn't change position animates as a blocked bonk, and stepping into
272
+ lava plays a death — so failed plans are shown faithfully. `random_room` is deterministic in
273
+ `seed` and solvable by construction; `solve()` proves it and serves as a reference agent.
274
+
275
+ ---
276
+
277
+ ## Characters
278
+
279
+ The character is decoupled from the simulation — Python produces a timeline of
280
+ poses/events, and a **Character** decides how to *look* while replaying it. Pick
281
+ one with the `character=` argument:
282
+
283
+ ```python
284
+ World(puzzle, character="rover") # a hovering drone-bot
285
+ World(puzzle, character="mo") # Mo the Mossball (default)
286
+ ```
287
+
288
+ Adding a character is a pure frontend change: subclass `Character`
289
+ (`js/characters/character.js`) and implement how it moves, reacts and emotes,
290
+ then register it in `js/characters/index.js`. The contract is a handful of async
291
+ methods — `move`, `turn`, `blocked`, `returnHome`, `react`, `setExpression`,
292
+ `update` — each handed the Viewer's tween clock so a character owns its own
293
+ choreography and pacing. The base class is a working (if plain) character, so
294
+ overrides are opt-in. No game logic lives in characters.
295
+
296
+ ---
297
+
298
+ ## Architecture (and why)
299
+
300
+ **One idea drives the whole design:**
301
+
302
+ > **Python is the authoritative simulator. The frontend is a deterministic replay of a
303
+ > precomputed timeline.**
304
+
305
+ marimo is reactive/dataflow, but the user writes *sequential, imperative* commands. The
306
+ pipeline reconciles the two:
307
+
308
+ 1. **Capture.** A program is run with a thread-local recorder active; each `move_forward()`
309
+ etc. appends one command (validated against the world's action space). (`commands.py`)
310
+ 2. **Simulate.** Python dispatches each recorded verb through an **action registry**
311
+ against the world's objects, producing a **timeline** — one entry per command
312
+ (move / turn / pickup / drop / toggle, with before/after state and what happened) —
313
+ plus an authoritative **result**. Adding a verb means registering a handler, not
314
+ editing the simulator. (`actions.py`, `world.py`)
315
+ 3. **Sync.** The timeline + a bumped `load_nonce`/`run_nonce` are pushed to the browser
316
+ over anywidget traits.
317
+ 4. **Replay.** The frontend resets the scene to the start state and animates each step in
318
+ order with eased motion — never snapping to the final state. (`js/`)
319
+ 5. **Read back.** `world.result` is already correct in Python (no round-trip needed); the
320
+ frontend also reports completion to the `state` trait for reactive cells.
321
+
322
+ **Why this is the right call:**
323
+
324
+ - *Determinism & replay fall out for free.* Re-running the program recomputes the same
325
+ timeline from the same start; the frontend resets and replays.
326
+ - *State readback is synchronous and reliable* — Python owns the truth, so outcomes don't
327
+ depend on a browser round-trip and work headless (great for tests).
328
+ - *The frontend stays a "dumb" renderer*, which keeps game logic in one place (Python).
329
+
330
+ **Technology choices:**
331
+
332
+ - **Rendering:** vanilla **Three.js**. The core experience is an imperative animation
333
+ *timeline*, which maps directly onto an imperative engine — and it keeps the bundle lean
334
+ (~135 KB gzipped) with full control over the art.
335
+ - **Animation:** a small hand-rolled tween scheduler with easing curves and squash/stretch
336
+ — Mo waddle-walks with scuttling feet, leans into turns, sways his sprout, blinks and
337
+ changes facial expression (determined / surprised / happy), teeters at ledges, and
338
+ glides home before each run.
339
+ - **Art:** original procedural geometry (rounded primitives), warm palette, soft shadows,
340
+ hemisphere + warm key lighting, ACES tone mapping. No external assets.
341
+ - **Build:** **esbuild** (`npm run build`) bundles `js/` → a single ESM file referenced by
342
+ anywidget's `_esm`. `npm run dev` watches for rebuilds.
343
+
344
+ ### Layout
345
+
346
+ ```
347
+ src/wanderland/ # Python package
348
+ __init__.py # public API
349
+ actions.py # verb registry + grid-world simulation semantics
350
+ commands.py # the recorded command vocabulary (+ action-space check)
351
+ puzzles.py # Puzzle + objects + from_ascii/from_dict/to_json + to_prompt + built-ins
352
+ generate.py # random_room(): a reproducible distribution of solvable rooms
353
+ solve.py # solve(): a BFS oracle (shortest solving plan / baseline)
354
+ render.py # render(): a 2D top-down image observation (stdlib PNG; numpy optional)
355
+ world.py # the AnyWidget: capture -> simulate -> sync; action_space; act()
356
+ static/ # built frontend (index.js, index.css)
357
+
358
+ js/ # frontend source (bundled into static/)
359
+ index.js # anywidget entry: wires model traits <-> Viewer
360
+ viewer.js # orchestrator: load/return-home/step playback + render loop
361
+ scene.js # renderer, lights, camera, orbit controls
362
+ board.js # tiles, water, walls, gems, objects, goal (built from a puzzle)
363
+ objects.js # low-poly geometry for walls + keys/balls/boxes/doors
364
+ characters/ # swappable characters (see "Characters" above)
365
+ character.js # the Character contract (base class)
366
+ mossball.js # Mo the Mossball (default)
367
+ rover.js # a hovering drone-bot
368
+ index.js # name -> class registry
369
+ hud.js # gem counter, status pill, Run button
370
+ palette.js # colours, sizes, animation timings
371
+ easing.js # easing curves
372
+
373
+ example.py # learn-to-code playground notebook
374
+ rl_playground.py # agent / RL research notebook (generate worlds, prompt, act, solve)
375
+ test/ # headless sim tests (test_sim.py) + render tests + fixtures
376
+ ```
377
+
378
+ ## License
379
+
380
+ MIT