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