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

# the export runs in a copy made by `simvx examples copy ports_tanks_of_freedom`
simvx examples run ports_tanks_of_freedom            # interactive
simvx examples run ports_tanks_of_freedom --test     # headless screenshots
simvx export web main.py \
    -o 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, where the tree exists, so the web export 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.

278

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.

182

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.

421

Source

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