Tiny Yurts

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

▶ Run in browser

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

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 Tier-1 menu/HUD baseline.

Run

All commands from ~/dev/simvx:

# Interactive
uv run python ported_games/tiny_yurts/simvx_port/main.py

# Headless capture (frame_30/60/120)
uv run python ported_games/tiny_yurts/simvx_port/main.py --test

# Scripted gameplay harness (7 stages incl. lose state)
uv run python ported_games/tiny_yurts/simvx_port/harness.py

# Web export (~2.5 MB self-contained HTML)
uv run simvx export web ported_games/tiny_yurts/simvx_port/main.py \
    -o ported_games/tiny_yurts/simvx_port/web/index.html

Controls

  • Drag (mouse or touch) between cells to lay a path.

  • Right-click a path tile to remove it.

  • R to restart, ESC to return to menu, ENTER/SPACE to start.

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.

File map

simvx_port/
├── main.py              # entry point, root, menu, game scenes, HUD
├── harness.py           # scripted-input harness
├── pyproject.toml       # [tool.simvx] root = "TinyYurtsRoot"
├── 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
├── screenshots/         # frame_30/60/120 + 7 harness stages
└── web/index.html       # web export (~2.5 MB)

See ../NOTES.md for porting friction and engine gaps.

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 inspired by Mini Motorways. Player drags paths between
 10animal farms (ox, goat, fish) and same-coloured yurts; settlers walk the
 11paths to fulfil farm demand. Run out of buffer at any farm and you lose.
 12
 13Run:
 14    uv run python ported_games/tiny_yurts/simvx_port/main.py
 15    uv run python ported_games/tiny_yurts/simvx_port/main.py --test
 16"""
 17
 18from __future__ import annotations
 19
 20import sys
 21from pathlib import Path
 22
 23# Allow running from any cwd
 24_PORT_DIR = Path(__file__).parent
 25if str(_PORT_DIR) not in sys.path:
 26    sys.path.insert(0, str(_PORT_DIR))
 27
 28from nodes import iso  # noqa: E402
 29from nodes.world import DELIVERIES_TO_WIN, World, world_centre_origin  # noqa: E402
 30
 31from simvx.core import Input, InputMap, Key, MouseButton, Node, Node2D, Text2D  # noqa: E402
 32from simvx.core.ui.enums import AnchorPreset  # noqa: E402
 33from simvx.core.ui.widgets import Label, Panel  # noqa: E402
 34from simvx.graphics import App  # noqa: E402
 35
 36WIDTH = 1280
 37HEIGHT = 720
 38
 39
 40# ---------------------------------------------------------------------------
 41# Menu
 42# ---------------------------------------------------------------------------
 43
 44
 45class TinyYurtsMenu(Node2D):
 46    """Menu-first landing screen (Tier-1 UX baseline)."""
 47
 48    def __init__(self, **kwargs):
 49        super().__init__(**kwargs)
 50        # Show one stylised tile in the background
 51        world_centre_origin(WIDTH, HEIGHT)
 52
 53    def on_ready(self) -> None:
 54        self.add_child(Text2D(
 55            text="Tiny Yurts",
 56            position=(WIDTH / 2 - 130, HEIGHT * 0.30), font_scale=3.6,
 57            colour=(0.95, 0.92, 0.78, 1.0),
 58        ))
 59        self.add_child(Text2D(
 60            text="A SimVX port of burntcustard's js13k routing puzzle",
 61            position=(WIDTH / 2 - 250, HEIGHT * 0.30 + 70), font_scale=1.1,
 62            colour=(0.85, 0.85, 0.78, 1.0),
 63        ))
 64        instructions = [
 65            "Drag from cell to cell to draw paths between farms and yurts.",
 66            "Same-coloured settlers walk the path to feed the farm.",
 67            "Right-click a path tile to remove it.",
 68            "Lose if any farm overflows. Reach " + str(DELIVERIES_TO_WIN) + " deliveries to win.",
 69        ]
 70        for idx, line in enumerate(instructions):
 71            self.add_child(Text2D(
 72                text=line,
 73                position=(WIDTH / 2 - 280, HEIGHT * 0.30 + 130 + 28 * idx), font_scale=1.05,
 74                colour=(0.93, 0.93, 0.85, 1.0),
 75            ))
 76        self.add_child(Text2D(
 77            text="ENTER  start    \u00b7    ESC  quit",
 78            position=(WIDTH / 2 - 130, HEIGHT * 0.78), font_scale=1.2,
 79            colour=(1.0, 0.95, 0.55, 1.0),
 80        ))
 81
 82    def on_draw(self, renderer) -> None:
 83        # Soft grass backdrop and a few static tiles for flavour
 84        renderer.draw_rect((0, 0), (WIDTH * 4, HEIGHT * 4),
 85                           colour=iso.COLOUR_GRASS_DARK, filled=True)
 86        for j in range(iso.GRID_ROWS):
 87            for i in range(iso.GRID_COLS):
 88                if (i + j) & 1:
 89                    continue
 90                corners = iso.tile_corners(i, j)
 91                renderer.draw_polygon(corners, colour=(0.42, 0.66, 0.34, 0.5))
 92
 93
 94# ---------------------------------------------------------------------------
 95# Game
 96# ---------------------------------------------------------------------------
 97
 98
 99class TinyYurtsGame(Node2D):
100    """In-game scene: world + HUD + bottom controls strip."""
101
102    def __init__(self, **kwargs):
103        super().__init__(**kwargs)
104        self.world = World()
105        self.add_child(self.world)
106
107        # HUD text overlays (Text2D goes through MSDF pass, sits above on_draw)
108        self.score_text = Text2D(text="Score 0", position=(20, 14), font_scale=1.4,
109                                 colour=(0.98, 0.98, 0.92, 1.0))
110        self.budget_text = Text2D(text="Paths 32", position=(180, 14), font_scale=1.4,
111                                  colour=(0.98, 0.98, 0.92, 1.0))
112        self.timer_text = Text2D(text="Time 0s", position=(340, 14), font_scale=1.4,
113                                 colour=(0.98, 0.98, 0.92, 1.0))
114        self.status_text = Text2D(text="", position=(WIDTH / 2 - 200, HEIGHT * 0.45), font_scale=2.4,
115                                  colour=(1.0, 0.95, 0.55, 1.0))
116        self.add_child(self.score_text)
117        self.add_child(self.budget_text)
118        self.add_child(self.timer_text)
119        self.add_child(self.status_text)
120
121        # Bottom controls strip (Tier-1 UX baseline: light grey)
122        self.controls_panel = Panel()
123        self.controls_panel.bg_colour = iso.COLOUR_CONTROLS_BG
124        self.add_child(self.controls_panel)
125
126        self.controls_label = Label(
127            text="Drag = path  \u00b7  Right-click = remove  \u00b7  R = restart  \u00b7  ESC = menu",
128        )
129        self.controls_label.text_colour = (0.18, 0.18, 0.20, 1.0)
130        self.controls_label.font_size = 20.0
131        self.controls_label.alignment = "center"
132        self.add_child(self.controls_label)
133
134    def on_ready(self) -> None:
135        # Resize hook
136        self._apply_layout(WIDTH, HEIGHT)
137        # Listen for state transitions
138        self.world.delivery_made.connect(self._refresh_hud)
139        self.world.game_over.connect(self._on_game_over)
140        self.world.victory.connect(self._on_victory)
141
142    def _apply_layout(self, w: int, h: int) -> None:
143        """Apply anchors + iso origin for the current viewport size."""
144        world_centre_origin(w, h)
145        # Bottom-wide controls strip: anchors stretch horizontally,
146        # margin_top = -44 places the panel 44 px above the bottom edge.
147        for ctl in (self.controls_panel, self.controls_label):
148            ctl.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
149            ctl.margin_left = 0
150            ctl.margin_right = 0
151            ctl.margin_top = -44
152            ctl.margin_bottom = 0
153            ctl.size_y = 44
154
155    def on_update(self, dt: float) -> None:
156        # Hot-keys
157        if Input.is_action_just_pressed("restart"):
158            self.world.reset()
159            self._refresh_hud()
160        # Apply layout if viewport changed (cheap to re-run)
161        win_w, win_h = self._window_size()
162        if (win_w, win_h) != (getattr(self, "_last_size", None)):
163            self._apply_layout(win_w, win_h)
164            self._last_size = (win_w, win_h)
165        self._refresh_hud()
166
167    def _window_size(self) -> tuple[int, int]:
168        try:
169            return int(self.app.width), int(self.app.height)
170        except Exception:
171            return WIDTH, HEIGHT
172
173    def _refresh_hud(self) -> None:
174        self.score_text.text = f"Score {self.world.deliveries}/{DELIVERIES_TO_WIN}"
175        self.budget_text.text = f"Paths {self.world.path_budget}"
176        self.timer_text.text = f"Time {int(self.world.elapsed)}s"
177        if self.world.game_state == "won":
178            self.status_text.text = "YOU WIN  \u2014  R to play again"
179            self.status_text.colour = (0.50, 1.00, 0.55, 1.0)
180        elif self.world.game_state == "lost":
181            self.status_text.text = "FARM OVERWHELMED  \u2014  R to retry"
182            self.status_text.colour = (1.00, 0.45, 0.40, 1.0)
183        else:
184            self.status_text.text = ""
185
186    def _on_game_over(self) -> None:
187        self._refresh_hud()
188
189    def _on_victory(self) -> None:
190        self._refresh_hud()
191
192    def on_draw(self, renderer) -> None:
193        # Background: sky band + dark grass to frame the iso board
194        renderer.draw_rect((0, 0), (WIDTH * 4, HEIGHT * 4),
195                           colour=iso.COLOUR_GRASS_DARK, filled=True)
196        # HUD strip
197        renderer.draw_rect((0, 0), (WIDTH * 4, 44),
198                           colour=iso.COLOUR_HUD_BG, filled=True)
199
200
201# ---------------------------------------------------------------------------
202# Root
203# ---------------------------------------------------------------------------
204
205
206class TinyYurtsRoot(Node):
207    """Top-level scene; owns the InputMap and toggles between menu and game."""
208
209    def on_ready(self) -> None:
210        # InputMap registration must live in the root's on_ready (for web export)
211        InputMap.add_action("start", [Key.ENTER, Key.SPACE])
212        InputMap.add_action("quit", [Key.ESCAPE])
213        InputMap.add_action("restart", [Key.R])
214        InputMap.add_action("place_path", [MouseButton.LEFT])
215        InputMap.add_action("remove_path", [MouseButton.RIGHT])
216        # Touch support: SimVX surfaces touch as MouseButton.LEFT by default,
217        # so the same action covers mouse and finger drag.
218        self.state = "menu"
219        self.menu = self.add_child(TinyYurtsMenu())
220        self.game: TinyYurtsGame | None = None
221
222    def on_update(self, dt: float) -> None:
223        if self.state == "menu":
224            if Input.is_action_just_pressed("start"):
225                self._enter_game()
226            elif Input.is_action_just_pressed("quit"):
227                self.app.quit()
228        elif self.state == "game":
229            if Input.is_action_just_pressed("quit"):
230                self._enter_menu()
231
232    def _enter_game(self) -> None:
233        self.menu.destroy()
234        self.menu = None
235        self.game = self.add_child(TinyYurtsGame())
236        self.state = "game"
237
238    def _enter_menu(self) -> None:
239        if self.game is not None:
240            self.game.destroy()
241            self.game = None
242        self.menu = self.add_child(TinyYurtsMenu())
243        self.state = "menu"
244
245
246# ---------------------------------------------------------------------------
247# Entry / harness
248# ---------------------------------------------------------------------------
249
250
251def _run_headless() -> None:
252    """Capture frame_30/60/120 for the standard acceptance bar."""
253    from simvx.graphics import save_png
254
255    captures = [30, 60, 120]
256    app = App(width=WIDTH, height=HEIGHT, title="Tiny Yurts (SimVX)", visible=False)
257    frames = app.run_headless(TinyYurtsRoot(), frames=130, capture_frames=captures)
258    out_dir = _PORT_DIR / "screenshots"
259    out_dir.mkdir(exist_ok=True)
260    for idx, img in zip(captures, frames, strict=False):
261        out_path = out_dir / f"frame_{idx}.png"
262        save_png(out_path, img)
263        print(f"saved {out_path}")
264
265
266def main() -> None:
267    if "--test" in sys.argv:
268        _run_headless()
269        return
270    app = App(width=WIDTH, height=HEIGHT, title="Tiny Yurts (SimVX)")
271    app.run(TinyYurtsRoot())
272
273
274if __name__ == "__main__":
275    main()