Build a Turn-Based Battle System

Build a Pokemon-style battle system in Godot 4.7 with type effectiveness.

Introduction

30 Second Summary

Every monster-catching game starts with the same core loop: pick a move, watch damage fly, and hope your type matchup gives you the edge. That battle system is the foundation the entire genre is built on.

In this project, you will build a playable turn-based battle scene in Godot 4.7. Two creatures face off with data-driven moves, a 10-type effectiveness matrix, animated HP bars, and a strategic AI opponent.

What You'll Build

A complete battle prototype that demonstrates data-driven game architecture in Godot using Custom Resources, signals, and a state machine to orchestrate turn flow.

By the end of this project, you'll have:

  • Custom Resource scripts for creatures, moves, and type effectiveness data that define your game's content without hardcoding.
  • A battle scene UI with HP bars, creature displays, a move selection panel, and effectiveness messages.
  • A battle manager that orchestrates turn flow using a state machine, calculates type-based damage, and runs a strategic AI that picks moves based on type advantage.
  • Secret Mission: Add a Shadow-type poison status effect with damage over time and a purple visual indicator.

Are there any prerequisites?

You need programming experience in any language. No prior Godot experience is required.

You also need Godot 4.7 installed on Windows. Godot is a standalone executable with no installer needed. Download it from the official site if you haven't already.

Set Up the Project and Data Layer

Every battle game needs a data backbone. Before you can pit creatures against each other, you need a clean way to define what a creature is, what moves it can use, and how types interact.

Godot's Custom Resources are the tool for this. They let you define reusable data templates (like a creature stat sheet) that you can fill out in the Inspector without writing a single line of loading code.

In this step, get ready to:
  • Create Resource scripts that define creature and move data shapes.
  • Build a 10-type effectiveness matrix with thematic triangles and immunities.
  • Create creature and move data files using the Godot Inspector.
Create the Creature and Move resource scripts

A Custom Resource in Godot is a data container you design yourself. You define which properties it holds (name, HP, type), and Godot gives you a form in the Inspector to fill those values in. No database, no JSON parsing, no boilerplate.

  • In the FileSystem dock (bottom-left panel), right-click the scripts folder.
  • Select New > Script... from the context menu.
  • In the script creation dialog, set the path to res://scripts/creature.gd.
  • Set Inherits to Resource.
  • Set Template to Object: Empty so you start with a clean file.
  • Click Create.

The Script Editor opens with an empty script. Replace everything with the following code:

class_name Creature
extends Resource

@export var creature_name: String = ""
@export var max_hp: int = 100
@export var type: String = "FLAME"
@export var moves: Array[Resource] = []
@export var sprite_color: Color = Color.WHITE

What does this code do?

This script defines the data shape for every creature in your game.

  • class_name Creature registers a global type name so Godot's editor knows about it.
  • extends Resource means this is a data container, not a scene node.
  • Each @export property appears as an editable field in the Inspector when you create a .tres file.
  • sprite_color is used as a placeholder for a full sprite. Colored rectangles keep your focus on game systems instead of art.
  • Save the file with Ctrl+S.

✔️ Awesome, I've got everything!

Great. Double check you've saved creature.gd and move on to creating the Move script.

ⓧ I'd like to double check the full code

class_name Creature
extends Resource

@export var creature_name: String = ""
@export var max_hp: int = 100
@export var type: String = "FLAME"
@export var moves: Array[Resource] = []
@export var sprite_color: Color = Color.WHITE

Now create the Move resource script using the same process.

  • Right-click the scripts folder in the FileSystem dock.
  • Select New > Script... again.
  • Set the path to res://scripts/move.gd.
  • Set Inherits to Resource and Template to Object: Empty.
  • Click Create.

Replace the contents with:

class_name Move
extends Resource

@export var move_name: String = ""
@export var power: int = 40
@export var type: String = "FLAME"
@export var accuracy: float = 1.0

What does this code do?

This defines the shape of a single move (attack) in your battle system.

  • power is the base damage the move deals before type multipliers.
  • type determines which row of the effectiveness chart this move uses.
  • accuracy is a value from 0.0 to 1.0 representing the chance the move lands. A value of 1.0 means it never misses.
  • Save move.gd with Ctrl+S.
  • Confirm both scripts appear in the FileSystem dock under res://scripts/.

Script not appearing in the FileSystem dock?

  • Make sure you selected Resource in the Inherits field, not Node or another class.
  • Check you saved the file. The script won't appear until saved.
  • If you accidentally created the file outside the scripts folder, drag it into res://scripts/ using the FileSystem dock.

Help me debug my Resource script creation

✔️ Awesome, I've got everything!

Great. Double check you've saved move.gd and move on to the type chart.

ⓧ I'd like to double check the full code

class_name Move
extends Resource

@export var move_name: String = ""
@export var power: int = 40
@export var type: String = "FLAME"
@export var accuracy: float = 1.0
Build the type effectiveness chart

Type matchups are what make battles strategic. A well-designed type chart creates situations where the player must think about which move to pick, not just spam the strongest one.

Your chart uses 10 custom types organized into three interlocking triangles, plus a wild-card disrupter type.

  • Create another script in the scripts folder using New > Script... as before.
  • Set the path to res://scripts/type_chart.gd.
  • Set Inherits to Resource and Template to Object: Empty.
  • Click Create, then replace the file contents with the following code:
class_name TypeChart
extends Resource

const TYPES: Array[String] = [
	"FLAME", "TIDE", "FLORA", "SKY", "VOLT",
	"EARTH", "IRON", "PSYCHE", "BRAWN", "SHADOW"
]

# Effectiveness matrix: effectiveness[attacker_type][defender_type] = multiplier
# 2.0 = super effective, 0.5 = not very effective, 0.0 = immune, 1.0 = neutral
# Triangles:
#   Elemental: Flame > Flora > Tide > Flame
#   Physical/Tactical: Earth > Volt > Sky > Earth (Volt immune to Earth attacks, Sky immune to Earth attacks)
#   Combat: Brawn > Iron > Psyche > Brawn
#   Wild card: Shadow > Psyche, weak to Flame and Iron
var effectiveness: Dictionary = {
	"FLAME": {"FLAME": 0.5, "TIDE": 0.5, "FLORA": 2.0, "SKY": 1.0, "VOLT": 1.0, "EARTH": 1.0, "IRON": 2.0, "PSYCHE": 1.0, "BRAWN": 1.0, "SHADOW": 2.0},
	"TIDE": {"FLAME": 2.0, "TIDE": 0.5, "FLORA": 0.5, "SKY": 2.0, "VOLT": 1.0, "EARTH": 2.0, "IRON": 1.0, "PSYCHE": 1.0, "BRAWN": 1.0, "SHADOW": 1.0},
	"FLORA": {"FLAME": 0.5, "TIDE": 2.0, "FLORA": 0.5, "SKY": 0.5, "VOLT": 1.0, "EARTH": 2.0, "IRON": 0.5, "PSYCHE": 1.0, "BRAWN": 1.0, "SHADOW": 1.0},
	"SKY": {"FLAME": 1.0, "TIDE": 0.5, "FLORA": 2.0, "SKY": 0.5, "VOLT": 0.5, "EARTH": 2.0, "IRON": 0.5, "PSYCHE": 1.0, "BRAWN": 2.0, "SHADOW": 1.0},
	"VOLT": {"FLAME": 1.0, "TIDE": 2.0, "FLORA": 1.0, "SKY": 2.0, "VOLT": 0.5, "EARTH": 0.0, "IRON": 1.0, "PSYCHE": 1.0, "BRAWN": 1.0, "SHADOW": 1.0},
	"EARTH": {"FLAME": 1.0, "TIDE": 0.5, "FLORA": 0.5, "SKY": 0.0, "VOLT": 2.0, "EARTH": 0.5, "IRON": 2.0, "PSYCHE": 1.0, "BRAWN": 0.5, "SHADOW": 1.0},
	"IRON": {"FLAME": 0.5, "TIDE": 1.0, "FLORA": 1.0, "SKY": 1.0, "VOLT": 1.0, "EARTH": 0.5, "IRON": 0.5, "PSYCHE": 2.0, "BRAWN": 0.5, "SHADOW": 2.0},
	"PSYCHE": {"FLAME": 1.0, "TIDE": 1.0, "FLORA": 1.0, "SKY": 1.0, "VOLT": 1.0, "EARTH": 1.0, "IRON": 0.5, "PSYCHE": 0.5, "BRAWN": 2.0, "SHADOW": 0.5},
	"BRAWN": {"FLAME": 1.0, "TIDE": 1.0, "FLORA": 1.0, "SKY": 0.5, "VOLT": 1.0, "EARTH": 2.0, "IRON": 2.0, "PSYCHE": 0.5, "BRAWN": 0.5, "SHADOW": 0.5},
	"SHADOW": {"FLAME": 0.5, "TIDE": 1.0, "FLORA": 1.0, "SKY": 1.0, "VOLT": 1.0, "EARTH": 1.0, "IRON": 0.5, "PSYCHE": 2.0, "BRAWN": 2.0, "SHADOW": 0.5}
}


func get_effectiveness(attacker_type: String, defender_type: String) -> float:
	if effectiveness.has(attacker_type) and effectiveness[attacker_type].has(defender_type):
		return effectiveness[attacker_type][defender_type]
	return 1.0

How does the type chart work?

The chart is a nested Dictionary. The outer key is the attacker's type, and the inner key is the defender's type. The value is the damage multiplier.

Three triangles create strategic depth:

  • Elemental Trinity: Flame beats Flora, Flora beats Tide, Tide beats Flame.
  • Physical/Tactical Trinity: Earth beats Volt, Volt beats Sky, Sky beats Earth. Earth attacks do nothing to Sky (0.0 immunity). Volt attacks do nothing to Earth (0.0 immunity).
  • Combat Triangle: Brawn beats Iron, Iron beats Psyche, Psyche beats Brawn.
  • Shadow is the wild card: super effective against Psyche and Brawn, but weak to Flame and Iron.

The get_effectiveness() function safely looks up a multiplier. If a type name is somehow invalid, it falls back to 1.0 (neutral damage).

  • Save type_chart.gd with Ctrl+S.
  • Confirm all three scripts appear in res://scripts/ in the FileSystem dock: creature.gd, move.gd, and type_chart.gd.

Seeing a parser error in the Output panel?

  • Check that every type name string (FLAME, TIDE, etc.) is enclosed in double quotes and matches exactly between the TYPES array and the effectiveness dictionary keys.
  • Ensure every inner dictionary has exactly 10 entries (one per type). A missing comma or colon will trigger a syntax error.
  • Verify the file starts with class_name TypeChart on its own line, followed by extends Resource on the next line.

Help me fix my type chart script

✔️ Awesome, I've got everything!

Great. Double check you've saved type_chart.gd and move on to creating data files.

ⓧ I'd like to double check the full code

class_name TypeChart
extends Resource

const TYPES: Array[String] = [
	"FLAME", "TIDE", "FLORA", "SKY", "VOLT",
	"EARTH", "IRON", "PSYCHE", "BRAWN", "SHADOW"
]

# Effectiveness matrix: effectiveness[attacker_type][defender_type] = multiplier
# 2.0 = super effective, 0.5 = not very effective, 0.0 = immune, 1.0 = neutral
# Triangles:
#   Elemental: Flame > Flora > Tide > Flame
#   Physical/Tactical: Earth > Volt > Sky > Earth (Volt immune to Earth attacks, Sky immune to Earth attacks)
#   Combat: Brawn > Iron > Psyche > Brawn
#   Wild card: Shadow > Psyche, weak to Flame and Iron
var effectiveness: Dictionary = {
	"FLAME": {"FLAME": 0.5, "TIDE": 0.5, "FLORA": 2.0, "SKY": 1.0, "VOLT": 1.0, "EARTH": 1.0, "IRON": 2.0, "PSYCHE": 1.0, "BRAWN": 1.0, "SHADOW": 2.0},
	"TIDE": {"FLAME": 2.0, "TIDE": 0.5, "FLORA": 0.5, "SKY": 2.0, "VOLT": 1.0, "EARTH": 2.0, "IRON": 1.0, "PSYCHE": 1.0, "BRAWN": 1.0, "SHADOW": 1.0},
	"FLORA": {"FLAME": 0.5, "TIDE": 2.0, "FLORA": 0.5, "SKY": 0.5, "VOLT": 1.0, "EARTH": 2.0, "IRON": 0.5, "PSYCHE": 1.0, "BRAWN": 1.0, "SHADOW": 1.0},
	"SKY": {"FLAME": 1.0, "TIDE": 0.5, "FLORA": 2.0, "SKY": 0.5, "VOLT": 0.5, "EARTH": 2.0, "IRON": 0.5, "PSYCHE": 1.0, "BRAWN": 2.0, "SHADOW": 1.0},
	"VOLT": {"FLAME": 1.0, "TIDE": 2.0, "FLORA": 1.0, "SKY": 2.0, "VOLT": 0.5, "EARTH": 0.0, "IRON": 1.0, "PSYCHE": 1.0, "BRAWN": 1.0, "SHADOW": 1.0},
	"EARTH": {"FLAME": 1.0, "TIDE": 0.5, "FLORA": 0.5, "SKY": 0.0, "VOLT": 2.0, "EARTH": 0.5, "IRON": 2.0, "PSYCHE": 1.0, "BRAWN": 0.5, "SHADOW": 1.0},
	"IRON": {"FLAME": 0.5, "TIDE": 1.0, "FLORA": 1.0, "SKY": 1.0, "VOLT": 1.0, "EARTH": 0.5, "IRON": 0.5, "PSYCHE": 2.0, "BRAWN": 0.5, "SHADOW": 2.0},
	"PSYCHE": {"FLAME": 1.0, "TIDE": 1.0, "FLORA": 1.0, "SKY": 1.0, "VOLT": 1.0, "EARTH": 1.0, "IRON": 0.5, "PSYCHE": 0.5, "BRAWN": 2.0, "SHADOW": 0.5},
	"BRAWN": {"FLAME": 1.0, "TIDE": 1.0, "FLORA": 1.0, "SKY": 0.5, "VOLT": 1.0, "EARTH": 2.0, "IRON": 2.0, "PSYCHE": 0.5, "BRAWN": 0.5, "SHADOW": 0.5},
	"SHADOW": {"FLAME": 0.5, "TIDE": 1.0, "FLORA": 1.0, "SKY": 1.0, "VOLT": 1.0, "EARTH": 1.0, "IRON": 0.5, "PSYCHE": 2.0, "BRAWN": 2.0, "SHADOW": 0.5}
}


func get_effectiveness(attacker_type: String, defender_type: String) -> float:
	if effectiveness.has(attacker_type) and effectiveness[attacker_type].has(defender_type):
		return effectiveness[attacker_type][defender_type]
	return 1.0
Create creature and move data files

With your Resource scripts saved, Godot now recognizes Creature and Move as resource types you can create from the editor. Time to make some actual game data.

You will create 2 creatures and 4 moves using the Inspector. This is the data-driven approach: no hardcoded stats in your scripts.

  • In the FileSystem dock, right-click the resources folder.
  • Select New Resource... from the context menu.
  • In the Create New Resource dialog, type Move in the search bar.
  • Select Move from the list and click Create.
  • Save the file as res://resources/flame_burst.tres.

The Inspector now shows the Move's exported properties on the right side of the screen.

  • Set Move Name to Flame Burst.
  • Set Power to 50.
  • Set Type to FLAME.
  • Set Accuracy to 0.95.
  • Press Ctrl+S to save.

Repeat this process to create three more moves:

  • Tidal Wave (save as tidal_wave.tres): Move Name = Tidal Wave, Power = 45, Type = TIDE, Accuracy = 1.0.
  • Vine Whip (save as vine_whip.tres): Move Name = Vine Whip, Power = 40, Type = FLORA, Accuracy = 1.0.
  • Thunder Bolt (save as thunder_bolt.tres): Move Name = Thunder Bolt, Power = 55, Type = VOLT, Accuracy = 0.9.

Why different accuracy values?

Moves with higher power tend to have lower accuracy. This is a classic risk-reward tradeoff. Thunder Bolt hits hard but misses 10% of the time, while Vine Whip always connects.

Now create two creature resource files. The process is the same, but search for Creature instead of Move in the Create New Resource dialog.

  • Right-click the resources folder and select New Resource... again.
  • Search for Creature, select it, and click Create.
  • Save as res://resources/emberfox.tres.
  • Set Creature Name to Emberfox.
  • Set Max Hp to 100.
  • Set Type to FLAME.
  • Set Sprite Color to an orange color (click the color swatch and pick one).
  • For the Moves array, click the Array[Resource] field to expand it. Set Size to 2.
  • Drag flame_burst.tres from the FileSystem dock onto the first slot. Drag vine_whip.tres onto the second slot.
  • Press Ctrl+S to save.

Create the second creature:

  • Right-click resources and select New Resource... once more.
  • Search for Creature, select it, and click Create.
  • Save as res://resources/aquashell.tres.
  • Set Creature Name to Aquashell.
  • Set Max Hp to 120.
  • Set Type to TIDE.
  • Set Sprite Color to a blue color.
  • Set Moves size to 2. Drag tidal_wave.tres to slot 0 and thunder_bolt.tres to slot 1.
  • Press Ctrl+S to save.
  • Confirm the resources folder now shows at least 6 .tres files: 4 moves and 2 creatures.
  • Run the project by pressing F5. Godot will ask you to select a main scene. Click Cancel for now (you do not have a scene yet). Check the Output panel at the bottom for errors.

You should see no script errors in the Output panel. If Godot prompts for a main scene, that is expected since you have not created one yet.

Creature or Move not appearing in the Create New Resource dialog?

  • The class_name line is what makes a resource type show up in the dialog. Open the script and confirm class_name Creature (or class_name Move) is the very first line.
  • Ensure the script has no syntax errors. Check the Output panel for red error text.
  • Try closing and reopening the project (Project > Quit to Project List, then reopen). This forces Godot to re-scan all scripts.

Help me get my custom Resource type to appear in the Godot editor

Your data layer is complete. You have reusable data templates for creatures and moves, a full 10-type effectiveness matrix, and actual game data files ready to use. Next up, you will build the visual battle scene that brings this data to life.

Build the Battle Scene UI

Your data layer is complete. Creatures, moves, and the type chart all exist as reusable Resources ready to plug into any scene.

Now you need a screen where battles actually happen. In this step, you will build the visual layout: creature info panels with HP bars, a move selection menu, and a message display area.

In this step, get ready to:
  • Create the battle scene with creature display panels and HP bars.
  • Add an action panel with move buttons for player input.
  • Add a message label and timer for battle text.
Create the battle scene and creature panels

Every battle needs two sides. You will create a scene with info panels for the player's creature (bottom-left) and the enemy creature (top-right), each showing a name, HP bar, HP text, and a colored square as a sprite placeholder.

  • In Godot, click the + button next to your open scene tabs at the top of the editor to create a new scene.
  • In the Scene dock on the left, click User Interface to add a Control root node.
  • Double-click the root node's name in the Scene dock and rename it to Battle.
  • Save the scene with Ctrl+S. Navigate to the scenes/ folder and save it as battle.tscn.

Why a Control root node?

Control is the base class for all UI elements in Godot. It supports anchors, offsets, and layout containers that let you position panels relative to the screen edges. A battle scene is essentially a full-screen UI, so Control is the right root type.

Now add the background and the player's creature panel.

  • Select the Battle root node, then click the Add Child Node button at the top-left of the Scene dock (or press Ctrl+A).
  • Type ColorRect in the search field, select it, and click Create.
  • With the ColorRect selected, open the Anchor menu in the toolbar above the viewport and choose Full Rect so it fills the entire screen.
  • In the Inspector on the right, set its Color to a dark grey or black.

Next, build the player's creature info panel.

  • Select the Battle root node and add a child node of type PanelContainer.
  • Rename it to PlayerPanel.
  • With PlayerPanel selected, use the Anchor menu to set it to Bottom Left.
  • Adjust the size and offset in the Inspector so it sits comfortably in the bottom-left area of the screen.
  • Select PlayerPanel and add a child node of type VBoxContainer.
  • Select the VBoxContainer and add a child Label node. Rename it to NameLabel.
  • Add another child to the VBoxContainer: a ProgressBar node. Rename it to HPBar.
  • In the Inspector for HPBar, set Min Value to 0, Max Value to 100, and uncheck Show Percentage.
  • Add another child Label to the VBoxContainer. Rename it to HPLabel.
  • Add a child ColorRect to the VBoxContainer. Rename it to SpriteRect.
  • Select SpriteRect and in the Inspector, set its Custom Minimum Size to 64 x 64 so it appears as a square placeholder.

What does this node tree look like?

Your Scene dock should now show: Battle > ColorRect, PlayerPanel > VBoxContainer > NameLabel, HPBar, HPLabel, SpriteRect. The VBoxContainer stacks its children vertically, so you get a clean column of name, health bar, HP text, and sprite.

Now duplicate this structure for the enemy.

  • Select the Battle root node and add another PanelContainer child. Rename it to EnemyPanel.
  • Use the Anchor menu to set EnemyPanel to Top Right.
  • Add a VBoxContainer as a child of EnemyPanel.
  • Inside that VBoxContainer, add the same four children: NameLabel (Label), HPBar (ProgressBar), HPLabel (Label), and SpriteRect (ColorRect, 64x64).
  • Configure HPBar the same way: Min Value = 0, Max Value = 100, Show Percentage unchecked.
  • Save the scene with Ctrl+S.

Panels overlapping or invisible?

Make sure each PanelContainer has its anchor preset applied (Bottom Left for PlayerPanel, Top Right for EnemyPanel). If a panel is hidden behind the ColorRect background, drag it above the ColorRect in the Scene dock so it renders on top, or reorder nodes so the background is listed first.

Help me position my panels with anchors in Godot.

Add the action panel with move buttons

The player needs a way to choose which move to use each turn. You will add a panel at the bottom-center of the screen containing a grid of four buttons.

  • Select the Battle root node and add a child PanelContainer. Rename it to ActionPanel.
  • Use the Anchor menu to set ActionPanel to Center Bottom.
  • Add a VBoxContainer as a child of ActionPanel.
  • Inside the VBoxContainer, add a Label node. In the Inspector, set its Text property to What will you do?.
  • Add a GridContainer as a sibling of the Label (still inside the VBoxContainer). Rename it to MoveGrid.
  • Select MoveGrid and in the Inspector, set Columns to 2.
  • Add four Button nodes as children of MoveGrid. You can name them Button1 through Button4 or leave the default names.
  • For each Button, set placeholder text in the Text property (e.g. Move 1, Move 2, etc.) so they are visible when you run the scene.
  • Save the scene with Ctrl+S.

Why a GridContainer with 2 columns?

GridContainer automatically arranges its children into a grid layout. With 2 columns, your 4 buttons appear in a clean 2x2 grid. The battle manager script will update each button's text at runtime to show the creature's actual move names.

Add the message label and timer

Battle messages like "It's super effective!" need to appear briefly in the center of the screen, then disappear. You will add a Label for the text and a Timer to control how long it stays visible.

  • Select the Battle root node and add a child Label node. Rename it to MessageLabel.
  • With MessageLabel selected, use the Anchor menu to set it to Center.
  • In the Inspector, check the Horizontal Alignment and set it to Center.
  • Scroll down in the Inspector to the Visibility section and uncheck Visible to hide it by default.
  • Select the Battle root node again and add a child node of type Timer. Rename it to MessageTimer.
  • In the Inspector for MessageTimer, set Wait Time to 1.5 and check One Shot.

What do these Timer settings mean?

Setting Wait Time to 1.5 means the timer counts down for 1.5 seconds before emitting its timeout signal. One Shot means it fires once and stops, rather than looping. Your battle script will start the timer each time a message appears, then hide the label when it times out.

  • Save the scene with Ctrl+S.
  • Press F6 to run the current scene.

You should see a dark background with the player panel in the bottom-left, the enemy panel in the top-right, and the action panel with four buttons at the bottom-center. The HP bars display at full. No errors appear in the Output panel.

Scene won't run or panels are missing?

If Godot asks you to select a main scene, click Select Current to run battle.tscn directly. If panels are not visible, check that each PanelContainer has its Anchor preset applied and that the ColorRect background is the first child of Battle (nodes render in tree order, so later siblings draw on top).

Help me debug my battle scene layout.

Your battle UI is laid out and ready. Next up, you will write the battle manager script that brings this scene to life with turn-based combat logic.

Implement the Battle Manager

Your battle scene has all the visual pieces in place, but nothing happens when you click a button. The scene needs a brain.

In this step, you will write the battle manager script that orchestrates every aspect of combat. It tracks whose turn it is, calculates damage using your type chart, animates HP bars, and ends the battle when a creature faints.

In this step, get ready to:
  • Attach a script to the Battle node and wire up all UI references.
  • Build the damage formula and player turn flow.
  • Implement the enemy turn, win/lose conditions, and Play Again button.
Attach the script and initialize the battle

The battle manager needs a reference to every UI node in the scene. You will also need a PlayAgainButton node that appears when the battle ends.

  • Switch back to the battle.tscn scene tab in Godot.
  • Right-click the Battle root node in the Scene dock.
  • Select Add Child Node, search for Button, and click Create.
  • Rename the new Button to PlayAgainButton by double-clicking its name in the Scene dock.
  • In the Inspector, set the Text property to Play Again.
  • Position it near the center of the screen using the viewport drag handles.

Now attach the battle manager script to the Battle node.

  • Right-click the Battle node in the Scene dock and select Attach Script.
  • In the Attach Node Script window, change the Path to res://scripts/battle_manager.gd.
  • Set Template to Object: Empty.
  • Click Create.

The Script workspace opens with an empty file. Replace everything with the initialization code below:

  • Paste the following code into battle_manager.gd:
extends Control

enum BattleState { PLAYER_TURN, ENEMY_TURN, ANIMATING, VICTORY, DEFEAT }

@export var player_creature: Creature
@export var enemy_creature: Creature

var state: BattleState = BattleState.PLAYER_TURN
var player_hp: int
var enemy_hp: int
var type_chart: TypeChart = TypeChart.new()

# Cache references to all UI nodes in the scene
@onready var player_name_label: Label = $PlayerPanel/VBoxContainer/NameLabel
@onready var player_hp_bar: ProgressBar = $PlayerPanel/VBoxContainer/HPBar
@onready var player_hp_label: Label = $PlayerPanel/VBoxContainer/HPLabel
@onready var player_sprite: ColorRect = $PlayerPanel/VBoxContainer/SpriteRect

@onready var enemy_name_label: Label = $EnemyPanel/VBoxContainer/NameLabel
@onready var enemy_hp_bar: ProgressBar = $EnemyPanel/VBoxContainer/HPBar
@onready var enemy_hp_label: Label = $EnemyPanel/VBoxContainer/HPLabel
@onready var enemy_sprite: ColorRect = $EnemyPanel/VBoxContainer/SpriteRect

@onready var action_panel: PanelContainer = $ActionPanel
@onready var move_grid: GridContainer = $ActionPanel/VBoxContainer/MoveGrid
@onready var message_label: Label = $MessageLabel
@onready var message_timer: Timer = $MessageTimer
@onready var play_again_button: Button = $PlayAgainButton


# Initialize the battle scene with creature data
func _ready() -> void:
	player_hp = player_creature.max_hp
	enemy_hp = enemy_creature.max_hp

	player_name_label.text = player_creature.creature_name
	player_hp_bar.max_value = player_creature.max_hp
	player_hp_bar.value = player_hp
	player_hp_label.text = "%d/%d" % [player_hp, player_creature.max_hp]
	player_sprite.color = player_creature.sprite_color

	enemy_name_label.text = enemy_creature.creature_name
	enemy_hp_bar.max_value = enemy_creature.max_hp
	enemy_hp_bar.value = enemy_hp
	enemy_hp_label.text = "%d/%d" % [enemy_hp, enemy_creature.max_hp]
	enemy_sprite.color = enemy_creature.sprite_color

	_setup_move_buttons()
	message_label.hide()
	play_again_button.hide()
	play_again_button.pressed.connect(_on_play_again_pressed)


# Label each move button and connect its pressed signal
func _setup_move_buttons() -> void:
	for i in range(player_creature.moves.size()):
		var move: Move = player_creature.moves[i] as Move
		var button: Button = move_grid.get_child(i) as Button
		button.text = "%s (%s)" % [move.move_name, move.type]
		button.pressed.connect(_on_move_selected.bind(i))

What does this code do?

The script starts by declaring five possible battle states as an enum. This acts as a simple state machine that prevents actions from firing at the wrong time (e.g., clicking a move during the enemy's turn).

The @onready keyword caches each node reference once the scene tree is ready. The $ syntax is a shorthand for get_node() using the node path.

In _ready(), the script reads each creature Resource's properties (name, max HP, color) and pushes them into the UI labels, progress bars, and sprite rects. Then _setup_move_buttons() labels each button with the move name and type, and connects its pressed signal to a handler.

You will not be able to run the scene yet because the connected function _on_move_selected does not exist. You will add it in the next substep.

Build the damage system and player turn

When the player clicks a move button, the battle manager must calculate damage, apply it to the enemy, and display feedback messages. The damage formula multiplies move power by the type effectiveness from your chart, with a small random variance.

  • Add the following functions directly below _setup_move_buttons() in battle_manager.gd:
# Handle player clicking a move button
func _on_move_selected(move_index: int) -> void:
	if state != BattleState.PLAYER_TURN:
		return
	state = BattleState.ANIMATING
	action_panel.hide()
	var move: Move = player_creature.moves[move_index] as Move
	await _execute_move(move, player_creature, enemy_creature, true)

	if enemy_hp <= 0:
		state = BattleState.VICTORY
		await _show_message("%s fainted! You win!" % enemy_creature.creature_name)
		play_again_button.show()
		return

	state = BattleState.ENEMY_TURN
	await _enemy_turn()


# Calculate and apply damage from one creature to another
func _execute_move(move: Move, attacker: Creature, defender: Creature, is_player_attacking: bool) -> void:
	var effectiveness_value: float = type_chart.get_effectiveness(move.type, defender.type)
	var damage: int = int(move.power * effectiveness_value * randf_range(0.85, 1.15))

	if randf() > move.accuracy:
		await _show_message("%s missed!" % move.move_name)
		return

	if effectiveness_value == 0.0:
		await _show_message("It has no effect!")
		return

	if is_player_attacking:
		enemy_hp = max(0, enemy_hp - damage)
		_animate_hp_bar(enemy_hp_bar, enemy_hp)
		enemy_hp_label.text = "%d/%d" % [enemy_hp, enemy_creature.max_hp]
		_flash_sprite(enemy_sprite)
	else:
		player_hp = max(0, player_hp - damage)
		_animate_hp_bar(player_hp_bar, player_hp)
		player_hp_label.text = "%d/%d" % [player_hp, player_creature.max_hp]
		_flash_sprite(player_sprite)

	await _show_message("%s used %s!" % [attacker.creature_name, move.move_name])

	if effectiveness_value >= 2.0:
		await _show_message("It's super effective!")
	elif effectiveness_value <= 0.5 and effectiveness_value > 0.0:
		await _show_message("It's not very effective...")


# Smoothly animate the HP bar decreasing with a tween
func _animate_hp_bar(bar: ProgressBar, new_value: int) -> void:
	var tween: Tween = create_tween()
	tween.tween_property(bar, "value", new_value, 0.5)


# Flash the creature's sprite red briefly when hit
func _flash_sprite(sprite: ColorRect) -> void:
	var original_color: Color = sprite.color
	sprite.color = Color.RED
	await get_tree().create_timer(0.15).timeout
	sprite.color = original_color


# Display a message on screen and wait for the timer
func _show_message(text: String) -> void:
	message_label.text = text
	message_label.show()
	message_timer.start()
	await message_timer.timeout
	message_label.hide()

What does this code do?

  • _on_move_selected() is the entry point for the player's turn. It hides the action panel, executes the chosen move, checks if the enemy fainted, and then hands control to the enemy turn.
  • _execute_move() handles damage calculation using your type chart. It checks accuracy first (a miss skips damage), then checks for immunities (0.0 effectiveness). The formula applies a random multiplier between 0.85 and 1.15 so damage feels slightly different each time.
  • _animate_hp_bar() uses a Tween to smoothly transition the progress bar value over 0.5 seconds instead of jumping instantly.
  • _flash_sprite() briefly turns the creature's ColorRect red to visually indicate a hit.
  • _show_message() displays text on the MessageLabel, starts the 1.5-second timer, and pauses execution with await until the timer fires. This keeps messages on screen long enough to read.

The script still needs the enemy turn logic. You will add that next.

Complete the enemy turn and game loop

After the player attacks, the enemy needs to fight back. For now, the AI picks a random move from its list. You will upgrade it to a strategic AI in the next step.

  • Add the following functions directly below _show_message() in battle_manager.gd:
# Execute the enemy's turn after a short delay
func _enemy_turn() -> void:
	await get_tree().create_timer(0.5).timeout
	var move: Move = _ai_select_move()
	await _execute_move(move, enemy_creature, player_creature, false)

	if player_hp <= 0:
		state = BattleState.DEFEAT
		await _show_message("%s fainted! You lose!" % player_creature.creature_name)
		play_again_button.show()
		return

	state = BattleState.PLAYER_TURN
	action_panel.show()


# AI picks a random move (upgraded to strategic in next step)
func _ai_select_move() -> Move:
	var best_moves: Array = []
	var best_effectiveness: float = 0.0

	for move_resource in enemy_creature.moves:
		var move: Move = move_resource as Move
		var eff: float = type_chart.get_effectiveness(move.type, player_creature.type)
		if eff > best_effectiveness:
			best_effectiveness = eff
			best_moves = [move]
		elif eff == best_effectiveness:
			best_moves.append(move)

	return best_moves[randi() % best_moves.size()] as Move


# Reload the scene to restart the battle
func _on_play_again_pressed() -> void:
	get_tree().reload_current_scene()

What does this code do?

  • _enemy_turn() waits half a second (so the player can see their own attack land), picks a move via the AI function, executes it against the player, and checks for defeat. If the player is still alive, it sets the state back to PLAYER_TURN and shows the action panel again.
  • _ai_select_move() loops through the enemy's moves, finds the ones with the highest type effectiveness against the player, and picks randomly among ties. This means the AI already picks super-effective moves when available.
  • _on_play_again_pressed() simply reloads the entire scene from scratch, resetting all state.

The script is now complete. Before running it, you need to assign creature Resources to the export variables in the Inspector.

  • Save battle_manager.gd with Ctrl+S.
  • Click the Battle root node in the Scene dock to open its properties in the Inspector.
  • In the Inspector, find the Player Creature export field.
  • Drag one of your creature .tres files from the FileSystem dock into the Player Creature slot.
  • Drag a different creature .tres file into the Enemy Creature slot.
  • Save the scene with Ctrl+S.

Why assign Resources in the Inspector?

The @export keyword exposes variables to the Godot editor. This is the data-driven design pattern in action. You can swap creatures by dragging different .tres files without editing any code.

  • Run the scene by pressing F6 (Play Current Scene).
  • Click any move button. You should see the enemy's HP bar decrease smoothly, a battle message appear, then the enemy attacks back automatically.
  • Keep clicking moves until one creature reaches 0 HP. A victory or defeat message should display, and the Play Again button should appear.

Seeing errors when you press F6?

  • If the Output panel says a node path was not found, double-check that your scene tree node names match the paths in the script exactly (e.g., PlayerPanel/VBoxContainer/NameLabel). Names are case-sensitive.
  • If you see a null reference error on the creature, confirm you dragged .tres files into both the Player Creature and Enemy Creature slots in the Inspector.
  • If buttons do nothing, make sure your creature .tres files have at least 4 moves assigned in their Moves array.

Help me debug my battle manager script.

✔️ Awesome, I've got everything!

Great. Double check you have saved both battle_manager.gd and battle.tscn.

ⓧ I'd like to double check the full code

Here is the complete battle_manager.gd file. Compare it against yours line by line:

extends Control

enum BattleState { PLAYER_TURN, ENEMY_TURN, ANIMATING, VICTORY, DEFEAT }

@export var player_creature: Creature
@export var enemy_creature: Creature

var state: BattleState = BattleState.PLAYER_TURN
var player_hp: int
var enemy_hp: int
var type_chart: TypeChart = TypeChart.new()

@onready var player_name_label: Label = $PlayerPanel/VBoxContainer/NameLabel
@onready var player_hp_bar: ProgressBar = $PlayerPanel/VBoxContainer/HPBar
@onready var player_hp_label: Label = $PlayerPanel/VBoxContainer/HPLabel
@onready var player_sprite: ColorRect = $PlayerPanel/VBoxContainer/SpriteRect

@onready var enemy_name_label: Label = $EnemyPanel/VBoxContainer/NameLabel
@onready var enemy_hp_bar: ProgressBar = $EnemyPanel/VBoxContainer/HPBar
@onready var enemy_hp_label: Label = $EnemyPanel/VBoxContainer/HPLabel
@onready var enemy_sprite: ColorRect = $EnemyPanel/VBoxContainer/SpriteRect

@onready var action_panel: PanelContainer = $ActionPanel
@onready var move_grid: GridContainer = $ActionPanel/VBoxContainer/MoveGrid
@onready var message_label: Label = $MessageLabel
@onready var message_timer: Timer = $MessageTimer
@onready var play_again_button: Button = $PlayAgainButton


func _ready() -> void:
	player_hp = player_creature.max_hp
	enemy_hp = enemy_creature.max_hp

	player_name_label.text = player_creature.creature_name
	player_hp_bar.max_value = player_creature.max_hp
	player_hp_bar.value = player_hp
	player_hp_label.text = "%d/%d" % [player_hp, player_creature.max_hp]
	player_sprite.color = player_creature.sprite_color

	enemy_name_label.text = enemy_creature.creature_name
	enemy_hp_bar.max_value = enemy_creature.max_hp
	enemy_hp_bar.value = enemy_hp
	enemy_hp_label.text = "%d/%d" % [enemy_hp, enemy_creature.max_hp]
	enemy_sprite.color = enemy_creature.sprite_color

	_setup_move_buttons()
	message_label.hide()
	play_again_button.hide()
	play_again_button.pressed.connect(_on_play_again_pressed)


func _setup_move_buttons() -> void:
	for i in range(player_creature.moves.size()):
		var move: Move = player_creature.moves[i] as Move
		var button: Button = move_grid.get_child(i) as Button
		button.text = "%s (%s)" % [move.move_name, move.type]
		button.pressed.connect(_on_move_selected.bind(i))


func _on_move_selected(move_index: int) -> void:
	if state != BattleState.PLAYER_TURN:
		return
	state = BattleState.ANIMATING
	action_panel.hide()
	var move: Move = player_creature.moves[move_index] as Move
	await _execute_move(move, player_creature, enemy_creature, true)

	if enemy_hp <= 0:
		state = BattleState.VICTORY
		await _show_message("%s fainted! You win!" % enemy_creature.creature_name)
		play_again_button.show()
		return

	state = BattleState.ENEMY_TURN
	await _enemy_turn()


func _enemy_turn() -> void:
	await get_tree().create_timer(0.5).timeout
	var move: Move = _ai_select_move()
	await _execute_move(move, enemy_creature, player_creature, false)

	if player_hp <= 0:
		state = BattleState.DEFEAT
		await _show_message("%s fainted! You lose!" % player_creature.creature_name)
		play_again_button.show()
		return

	state = BattleState.PLAYER_TURN
	action_panel.show()


func _execute_move(move: Move, attacker: Creature, defender: Creature, is_player_attacking: bool) -> void:
	var effectiveness_value: float = type_chart.get_effectiveness(move.type, defender.type)
	var damage: int = int(move.power * effectiveness_value * randf_range(0.85, 1.15))

	if randf() > move.accuracy:
		await _show_message("%s missed!" % move.move_name)
		return

	if effectiveness_value == 0.0:
		await _show_message("It has no effect!")
		return

	if is_player_attacking:
		enemy_hp = max(0, enemy_hp - damage)
		_animate_hp_bar(enemy_hp_bar, enemy_hp)
		enemy_hp_label.text = "%d/%d" % [enemy_hp, enemy_creature.max_hp]
		_flash_sprite(enemy_sprite)
	else:
		player_hp = max(0, player_hp - damage)
		_animate_hp_bar(player_hp_bar, player_hp)
		player_hp_label.text = "%d/%d" % [player_hp, player_creature.max_hp]
		_flash_sprite(player_sprite)

	await _show_message("%s used %s!" % [attacker.creature_name, move.move_name])

	if effectiveness_value >= 2.0:
		await _show_message("It's super effective!")
	elif effectiveness_value <= 0.5 and effectiveness_value > 0.0:
		await _show_message("It's not very effective...")


func _ai_select_move() -> Move:
	var best_moves: Array = []
	var best_effectiveness: float = 0.0

	for move_resource in enemy_creature.moves:
		var move: Move = move_resource as Move
		var eff: float = type_chart.get_effectiveness(move.type, player_creature.type)
		if eff > best_effectiveness:
			best_effectiveness = eff
			best_moves = [move]
		elif eff == best_effectiveness:
			best_moves.append(move)

	return best_moves[randi() % best_moves.size()] as Move


func _animate_hp_bar(bar: ProgressBar, new_value: int) -> void:
	var tween: Tween = create_tween()
	tween.tween_property(bar, "value", new_value, 0.5)


func _flash_sprite(sprite: ColorRect) -> void:
	var original_color: Color = sprite.color
	sprite.color = Color.RED
	await get_tree().create_timer(0.15).timeout
	sprite.color = original_color


func _show_message(text: String) -> void:
	message_label.text = text
	message_label.show()
	message_timer.start()
	await message_timer.timeout
	message_label.hide()


func _on_play_again_pressed() -> void:
	get_tree().reload_current_scene()

Your battle runs end-to-end. Next up, you will upgrade the enemy AI to pick moves strategically and add visual polish like animated HP bars and type badges.

Add the AI and Type Strategy

Your battle runs end-to-end, but right now there is an information gap. The enemy AI already uses type effectiveness to pick moves, but the player has no way of knowing what type the opponent is.

In this step, you will verify the strategic AI in action, confirm that the battle polish (animated HP bars, hit flashes, effectiveness messages) works correctly, and add type badge Labels so the player can make informed decisions too.

In this step, get ready to:
  • Verify the enemy AI consistently picks super-effective moves.
  • Confirm battle polish features (Tween animations, hit flash, effectiveness messages).
  • Add type badge Labels to both creature panels.
Verify the strategic AI picks the best moves

The _ai_select_move() function already calculates type effectiveness for every move the enemy has and picks the one with the highest multiplier. If multiple moves tie, it picks randomly among the best options.

  • Run the scene by pressing F6 (with battle.tscn as the current scene).
  • Play through 3-4 full battles, paying attention to which moves the enemy picks.
  • Notice that the enemy uses super-effective moves whenever it has one available against your creature's type.

How does the AI decide?

Open battle_manager.gd and find the _ai_select_move() function. It loops through every move in the enemy's moveset, calls type_chart.get_effectiveness() to get the multiplier against the player's type, and tracks which moves have the highest value.

If the enemy is a FLAME creature fighting your FLORA creature, it will always pick FLAME-type moves because they deal 2.0x damage. Random tiebreaking only kicks in when two moves have equal effectiveness.

Confirm battle polish features work

The battle manager already includes three polish features that make combat readable and satisfying. Let's confirm each one works during gameplay.

  • Run a battle and watch the HP bar when a creature takes damage. It should animate smoothly over 0.5 seconds (powered by _animate_hp_bar() using a Tween).
  • Watch the defender's colored rectangle when hit. It should flash red briefly then return to its original color (powered by _flash_sprite()).
  • Watch for effectiveness messages after attacks. You should see "It's super effective!" for 2.0x hits, "It's not very effective..." for 0.5x hits, and "It has no effect!" for 0.0x (immune) interactions.

What are Tweens?

A Tween smoothly interpolates a property from its current value to a target value over a duration. The create_tween() function creates a new Tween, and tween_property() tells it which node property to animate, the end value, and how long the animation takes.

This is the standard Godot 4.x pattern for animating UI values. It replaces the old AnimationPlayer approach for simple numeric transitions.

HP bar jumps instantly instead of animating?

Make sure you are not also setting bar.value directly elsewhere in the code. If you assign the value before the Tween runs, the Tween has nothing to animate because the property already reached the target.

Help me debug why my HP bar Tween animation is not working.

Add type badge Labels to creature panels

The player needs to see the enemy's type to make strategic move choices. You will add a Label node to each creature's panel that displays their type as a badge.

  • In the Scene dock, expand PlayerPanel > VBoxContainer.
  • Select the NameLabel node inside the VBoxContainer.
  • Click the Add Child Node button (the plus icon at the top-left of the Scene dock).
  • Type Label in the search field, select it, and click Create.
  • Rename the new node to TypeBadge by double-clicking its name in the Scene dock.
  • Drag TypeBadge in the Scene dock so it sits directly below NameLabel (as a sibling, not a child of NameLabel).
  • In the Inspector dock, set the Text property to the player creature's type (for example, FLORA).

Now repeat the same process for the enemy panel.

  • Expand EnemyPanel > VBoxContainer in the Scene dock.
  • Select NameLabel inside the EnemyPanel's VBoxContainer.
  • Click Add Child Node, search for Label, and click Create.
  • Rename it to TypeBadge.
  • Drag it below NameLabel as a sibling.
  • Set the Text property to the enemy creature's type (for example, FLAME).
  • Save the scene with Ctrl+S.
  • Run the scene with F6. You should see a type label (for example, FLORA or FLAME) below each creature's name.

Why show types to the player?

The enemy AI already knows your creature's type and exploits it. Without type badges, the player is at a disadvantage because they cannot see what type the enemy is. Showing the type turns the battle from guesswork into a puzzle where you choose moves based on the effectiveness chart.

The move buttons already display their type in parentheses (for example, "Ember (FLAME)"). Combined with the type badge, the player now has all the information needed to pick the most effective attack.

Type badge not visible when you run the scene?

Make sure the TypeBadge Label is a direct child of the VBoxContainer, not a child of NameLabel. If it is nested under NameLabel, it won't participate in the VBoxContainer's vertical layout and may be hidden behind the name text.

Also check that the Text property in the Inspector is not empty. An empty Label renders nothing on screen.

Help me figure out why my type badge Label is not showing up in the battle scene.

Your battle system is now fully strategic. The AI exploits type matchups, and the player has the same information available to fight back. Next up, you will take on a secret mission to add a poison status effect.

Secret mission

Add Poison Status Effect

Your battle system deals instant damage, but real monster-battling games use status effects to create strategic depth. In this secret mission, you'll add a Shadow-type move called Venom Strike that poisons the target, dealing damage at the end of every turn with a purple visual indicator.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files to continue experimenting, or delete them entirely.

Resources you used:

  • The BattleSystem project folder (GDScript files, resource files, and scene files).

Keep everything running

No action needed. Choose this if you want to keep building on your battle system. Your project is saved locally and ready to open any time in Godot.

  • Your BattleSystem folder stays on your machine wherever you created it.
  • Open Godot and select the project from the Project Manager to pick up where you left off.

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Your files stay on disk until you decide to delete them.

  • Close Godot if it is still open.
  • Your project files take up minimal disk space and will be waiting for you when you return.

Delete - I don't want to use this again

Remove the project folder entirely to free up disk space and start fresh.

  • Close Godot if it is still open.
  • Open File Explorer and navigate to the location where you created the BattleSystem folder.
  • Right-click the BattleSystem folder and select Delete.
  • Empty your Recycle Bin to permanently remove the files.

Nice Work!

Nice Work!

You just built a complete turn-based battle system from scratch in Godot 4.7. Two creatures, a type effectiveness matrix, animated HP bars, strategic AI, and a full gameplay loop. This is the core engine that every monster-catching game is built on.

You've learned how to:

  • Create a data-driven architecture using custom Resource classes for creatures, moves, and type data that serialize to .tres files.
  • Implement a 10-type effectiveness matrix with interlocking thematic triangles (Elemental, Physical/Tactical, Combat) and immunities.
  • Build a responsive battle UI with animated HP bars using Tweens, a state machine for turn flow, and a strategic AI that selects moves based on type advantage.
  • Secret Mission: Added a poison status effect via Shadow-type "Venom Strike" with end-of-turn damage and a purple visual indicator.

Ready to quiz yourself?