Tanks of Freedom

Isometric turn-based strategy, three units, four buildings, AI opponent.

▶ Run in browser

Upstream: https://github.com/w84death/Tanks-of-Freedom

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

Tanks of Freedom: SimVX port

Turn-based isometric strategy: a single 12x12 skirmish against a heuristic AI, re-implemented in SimVX from P1X’s open-source Godot game.

Run

# from the repo root
uv run python examples/ports/tanks_of_freedom/main.py            # interactive
uv run python examples/ports/tanks_of_freedom/main.py --test     # headless screenshots
uv run python examples/ports/tanks_of_freedom/harness.py         # logic checks
uv run simvx export web examples/ports/tanks_of_freedom/main.py \
    -o /tmp/tanks_of_freedom.html

Controls

  • Click a blue unit to select it. Yellow tiles are its move range, red tiles are enemies it can attack this turn, yellow dots preview the planned path.

  • Click a yellow tile to move there, a red tile to attack.

  • Click a building to read its panel; click empty ground or press Escape to deselect.

  • End Turn (top right) or Enter passes the turn to red.

  • Escape / Q quits from the menu.

Win condition

Capture the red HQ or destroy every red unit. Only infantry capture: end an infantry move on a building’s tile and it changes hands. The mid-map neutral barracks and factory can be flipped on the way, but this port has no unit production, so only the HQ decides the game.

What this port demonstrates

  • Isometric tile maps. TankTileMap subclasses the engine’s TileMap, sets mode="isometric" and a 64x32 cell, and lets the engine Y-sort its children so units and buildings occlude each other correctly as they walk up the diamond (nodes/tile_map.py).

  • Procedural art, no image files. Terrain diamonds, unit silhouettes, buildings, flags, health bars, range overlays and explosion frames are all numpy RGBA arrays handed to Sprite2D(texture=...) with nearest-neighbour filtering (nodes/textures.py).

  • Signals as the turn spine. TurnManager emits turn_started, units emit move_finished when their walk animation lands, and the HUD buttons emit their own signals: the world only ever reacts. Move handlers connect with once=True so a completed move cannot fire again later (nodes/world.py).

  • Sprite pooling for overlays. Highlighting 30+ reachable cells every frame reuses a pool of hidden Sprite2D children instead of creating and destroying nodes (nodes/cursor.py).

  • Input actions, not raw keys. primary, end_turn, cancel, menu_start and menu_quit are registered in the root node’s ready path, so the web export (which never calls main()) gets the same bindings as the desktop run.

  • Audio. The upstream CC-BY-SA effects are loaded into AudioPlayer nodes and triggered on select, move, capture, hit, explosion and end of turn.

Upstream

w84death/Tanks-of-Freedom: Godot 2.1, MIT code and graphics, CC-BY-SA 4.0 audio. This port re-implements the game in Python and reuses only the sound effects. See ATTRIBUTION.md and UPSTREAM_LICENSE.md.

Layout

  • main.py: App entry, menu/world swap, headless capture script.

  • nodes/data.py: constants, unit stats, map layout, starting line-up.

  • nodes/textures.py: procedural numpy sprites.

  • nodes/tile_map.py: iso TileMap wrapper + terrain grid.

  • nodes/unit.py: Unit (Sprite2D) with move animation.

  • nodes/building.py: Building (HQ / Barracks / Factory / Airport).

  • nodes/cursor.py: hover cursor + range/path overlays.

  • nodes/pathfinder.py: BFS path + AP flood fill.

  • nodes/combat.py: pure-function attack resolution.

  • nodes/turn.py: TurnManager state machine.

  • nodes/ai.py: red-side heuristic AI.

  • nodes/hud.py: top bar + side panel + buttons.

  • nodes/world.py: TanksWorld; ties everything together.

Source files

File

Summary

Lines

main.py

Tanks of Freedom: Isometric turn-based strategy, three units, four buildings, AI opponent.

281

harness.py

Scripted runtime harness: drives the world headless and verifies state.

106

nodes/__init__.py

Tanks of Freedom port nodes.

1

nodes/ai.py

Simple turn-based AI for the red side.

140

nodes/building.py

Building node: owns a procedural sprite + flag overlay.

62

nodes/combat.py

Pure-function combat resolution.

48

nodes/cursor.py

Hover cursor + selection / range overlays.

95

nodes/data.py

Game constants and data tables for Tanks of Freedom.

207

nodes/effects.py

Short-lived visual effects: capture flash, attack flash, explosions.

108

nodes/hud.py

HUD: top status bar + right-side info panel + end-turn button.

164

nodes/pathfinder.py

Pathfinding + flood-fill helpers.

116

nodes/textures.py

Procedural numpy textures for Tanks of Freedom.

356

nodes/tile_map.py

TankTileMap: wraps simvx TileMap(mode=”isometric”) with terrain layout.

122

nodes/turn.py

TurnManager: alternates blue / red turns, tracks game-over state.

47

nodes/unit.py

Unit nodes: Sprite2D wrapper with stats, AP and per-turn flags.

167

nodes/world.py

TanksWorld: top-level game scene.

420

Source

  1"""Tanks of Freedom: Isometric turn-based strategy, three units, four buildings, AI opponent.
  2
  3# /// simvx
  4# tags = ["port", "tier-2"]
  5# upstream = "https://github.com/w84death/Tanks-of-Freedom"
  6# web = { width = 1280, height = 720, responsive = true }
  7# ///
  8
  9Port of P1X's open-source Godot game Tanks of Freedom: a 12x12 skirmish
 10against a heuristic AI. Click a blue unit to select it, move inside its
 11yellow AP range, attack an adjacent enemy on a red tile, and capture the
 12red HQ with infantry (or destroy every red unit) to win.
 13
 14Exercises TileMap(mode="isometric") with Y-sorted unit and building
 15children, procedural numpy sprite textures handed straight to Sprite2D,
 16Signal-driven turn flow, BFS movement / attack-range overlays drawn from
 17pooled sprites, and AudioPlayer for the upstream CC-BY-SA sound effects.
 18
 19Run:
 20    uv run python examples/ports/tanks_of_freedom/main.py            # interactive
 21    uv run python examples/ports/tanks_of_freedom/main.py --test     # headless screenshots
 22    uv run python examples/ports/tanks_of_freedom/harness.py         # logic checks
 23    uv run simvx export web examples/ports/tanks_of_freedom/main.py -o /tmp/tanks_of_freedom.html
 24"""
 25
 26from __future__ import annotations
 27
 28import sys
 29from pathlib import Path
 30
 31_PORT_DIR = Path(__file__).parent
 32if str(_PORT_DIR) not in sys.path:
 33    sys.path.insert(0, str(_PORT_DIR))
 34
 35from nodes.data import WINDOW_HEIGHT, WINDOW_WIDTH  # noqa: E402
 36from nodes.world import TanksWorld  # noqa: E402
 37
 38from simvx.core import Input, Node2D  # noqa: E402
 39from simvx.core.input.enums import Key, MouseButton  # noqa: E402
 40from simvx.core.input.map import InputMap  # noqa: E402
 41from simvx.core.ui.widgets import Label  # noqa: E402
 42from simvx.graphics import App  # noqa: E402
 43
 44ASSET_DIR = _PORT_DIR / "assets"
 45
 46
 47# ---------------------------------------------------------------------- menu
 48class _Menu(Node2D):
 49    """Splash screen: press Enter / click to begin."""
 50
 51    def __init__(self, root, **kwargs):
 52        super().__init__(**kwargs)
 53        self._root = root
 54        self._blink = 0.0
 55
 56        title = Label("TANKS OF FREEDOM")
 57        title.position = (WINDOW_WIDTH / 2 - 220, 130)
 58        title.size = (440, 60)
 59        title.font_size = 48.0
 60        title.text_colour = (1.0, 0.95, 0.6, 1.0)
 61        title.alignment = "center"
 62        self.add_child(title)
 63
 64        sub = Label("SimVX port: turn-based isometric strategy")
 65        sub.position = (WINDOW_WIDTH / 2 - 260, 200)
 66        sub.size = (520, 24)
 67        sub.font_size = 18.0
 68        sub.text_colour = (0.85, 0.88, 0.92, 1.0)
 69        sub.alignment = "center"
 70        self.add_child(sub)
 71
 72        # How-to lines
 73        lines = [
 74            "Click a unit to select.",
 75            "Yellow = move range. Red = attack range.",
 76            "Click a highlighted tile to commit.",
 77            "Capture the enemy HQ or destroy all enemy units.",
 78            "End-Turn button or Enter to pass the turn.",
 79        ]
 80        for i, line in enumerate(lines):
 81            lbl = Label(line)
 82            lbl.position = (WINDOW_WIDTH / 2 - 260, 280 + i * 28)
 83            lbl.size = (520, 24)
 84            lbl.font_size = 16.0
 85            lbl.text_colour = (0.80, 0.85, 0.92, 1.0)
 86            lbl.alignment = "center"
 87            self.add_child(lbl)
 88
 89        prompt = Label("PRESS ENTER OR CLICK TO START")
 90        prompt.position = (WINDOW_WIDTH / 2 - 220, WINDOW_HEIGHT - 140)
 91        prompt.size = (440, 32)
 92        prompt.font_size = 20.0
 93        prompt.text_colour = (1.0, 0.95, 0.7, 1.0)
 94        prompt.alignment = "center"
 95        self.add_child(prompt)
 96        self._prompt = prompt
 97
 98        credit = Label("Upstream MIT/CC-BY-SA, w84death/Tanks-of-Freedom")
 99        credit.position = (WINDOW_WIDTH / 2 - 230, WINDOW_HEIGHT - 60)
100        credit.size = (460, 22)
101        credit.font_size = 14.0
102        credit.text_colour = (0.70, 0.74, 0.80, 1.0)
103        credit.alignment = "center"
104        self.add_child(credit)
105
106    def on_update(self, dt: float) -> None:
107        self._blink += dt
108        on = int(self._blink * 2) % 2 == 0
109        self._prompt.text_colour = (1.0, 0.95, 0.7, 1.0 if on else 0.4)
110        if Input.is_action_just_pressed("menu_start") or Input.is_action_just_pressed("primary"):
111            self._root.start_game()
112        if Input.is_action_just_pressed("menu_quit"):
113            self.app.quit()
114
115
116# ---------------------------------------------------------------------- root
117class TanksRoot(Node2D):
118    """Top-level swap: menu -> world -> menu (on restart / game over)."""
119
120    def __init__(self, *, autostart: bool = False, **kwargs):
121        super().__init__(**kwargs)
122        self._world: TanksWorld | None = None
123        self._menu: _Menu | None = None
124        self._autostart = autostart
125
126    def on_ready(self) -> None:
127        # All InputMap actions live in root.on_ready (web exporter skips main).
128        InputMap.add_action("menu_start", [Key.ENTER, Key.SPACE])
129        InputMap.add_action("menu_quit", [Key.ESCAPE, Key.Q])
130        InputMap.add_action("primary", [MouseButton.LEFT])
131        InputMap.add_action("end_turn", [Key.ENTER, Key.SPACE])
132        InputMap.add_action("cancel", [Key.ESCAPE])
133
134        if self._autostart:
135            self.start_game()
136        else:
137            self._show_menu()
138
139    def _show_menu(self) -> None:
140        for c in list(self.children):
141            c.destroy()
142        self._world = None
143        self._menu = self.add_child(_Menu(self))
144
145    def start_game(self) -> None:
146        for c in list(self.children):
147            c.destroy()
148        self._menu = None
149        self._world = self.add_child(TanksWorld(asset_dir=ASSET_DIR))
150        self._world.game_over.connect(self._on_game_over)
151
152    def _on_game_over(self, winner: int) -> None:
153        # Stay on the world; restart button (in HUD) takes us back to a fresh world.
154        if winner == -1:
155            # Explicit restart request.
156            self.start_game()
157
158    def on_draw(self, renderer) -> None:
159        # Dark slate background drawn once per frame; child draws on top.
160        renderer.draw_rect((0, 0), (WINDOW_WIDTH, WINDOW_HEIGHT), colour=(0.10, 0.13, 0.18, 1.0), filled=True)
161
162
163# -------------------------------------------------------------------- entry
164def _drive_test_input(root, frame_idx: int) -> None:
165    """Scripted input for headless --test capture.
166
167    Frame events:
168      F30  : hover over the blue infantry at (2, 10), neutral hover state
169      F55  : click it to select (move-range overlay shows on F60)
170      F90  : hover at (3, 8) so planned path renders
171      F130 : click to commit move (mid-step on F145)
172      F200 : Enter, end blue's turn, AI starts thinking
173      F260 : hover mid-map to keep the cursor visible during the AI turn
174      F360 : Enter again, end red's turn
175      F420 : select the blue helicopter at (4, 9)
176      F470 : hover the neutral factory at (6, 6)
177      F510 : click it to send the helicopter forward
178      F560 : Enter, end blue's second turn
179    """
180    from simvx.core.testing import InputSimulator
181
182    sim = InputSimulator()
183    world = getattr(root, "_world", None)
184    if world is None:
185        return
186    tm = world.tile_map
187
188    def world_xy(cell):
189        wx, wy = tm.map_to_world(cell)
190        return (tm.position.x + wx, tm.position.y + wy)
191
192    # Step events
193    if frame_idx == 30:
194        sim.move_mouse(*world_xy((2, 10)))
195    elif frame_idx == 55:
196        sim.move_mouse(*world_xy((2, 10)))
197        sim.click(world_xy((2, 10)), button=0)
198    elif frame_idx == 90:
199        sim.move_mouse(*world_xy((3, 8)))
200    elif frame_idx == 130:
201        sim.move_mouse(*world_xy((3, 8)))
202        sim.click(world_xy((3, 8)), button=0)
203    elif frame_idx == 200:
204        sim.tap_key(Key.ENTER)
205    elif frame_idx == 260:
206        sim.move_mouse(*world_xy((6, 6)))
207    elif frame_idx == 360:
208        sim.tap_key(Key.ENTER)
209    elif frame_idx == 420:
210        # Pick another blue unit (helicopter at (4,9)), probably moved
211        sim.click(world_xy((4, 9)), button=0)
212    elif frame_idx == 470:
213        sim.move_mouse(*world_xy((6, 6)))
214    elif frame_idx == 510:
215        # Move heli far forward toward neutral building
216        sim.click(world_xy((6, 6)), button=0)
217    elif frame_idx == 560:
218        sim.tap_key(Key.ENTER)
219
220
221def _save_frames(out_dir: Path, capture_at, frames, prefix: str = "frame") -> None:
222    from simvx.graphics import save_png
223
224    out_dir.mkdir(exist_ok=True)
225    for idx, img in zip(capture_at, frames, strict=False):
226        # Force alpha to 255: the framebuffer leaves alpha=0 in regions
227        # that were never explicitly written, which PNG viewers
228        # composite onto white and display as washed-out.
229        img = img.copy()
230        img[:, :, 3] = 255
231        out_path = out_dir / f"{prefix}_{idx}.png"
232        save_png(img, out_path)
233        print(f"saved {out_path}")
234
235
236def main() -> None:
237    headless = "--test" in sys.argv
238    if headless:
239        out_dir = _PORT_DIR / "screenshots"
240
241        # Pass 1: menu screen (no auto-start).
242        menu_capture = [20]
243        app = App(
244            width=WINDOW_WIDTH,
245            height=WINDOW_HEIGHT,
246            title="Tanks of Freedom (SimVX)",
247            visible=False,
248            bg_colour=(0.10, 0.13, 0.18, 1.0),
249        )
250        menu_frames = app.run_headless(TanksRoot(autostart=False), frames=30, capture_frames=menu_capture)
251        _save_frames(out_dir, menu_capture, menu_frames, prefix="menu")
252
253        # Pass 2: gameplay scripted: select, move, end turn, AI move.
254        capture_at = [10, 30, 60, 100, 145, 220, 320, 425, 480, 540, 620]
255        app = App(
256            width=WINDOW_WIDTH,
257            height=WINDOW_HEIGHT,
258            title="Tanks of Freedom (SimVX)",
259            visible=False,
260            bg_colour=(0.10, 0.13, 0.18, 1.0),
261        )
262        root = TanksRoot(autostart=True)
263        frames = app.run_headless(
264            root,
265            frames=650,
266            capture_frames=capture_at,
267            on_frame=lambda idx, t: _drive_test_input(root, idx),
268        )
269        _save_frames(out_dir, capture_at, frames, prefix="frame")
270    else:
271        app = App(
272            width=WINDOW_WIDTH,
273            height=WINDOW_HEIGHT,
274            title="Tanks of Freedom (SimVX)",
275            bg_colour=(0.10, 0.13, 0.18, 1.0),
276        )
277        app.run(TanksRoot())
278
279
280if __name__ == "__main__":
281    main()