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, inside the tree, so the desktop run and the browser export get the same bindings.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.
Run¶
Run it with the example library installed; the export runs in a copy made by simvx examples copy ports_tower_defence_tut:
# Interactive
simvx examples run ports_tower_defence_tut
# Headless smoke test (frame_30/60/120 captures)
simvx examples run ports_tower_defence_tut --test
# Web export
simvx export web main.py -o 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 Mouse.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. |
196 |
|
Scripted-input harness for the Tower Defence port. |
209 |
|
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. |
668 |
|
Tower / turret node. |
333 |
Source¶
1# /// simvx
2# tags = ["port", "tier-1"]
3# upstream = "https://github.com/russs123/tower_defence_tut"
4# web = { width = 1280, height = 720, responsive = true }
5# ///
6"""Tower Defence: waves, tile-aligned turret placement, upgrades, and a shop panel.
7
8A SimVX port of Coding With Russ's pygame tower-defence tutorial. Waypoint-following
9enemies walk a hand-authored path; three turret types (basic, slow, sniper) with four
10upgrade tiers each shoot back, paid for out of a wave-by-wave currency budget. Built on
11the node tree, Properties, Signals, node groups, an AnimatedSprite2D fire cycle, and a
12shot effect synthesised at load time.
13
14Run:
15 simvx examples run ports_tower_defence_tut
16 simvx examples run ports_tower_defence_tut --test # headless capture
17 simvx export web main.py -o tower_defence.html
18"""
19
20from __future__ import annotations
21
22import sys
23from pathlib import Path
24
25_PORT_DIR = Path(__file__).parent
26
27from nodes.td_data import ( # noqa: E402
28 WINDOW_HEIGHT,
29 WINDOW_WIDTH,
30)
31from nodes.td_world import TowerDefenceWorld # noqa: E402
32
33from simvx.core import Input, InputMap, Key, Mouse, Node2D, Sprite2D, Text2D, Vec2
34from simvx.graphics import App # noqa: E402
35
36# ---------------------------------------------------------------------------
37# Menu / Root wrapper
38# ---------------------------------------------------------------------------
39
40
41class _Menu(Node2D):
42 """Simple menu screen -- press Enter to begin, Esc to quit.
43
44 Every label is centred with ``Text2D.align="centre"`` on the window's mid-line,
45 so the screen re-lays-out to whatever size the window is dragged to instead of
46 relying on per-string pixel offsets.
47 """
48
49 def __init__(self, root: TowerDefenceRoot, **kwargs):
50 super().__init__(**kwargs)
51 self._root = root
52 self._blink = 0.0
53 self._laid_out: tuple[int, int] | None = None
54
55 # Logo background -- the port's own logo, composed from the Kenney parts.
56 self._logo = self.add_child(
57 Sprite2D(
58 texture=str(_PORT_DIR / "assets" / "images" / "gui" / "logo.png"),
59 width=300,
60 height=320,
61 )
62 )
63 self._title = self.add_child(
64 Text2D(text="TOWER DEFENCE", font_scale=2.4, align="centre", colour=(1.0, 1.0, 1.0, 1.0))
65 )
66 self._credit = self.add_child(
67 Text2D(
68 text="SimVX port of russs123/tower_defence_tut",
69 font_scale=1.0,
70 align="centre",
71 colour=(0.8, 0.85, 0.9, 1.0),
72 )
73 )
74 self._prompt = self.add_child(
75 Text2D(
76 text="PRESS ENTER OR CLICK TO START",
77 font_scale=1.3,
78 align="centre",
79 colour=(1.0, 0.95, 0.7, 1.0),
80 )
81 )
82 self._controls = self.add_child(
83 Text2D(
84 text="Controls: 1 / 2 / 3 = type select - U upgrade - R restart - Q quit",
85 font_scale=0.9,
86 align="centre",
87 colour=(0.75, 0.78, 0.82, 1.0),
88 )
89 )
90
91 def on_ready(self) -> None:
92 self._layout()
93
94 def _layout(self) -> None:
95 """Place the labels for the current window size (a no-op until it changes)."""
96 size = (int(self.app.width), int(self.app.height))
97 if size == self._laid_out:
98 return
99 self._laid_out = size
100 w, h = size
101 self._logo.position = Vec2(w / 2, h / 2 - 40)
102 self._title.position = Vec2(w / 2, 80)
103 self._credit.position = Vec2(w / 2, 130)
104 self._prompt.position = Vec2(w / 2, h - 130)
105 self._controls.position = Vec2(w / 2, h - 70)
106
107 def on_update(self, dt: float) -> None:
108 self._layout()
109 self._blink += dt
110 self._prompt.colour = (1.0, 0.95, 0.7, 1.0 if int(self._blink * 2) % 2 == 0 else 0.55)
111 if Input.just_pressed("menu_start") or Input.is_mouse_button_just_pressed(Mouse.LEFT):
112 self._root.start_game()
113 if Input.just_pressed("menu_quit"):
114 self.app.quit()
115
116
117class TowerDefenceRoot(Node2D):
118 """Top-level scene swap: menu -> game -> menu (on restart key)."""
119
120 def __init__(self, **kwargs):
121 super().__init__(**kwargs)
122 self._menu: _Menu | None = None
123 self._world: TowerDefenceWorld | None = None
124 self._show_menu = True
125
126 def on_ready(self) -> None:
127 # ALL InputMap actions live on the root, registered once. Re-entering
128 # the menu and re-starting the world is therefore safe: no double
129 # registration and no actions lost on world destroy.
130 InputMap.add_action("menu_start", [Key.ENTER, Key.SPACE])
131 InputMap.add_action("menu_quit", [Key.ESCAPE, Key.Q])
132 InputMap.add_action("begin_wave", [Key.ENTER, Key.SPACE])
133 InputMap.add_action("fast_forward", [Key.F])
134 InputMap.add_action("place_basic", [Key.KEY_1, Key.B])
135 InputMap.add_action("place_slow", [Key.KEY_2])
136 InputMap.add_action("place_sniper", [Key.KEY_3])
137 InputMap.add_action("cancel_place", [Key.ESCAPE])
138 InputMap.add_action("upgrade", [Key.U])
139 InputMap.add_action("restart", [Key.R])
140 InputMap.add_action("quit", [Key.Q])
141 self._show_menu_screen()
142
143 def _show_menu_screen(self) -> None:
144 for c in list(self.children):
145 c.destroy()
146 self._world = None
147 self._menu = self.add_child(_Menu(self))
148
149 def start_game(self) -> None:
150 for c in list(self.children):
151 c.destroy()
152 self._menu = None
153 # The world stays on screen after a win or a loss: it draws its own
154 # end-game dialog and the RESTART button cycles its state in place.
155 self._world = self.add_child(TowerDefenceWorld())
156
157
158# ---------------------------------------------------------------------------
159# Entry
160# ---------------------------------------------------------------------------
161
162
163def main() -> None:
164 headless = "--test" in sys.argv
165 if headless:
166 from simvx.graphics import save_png
167
168 capture_at = [30, 60, 120]
169 app = App(
170 width=WINDOW_WIDTH,
171 height=WINDOW_HEIGHT,
172 title="Tower Defence (SimVX)",
173 visible=False,
174 )
175 frames = app.run_headless(
176 TowerDefenceRoot(),
177 frames=130,
178 capture_frames=capture_at,
179 )
180 out_dir = _PORT_DIR / "screenshots"
181 out_dir.mkdir(exist_ok=True)
182 for idx, img in zip(capture_at, frames, strict=False):
183 out_path = out_dir / f"frame_{idx}.png"
184 save_png(img, out_path)
185 print(f"saved {out_path}")
186 else:
187 app = App(
188 width=WINDOW_WIDTH,
189 height=WINDOW_HEIGHT,
190 title="Tower Defence (SimVX)",
191 )
192 app.run(TowerDefenceRoot())
193
194
195if __name__ == "__main__":
196 main()