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

# the export runs in a copy made by `simvx examples copy ports_tiny_yurts`
simvx examples run ports_tiny_yurts            # interactive
simvx examples run ports_tiny_yurts --test     # headless capture (3 frames)
simvx export web main.py \
    -o 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_context declares every binding on the root, so they are in force across the whole tree while the game is mounted, on the desktop and on the web alike. Touch arrives as Mouse.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
└── 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.

279

harness.py

Scripted-input harness for Tiny Yurts.

140

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.

116

nodes/world.py

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

330

Source

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