Tower Defence¶
waves, tile-aligned turret placement, upgrades, and a shop panel.
▶ Run in browserUpstream: https://github.com/russs123/tower_defence_tut
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
Tower Defence (SimVX port)¶
SimVX port of russs123/tower_defence_tut (Coding With Russ YouTube tutorial; the upstream repo declares no licence, so nothing from it is redistributed here). Wave-based tower defence with an animated basic turret + four upgrade tiers, plus slow and sniper turret variants added by the port.
Licensing: clean re-implementation under MIT; the level is an original design
and all art is Kenney CC0 or procedural, audio is synthesised. See
ATTRIBUTION.md and LICENSE.
What this shows in SimVX¶
Node tree + scene swap – a root node that tears down the menu and builds the gameplay scene (
main.py), with every input action registered once on the root so the browser export (which never callsmain()) gets them too.Properties – money, lives, level and game speed are
Propertydescriptors with validated ranges, so an out-of-range write is clamped rather than silently wrong.Signals – enemies emit
died/escaped/damaged, turrets emittarget_acquired(the world turns it into a tracer), and the world emitslevel_started/game_won/game_lostfor whatever hosts it.Node groups – turrets and enemies join
"turrets"/"enemies", and targeting and cleanup iterate the group instead of keeping their own lists.AnimatedSprite2D – the basic turret’s 8-frame fire cycle is driven from the turret’s cooldown, and an upgrade swaps the sheet by reassigning
texture.Text2D and
on_draw– HUD labels are nodes; the panel plate, buttons, placement preview and end-game dialog are immediate-mode shapes with box-aligned text.Procedural audio – the shot effect is synthesised into an
AudioClipat load time (nodes/audio.py), so no audio files are bundled.Headless capture –
main.py --testrenders frames without a visible window, andharness.pyplays several waves throughInputSimulator(simulated mouse and keyboard, never direct method calls) to screenshot each stage.
Run¶
All commands from the SimVX repo root (uv workspace requirement):
# Interactive
uv run python examples/ports/tower_defence_tut/main.py
# Headless smoke test (frame_30/60/120 captures)
uv run python examples/ports/tower_defence_tut/main.py --test
# Scripted harness (10 stage screenshots covering 3+ waves)
uv run python examples/ports/tower_defence_tut/harness.py
# Web export
uv run simvx export web examples/ports/tower_defence_tut/main.py -o /tmp/tower_defence.html
Controls¶
Action |
Keyboard |
Mouse |
|---|---|---|
Start (menu) |
|
Click anywhere |
Place BASIC turret |
|
“BUY BASIC” panel button |
Place SLOW turret |
|
Cycle TYPE then “BUY” |
Place SNIPER turret |
|
Cycle TYPE then “BUY” |
Cancel placement |
|
“CANCEL” panel button |
Select a placed turret |
- |
Click it (shows its range ring) |
Begin wave |
|
“BEGIN WAVE” panel button |
Fast-forward x2 |
|
“FAST x2” panel button |
Upgrade selected turret |
|
“UPGRADE -> Lx (100c)” panel button |
Restart |
|
“RESTART” panel button |
Quit |
|
- |
Touch / mobile¶
Tower placement is a single tap on a grass tile – the SimVX web runtime
surfaces touchstart/end as MouseButton.LEFT, so the browser export is
playable on mobile without code changes.
Tower types¶
Type |
Cost |
Range (L1) |
DPS (L1) |
Notes |
|---|---|---|---|---|
Basic |
200 |
120 |
~5.5 |
Animated turret (Kenney CC0 parts, baked 8-frame fire cycle) |
Slow |
250 |
70 |
~2.5 |
Applies a 0.5x slow debuff for 1 s on hit |
Sniper |
350 |
200 |
~5.8 |
Long range, slow rate, big single-shot damage |
All three types support 4-tier upgrades.
Source files¶
File |
Summary |
Lines |
|---|---|---|
Tower Defence: waves, tile-aligned turret placement, upgrades, and a shop panel. |
209 |
|
Scripted-input harness for the Tower Defence port. |
208 |
|
0 |
||
Procedural audio: built on the engine’s AudioSynth API. |
37 |
|
Waypoint-following enemy. |
129 |
|
Level data for the Tower Defence port. |
264 |
|
Tower Defence constants + spawn / turret data tables. |
128 |
|
TowerDefenceWorld – the main gameplay scene. |
722 |
|
Tower / turret node. |
333 |
Source¶
1"""Tower Defence: waves, tile-aligned turret placement, upgrades, and a shop panel.
2
3A SimVX port of Coding With Russ's pygame tower-defence tutorial. Waypoint-following
4enemies walk a hand-authored path; three turret types (basic, slow, sniper) with four
5upgrade tiers each shoot back, paid for out of a wave-by-wave currency budget. Built on
6the node tree, Properties, Signals, node groups, an AnimatedSprite2D fire cycle, and a
7shot effect synthesised at load time.
8
9# /// simvx
10# tags = ["port", "tier-1"]
11# upstream = "https://github.com/russs123/tower_defence_tut"
12# web = { width = 1280, height = 720, responsive = true }
13# ///
14
15Run:
16 uv run python examples/ports/tower_defence_tut/main.py
17 uv run python examples/ports/tower_defence_tut/main.py --test # headless capture
18 uv run python examples/ports/tower_defence_tut/harness.py # scripted run
19 uv run simvx export web examples/ports/tower_defence_tut/main.py -o /tmp/tower_defence.html
20"""
21
22from __future__ import annotations
23
24import sys
25from pathlib import Path
26
27_PORT_DIR = Path(__file__).parent
28if str(_PORT_DIR) not in sys.path:
29 sys.path.insert(0, str(_PORT_DIR))
30
31from nodes.td_data import ( # noqa: E402
32 WINDOW_HEIGHT,
33 WINDOW_WIDTH,
34)
35from nodes.td_world import TowerDefenceWorld # noqa: E402
36
37from simvx.core import ( # noqa: E402
38 Input,
39 InputMap,
40 Key,
41 MouseButton,
42 Node2D,
43 Sprite2D,
44 Text2D,
45 Vec2,
46)
47from simvx.graphics import App # noqa: E402
48
49# ---------------------------------------------------------------------------
50# Menu / Root wrapper
51# ---------------------------------------------------------------------------
52
53
54class _Menu(Node2D):
55 """Simple menu screen -- press Enter to begin, Esc to quit.
56
57 Every label is centred with ``Text2D.align="centre"`` on the window's mid-line,
58 so the screen re-lays-out to whatever size the window is dragged to instead of
59 relying on per-string pixel offsets.
60 """
61
62 def __init__(self, root: TowerDefenceRoot, **kwargs):
63 super().__init__(**kwargs)
64 self._root = root
65 self._blink = 0.0
66 self._laid_out: tuple[int, int] | None = None
67
68 # Logo background -- the port's own logo, composed from the Kenney parts.
69 self._logo = self.add_child(
70 Sprite2D(
71 texture=str(_PORT_DIR / "assets" / "images" / "gui" / "logo.png"),
72 width=300,
73 height=320,
74 )
75 )
76 self._title = self.add_child(
77 Text2D(text="TOWER DEFENCE", font_scale=2.4, align="centre", colour=(1.0, 1.0, 1.0, 1.0))
78 )
79 self._credit = self.add_child(
80 Text2D(
81 text="SimVX port of russs123/tower_defence_tut",
82 font_scale=1.0,
83 align="centre",
84 colour=(0.8, 0.85, 0.9, 1.0),
85 )
86 )
87 self._prompt = self.add_child(
88 Text2D(
89 text="PRESS ENTER OR CLICK TO START",
90 font_scale=1.3,
91 align="centre",
92 colour=(1.0, 0.95, 0.7, 1.0),
93 )
94 )
95 self._controls = self.add_child(
96 Text2D(
97 text="Controls: 1 / 2 / 3 = type select - U upgrade - R restart - Q quit",
98 font_scale=0.9,
99 align="centre",
100 colour=(0.75, 0.78, 0.82, 1.0),
101 )
102 )
103
104 def on_ready(self) -> None:
105 self._layout()
106
107 def _layout(self) -> None:
108 """Place the labels for the current window size (a no-op until it changes)."""
109 size = (int(self.app.width), int(self.app.height))
110 if size == self._laid_out:
111 return
112 self._laid_out = size
113 w, h = size
114 self._logo.position = Vec2(w / 2, h / 2 - 40)
115 self._title.position = Vec2(w / 2, 80)
116 self._credit.position = Vec2(w / 2, 130)
117 self._prompt.position = Vec2(w / 2, h - 130)
118 self._controls.position = Vec2(w / 2, h - 70)
119
120 def on_update(self, dt: float) -> None:
121 self._layout()
122 self._blink += dt
123 self._prompt.colour = (1.0, 0.95, 0.7, 1.0 if int(self._blink * 2) % 2 == 0 else 0.55)
124 if Input.is_action_just_pressed("menu_start") or Input.is_mouse_button_just_pressed(MouseButton.LEFT):
125 self._root.start_game()
126 if Input.is_action_just_pressed("menu_quit"):
127 self.app.quit()
128
129
130class TowerDefenceRoot(Node2D):
131 """Top-level scene swap: menu -> game -> menu (on restart key)."""
132
133 def __init__(self, **kwargs):
134 super().__init__(**kwargs)
135 self._menu: _Menu | None = None
136 self._world: TowerDefenceWorld | None = None
137 self._show_menu = True
138
139 def on_ready(self) -> None:
140 # ALL InputMap actions live on the root, registered once. Re-entering
141 # the menu and re-starting the world is therefore safe: no double
142 # registration and no actions lost on world destroy.
143 InputMap.add_action("menu_start", [Key.ENTER, Key.SPACE])
144 InputMap.add_action("menu_quit", [Key.ESCAPE, Key.Q])
145 InputMap.add_action("begin_wave", [Key.ENTER, Key.SPACE])
146 InputMap.add_action("fast_forward", [Key.F])
147 InputMap.add_action("place_basic", [Key.KEY_1, Key.B])
148 InputMap.add_action("place_slow", [Key.KEY_2])
149 InputMap.add_action("place_sniper", [Key.KEY_3])
150 InputMap.add_action("cancel_place", [Key.ESCAPE])
151 InputMap.add_action("upgrade", [Key.U])
152 InputMap.add_action("restart", [Key.R])
153 InputMap.add_action("quit", [Key.Q])
154 self._show_menu_screen()
155
156 def _show_menu_screen(self) -> None:
157 for c in list(self.children):
158 c.destroy()
159 self._world = None
160 self._menu = self.add_child(_Menu(self))
161
162 def start_game(self) -> None:
163 for c in list(self.children):
164 c.destroy()
165 self._menu = None
166 # The world stays on screen after a win or a loss: it draws its own
167 # end-game dialog and the RESTART button cycles its state in place.
168 self._world = self.add_child(TowerDefenceWorld())
169
170
171# ---------------------------------------------------------------------------
172# Entry
173# ---------------------------------------------------------------------------
174
175
176def main() -> None:
177 headless = "--test" in sys.argv
178 if headless:
179 from simvx.graphics import save_png
180
181 capture_at = [30, 60, 120]
182 app = App(
183 width=WINDOW_WIDTH,
184 height=WINDOW_HEIGHT,
185 title="Tower Defence (SimVX)",
186 visible=False,
187 )
188 frames = app.run_headless(
189 TowerDefenceRoot(),
190 frames=130,
191 capture_frames=capture_at,
192 )
193 out_dir = _PORT_DIR / "screenshots"
194 out_dir.mkdir(exist_ok=True)
195 for idx, img in zip(capture_at, frames, strict=False):
196 out_path = out_dir / f"frame_{idx}.png"
197 save_png(img, out_path)
198 print(f"saved {out_path}")
199 else:
200 app = App(
201 width=WINDOW_WIDTH,
202 height=WINDOW_HEIGHT,
203 title="Tower Defence (SimVX)",
204 )
205 app.run(TowerDefenceRoot())
206
207
208if __name__ == "__main__":
209 main()