Tower Defence

waves, tile-aligned turret placement, upgrades, and a shop panel.

▶ Run in browser

Upstream: 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 calls main()) gets them too.

  • Properties – money, lives, level and game speed are Property descriptors with validated ranges, so an out-of-range write is clamped rather than silently wrong.

  • Signals – enemies emit died / escaped / damaged, turrets emit target_acquired (the world turns it into a tracer), and the world emits level_started / game_won / game_lost for 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 AudioClip at load time (nodes/audio.py), so no audio files are bundled.

  • Headless capturemain.py --test renders frames without a visible window, and harness.py plays several waves through InputSimulator (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)

Enter / Space

Click anywhere

Place BASIC turret

1 / B

“BUY BASIC” panel button

Place SLOW turret

2

Cycle TYPE then “BUY”

Place SNIPER turret

3

Cycle TYPE then “BUY”

Cancel placement

Esc

“CANCEL” panel button

Select a placed turret

-

Click it (shows its range ring)

Begin wave

Enter / Space

“BEGIN WAVE” panel button

Fast-forward x2

F

“FAST x2” panel button

Upgrade selected turret

U

“UPGRADE -> Lx (100c)” panel button

Restart

R

“RESTART” panel button

Quit

Q / Esc (menu)

-

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

main.py

Tower Defence: waves, tile-aligned turret placement, upgrades, and a shop panel.

209

harness.py

Scripted-input harness for the Tower Defence port.

208

nodes/__init__.py

0

nodes/audio.py

Procedural audio: built on the engine’s AudioSynth API.

37

nodes/enemy.py

Waypoint-following enemy.

129

nodes/level_data.py

Level data for the Tower Defence port.

264

nodes/td_data.py

Tower Defence constants + spawn / turret data tables.

128

nodes/td_world.py

TowerDefenceWorld – the main gameplay scene.

722

nodes/turret.py

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