Tiny Yurts¶
Isometric grid, BFS path-following agents, drawable routes.
▶ Run in browserUpstream: 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()