Tower Defence

Waves, placement, currency UI, navigation curves.

▶ Run in browser

Upstream: https://github.com/russs123/tower_defence_tut

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.

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

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

  1"""Tower Defence: Waves, placement, currency UI, navigation curves.
  2
  3# /// simvx
  4# tags = ["port", "tier-1"]
  5# upstream = "https://github.com/russs123/tower_defence_tut"
  6# web = { width = 1280, height = 720, responsive = true }
  7# ///
  8
  9Run:
 10    uv run python examples/ports/tower_defence_tut/main.py
 11    uv run python examples/ports/tower_defence_tut/main.py --test     # headless capture
 12    uv run python examples/ports/tower_defence_tut/harness.py         # scripted run
 13    uv run simvx export web examples/ports/tower_defence_tut/main.py -o /tmp/tower_defence.html
 14"""
 15
 16from __future__ import annotations
 17
 18import sys
 19from pathlib import Path
 20
 21_PORT_DIR = Path(__file__).parent
 22if str(_PORT_DIR) not in sys.path:
 23    sys.path.insert(0, str(_PORT_DIR))
 24
 25from nodes.td_data import (  # noqa: E402
 26    WINDOW_HEIGHT,
 27    WINDOW_WIDTH,
 28)
 29from nodes.td_world import TowerDefenceWorld  # noqa: E402
 30
 31from simvx.core import (  # noqa: E402
 32    Input,
 33    InputMap,
 34    Key,
 35    MouseButton,
 36    Node2D,
 37    Sprite2D,
 38    Text2D,
 39    Vec2,
 40)
 41from simvx.graphics import App  # noqa: E402
 42
 43# ---------------------------------------------------------------------------
 44# Menu / Root wrapper
 45# ---------------------------------------------------------------------------
 46
 47
 48class _Menu(Node2D):
 49    """Simple menu screen -- press Enter to begin, Esc to quit."""
 50
 51    def __init__(self, root: TowerDefenceRoot, **kwargs):
 52        super().__init__(**kwargs)
 53        self._root = root
 54        self._blink = 0.0
 55
 56        # Logo background -- the tutorial ships a logo PNG
 57        self.add_child(
 58            Sprite2D(
 59                texture=str(_PORT_DIR / "assets" / "images" / "gui" / "logo.png"),
 60                position=Vec2(WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2 - 40),
 61                width=300, height=320,
 62            )
 63        )
 64        self.add_child(
 65            Text2D(
 66                text="TOWER DEFENCE",
 67                position=(WINDOW_WIDTH / 2 - 110, 80), font_scale=2.4,
 68                colour=(1.0, 1.0, 1.0, 1.0),
 69            )
 70        )
 71        self.add_child(
 72            Text2D(
 73                text="SimVX port of russs123/tower_defence_tut",
 74                position=(WINDOW_WIDTH / 2 - 200, 130), font_scale=1.0,
 75                colour=(0.8, 0.85, 0.9, 1.0),
 76            )
 77        )
 78        self._prompt = self.add_child(
 79            Text2D(
 80                text="PRESS ENTER OR CLICK TO START",
 81                position=(WINDOW_WIDTH / 2 - 195, WINDOW_HEIGHT - 130), font_scale=1.3,
 82                colour=(1.0, 0.95, 0.7, 1.0),
 83            )
 84        )
 85        self.add_child(
 86            Text2D(
 87                text="Controls: 1 / 2 / 3 = type select  -  U upgrade  -  R restart  -  Q quit",
 88                position=(WINDOW_WIDTH / 2 - 295, WINDOW_HEIGHT - 70), font_scale=0.9,
 89                colour=(0.75, 0.78, 0.82, 1.0),
 90            )
 91        )
 92
 93    def on_update(self, dt: float) -> None:
 94        self._blink += dt
 95        self._prompt.colour = (
 96            1.0, 0.95, 0.7, 0.55 + 0.45 * (0.5 + 0.5 * (1.0 if int(self._blink * 2) % 2 == 0 else -1.0))
 97        )
 98        if (
 99            Input.is_action_just_pressed("menu_start")
100            or Input.is_mouse_button_just_pressed(MouseButton.LEFT)
101        ):
102            self._root.start_game()
103        if Input.is_action_just_pressed("menu_quit"):
104            self.app.quit()
105
106
107class TowerDefenceRoot(Node2D):
108    """Top-level scene swap: menu -> game -> menu (on restart key)."""
109
110    def __init__(self, **kwargs):
111        super().__init__(**kwargs)
112        self._menu: _Menu | None = None
113        self._world: TowerDefenceWorld | None = None
114        self._show_menu = True
115
116    def on_ready(self) -> None:
117        # ALL InputMap actions live on the root, registered once. Re-entering
118        # the menu and re-starting the world is therefore safe: no double
119        # registration and no actions lost on world destroy.
120        InputMap.add_action("menu_start", [Key.ENTER, Key.SPACE])
121        InputMap.add_action("menu_quit", [Key.ESCAPE, Key.Q])
122        InputMap.add_action("primary", [MouseButton.LEFT])
123        InputMap.add_action("begin_wave", [Key.ENTER, Key.SPACE])
124        InputMap.add_action("fast_forward", [Key.F])
125        InputMap.add_action("place_basic", [Key.KEY_1, Key.B])
126        InputMap.add_action("place_slow", [Key.KEY_2])
127        InputMap.add_action("place_sniper", [Key.KEY_3])
128        InputMap.add_action("cancel_place", [Key.ESCAPE])
129        InputMap.add_action("upgrade", [Key.U])
130        InputMap.add_action("restart", [Key.R])
131        InputMap.add_action("quit", [Key.Q])
132        self._show_menu_screen()
133
134    def _show_menu_screen(self) -> None:
135        for c in list(self.children):
136            c.destroy()
137        self._world = None
138        self._menu = self.add_child(_Menu(self))
139
140    def start_game(self) -> None:
141        for c in list(self.children):
142            c.destroy()
143        self._menu = None
144        self._world = self.add_child(TowerDefenceWorld())
145        self._world.game_lost.connect(self._on_game_end)
146        self._world.game_won.connect(self._on_game_end)
147
148    def _on_game_end(self) -> None:
149        # Stay on the world scene -- restart button cycles state in-place.
150        pass
151
152
153# ---------------------------------------------------------------------------
154# Entry
155# ---------------------------------------------------------------------------
156
157
158def main() -> None:
159    headless = "--test" in sys.argv
160    if headless:
161        from simvx.graphics import save_png
162
163        capture_at = [30, 60, 120]
164        app = App(
165            width=WINDOW_WIDTH, height=WINDOW_HEIGHT,
166            title="Tower Defence (SimVX)", visible=False,
167        )
168        frames = app.run_headless(
169            TowerDefenceRoot(), frames=130, capture_frames=capture_at,
170        )
171        out_dir = _PORT_DIR / "screenshots"
172        out_dir.mkdir(exist_ok=True)
173        for idx, img in zip(capture_at, frames, strict=False):
174            out_path = out_dir / f"frame_{idx}.png"
175            save_png(out_path, img)
176            print(f"saved {out_path}")
177    else:
178        app = App(
179            width=WINDOW_WIDTH, height=WINDOW_HEIGHT,
180            title="Tower Defence (SimVX)",
181        )
182        app.run(TowerDefenceRoot())
183
184
185if __name__ == "__main__":
186    main()