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