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