https://github.com/ktaletsk/wanderland
Interactive 3D learn-to-code playground for Python notebooks
https://github.com/ktaletsk/wanderland
3d anywidget education jupyter marimo python threejs
Last synced: 3 days ago
JSON representation
Interactive 3D learn-to-code playground for Python notebooks
- Host: GitHub
- URL: https://github.com/ktaletsk/wanderland
- Owner: ktaletsk
- License: mit
- Created: 2026-06-18T19:28:25.000Z (9 days ago)
- Default Branch: main
- Last Pushed: 2026-06-21T01:06:39.000Z (7 days ago)
- Last Synced: 2026-06-21T03:07:28.810Z (7 days ago)
- Topics: 3d, anywidget, education, jupyter, marimo, python, threejs
- Language: JavaScript
- Size: 442 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
๐ฑ
Wanderland
An interactive low-poly **3D coding playground** as an [anywidget](https://anywidget.dev),
built for Python notebooks. Write simple Python commands and watch a charming
little character โ **Mo the Mossball** โ animate through a stylized world, collecting
gems and reaching goals.
It captures the joy of a learn-to-code playground โ *write code, watch the character act
it out* โ with an original character, original art, and a small Python command API,
running entirely inside an interactive notebook.
```python
# Create and show the world (it has its own โถ Run My Code button)
# Any anywidget-compatible notebook works: Jupyter, marimo, VS Code...
import wanderland as mp
from wanderland import move_forward, turn_right, collect_gem
# For marimo, you might wrap this in mo.ui.anywidget()
world = mp.World(mp.puzzles.gem_path())
world # renders the 3D scene + the in-scene Run button
# Your program (editing this just *loads* it; the character doesn't move yet)
# gem_path's gems are non-blocking: walk the character onto each one, then collect_gem().
def solution():
move_forward()
move_forward()
move_forward()
collect_gem() # first gem
turn_right()
move_forward()
move_forward()
turn_right()
move_forward()
move_forward()
move_forward()
collect_gem() # second gem
turn_right()
turn_right()
move_forward()
move_forward()
move_forward()
# Hand the program to the widget
world.load(solution)
# ...now press โถ Run My Code in the scene to animate it once.
# Read the outcome back in Python (synchronous, after load)
world.success # True
world.gems_collected # 2
world.reached_goal # True
```
The **โถ Run My Code** button lives inside the widget. Editing the program reloads its
timeline silently; the character only moves when you press Run, and then stays at their final pose.
---
## Install & run
```bash
pip install wanderland # or: uv add wanderland
```
The published package ships the prebuilt 3D frontend โ **no Node required**. It works in
any notebook that supports [anywidget](https://anywidget.dev) (marimo, Jupyter). Open the
example notebook to play:
```bash
uv run marimo edit example.py # the teaching playground
```
You'll see your character (like Mo the Mossball) standing in a warm low-poly world; running a program animates them through
your commands step by step. Drag to orbit the camera.
Develop from source (rebuild the frontend)
Requires Python โฅ 3.10 and Node โฅ 18.
```bash
npm install && npm run build # build the 3D bundle -> src/wanderland/static/index.js
uv venv && uv pip install -e ".[dev]"
uv run marimo edit example.py
```
---
## Solve puzzles with code
A **program** is an ordinary Python function. Inside it you call commands in the order
you want them to happen:
| command | what it does |
|---|---|
| `move_forward()` | step one tile in the direction faced |
| `turn_left()` / `turn_right()` | rotate 90ยฐ โ turning is **egocentric** (relative to the current heading) |
| `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. |
| `drop()` | drop the carried object onto the empty floor cell faced |
| `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 |
| `collect_gem()` | collect the **non-blocking** gem on the tile you're standing on (walk on, then collect; scores, not carried) |
| `move_backward()` | step back without turning โ **free-play only**, off the canonical action set |
Interaction is always on the cell you **face**, standing adjacent โ you never walk onto
a blocking object. A blocked move is animated by *why* it failed: the character teeters at the brink
of the world's edge (a near-fall), and bonks off a wall, door, or object.
### Action space
Every world declares the **exact set of verbs it permits** โ explicitly, with no default
and no canonical bundle. That declared set *is* the action space you're allowed to use, and
it's enforced: calling a verb the world didn't list raises.
```python
world.action_space # ('move_forward', 'turn_left', 'turn_right', 'pickup', 'drop', 'toggle')
world.actions_doc # [{'name': 'pickup', 'doc': '...'}, ...] โ documentation for the actions
```
### Running a program
Three ways to drive the character, depending on who pulls the trigger:
- **`world.load(solution)`** โ the recommended notebook flow. Captures the commands,
simulates them, and hands the timeline to the widget **without playing**. The user
presses the widget's own **โถ Run My Code** button to animate it once; the character stays at their
final pose. Editing the program reloads silently.
- **`world.run(solution)`** โ captures *and plays immediately* (no button). Handy for
programmatic or headless use; returns the result dict.
- **`@world.program`** โ decorator form of `run()`; plays whenever the defining cell
re-executes.
All three capture the command sequence and simulate it in Python (the source of truth);
`world.success` and friends are available synchronously regardless of which you use. The
example notebook uses `load()` + the in-scene button.
### Reading the outcome
Because the simulation runs in Python, results are available **synchronously** right
after the program runs (and work even without a browser):
```python
world.success # all (non-blocking) gems collected AND goal reached
world.gems_collected # int
world.total_gems # int
world.reached_goal # bool
world.result # the full dict: final pose, what's carried, ...
```
For reactive readback (like in marimo), read `world.value` (or `world.state`) in another cell โ the
frontend writes a playback report there when the animation finishes.
## For Educators: Creating Custom Worlds
Wanderland makes it easy to design your own levels and assignments for students. You can sketch out puzzles visually using simple ASCII text strings.
```python
from wanderland import from_ascii, World
level_design = """
> . # .
. . Ly .
Ky . # O
"""
allowed_actions = (
"move_forward",
"turn_left",
"turn_right",
"pickup",
"toggle",
)
puzzle = from_ascii(
"Locked Room",
level_design,
actions=allowed_actions
)
# You can also pick a different character!
world = World(puzzle, character="rover") # A hovering drone-bot instead of Mo
```
Each cell is one whitespace-separated token; the top row is north, columns go east:
| token | meaning |
|---|---|
| `^ > v <` | start tile **and** the character's facing (N/E/S/W) โ `S` also works with `heading=` |
| `.` `#` `~` `!` `O` | floor ยท **wall** ยท water (impassable) ยท **lava** (walkable but deadly) ยท goal |
| `g` / `G` | non-blocking gem (walk on, then `collect_gem()`) / blocking gem (`pickup()` from the front) |
| `Kc` `Bc` `Xc` | key / ball / box of color `c` (`r g b p y e`) โ `Xc:obj` gives a box hidden contents |
| `Dc` `Lc` | closed / locked door of color `c` |
`actions=` is **required**. Built-in worlds live in `mp.puzzles` (`first_steps`,
`gem_path`, `spiral`, `locked_room`).
> **Rendering:** floor, water, walls, gems, and the colored objects (keys, balls,
> boxes, doors) all render in 3D, and `pickup`/`drop`/`toggle` animate โ the carried
> item floats above the character, doors unlock and swing open, boxes open to their
> contents. (Box contents stay hidden when printing the world state.)
## License
MIT