An open API service indexing awesome lists of open source software.

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

Awesome Lists containing this project

README

          



๐ŸŒฑ

Wanderland


A top-down Wanderland grid: a red character facing a locked yellow door, a key, and a goal ring


Open in marimo
Open on notebook.link

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