Tiny Yurts

Isometric grid, BFS path-following agents, drawable routes.

▶ Run in browser

Upstream: https://github.com/burntcustard/tiny-yurts

Licence: this port's own code is offered under MIT, not the SimVX Examples Licence the rest of the gallery carries. See ATTRIBUTION.md for the upstream it re-implements, the terms of anything it bundles, and the credit each one requires.

Ports live in the repository only, not in the simvx-examples distribution, because each is a derivative work licensed individually against the game it re-implements. Read it with git clone https://git.simvx.com/simvx/simvx.

Tags: port tier-1

Tiny Yurts (SimVX port)

A SimVX port of burntcustard/tiny-yurts, a Mini-Motorways-inspired routing puzzle from js13kGames 2023.

The original is HTML-CSS-SVG-in-JS and 13 KB zipped. This port keeps the gameplay shape (drag paths, settlers walk routes, farms overflow if unfed) but adds an isometric (~30 deg) projection, three farm/yurt pairs, and a menu plus HUD around the board.

Licensing: a clean re-implementation under MIT. The board, the backdrop and the HUD bar are procedural on_draw calls, and the text uses the engine’s own font and widgets, so nothing from upstream (art, audio, fonts, or data) is bundled. See ATTRIBUTION.md and UPSTREAM_LICENSE.md.

Run

# from the repo root
uv run python examples/ports/tiny_yurts/main.py            # interactive
uv run python examples/ports/tiny_yurts/main.py --test     # headless capture (3 frames)
uv run python examples/ports/tiny_yurts/harness.py         # scripted-input capture (7 stages)
uv run simvx export web examples/ports/tiny_yurts/main.py \
    -o /tmp/tiny_yurts.html

Controls

Action

Input

Draw a path

Drag (mouse or touch) from cell to adjacent cell

Remove a path tile

Right-click near it

Restart

R

Back to menu

ESC (in game)

Quit

ESC (on the menu)

Start

ENTER or SPACE

Win / lose

  • Reach 12 deliveries to win.

  • If any farm’s demand exceeds its capacity, you lose.

  • Settlers spawn at yurts, walk paths to matching farms, deliver, return.

What this shows in SimVX

  • Retained 2D drawing. The board is one Node2D.on_draw body. It reads plain (non-Property) state that changes every tick, so World sets dynamic = True to re-collect its draw ops each frame; the menu and the HUD backdrop are static and call queue_redraw() only when the layout moves.

  • Named input actions. TinyYurtsRoot.input_actions declares every binding on the root, which is where the scene tree picks them up (the web runtime instantiates the root directly and never calls main()). Touch arrives as MouseButton.LEFT, so place_path covers mouse and finger alike.

  • Signals. World emits delivery_made, game_over, and victory; the game scene connects each to the one piece of HUD it owns, and polls only the clock and the path budget, which have no event behind them.

  • Resize-aware layout. Both scenes connect tree.screen_resized and re-run a single _apply_layout, which re-anchors the controls strip, re-centres the text, and re-anchors the isometric projection origin.

  • BFS routing. Grid.find_route runs a breadth-first search over the path graph the player has drawn, not over free space, so settlers only ever walk on laid paths.

File map

tiny_yurts/
├── main.py              # entry point: root, menu and game scenes, HUD
├── harness.py           # scripted-input capture (7 stages)
└── nodes/
    ├── iso.py           # isometric projection helpers + colour palette
    ├── grid.py          # path graph, farm/yurt entities, BFS routing
    ├── world.py         # World node: input, simulation, draw
    └── settler.py       # animated agent that walks a precomputed route

Source files

File

Summary

Lines

main.py

Tiny Yurts: Isometric grid, BFS path-following agents, drawable routes.

289

harness.py

Scripted-input harness for Tiny Yurts.

139

nodes/__init__.py

0

nodes/grid.py

Grid state: path graph, farms, yurts, and BFS pathfinder.

143

nodes/iso.py

Isometric projection helpers and the colour palette.

77

nodes/settler.py

Settler: an agent that walks a precomputed cell path between yurt and farm.

117

nodes/world.py

World: owns the Grid, dispatches Settlers, and renders everything iso.

335

Source

  1"""Tiny Yurts: Isometric grid, BFS path-following agents, drawable routes.
  2
  3# /// simvx
  4# tags = ["port", "tier-1"]
  5# upstream = "https://github.com/burntcustard/tiny-yurts"
  6# web = { width = 1280, height = 720, responsive = true }
  7# ///
  8
  9Routing puzzle in the spirit of Mini Motorways, after burntcustard's js13kGames
 102023 entry "Tiny Yurts". Drag paths between the animal farms (ox, goat, fish)
 11and the yurt of the same kind; settlers walk those paths and work each farm's
 12demand back down. Let one farm's demand pass its capacity and you lose; land 12
 13deliveries and you win.
 14
 15The port shows an isometric projection drawn through the retained 2D renderer
 16(the board node is marked ``dynamic`` so its hover preview and demand bars
 17re-collect every frame), drag input polled through named input actions so mouse
 18and touch share one code path, BFS routing over a graph the player builds,
 19Signals carrying the score and the win/lose banner into the HUD, and a
 20menu-to-game swap under a single root node. Both scenes re-lay out on resize.
 21
 22Run (from the repo root):
 23    uv run python examples/ports/tiny_yurts/main.py
 24    uv run python examples/ports/tiny_yurts/main.py --test    # headless frame captures
 25"""
 26
 27from __future__ import annotations
 28
 29import sys
 30from pathlib import Path
 31
 32# Allow running from any cwd
 33_PORT_DIR = Path(__file__).parent
 34if str(_PORT_DIR) not in sys.path:
 35    sys.path.insert(0, str(_PORT_DIR))
 36
 37from nodes import iso  # noqa: E402
 38from nodes.world import DELIVERIES_TO_WIN, World, world_centre_origin  # noqa: E402
 39
 40from simvx.core import Input, Key, MouseButton, Node, Node2D, Text2D  # noqa: E402
 41from simvx.core.ui.enums import AnchorPreset  # noqa: E402
 42from simvx.core.ui.widgets import Label, Panel  # noqa: E402
 43from simvx.graphics import App  # noqa: E402
 44
 45WIDTH = 1280
 46HEIGHT = 720
 47
 48HUD_HEIGHT = 44
 49CONTROLS_HEIGHT = 44
 50
 51
 52# ---------------------------------------------------------------------------
 53# Menu
 54# ---------------------------------------------------------------------------
 55
 56
 57class TinyYurtsMenu(Node2D):
 58    """Landing screen: title, how-to-play lines, and the start prompt."""
 59
 60    INSTRUCTIONS = (
 61        "Drag from cell to cell to draw paths between farms and yurts.",
 62        "Settlers walk the path to feed the farm of their own kind.",
 63        "Right-click a path tile to remove it.",
 64        f"Lose if any farm overflows. Reach {DELIVERIES_TO_WIN} deliveries to win.",
 65    )
 66
 67    def __init__(self, **kwargs):
 68        super().__init__(**kwargs)
 69        self.title = self._add_line("Tiny Yurts", 3.6, (0.95, 0.92, 0.78, 1.0))
 70        self.subtitle = self._add_line(
 71            "A SimVX port of burntcustard's js13k routing puzzle", 1.1, (0.85, 0.85, 0.78, 1.0)
 72        )
 73        self.lines = [self._add_line(text, 1.05, (0.93, 0.93, 0.85, 1.0)) for text in self.INSTRUCTIONS]
 74        self.prompt = self._add_line("ENTER  start    ·    ESC  quit", 1.2, (1.0, 0.95, 0.55, 1.0))
 75
 76    def _add_line(self, text: str, font_scale: float, colour) -> Text2D:
 77        """Add one centred line of text. ``align`` centres on ``position.x``."""
 78        node = Text2D(text=text, font_scale=font_scale, colour=colour, align="centre")
 79        self.add_child(node)
 80        return node
 81
 82    def on_ready(self) -> None:
 83        self._apply_layout(self.tree.screen_size)
 84        self.tree.screen_resized.connect(self._apply_layout)
 85
 86    def on_exit_tree(self) -> None:
 87        self.tree.screen_resized.disconnect(self._apply_layout)
 88
 89    def _apply_layout(self, size) -> None:
 90        """Centre the text block and the iso board on the current viewport."""
 91        w, h = size
 92        world_centre_origin(w, h)
 93        self.title.position = (w / 2, h * 0.30)
 94        self.subtitle.position = (w / 2, h * 0.30 + 70)
 95        for idx, node in enumerate(self.lines):
 96            node.position = (w / 2, h * 0.30 + 130 + 28 * idx)
 97        self.prompt.position = (w / 2, h * 0.78)
 98        # The backdrop tiles below are drawn from the (plain, non-Property) iso
 99        # origin, so the draw has to be re-collected once per layout change.
100        self.queue_redraw()
101
102    def on_draw(self, renderer) -> None:
103        # Soft grass backdrop and a few static tiles for flavour
104        w, h = self.tree.screen_size
105        renderer.draw_rect((0, 0), (w, h), colour=iso.COLOUR_GRASS_DARK, filled=True)
106        for j in range(iso.GRID_ROWS):
107            for i in range(iso.GRID_COLS):
108                if (i + j) & 1:
109                    continue
110                corners = iso.tile_corners(i, j)
111                renderer.draw_polygon(corners, colour=(0.42, 0.66, 0.34, 0.5))
112
113
114# ---------------------------------------------------------------------------
115# Game
116# ---------------------------------------------------------------------------
117
118
119class TinyYurtsGame(Node2D):
120    """In-game scene: world + HUD + bottom controls strip."""
121
122    def __init__(self, **kwargs):
123        super().__init__(**kwargs)
124        self.world = World()
125        self.add_child(self.world)
126
127        # HUD text overlays (Text2D goes through MSDF pass, sits above on_draw)
128        self.score_text = Text2D(text="Score 0", position=(20, 14), font_scale=1.4, colour=(0.98, 0.98, 0.92, 1.0))
129        self.budget_text = Text2D(text="Paths 32", position=(180, 14), font_scale=1.4, colour=(0.98, 0.98, 0.92, 1.0))
130        self.timer_text = Text2D(text="Time 0s", position=(340, 14), font_scale=1.4, colour=(0.98, 0.98, 0.92, 1.0))
131        self.status_text = Text2D(text="", font_scale=2.4, align="centre", colour=(1.0, 0.95, 0.55, 1.0))
132        self.add_child(self.score_text)
133        self.add_child(self.budget_text)
134        self.add_child(self.timer_text)
135        self.add_child(self.status_text)
136
137        # Bottom controls strip: a light bar so the controls read on any backdrop
138        self.controls_panel = Panel()
139        self.controls_panel.bg_colour = iso.COLOUR_CONTROLS_BG
140        self.add_child(self.controls_panel)
141
142        self.controls_label = Label(
143            text="Drag = path  ·  Right-click = remove  ·  R = restart  ·  ESC = menu",
144        )
145        self.controls_label.text_colour = (0.18, 0.18, 0.20, 1.0)
146        self.controls_label.font_size = 20.0
147        self.controls_label.alignment = "center"
148        self.add_child(self.controls_label)
149
150    def on_ready(self) -> None:
151        self._apply_layout(self.tree.screen_size)
152        self.tree.screen_resized.connect(self._apply_layout)
153        # State transitions arrive as signals; each one owns one piece of the HUD.
154        self.world.delivery_made.connect(self._refresh_score)
155        self.world.game_over.connect(self._show_defeat)
156        self.world.victory.connect(self._show_victory)
157        self._refresh_score()
158
159    def on_exit_tree(self) -> None:
160        self.tree.screen_resized.disconnect(self._apply_layout)
161
162    def _apply_layout(self, size) -> None:
163        """Apply anchors, banner placement, and iso origin for the viewport size."""
164        w, h = size
165        world_centre_origin(w, h)
166        # Banner sits above the board (which world_centre_origin puts at ~55%)
167        # so it never lands on top of the farms.
168        self.status_text.position = (w / 2, h * 0.20)
169        # Bottom-wide controls strip: anchors stretch horizontally, and a negative
170        # margin_top lifts the panel its own height above the bottom edge.
171        for ctl in (self.controls_panel, self.controls_label):
172            ctl.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
173            ctl.margin_left = 0
174            ctl.margin_right = 0
175            ctl.margin_top = -CONTROLS_HEIGHT
176            ctl.margin_bottom = 0
177            ctl.size_y = CONTROLS_HEIGHT
178            # An anchored control's rect follows the viewport, not its own
179            # Properties, so a pure resize leaves its retained draw stale.
180            ctl.queue_redraw()
181        # The backdrop below is drawn from the viewport size, not from Properties.
182        self.queue_redraw()
183
184    def on_update(self, dt: float) -> None:
185        if Input.is_action_just_pressed("restart"):
186            self.world.reset()
187            self._refresh_score()
188            self.status_text.text = ""
189        # The two readouts with no signal behind them: the clock advances every
190        # frame and the path budget moves with every drag.
191        self.timer_text.text = f"Time {int(self.world.elapsed)}s"
192        self.budget_text.text = f"Paths {self.world.path_budget}"
193
194    def _refresh_score(self) -> None:
195        self.score_text.text = f"Score {self.world.deliveries}/{DELIVERIES_TO_WIN}"
196
197    def _show_defeat(self) -> None:
198        self.status_text.text = "FARM OVERWHELMED  ·  R to retry"
199        self.status_text.colour = (1.00, 0.45, 0.40, 1.0)
200
201    def _show_victory(self) -> None:
202        self.status_text.text = "YOU WIN  ·  R to play again"
203        self.status_text.colour = (0.50, 1.00, 0.55, 1.0)
204
205    def on_draw(self, renderer) -> None:
206        # Dark grass to frame the iso board, plus the HUD strip along the top
207        w, h = self.tree.screen_size
208        renderer.draw_rect((0, 0), (w, h), colour=iso.COLOUR_GRASS_DARK, filled=True)
209        renderer.draw_rect((0, 0), (w, HUD_HEIGHT), colour=iso.COLOUR_HUD_BG, filled=True)
210
211
212# ---------------------------------------------------------------------------
213# Root
214# ---------------------------------------------------------------------------
215
216
217class TinyYurtsRoot(Node):
218    """Top-level scene; declares the input actions and toggles menu/game."""
219
220    # Declared on the root so the scene tree registers them on mount: the web
221    # runtime instantiates this class directly and never calls main().
222    # Touch surfaces as MouseButton.LEFT, so "place_path" covers mouse and finger.
223    input_actions = {
224        "start": [Key.ENTER, Key.SPACE],
225        "quit": [Key.ESCAPE],
226        "restart": [Key.R],
227        "place_path": [MouseButton.LEFT],
228        "remove_path": [MouseButton.RIGHT],
229    }
230
231    def on_ready(self) -> None:
232        self.state = "menu"
233        self.menu = self.add_child(TinyYurtsMenu())
234        self.game: TinyYurtsGame | None = None
235
236    def on_update(self, dt: float) -> None:
237        if self.state == "menu":
238            if Input.is_action_just_pressed("start"):
239                self._enter_game()
240            elif Input.is_action_just_pressed("quit"):
241                self.app.quit()
242        elif self.state == "game":
243            if Input.is_action_just_pressed("quit"):
244                self._enter_menu()
245
246    def _enter_game(self) -> None:
247        self.menu.destroy()
248        self.menu = None
249        self.game = self.add_child(TinyYurtsGame())
250        self.state = "game"
251
252    def _enter_menu(self) -> None:
253        if self.game is not None:
254            self.game.destroy()
255            self.game = None
256        self.menu = self.add_child(TinyYurtsMenu())
257        self.state = "menu"
258
259
260# ---------------------------------------------------------------------------
261# Entry / harness
262# ---------------------------------------------------------------------------
263
264
265def _run_headless() -> None:
266    """Capture frame_30/60/120 into screenshots/."""
267    from simvx.graphics import save_png
268
269    captures = [30, 60, 120]
270    app = App(width=WIDTH, height=HEIGHT, title="Tiny Yurts (SimVX)", visible=False)
271    frames = app.run_headless(TinyYurtsRoot(), frames=130, capture_frames=captures)
272    out_dir = _PORT_DIR / "screenshots"
273    out_dir.mkdir(exist_ok=True)
274    for idx, img in zip(captures, frames, strict=False):
275        out_path = out_dir / f"frame_{idx}.png"
276        save_png(img, out_path)
277        print(f"saved {out_path}")
278
279
280def main() -> None:
281    if "--test" in sys.argv:
282        _run_headless()
283        return
284    app = App(width=WIDTH, height=HEIGHT, title="Tiny Yurts (SimVX)")
285    app.run(TinyYurtsRoot())
286
287
288if __name__ == "__main__":
289    main()