Computer monitor showing a pixel-art style tower defense game scene, with a keyboard and notebook on the desk
Concept visualization: a game development desk with a pixel-art tower defense scene on screen. Generated illustration, not a screenshot of the actual game.
2024 Game Development GitHub Profile ↗

Overview

Depot Gato is a 2D tower defense game built in Godot 4 using GDScript. Waves of enemies follow a navigation path toward a central depot, and the player places cat-themed towers along buildable tiles to intercept and eliminate them before they arrive. The project was built from scratch to explore Godot's scene composition model, signal-based decoupled architecture, and NavigationAgent2D-driven pathfinding.

Core systems include a wave spawning manager driven by configurable WaveData resources, three distinct tower types with independent attack ranges and damage profiles, enemy pathfinding via NavigationAgent2D with a live HP bar and animated states, a currency economy that rewards kills and gates tower placement, and a central GameState autoload singleton that brokers win and lose conditions through Godot signals.

Tech Stack

  • Godot 4, game engine, scene tree, physics layers, CanvasLayer UI
  • GDScript, all game logic: towers, enemies, wave manager, game state
  • NavigationAgent2D, dynamic pathfinding and steering for enemies
  • TileMap, path tiles and buildable zones; grid snapping for tower placement
  • Area2D, tower detection radius; projectile hit detection
  • AnimationPlayer, enemy walk, hurt, and death animations
  • Aseprite, pixel art sprite sheets, exported as PNG sprite atlases
  • Git, version control

Scene & System Architecture

The game is structured around Godot's composable scene tree. The root Main.tscn owns the TileMap, the HUD CanvasLayer, and the WaveManager node. Each gameplay entity (enemies and towers) is a self-contained scene instanced at runtime. A GameState autoload singleton acts as a shared data bus, no node directly queries another node's state; instead, all components emit and subscribe to signals.

graph TD A["Main.tscn (Root)"]:::primary A --> B["HUD.tscn (CanvasLayer)"]:::secondary B --> B1["Currency Label"]:::secondary B --> B2["Lives Label"]:::secondary B --> B3["Wave Counter"]:::secondary B --> B4["Tower Palette"]:::secondary A --> C["TileMap"]:::secondary C --> C1["Path Tiles (layer 0)"]:::secondary C --> C2["Buildable Tiles (layer 1)"]:::secondary A --> D["WaveManager.gd"]:::primary D --> E["Enemy.tscn (instanced per spawn)"]:::primary E --> E1["NavigationAgent2D"]:::secondary E --> E2["HP ProgressBar"]:::secondary E --> E3["AnimationPlayer"]:::secondary E --> E4["Enemy.gd"]:::secondary A --> F["Tower Variants"]:::primary F --> F1["BasicTower.tscn"]:::success F --> F2["SniperTower.tscn"]:::success F --> F3["SplashTower.tscn"]:::success subgraph Systems G["TowerPlacement.gd"]:::primary H["Tower.gd (base)"]:::primary I["Enemy.gd"]:::primary J["GameState.gd (autoload)"]:::primary end G -->|"check buildable tile"| C2 G -->|"deduct currency"| J G -->|"instance tower scene"| F H -->|"Area2D detects enemies"| E H -->|"select nearest target"| I H -->|"fire Projectile"| K["Projectile.tscn"]:::secondary I -->|"follow navigation path"| E1 I -->|"on reach depot"| J J -->|"tracks lives, currency, wave"| B J -->|"emits game_over / victory"| A classDef primary fill:#1a1a2e,stroke:#00d4ff,color:#e0e0e0 classDef secondary fill:#181818,stroke:#1e1e1e,color:#888 classDef success fill:#1a1a2e,stroke:#00ff88,color:#e0e0e0

Build Process

  1. 01

    Project Setup and Physics Layers

    Created a new Godot 4 project with a 2D scene root. Configured three named physics collision layers in Project Settings: Layer 1 for enemies, Layer 2 for towers, and Layer 3 for projectiles. This separation ensures that Area2D nodes on towers only detect the enemy layer, preventing false collision signals. Set pixel art import defaults globally: Filter set to Nearest, mipmaps disabled, and Snap 2D Transforms to Pixel enabled under Rendering settings to eliminate sub-pixel blurring on scaled sprites.

  2. 02

    TileMap and NavigationRegion2D Pathfinding

    Designed the TileMap with two layers: a path layer (tiles enemies traverse) and a buildable layer (tiles where towers may be placed). Added a NavigationRegion2D node covering the path layer and baked the navigation mesh. A known pitfall here is that NavigationRegion2D.bake_navigation_polygon() must be deferred to the next frame after the scene is fully loaded, calling it on _ready() directly caused enemies to spawn before the mesh was valid, placing them inside walls. The fix: call_deferred("_bake_nav").

  3. 03

    Enemy Scene

    Each enemy is a CharacterBody2D scene with three child nodes: a NavigationAgent2D for steering, a ProgressBar positioned above the sprite for the HP bar, and an AnimationPlayer with three named animations, walk, hurt, and death. The Enemy.gd script calls navigation_agent.get_next_path_position() each physics frame and drives movement with move_and_slide(). On reaching the depot (checked via NavigationAgent2D.is_navigation_finished()), the script emits a reached_depot signal caught by GameState.gd.

  4. 04

    Tower System

    Tower.gd is a base script inherited by all three tower variants. Each tower has an Area2D child sized to its detection radius; the body_entered and body_exited signals maintain a live targets_in_range: Array. A Timer node fires on the attack cooldown interval; the handler selects the first valid target with is_instance_valid(target) and not target.is_queued_for_deletion(), then instances a Projectile.tscn at a Marker2D spawn point with velocity aimed at the target's position. BasicTower has the shortest range and fastest fire rate; SniperTower has maximum range and high single-target damage; SplashTower uses an Area2D on the projectile itself to hit all enemies within an explosion radius on impact.

  5. 05

    Wave Manager

    Wave data is stored as WaveData resources, custom Godot Resource subclasses with exported fields: an array of dictionaries specifying enemy scene path, count, and spawn interval. WaveManager.gd iterates through the current wave's entries using a spawn Timer. When all enemies in a wave are dead (tracked by a counter decremented via the enemy_died signal), the manager emits wave_complete. If no more waves remain, it emits victory; otherwise it pauses briefly and begins the next wave. This resource-driven approach makes adding new waves a data-only change without touching code.

  6. 06

    UI and HUD

    The HUD is a separate CanvasLayer scene instanced under Main. It holds four Label nodes (currency, lives, wave number, next-wave countdown) and a tower placement palette built from TextureButton nodes. Labels are updated exclusively through GameState signals, currency_changed(new_val), lives_changed(new_val), wave_changed(wave_num), so the HUD has zero direct references to gameplay nodes. Clicking a palette button sets a selected tower type variable in TowerPlacement.gd, which then listens for mouse input on unbuildable vs. buildable cells.

Game Loop Workflow

The following diagram traces the runtime flow of a single round: from the player placing a tower through enemy spawning, combat resolution, and terminal win/lose branching.

flowchart TD P["Player clicks buildable tile"]:::primary PC{"TowerPlacement:\nenough currency?"}:::primary PD["Deduct cost\nInstance Tower scene"]:::success PE["Show: insufficient funds"]:::secondary W["WaveManager Timer fires"]:::primary WS["Spawn Enemy at start node"]:::primary WN["Enemy navigates path via\nNavigationAgent2D"]:::primary TA{"Tower Area2D:\nenemy in range?"}:::primary TF["Tower targets enemy\nfire Projectile"]:::success PH{"Projectile hits Enemy"}:::primary HD["Deal damage\nreduce Enemy HP"]:::primary EK{"HP <= 0?"}:::primary ED["Enemy dies\nPlayer gains currency"]:::success EC["Enemy continues on path"]:::secondary ER{"Enemy reaches Depot?"}:::primary EL["Deduct life from GameState"]:::secondary GL{"Lives == 0?"}:::primary GO["Emit game_over signal\nShow Game Over screen"]:::secondary WD{"All wave enemies dead?"}:::primary LW{"Last wave?"}:::primary NW["Start next wave"]:::primary VC["Emit victory signal\nShow Victory screen"]:::success P --> PC PC -->|Yes| PD PC -->|No| PE PD --> TA W --> WS --> WN --> TA TA -->|Yes| TF --> PH --> HD --> EK TA -->|No| WN EK -->|Yes| ED --> WD EK -->|No| EC --> ER ER -->|Yes| EL --> GL ER -->|No| TA GL -->|Yes| GO GL -->|No| WD WD -->|Yes| LW WD -->|No| WN LW -->|No| NW --> W LW -->|Yes| VC classDef primary fill:#1a1a2e,stroke:#00d4ff,color:#e0e0e0 classDef secondary fill:#181818,stroke:#1e1e1e,color:#888 classDef success fill:#1a1a2e,stroke:#00ff88,color:#e0e0e0

Enemy State Machine

Each enemy instance transitions through four states managed in Enemy.gd. The state variable gates which AnimationPlayer track plays and whether movement logic runs each physics frame.

stateDiagram-v2 [*] --> Idle : scene instantiated Idle --> Walking : navigation path set\n(on spawn) Walking --> Attacking : reached depot\nor in attack range Walking --> Dead : HP <= 0\n(projectile hit) Attacking --> Dead : HP <= 0 Dead --> [*] : queue_free()\nenemy removed

In the Idle state the enemy waits for NavigationAgent2D.set_target_position() to be called by WaveManager immediately after spawning. Once the path is ready the state flips to Walking and the walk animation loops. Attacking is entered if the enemy reaches the depot node or steps into a melee-range trigger (reserved for a melee enemy variant). Dead plays the death animation and then calls queue_free(), which is caught by WaveManager's enemy counter to track wave completion.

Challenges & Solutions

  • NavigationAgent2D jitter at path corners. Enemies visibly oscillated when rounding tight corners because the agent re-targeted the same waypoint on alternating frames. Fixed by increasing both path_desired_distance and target_desired_distance on the agent, giving the steering system a wider acceptance radius so it advances to the next waypoint cleanly.
  • Towers targeting enemies about to die. When two towers selected the same low-HP enemy simultaneously, one projectile was wasted on a body already queued for deletion. Solved by wrapping all target reads in is_instance_valid(target) and not target.is_queued_for_deletion() before firing, and clearing the target slot when an enemy emits its died signal.
  • Pixel art appearing blurry after scaling. Godot 4's default texture filter is Linear, which interpolates between pixels and softens low-resolution sprites. Fixed globally by setting the project-wide texture import default to Filter: Nearest and enabling Rendering > 2D > Snap 2D Transforms to Pixel, which locks sprite positions to whole pixel coordinates and eliminates sub-pixel shimmer during movement.
  • Enemies spawning inside walls after navigation bake. Calling NavigationRegion2D.bake_navigation_polygon() synchronously in _ready() completed before the TileMap nodes had fully initialized their collision shapes for the frame. Solved with call_deferred("_bake_nav") so the bake runs at the start of the next frame after all nodes are settled, ensuring enemies always spawn on a valid navigation mesh.

What I Learned

  • Godot 4 scene composition, designing reusable, self-contained scenes and instancing them at runtime vs. placing them statically in the editor tree.
  • NavigationAgent2D pathfinding and steering, baking navigation meshes, setting target positions, reading next path positions per physics frame, and tuning desired-distance thresholds.
  • Signal-based decoupled architecture, emitting named signals from gameplay nodes and subscribing in the HUD and game state manager, so no node holds a hard reference to another's internal state.
  • Autoload singletons for shared state, using Godot's autoload system to create a globally accessible GameState that owns lives, currency, and wave data without coupling scene nodes to each other.
  • Pixel art workflow from Aseprite to Godot, authoring animations in Aseprite, exporting sprite sheets with JSON metadata, and configuring Godot's SpriteFrames resource to map sheet regions to named animation tracks.
  • Tower defense game loop fundamentals, balancing economy (kill reward vs. tower cost), tuning attack cooldown and range per tower class, and designing wave data to create escalating difficulty without hardcoding values.
Godot 4 GDScript Tower Defense Pixel Art Game Development NavigationAgent2D 2D Game

How I Built It

The full step-by-step build process is documented above in the Build Process section; this is the same story in chronological order, at a higher altitude.

01
Project setup and physics layers

A new Godot 4 project with a 2D root and three named collision layers: enemies, towers, projectiles. Tower Area2D nodes only detect the enemy layer, so no false collision signals. Pixel-art import defaults set globally: Nearest filter, no mipmaps, Snap 2D Transforms to Pixel.

02
TileMap and navigation

Two TileMap layers, one for the path and one for buildable tiles, with a baked NavigationRegion2D over the path. The bake had to be deferred past _ready() with call_deferred, or enemies spawned inside walls on the first frame.

03
Enemy scene

Each enemy is a CharacterBody2D with a NavigationAgent2D for steering, an HP ProgressBar, and an AnimationPlayer with walk, hurt, and death animations. Reaching the depot emits a reached_depot signal caught by the GameState autoload.

04
Tower system

A Tower.gd base script inherited by three variants: basic, sniper, splash. An Area2D maintains the live targets list, a Timer drives the attack cooldown, and projectiles spawn at a Marker2D. Every target read is guarded with is_instance_valid and is_queued_for_deletion checks.

05
Wave manager

WaveData resources hold enemy scene paths, counts, and spawn intervals, so adding a wave is a data-only change with no code edits. Wave completion and victory flow through signals into GameState.

06
HUD

A separate CanvasLayer scene holding currency, lives, wave counter, and the tower placement palette. Labels update exclusively through GameState signals, so the HUD has zero direct references to gameplay nodes.

What You Need

CategoryItemNotes
HardwareAny PC that runs Godot 4No special requirements; the game is 2D pixel art
SoftwareGodot 4, GDScript (built in), Aseprite for sprite sheets, Git for version controlAll free or low cost; the Build Process section above documents the exact APIs used
SkillsBasic programming, 2D game concepts (scenes, signals, physics layers)Godot's documentation covers the rest; this project is a reasonable first Godot game
AccountsNone requireditch.io is the natural free home if the game gets published

Challenges and Lessons Learned

  • Navigation timing bugs are frame-order bugs. The navmesh bake completing before TileMap collision shapes initialized was fixed by deferring one frame. The lesson: _ready() order is not a guarantee, and anything that depends on the whole scene being settled should run a frame later.
  • Steering jitter at corners. Enemies visibly oscillated rounding tight corners because the agent re-targeted the same waypoint on alternating frames. Increasing path_desired_distance and target_desired_distance gave the steering a wider acceptance radius so it advances to the next waypoint cleanly.
  • Wasted projectiles on dying enemies. Two towers selecting the same low-HP target wasted a shot on a body already queued for deletion. Solved by wrapping every target read in is_instance_valid(target) and not target.is_queued_for_deletion(), and clearing the target slot when an enemy emits its died signal.
  • Pixel blur from defaults. Godot 4's default Linear texture filter interpolates between pixels and softens low-resolution sprites. Project-wide Nearest filtering plus Snap 2D Transforms to Pixel locked sprites to whole pixel coordinates.
  • Balancing is data design. Kill rewards, tower costs, and wave composition live in resources, so difficulty tuning never requires code changes. That separation paid for itself the first time a wave felt too easy.

What Is Next

  • More tower types and enemy variants; a melee attacker is already reserved in the enemy state machine.
  • Additional maps and tilesets beyond the first TileMap.
  • Sound effects and music.
  • Difficulty balancing driven by playtesting data rather than gut feel.
  • A published build on itch.io, and longer term, mobile export.