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, inside the tree, so the desktop run and the browser export get the same bindings.

  • 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 capture – main.py --test renders 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)

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

main.py

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

196

harness.py

Scripted-input harness for the Tower Defence port.

209

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.

668

nodes/turret.py

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