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 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()