Polish Your Battle UI with a Pokemon Theme
Style a Godot battle scene with type-colored buttons and animated HP bars.
Introduction
30 Second Summary
Think about the difference between a game prototype and a finished game. Often, what separates a "student project" from something that feels real is a cohesive visual theme that ties every element together.
In this project, you will transform your battle game's functional UI into a polished, Pokemon-inspired interface with dark themed panels, color-coded HP bars, type-colored move buttons, and smooth hover animations.
What You'll Build
A complete visual overhaul of your Godot battle scene using programmatic StyleBoxFlat theming and Tween animations.
By the end of this project, you'll have:
- A battle_theme.gd Autoload script that centralizes type colors and reusable StyleBoxFlat helper functions for consistent styling across the project.
- Color-coded HP bars that shift from green to yellow to red based on remaining health percentage.
- Type-colored move buttons in a 2x2 grid with smooth Tween-based hover animations and a styled message panel for battle narration.
- Secret Mission: Extend the theme to your character select and move loadout screens with type-colored creature cards and animated hover effects.
Are there any prerequisites?
This project builds directly on the "Build a Creature Roster and Selection System" project. You need a working BattleSystem Godot project containing battle.tscn, battle_manager.gd, game_data.gd, and the character select and move select scenes.
No additional tools or accounts are needed. Everything runs locally in Godot 4.7.
Create the Battle Theme Autoload
Your battle system works, but it looks like a default UI prototype. Every button, panel, and label uses Godot's built-in gray theme.
To transform your game into something that feels polished, you need a centralized theming system. In this step, you will create a BattleTheme Autoload script that stores type colors and generates StyleBoxFlat resources on demand.
In this step, get ready to:
- Create battle_theme.gd with type color constants and StyleBoxFlat helper functions.
- Register battle_theme.gd as an Autoload singleton named BattleTheme.
- Run the project and confirm the Autoload loads successfully.
Create the battle theme script
The battle theme script centralizes every color and style decision for your game. Other scripts will call its helper functions to get pre-configured StyleBoxFlat resources instead of building styles inline.
- In the FileSystem dock (bottom-left panel), right-click the scripts folder.
- Select Create New then Script.
- In the dialog, set the path to res://scripts/battle_theme.gd and ensure it inherits from Node.
- Click Create.
- Clear any default content in the new script and paste the following code that defines the type color dictionary, base theme colors, and the first helper function:
extends Node
# Type color constants matching the creature roster types
var type_colors: Dictionary = {
"FLAME": Color(1.0, 0.42, 0.21),
"TIDE": Color(0.12, 0.56, 1.0),
"FLORA": Color(0.13, 0.55, 0.13),
"SKY": Color(0.53, 0.81, 0.92),
"VOLT": Color(1.0, 0.84, 0.0),
"EARTH": Color(0.55, 0.27, 0.07),
"IRON": Color(0.44, 0.5, 0.56),
"PSYCHE": Color(0.58, 0.44, 0.86),
"BRAWN": Color(0.7, 0.13, 0.13),
"SHADOW": Color(0.18, 0.0, 0.33),
}
# Dark theme base colors
var bg_dark: Color = Color(0.1, 0.1, 0.15)
var panel_dark: Color = Color(0.15, 0.15, 0.2)
var panel_border: Color = Color(0.3, 0.3, 0.4)
var text_white: Color = Color(0.95, 0.95, 0.95)
func _ready() -> void:
print("BattleTheme loaded")
func get_type_color(type_name: String) -> Color:
if type_colors.has(type_name):
return type_colors[type_name]
return Color.WHITE
What does this code do?
This first section establishes the data layer for your theme:
- type_colors maps each of the 10 creature types to a unique Color value. Every UI element that needs a type-specific tint will pull from this dictionary.
- The four base color variables (bg_dark, panel_dark, panel_border, text_white) define the dark theme palette used across all panels.
- get_type_color() safely returns a color for any type name, falling back to white if the type is not found.
- Save battle_theme.gd with Ctrl+S (Windows) or Cmd+S (macOS).
- Confirm battle_theme.gd appears in the FileSystem dock under scripts/.
Don't see the file in the FileSystem dock?
- Make sure you saved after creating. Unsaved scripts may not appear until saved.
- Right-click the scripts folder and select Refresh to force the dock to re-scan.
Why isn't my script appearing in the FileSystem dock?
Now add the panel and button helper functions below the existing code. These generate StyleBoxFlat resources that other scripts will apply to UI elements.
- Add the following functions below get_type_color() in battle_theme.gd:
func create_panel_stylebox(border_color: Color = panel_border) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = panel_dark
style.border_color = border_color
style.set_border_width_all(2)
style.set_corner_radius_all(8)
style.set_content_margin_all(12)
return style
func create_button_stylebox(type_name: String) -> StyleBoxFlat:
var base_color: Color = get_type_color(type_name)
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = base_color.darkened(0.4)
style.border_color = base_color
style.set_border_width_all(2)
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
return style
func create_button_hover_stylebox(type_name: String) -> StyleBoxFlat:
var base_color: Color = get_type_color(type_name)
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = base_color.darkened(0.2)
style.border_color = base_color.lightened(0.3)
style.set_border_width_all(2)
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
return style
func create_button_pressed_stylebox(type_name: String) -> StyleBoxFlat:
var base_color: Color = get_type_color(type_name)
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = base_color.darkened(0.6)
style.border_color = base_color.darkened(0.2)
style.set_border_width_all(2)
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
return style
What do these helpers do?
Each function creates a fresh StyleBoxFlat resource and returns it. Callers apply these to UI nodes using add_theme_stylebox_override().
- create_panel_stylebox() produces a dark rounded panel with an optional colored border. It defaults to the neutral panel_border color but accepts a type color for creature-specific panels.
- The three button functions (normal, hover, pressed) each darken or lighten the type's base color to different degrees, creating distinct visual states.
- You won't see a change yet. The next chunk completes the remaining helper functions.
Finally, add the HP bar, message panel, and dark button helpers below the existing functions.
- Add the following functions at the bottom of battle_theme.gd:
func create_hp_bar_fill(hp_percent: float) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
if hp_percent > 0.5:
style.bg_color = Color(0.2, 0.8, 0.2)
elif hp_percent > 0.2:
style.bg_color = Color(0.9, 0.75, 0.1)
else:
style.bg_color = Color(0.9, 0.2, 0.2)
style.set_corner_radius_all(3)
return style
func create_hp_bar_background() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(0.2, 0.2, 0.25)
style.border_color = Color(0.4, 0.4, 0.5)
style.set_border_width_all(1)
style.set_corner_radius_all(3)
return style
func create_message_stylebox() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(0.1, 0.1, 0.15, 0.9)
style.border_color = Color(0.4, 0.4, 0.5)
style.set_border_width_all(1)
style.set_corner_radius_all(8)
style.set_content_margin_all(16)
return style
func create_dark_button_stylebox() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(0.2, 0.2, 0.3)
style.border_color = Color(0.4, 0.4, 0.5)
style.set_border_width_all(1)
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
return style
func create_dark_button_hover_stylebox() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(0.3, 0.3, 0.4)
style.border_color = Color(0.5, 0.5, 0.6)
style.set_border_width_all(1)
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
return style
What does this code do?
These five functions complete the theme toolkit:
- create_hp_bar_fill() picks green, yellow, or red based on the HP percentage. This follows the classic Pokemon convention: green above 50%, yellow between 20-50%, red at 20% or below.
- create_hp_bar_background() creates a subtle dark track behind the colored fill.
- create_message_stylebox() styles the battle narration label with a semi-transparent dark background and generous padding.
- The two dark button functions provide neutral styling for non-type-specific buttons like Play Again and Back.
- Save battle_theme.gd.
- You won't see any visual change yet. The script needs to be registered as an Autoload before any other script can access it.
Seeing a syntax error?
- Check that each function starts with func and uses a tab for indentation. Godot uses tabs by default.
- Make sure there is a blank line between each function definition.
- Verify parentheses and colons match exactly. A missing -> StyleBoxFlat: return type annotation will cause a parse error.
Help me fix a syntax error in my GDScript battle theme file.
✔️ Awesome, I've got everything!
Great. Double check you have saved battle_theme.gd and that there are no red error indicators in the script editor.
ⓧ I'd like to double check the full code
Here is the complete battle_theme.gd file. Make sure yours matches:
extends Node
# Type color constants matching the creature roster types
var type_colors: Dictionary = {
"FLAME": Color(1.0, 0.42, 0.21),
"TIDE": Color(0.12, 0.56, 1.0),
"FLORA": Color(0.13, 0.55, 0.13),
"SKY": Color(0.53, 0.81, 0.92),
"VOLT": Color(1.0, 0.84, 0.0),
"EARTH": Color(0.55, 0.27, 0.07),
"IRON": Color(0.44, 0.5, 0.56),
"PSYCHE": Color(0.58, 0.44, 0.86),
"BRAWN": Color(0.7, 0.13, 0.13),
"SHADOW": Color(0.18, 0.0, 0.33),
}
# Dark theme base colors
var bg_dark: Color = Color(0.1, 0.1, 0.15)
var panel_dark: Color = Color(0.15, 0.15, 0.2)
var panel_border: Color = Color(0.3, 0.3, 0.4)
var text_white: Color = Color(0.95, 0.95, 0.95)
func _ready() -> void:
print("BattleTheme loaded")
func get_type_color(type_name: String) -> Color:
if type_colors.has(type_name):
return type_colors[type_name]
return Color.WHITE
func create_panel_stylebox(border_color: Color = panel_border) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = panel_dark
style.border_color = border_color
style.set_border_width_all(2)
style.set_corner_radius_all(8)
style.set_content_margin_all(12)
return style
func create_button_stylebox(type_name: String) -> StyleBoxFlat:
var base_color: Color = get_type_color(type_name)
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = base_color.darkened(0.4)
style.border_color = base_color
style.set_border_width_all(2)
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
return style
func create_button_hover_stylebox(type_name: String) -> StyleBoxFlat:
var base_color: Color = get_type_color(type_name)
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = base_color.darkened(0.2)
style.border_color = base_color.lightened(0.3)
style.set_border_width_all(2)
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
return style
func create_button_pressed_stylebox(type_name: String) -> StyleBoxFlat:
var base_color: Color = get_type_color(type_name)
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = base_color.darkened(0.6)
style.border_color = base_color.darkened(0.2)
style.set_border_width_all(2)
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
return style
func create_hp_bar_fill(hp_percent: float) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
if hp_percent > 0.5:
style.bg_color = Color(0.2, 0.8, 0.2)
elif hp_percent > 0.2:
style.bg_color = Color(0.9, 0.75, 0.1)
else:
style.bg_color = Color(0.9, 0.2, 0.2)
style.set_corner_radius_all(3)
return style
func create_hp_bar_background() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(0.2, 0.2, 0.25)
style.border_color = Color(0.4, 0.4, 0.5)
style.set_border_width_all(1)
style.set_corner_radius_all(3)
return style
func create_message_stylebox() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(0.1, 0.1, 0.15, 0.9)
style.border_color = Color(0.4, 0.4, 0.5)
style.set_border_width_all(1)
style.set_corner_radius_all(8)
style.set_content_margin_all(16)
return style
func create_dark_button_stylebox() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(0.2, 0.2, 0.3)
style.border_color = Color(0.4, 0.4, 0.5)
style.set_border_width_all(1)
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
return style
func create_dark_button_hover_stylebox() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(0.3, 0.3, 0.4)
style.border_color = Color(0.5, 0.5, 0.6)
style.set_border_width_all(1)
style.set_corner_radius_all(6)
style.set_content_margin_all(8)
return style
Register the Autoload singleton
An Autoload makes your script globally accessible from any other script without needing a direct reference. Your existing GameData script uses this same pattern.
- In the top menu bar, click Project.
- Select Project Settings.
- Click the Globals tab at the top of the Project Settings window.
- Click the Autoload sub-tab. You should see your existing GameData entry here.
- In the Path field, click the folder icon and browse to res://scripts/battle_theme.gd.
- The Node Name field should auto-fill with BattleTheme. If it shows something else, change it to BattleTheme.
- Click Add.
You should now see BattleTheme listed alongside GameData in the Autoload list.
Why an Autoload instead of a static class?
Your move buttons and creature panels are created dynamically in code. They do not exist at design time in the scene tree. An Autoload gives every script access to BattleTheme's functions without passing references around.
A static class could hold the helper functions, but it cannot store mutable state or connect signals. The Autoload pattern keeps your options open as the theme grows.
Don't see BattleTheme in the Autoload list?
- Make sure you browsed to the correct path: res://scripts/battle_theme.gd. If the file is not showing in the browser, save the script first and try again.
- Check that the Node Name field says BattleTheme exactly. The name is case-sensitive.
Help me register my BattleTheme script as an Autoload in Godot.
Run the project and confirm the Autoload loads
- Close the Project Settings window.
- Press F5 to run the project (or click the play button in the top-right toolbar).
- Godot will ask you to select a main scene if one is not set. Choose character_select.tscn or any scene from your project.
- Look at the Output panel at the bottom of the Godot editor.
You should see BattleTheme loaded printed in the Output panel. This confirms the Autoload is active and its _ready() function ran successfully.
Don't see the print message?
- Verify the Enable checkbox is checked next to BattleTheme in the Autoload list. Godot enables it by default, but double-check.
- Make sure you saved battle_theme.gd after adding the _ready() function. An outdated version on disk will not include the print statement.
- Check for any error messages in red in the Output panel. A script error prevents the Autoload from loading.
Help me figure out why my BattleTheme Autoload is not printing its loaded message.
Your theming system is ready. Next up, you will use these helper functions to style the creature info panels and HP bars in the battle scene.
Style the Creature Info Panels and HP Bars
Your BattleTheme Autoload is loaded and ready to generate styled UI resources on demand. The battle scene itself still looks completely unstyled, though.
In this step, you'll apply your theme to the creature info panels and HP bars. Each creature's panel will get a dark rounded background with a border that matches its element type, and the HP bars will shift from green to yellow to red as damage is taken.
In this step, get ready to:
- Apply a dark background to the battle scene's root Control.
- Style creature panels with type-colored borders and white labels.
- Add color-coded HP bars that shift from green to yellow to red based on health percentage.
Apply a dark background to the battle scene
The root node of the battle scene is a Control node. You can set a StyleBoxFlat panel override on it to give the entire scene a dark background color.
- Switch back to battle_manager.gd in the Godot Script Editor.
- Add the following _apply_background() function below your _ready() function:
func _apply_background() -> void:
var bg_style: StyleBoxFlat = StyleBoxFlat.new()
bg_style.bg_color = BattleTheme.bg_dark
add_theme_stylebox_override("panel", bg_style)
What does this code do?
This creates a new StyleBoxFlat resource and sets its background color to the dark near-black value defined in BattleTheme.bg_dark.
The add_theme_stylebox_override("panel", ...) method applies this style as a local override on the root Control. Since the battle scene's root extends Control, this paints the entire background dark.
- Now scroll up to your _ready() function and add the call to _apply_background() at the very top of the function body, before any other code:
func _ready() -> void:
# Apply dark background
_apply_background()
- Save battle_manager.gd and run the battle scene by pressing F5 (or F6 to run the current scene).
- You should see the battle scene's background turn dark instead of the default gray.
Still seeing a gray background?
- Make sure _apply_background() is called at the top of _ready() before any other setup code.
- Confirm that your BattleTheme Autoload is still registered. Check Project > Project Settings > Globals > Autoload for the entry.
- Check that the script file starts with extends Control. The panel override only works on Control-based nodes.
Help me debug why my dark background isn't showing in the battle scene.
Style creature panels with type-colored borders
Each creature's info panel should visually represent its element type. You'll apply a dark rounded StyleBoxFlat to each PanelContainer and tint the border to match the creature's type color.
- Add the following _style_creature_panels() function below _apply_background():
func _style_creature_panels() -> void:
var player_style: StyleBoxFlat = BattleTheme.create_panel_stylebox(
BattleTheme.get_type_color(player_creature.type)
)
$PlayerPanel.add_theme_stylebox_override("panel", player_style)
var enemy_style: StyleBoxFlat = BattleTheme.create_panel_stylebox(
BattleTheme.get_type_color(enemy_creature.type)
)
$EnemyPanel.add_theme_stylebox_override("panel", enemy_style)
# Style labels
player_name_label.add_theme_font_size_override("font_size", 20)
player_name_label.add_theme_color_override("font_color", BattleTheme.text_white)
player_hp_label.add_theme_color_override("font_color", BattleTheme.text_white)
enemy_name_label.add_theme_font_size_override("font_size", 20)
enemy_name_label.add_theme_color_override("font_color", BattleTheme.text_white)
enemy_hp_label.add_theme_color_override("font_color", BattleTheme.text_white)
# Style HP bars
player_hp_bar.add_theme_stylebox_override("background", BattleTheme.create_hp_bar_background())
enemy_hp_bar.add_theme_stylebox_override("background", BattleTheme.create_hp_bar_background())
What does this code do?
- BattleTheme.create_panel_stylebox() generates a dark rounded panel with a border. You pass in the creature's type color so the border matches its element (e.g. blue for TIDE, red for FLAME).
- add_theme_font_size_override("font_size", 20) bumps the creature name labels to a larger, more readable size.
- add_theme_color_override("font_color", BattleTheme.text_white) makes all label text white so it's visible against the dark panels.
- The HP bar "background" override gives the empty portion of each ProgressBar a dark styled look instead of the default flat gray.
- In your _ready() function, add the call to _style_creature_panels() after you assign player_hp and enemy_hp but before you set up the UI labels:
player_hp = player_creature.max_hp
enemy_hp = enemy_creature.max_hp
# Style panels
_style_creature_panels()
# Set up player UI
player_name_label.text = player_creature.creature_name
Why call it here?
The panel styling needs access to player_creature.type and enemy_creature.type, so it must come after the creature data is loaded from GameData. Placing it before the label text assignments keeps the styling logic cleanly separated from the data population.
- Save battle_manager.gd and run the scene again.
- You should see both creature panels with dark rounded backgrounds and colored borders that match each creature's type. The name and HP labels should now be white.
Panels still look unstyled?
- Verify that $PlayerPanel and $EnemyPanel are PanelContainer nodes in your scene tree. The "panel" override only works on PanelContainer.
- Check that _style_creature_panels() is called after player_creature and enemy_creature are assigned. If called too early, the type will be null.
Help me figure out why my creature panels aren't showing the dark styled look.
Add color-coded HP bars
In Pokemon games, the HP bar changes color as a creature takes damage: green when healthy, yellow when hurt, red when critical. This gives players an instant visual read on how much health remains.
- Add the following _update_hp_bar_colors() function below _style_creature_panels():
func _update_hp_bar_colors() -> void:
var player_percent: float = float(player_hp) / float(player_creature.max_hp)
player_hp_bar.add_theme_stylebox_override("fill", BattleTheme.create_hp_bar_fill(player_percent))
var enemy_percent: float = float(enemy_hp) / float(enemy_creature.max_hp)
enemy_hp_bar.add_theme_stylebox_override("fill", BattleTheme.create_hp_bar_fill(enemy_percent))
What does this code do?
- It calculates the HP percentage for each creature by dividing current HP by max HP.
- BattleTheme.create_hp_bar_fill() returns a StyleBoxFlat with a green, yellow, or red color depending on the percentage: green when above 50%, yellow between 20-50%, and red at 20% or below.
- The "fill" override targets the filled portion of the ProgressBar. Each time this function runs, it recalculates and reapplies the color.
Why is this a separate function?
Unlike panel styling which only runs once, HP bar colors need to update every time a creature takes damage. By keeping this in its own function, you can call it again later whenever HP changes during battle.
- In your _ready() function, add the call to _update_hp_bar_colors() after the lines that set up the enemy UI (after enemy_sprite.color = enemy_creature.sprite_color):
enemy_sprite.color = enemy_creature.sprite_color
_update_hp_bar_colors()
- Save battle_manager.gd and run the battle scene.
- Both HP bars should now appear green (since both creatures start at full health). The bars have a dark background behind the colored fill with rounded corners.
Test the color shifts
Play through a battle and attack the enemy. Watch the HP bar shift from green to yellow as health drops below 50%, and then to red below 20%. This is the same pattern used in Pokemon games.
HP bars not changing color during battle?
- The color only updates when _update_hp_bar_colors() is called. Right now it only runs in _ready(). You will connect it to damage events in the next step when you hook up the full battle flow.
- If the bars look completely unstyled (no rounded corners, default color), make sure both the "background" override (from _style_creature_panels()) and the "fill" override are both applied.
Help me debug why my HP bars aren't showing the green fill color.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the updated portion of battle_manager.gd showing the new theme functions and the restructured _ready(). Your existing battle logic functions below these remain unchanged.
extends Control
enum BattleState { PLAYER_TURN, ENEMY_TURN, ANIMATING, VICTORY, DEFEAT }
var player_creature: Creature
var enemy_creature: Creature
var player_moves: Array[Move] = []
var enemy_moves: Array[Move] = []
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
@onready var back_button: Button = $BackButton
func _ready() -> void:
_apply_background()
player_creature = GameData.selected_creature
player_moves = GameData.selected_moves
enemy_creature = GameData.get_random_creature()
enemy_moves = GameData.get_random_moves(4)
player_hp = player_creature.max_hp
enemy_hp = enemy_creature.max_hp
_style_creature_panels()
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
_update_hp_bar_colors()
message_label.hide()
play_again_button.hide()
back_button.hide()
play_again_button.pressed.connect(_on_play_again_pressed)
back_button.pressed.connect(_on_back_pressed)
if player_creature.speed >= enemy_creature.speed:
state = BattleState.PLAYER_TURN
action_panel.show()
else:
state = BattleState.ENEMY_TURN
action_panel.hide()
_enemy_turn()
func _apply_background() -> void:
var bg_style: StyleBoxFlat = StyleBoxFlat.new()
bg_style.bg_color = BattleTheme.bg_dark
add_theme_stylebox_override("panel", bg_style)
func _style_creature_panels() -> void:
var player_style: StyleBoxFlat = BattleTheme.create_panel_stylebox(
BattleTheme.get_type_color(player_creature.type)
)
$PlayerPanel.add_theme_stylebox_override("panel", player_style)
var enemy_style: StyleBoxFlat = BattleTheme.create_panel_stylebox(
BattleTheme.get_type_color(enemy_creature.type)
)
$EnemyPanel.add_theme_stylebox_override("panel", enemy_style)
player_name_label.add_theme_font_size_override("font_size", 20)
player_name_label.add_theme_color_override("font_color", BattleTheme.text_white)
player_hp_label.add_theme_color_override("font_color", BattleTheme.text_white)
enemy_name_label.add_theme_font_size_override("font_size", 20)
enemy_name_label.add_theme_color_override("font_color", BattleTheme.text_white)
enemy_hp_label.add_theme_color_override("font_color", BattleTheme.text_white)
player_hp_bar.add_theme_stylebox_override("background", BattleTheme.create_hp_bar_background())
enemy_hp_bar.add_theme_stylebox_override("background", BattleTheme.create_hp_bar_background())
func _update_hp_bar_colors() -> void:
var player_percent: float = float(player_hp) / float(player_creature.max_hp)
player_hp_bar.add_theme_stylebox_override("fill", BattleTheme.create_hp_bar_fill(player_percent))
var enemy_percent: float = float(enemy_hp) / float(enemy_creature.max_hp)
enemy_hp_bar.add_theme_stylebox_override("fill", BattleTheme.create_hp_bar_fill(enemy_percent))
Your creature panels now look like proper RPG info cards with type-colored accents and readable white text. Next up, you'll style the move buttons and message panel to complete the battle UI transformation.
Style the Move Buttons and Message Panel
Your creature panels look polished with dark backgrounds and color-coded HP bars. The move buttons, though, are still plain default Godot buttons with no visual connection to the type system you built.
In this step, you will rebuild the move button grid so each button is tinted to its move type, add smooth hover animations using Tween, and style the message panel to match the dark theme.
In this step, get ready to:
- Create type-colored move buttons arranged in a 2x2 grid.
- Add Tween-based hover animations that scale buttons on mouse-over.
- Style the message panel and end-of-battle buttons with a dark theme.
Create type-colored move buttons in a 2x2 grid
Each move in the game has a type (FLAME, TIDE, FLORA, etc.). Coloring buttons to match their move type gives the player instant visual feedback about what kind of attack they are choosing.
- In battle_manager.gd, scroll down below the _update_hp_bar_colors() function.
- Add the _setup_move_buttons() function by pasting this code at the bottom of the file:
func _setup_move_buttons() -> void:
for child in move_grid.get_children():
child.queue_free()
move_grid.columns = 2
for i in range(player_moves.size()):
var move: Move = player_moves[i]
var button: Button = Button.new()
button.text = "%s\n%s | Pow:%d" % [move.move_name, move.type, move.power]
button.custom_minimum_size = Vector2(180, 60)
# Apply type-colored styling
button.add_theme_stylebox_override("normal", BattleTheme.create_button_stylebox(move.type))
button.add_theme_stylebox_override("hover", BattleTheme.create_button_hover_stylebox(move.type))
button.add_theme_stylebox_override("pressed", BattleTheme.create_button_pressed_stylebox(move.type))
button.add_theme_color_override("font_color", BattleTheme.text_white)
button.add_theme_color_override("font_hover_color", BattleTheme.text_white)
button.add_theme_color_override("font_pressed_color", BattleTheme.text_white)
# Set pivot for scale animation
button.pivot_offset = button.custom_minimum_size / 2.0
# Connect signals
button.pressed.connect(_on_move_selected.bind(i))
button.mouse_entered.connect(_on_button_hover.bind(button))
button.mouse_exited.connect(_on_button_unhover.bind(button))
move_grid.add_child(button)
# Style the action panel
var action_style: StyleBoxFlat = BattleTheme.create_panel_stylebox()
action_panel.add_theme_stylebox_override("panel", action_style)
What does this code do?
This function rebuilds the move grid from scratch each time the battle loads.
- move_grid.columns = 2 turns the GridContainer into a 2x2 layout for 4 moves.
- Each button gets three StyleBoxFlat overrides (normal, hover, pressed) tinted to the move's type color.
- pivot_offset is set to the button's center so scale animations grow outward from the middle instead of the top-left corner.
- mouse_entered and mouse_exited signals connect to hover animation functions you will write next.
- Save battle_manager.gd.
- You won't run the scene yet because the hover functions don't exist. The next chunk adds them.
Seeing a red error in the Script Editor?
If Godot underlines _on_button_hover or _on_button_unhover as undefined, that is expected. You will add those functions in the next chunk.
If the error points to BattleTheme, make sure your Autoload is still registered under Project Settings > Globals > Autoload.
Help me fix a BattleTheme reference error in my move button setup code.
Add Tween hover animations
A subtle scale effect on hover gives the player clear feedback about which button they are targeting. Tween handles this smoothly without requiring pre-authored animations.
- Directly below the _setup_move_buttons() function you just added, paste these two functions:
func _on_button_hover(button: Button) -> void:
var tween: Tween = create_tween()
tween.tween_property(button, "scale", Vector2(1.05, 1.05), 0.1)
func _on_button_unhover(button: Button) -> void:
var tween: Tween = create_tween()
tween.tween_property(button, "scale", Vector2(1.0, 1.0), 0.1)
Why Tween instead of AnimationPlayer?
Your move buttons are created dynamically in code. AnimationPlayer requires pre-authored animations on nodes that exist in the scene tree at design time. Tween is lightweight and procedural, making it perfect for fire-and-forget animations on dynamically generated controls.
Each call to create_tween() returns a fresh Tween that automatically plays and cleans itself up when done.
- Save battle_manager.gd.
- The hover functions are now in place. You still need to wire everything into _ready() before testing. The next chunk handles that along with message panel styling.
Style the message panel and end-of-battle buttons
The battle narration text ("Ember used Fireball!") needs a styled container so it stands out against the dark background. The Play Again and Back buttons also need to match the overall theme.
- Below the _on_button_unhover() function, add the _style_message_label() function:
func _style_message_label() -> void:
var msg_panel: StyleBoxFlat = BattleTheme.create_message_stylebox()
message_label.add_theme_stylebox_override("normal", msg_panel)
message_label.add_theme_font_size_override("font_size", 18)
message_label.add_theme_color_override("font_color", BattleTheme.text_white)
message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
What does this code do?
The message label gets a semi-transparent dark background with rounded corners and generous padding (16px on all sides from create_message_stylebox()). Centering the text horizontally ensures battle narration reads cleanly at any panel width.
- Directly below _style_message_label(), add the _style_end_buttons() function:
func _style_end_buttons() -> void:
var normal: StyleBoxFlat = BattleTheme.create_dark_button_stylebox()
var hover: StyleBoxFlat = BattleTheme.create_dark_button_hover_stylebox()
play_again_button.add_theme_stylebox_override("normal", normal)
play_again_button.add_theme_stylebox_override("hover", hover)
play_again_button.add_theme_color_override("font_color", BattleTheme.text_white)
play_again_button.add_theme_color_override("font_hover_color", BattleTheme.text_white)
var normal2: StyleBoxFlat = BattleTheme.create_dark_button_stylebox()
var hover2: StyleBoxFlat = BattleTheme.create_dark_button_hover_stylebox()
back_button.add_theme_stylebox_override("normal", normal2)
back_button.add_theme_stylebox_override("hover", hover2)
back_button.add_theme_color_override("font_color", BattleTheme.text_white)
back_button.add_theme_color_override("font_hover_color", BattleTheme.text_white)
Why create separate StyleBoxFlat instances?
Each button needs its own StyleBoxFlat resource. If you reuse the same instance on multiple nodes, changing one would change all of them. Creating normal2 and hover2 for the back button keeps both buttons independent.
Now you need to call all three new functions from _ready() so they run when the battle loads.
- Scroll up to the _ready() function.
- Find this line:
_update_hp_bar_colors()
- Add these three lines directly above _update_hp_bar_colors(), so the section now looks like this:
_setup_move_buttons()
_style_message_label()
_style_end_buttons()
_update_hp_bar_colors()
- Save battle_manager.gd.
- Run the battle scene by pressing F5 (or F6 to run the current scene). You should see move buttons in a 2x2 grid, each tinted to its move type color. Hovering over a button should make it smoothly scale up slightly.
Buttons not scaling on hover?
Make sure pivot_offset is set before the button is added to the grid. Without it, scale still works but animates from the top-left corner, which looks off.
Check that your signal connections use _on_button_hover.bind(button) with .bind(button). Without binding the button reference, the hover functions won't know which button to animate.
Help me debug why my move buttons don't animate on hover.
✔️ Awesome, I've got everything!
Great. Double check you have saved battle_manager.gd before moving on.
ⓧ I'd like to double check the full code
Here is the complete battle_manager.gd as it should look at the end of this step:
extends Control
enum BattleState { PLAYER_TURN, ENEMY_TURN, ANIMATING, VICTORY, DEFEAT }
var player_creature: Creature
var enemy_creature: Creature
var player_moves: Array[Move] = []
var enemy_moves: Array[Move] = []
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
@onready var back_button: Button = $BackButton
func _ready() -> void:
_apply_background()
player_creature = GameData.selected_creature
player_moves = GameData.selected_moves
enemy_creature = GameData.get_random_creature()
enemy_moves = GameData.get_random_moves(4)
player_hp = player_creature.max_hp
enemy_hp = enemy_creature.max_hp
_style_creature_panels()
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()
_style_message_label()
_style_end_buttons()
_update_hp_bar_colors()
message_label.hide()
play_again_button.hide()
back_button.hide()
play_again_button.pressed.connect(_on_play_again_pressed)
back_button.pressed.connect(_on_back_pressed)
if player_creature.speed >= enemy_creature.speed:
state = BattleState.PLAYER_TURN
action_panel.show()
else:
state = BattleState.ENEMY_TURN
action_panel.hide()
_enemy_turn()
func _apply_background() -> void:
var bg_style: StyleBoxFlat = StyleBoxFlat.new()
bg_style.bg_color = BattleTheme.bg_dark
add_theme_stylebox_override("panel", bg_style)
func _style_creature_panels() -> void:
var player_style: StyleBoxFlat = BattleTheme.create_panel_stylebox(
BattleTheme.get_type_color(player_creature.type)
)
$PlayerPanel.add_theme_stylebox_override("panel", player_style)
var enemy_style: StyleBoxFlat = BattleTheme.create_panel_stylebox(
BattleTheme.get_type_color(enemy_creature.type)
)
$EnemyPanel.add_theme_stylebox_override("panel", enemy_style)
player_name_label.add_theme_font_size_override("font_size", 20)
player_name_label.add_theme_color_override("font_color", BattleTheme.text_white)
player_hp_label.add_theme_color_override("font_color", BattleTheme.text_white)
enemy_name_label.add_theme_font_size_override("font_size", 20)
enemy_name_label.add_theme_color_override("font_color", BattleTheme.text_white)
enemy_hp_label.add_theme_color_override("font_color", BattleTheme.text_white)
player_hp_bar.add_theme_stylebox_override("background", BattleTheme.create_hp_bar_background())
enemy_hp_bar.add_theme_stylebox_override("background", BattleTheme.create_hp_bar_background())
func _update_hp_bar_colors() -> void:
var player_percent: float = float(player_hp) / float(player_creature.max_hp)
player_hp_bar.add_theme_stylebox_override("fill", BattleTheme.create_hp_bar_fill(player_percent))
var enemy_percent: float = float(enemy_hp) / float(enemy_creature.max_hp)
enemy_hp_bar.add_theme_stylebox_override("fill", BattleTheme.create_hp_bar_fill(enemy_percent))
func _style_message_label() -> void:
var msg_panel: StyleBoxFlat = BattleTheme.create_message_stylebox()
message_label.add_theme_stylebox_override("normal", msg_panel)
message_label.add_theme_font_size_override("font_size", 18)
message_label.add_theme_color_override("font_color", BattleTheme.text_white)
message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
func _style_end_buttons() -> void:
var normal: StyleBoxFlat = BattleTheme.create_dark_button_stylebox()
var hover: StyleBoxFlat = BattleTheme.create_dark_button_hover_stylebox()
play_again_button.add_theme_stylebox_override("normal", normal)
play_again_button.add_theme_stylebox_override("hover", hover)
play_again_button.add_theme_color_override("font_color", BattleTheme.text_white)
play_again_button.add_theme_color_override("font_hover_color", BattleTheme.text_white)
var normal2: StyleBoxFlat = BattleTheme.create_dark_button_stylebox()
var hover2: StyleBoxFlat = BattleTheme.create_dark_button_hover_stylebox()
back_button.add_theme_stylebox_override("normal", normal2)
back_button.add_theme_stylebox_override("hover", hover2)
back_button.add_theme_color_override("font_color", BattleTheme.text_white)
back_button.add_theme_color_override("font_hover_color", BattleTheme.text_white)
func _setup_move_buttons() -> void:
for child in move_grid.get_children():
child.queue_free()
move_grid.columns = 2
for i in range(player_moves.size()):
var move: Move = player_moves[i]
var button: Button = Button.new()
button.text = "%s\n%s | Pow:%d" % [move.move_name, move.type, move.power]
button.custom_minimum_size = Vector2(180, 60)
button.add_theme_stylebox_override("normal", BattleTheme.create_button_stylebox(move.type))
button.add_theme_stylebox_override("hover", BattleTheme.create_button_hover_stylebox(move.type))
button.add_theme_stylebox_override("pressed", BattleTheme.create_button_pressed_stylebox(move.type))
button.add_theme_color_override("font_color", BattleTheme.text_white)
button.add_theme_color_override("font_hover_color", BattleTheme.text_white)
button.add_theme_color_override("font_pressed_color", BattleTheme.text_white)
button.pivot_offset = button.custom_minimum_size / 2.0
button.pressed.connect(_on_move_selected.bind(i))
button.mouse_entered.connect(_on_button_hover.bind(button))
button.mouse_exited.connect(_on_button_unhover.bind(button))
move_grid.add_child(button)
var action_style: StyleBoxFlat = BattleTheme.create_panel_stylebox()
action_panel.add_theme_stylebox_override("panel", action_style)
func _on_button_hover(button: Button) -> void:
var tween: Tween = create_tween()
tween.tween_property(button, "scale", Vector2(1.05, 1.05), 0.1)
func _on_button_unhover(button: Button) -> void:
var tween: Tween = create_tween()
tween.tween_property(button, "scale", Vector2(1.0, 1.0), 0.1)
Your move buttons and message panel now match the dark, type-colored aesthetic of the creature panels. Next up, you will add a dark background to the entire scene and polish the entrance with fade-in animations.
Add Background and Final Polish
Your battle scene already has themed panels, color-coded HP bars, and type-colored move buttons. The visual foundation is solid, but two things are missing: the creatures appear instantly (no dramatic entrance) and the battle itself has no logic to drive gameplay to a conclusion.
In this step, you will add a fade-in entrance animation using Tween and complete the battle logic so the game plays through to a victory or defeat screen.
In this step, get ready to:
- Verify the dark background displays correctly.
- Add a Tween-based fade-in animation for both creature panels.
- Complete the battle logic for turns, damage, and end-of-battle navigation.
Confirm the dark background
Your _apply_background() function from the previous step already sets a dark StyleBoxFlat on the root Control node. Let's verify it renders correctly before adding animations on top.
- Run the battle scene by pressing F5 (or F6 if battle.tscn is your current scene).
- Confirm the entire scene background is a dark near-black color, not the default Godot gray.
Still seeing a gray background?
Make sure your root node in battle.tscn is a Control node (not a Node2D). The add_theme_stylebox_override("panel", ...) method only works on Control-based nodes.
Help me debug my dark background not showing up in Godot
Add the entrance fade-in animation
Right now, both creature panels appear instantly when the battle loads. A short fade-in creates a more dramatic entrance, similar to how Pokemon games reveal each battler with a brief animation.
- In battle_manager.gd, add the following function below _style_end_buttons():
func _play_entrance_animation() -> void:
$PlayerPanel.modulate.a = 0.0
$EnemyPanel.modulate.a = 0.0
var tween: Tween = create_tween()
tween.set_parallel(true)
tween.tween_property($PlayerPanel, "modulate:a", 1.0, 0.5)
tween.tween_property($EnemyPanel, "modulate:a", 1.0, 0.5)
What does this code do?
The function starts by setting both panels' alpha (transparency) to 0, making them invisible. Then it creates a Tween and calls set_parallel(true) so both animations run at the same time instead of one after the other.
Each tween_property() call animates modulate:a from 0 to 1 over 0.5 seconds, creating a smooth fade-in effect.
Now you need to call this function at the end of _ready() so it triggers when the battle scene loads.
- In _ready(), scroll to the end of the function. Find the else block that calls _enemy_turn():
else:
state = BattleState.ENEMY_TURN
action_panel.hide()
_enemy_turn()
- After the entire if/else block (outside it, at the same indentation level as if), add this line:
# Fade-in animation
_play_entrance_animation()
- Save battle_manager.gd.
- Run the battle scene. Both creature panels should fade in from transparent to fully visible over half a second when the scene loads.
Panels appear instantly with no fade?
Make sure _play_entrance_animation() is called AFTER the if/else block in _ready(), not inside it. The call must be at the same indentation level as the if statement.
Also check that you're setting modulate.a = 0.0 before creating the tween. Without this, the panels start visible and there's nothing to animate from.
Help me debug my panel fade-in animation in Godot
Complete the battle logic functions
The scene looks polished, but you still need the game logic that drives turns, calculates damage, and triggers the end-of-battle state. These functions connect the styled UI elements to actual gameplay.
- Add the _on_move_selected() function below _on_button_unhover(). This handles what happens when the player clicks 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_moves[move_index]
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()
back_button.show()
return
state = BattleState.ENEMY_TURN
await _enemy_turn()
What does this code do?
When a player clicks a move button, the function first checks it's actually the player's turn. It sets the state to ANIMATING to prevent double-clicks, hides the action panel, and executes the selected move.
After the move resolves, if the enemy's HP hits zero, the battle ends with a victory message and shows the styled end buttons. Otherwise, it passes control to the enemy's turn.
- Add the _enemy_turn() function next. This handles the AI's attack:
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()
back_button.show()
return
state = BattleState.PLAYER_TURN
action_panel.show()
What does this code do?
The enemy waits 0.5 seconds (so the player can see what's happening), picks a move via the AI function, and executes it. If the player faints, the defeat state triggers. Otherwise, control returns to the player.
- Add the _execute_move() function. This is the core damage calculation that also updates the themed HP bars:
func _execute_move(move: Move, attacker: Creature, defender: Creature, is_player_attacking: bool) -> void:
if randf() > move.accuracy:
await _show_message("%s missed!" % move.move_name)
return
var effectiveness_value: float = type_chart.get_effectiveness(move.type, defender.type)
if effectiveness_value == 0.0:
await _show_message("It has no effect!")
return
var attack_stat: float
var defense_stat: float
if move.category == "Physical":
attack_stat = float(attacker.attack)
defense_stat = float(defender.defense)
else:
attack_stat = float(attacker.sp_attack)
defense_stat = float(defender.sp_defense)
var stat_ratio: float = attack_stat / max(1.0, defense_stat)
var damage: int = int(move.power * stat_ratio * effectiveness_value * randf_range(0.85, 1.15))
damage = max(1, damage)
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)
_update_hp_bar_colors()
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...")
What does this code do?
This function handles accuracy checks, type effectiveness, damage calculation based on physical/special stats, and applies the result. The key line is _update_hp_bar_colors() which triggers the themed color shift (green to yellow to red) every time HP changes.
It also calls _animate_hp_bar() for smooth bar transitions and _flash_sprite() for hit feedback. You will add both of those helpers next.
- Add the _ai_select_move() function. The AI picks the most effective move against the player's type:
func _ai_select_move() -> Move:
var best_moves: Array = []
var best_effectiveness: float = 0.0
for move in enemy_moves:
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
What does this code do?
The AI evaluates each available move's type effectiveness against the player's creature. It collects all moves tied for the best effectiveness and randomly picks one. This gives the enemy some intelligence without being perfectly predictable.
- Finally, add the helper and navigation functions that support the battle flow:
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()
func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/character_select.tscn")
What do these helpers do?
- _animate_hp_bar() uses a Tween to smoothly slide the HP bar value down over 0.5 seconds.
- _flash_sprite() briefly turns the target's ColorRect red for 0.15 seconds to show damage feedback.
- _show_message() displays battle text in the styled message panel, waits for the timer, then hides it.
- _on_play_again_pressed() reloads the battle scene for a rematch.
- _on_back_pressed() returns to the character select screen.
- Save battle_manager.gd.
- Run the full game from the character select screen. Pick a creature, select 4 moves, and start the battle.
- You should see both creature panels fade in, the move buttons respond to clicks with type-effectiveness messages, HP bars animate down with color changes, and a victory or defeat screen at the end with styled Play Again and Back buttons.
Battle freezes or buttons don't respond?
Check that every await keyword is present before _execute_move(), _show_message(), and _enemy_turn(). Missing an await causes the turn flow to skip ahead before animations finish.
If the Output panel shows an error about a null object, confirm that message_timer exists as a Timer child node in your battle scene with a non-zero wait time.
Help me debug my battle logic in Godot
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete battle_manager.gd file. Compare it against yours:
extends Control
enum BattleState { PLAYER_TURN, ENEMY_TURN, ANIMATING, VICTORY, DEFEAT }
var player_creature: Creature
var enemy_creature: Creature
var player_moves: Array[Move] = []
var enemy_moves: Array[Move] = []
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
@onready var back_button: Button = $BackButton
func _ready() -> void:
# Apply dark background
_apply_background()
# Load player's selections from GameData
player_creature = GameData.selected_creature
player_moves = GameData.selected_moves
enemy_creature = GameData.get_random_creature()
enemy_moves = GameData.get_random_moves(4)
player_hp = player_creature.max_hp
enemy_hp = enemy_creature.max_hp
# Style panels
_style_creature_panels()
# Set up player UI
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
# Set up enemy UI
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()
_style_message_label()
_style_end_buttons()
_update_hp_bar_colors()
message_label.hide()
play_again_button.hide()
back_button.hide()
play_again_button.pressed.connect(_on_play_again_pressed)
back_button.pressed.connect(_on_back_pressed)
# Speed determines who goes first
if player_creature.speed >= enemy_creature.speed:
state = BattleState.PLAYER_TURN
action_panel.show()
else:
state = BattleState.ENEMY_TURN
action_panel.hide()
_enemy_turn()
# Fade-in animation
_play_entrance_animation()
func _apply_background() -> void:
var bg_style: StyleBoxFlat = StyleBoxFlat.new()
bg_style.bg_color = BattleTheme.bg_dark
add_theme_stylebox_override("panel", bg_style)
func _style_creature_panels() -> void:
var player_style: StyleBoxFlat = BattleTheme.create_panel_stylebox(
BattleTheme.get_type_color(player_creature.type)
)
$PlayerPanel.add_theme_stylebox_override("panel", player_style)
var enemy_style: StyleBoxFlat = BattleTheme.create_panel_stylebox(
BattleTheme.get_type_color(enemy_creature.type)
)
$EnemyPanel.add_theme_stylebox_override("panel", enemy_style)
# Style labels
player_name_label.add_theme_font_size_override("font_size", 20)
player_name_label.add_theme_color_override("font_color", BattleTheme.text_white)
player_hp_label.add_theme_color_override("font_color", BattleTheme.text_white)
enemy_name_label.add_theme_font_size_override("font_size", 20)
enemy_name_label.add_theme_color_override("font_color", BattleTheme.text_white)
enemy_hp_label.add_theme_color_override("font_color", BattleTheme.text_white)
# Style HP bars
player_hp_bar.add_theme_stylebox_override("background", BattleTheme.create_hp_bar_background())
enemy_hp_bar.add_theme_stylebox_override("background", BattleTheme.create_hp_bar_background())
func _update_hp_bar_colors() -> void:
var player_percent: float = float(player_hp) / float(player_creature.max_hp)
player_hp_bar.add_theme_stylebox_override("fill", BattleTheme.create_hp_bar_fill(player_percent))
var enemy_percent: float = float(enemy_hp) / float(enemy_creature.max_hp)
enemy_hp_bar.add_theme_stylebox_override("fill", BattleTheme.create_hp_bar_fill(enemy_percent))
func _style_message_label() -> void:
var msg_panel: StyleBoxFlat = BattleTheme.create_message_stylebox()
message_label.add_theme_stylebox_override("normal", msg_panel)
message_label.add_theme_font_size_override("font_size", 18)
message_label.add_theme_color_override("font_color", BattleTheme.text_white)
message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
func _style_end_buttons() -> void:
var normal: StyleBoxFlat = BattleTheme.create_dark_button_stylebox()
var hover: StyleBoxFlat = BattleTheme.create_dark_button_hover_stylebox()
play_again_button.add_theme_stylebox_override("normal", normal)
play_again_button.add_theme_stylebox_override("hover", hover)
play_again_button.add_theme_color_override("font_color", BattleTheme.text_white)
play_again_button.add_theme_color_override("font_hover_color", BattleTheme.text_white)
var normal2: StyleBoxFlat = BattleTheme.create_dark_button_stylebox()
var hover2: StyleBoxFlat = BattleTheme.create_dark_button_hover_stylebox()
back_button.add_theme_stylebox_override("normal", normal2)
back_button.add_theme_stylebox_override("hover", hover2)
back_button.add_theme_color_override("font_color", BattleTheme.text_white)
back_button.add_theme_color_override("font_hover_color", BattleTheme.text_white)
func _play_entrance_animation() -> void:
$PlayerPanel.modulate.a = 0.0
$EnemyPanel.modulate.a = 0.0
var tween: Tween = create_tween()
tween.set_parallel(true)
tween.tween_property($PlayerPanel, "modulate:a", 1.0, 0.5)
tween.tween_property($EnemyPanel, "modulate:a", 1.0, 0.5)
func _setup_move_buttons() -> void:
for child in move_grid.get_children():
child.queue_free()
move_grid.columns = 2
for i in range(player_moves.size()):
var move: Move = player_moves[i]
var button: Button = Button.new()
button.text = "%s\n%s | Pow:%d" % [move.move_name, move.type, move.power]
button.custom_minimum_size = Vector2(180, 60)
# Apply type-colored styling
button.add_theme_stylebox_override("normal", BattleTheme.create_button_stylebox(move.type))
button.add_theme_stylebox_override("hover", BattleTheme.create_button_hover_stylebox(move.type))
button.add_theme_stylebox_override("pressed", BattleTheme.create_button_pressed_stylebox(move.type))
button.add_theme_color_override("font_color", BattleTheme.text_white)
button.add_theme_color_override("font_hover_color", BattleTheme.text_white)
button.add_theme_color_override("font_pressed_color", BattleTheme.text_white)
# Set pivot for scale animation
button.pivot_offset = button.custom_minimum_size / 2.0
# Connect signals
button.pressed.connect(_on_move_selected.bind(i))
button.mouse_entered.connect(_on_button_hover.bind(button))
button.mouse_exited.connect(_on_button_unhover.bind(button))
move_grid.add_child(button)
# Style the action panel
var action_style: StyleBoxFlat = BattleTheme.create_panel_stylebox()
action_panel.add_theme_stylebox_override("panel", action_style)
func _on_button_hover(button: Button) -> void:
var tween: Tween = create_tween()
tween.tween_property(button, "scale", Vector2(1.05, 1.05), 0.1)
func _on_button_unhover(button: Button) -> void:
var tween: Tween = create_tween()
tween.tween_property(button, "scale", Vector2(1.0, 1.0), 0.1)
func _on_move_selected(move_index: int) -> void:
if state != BattleState.PLAYER_TURN:
return
state = BattleState.ANIMATING
action_panel.hide()
var move: Move = player_moves[move_index]
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()
back_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()
back_button.show()
return
state = BattleState.PLAYER_TURN
action_panel.show()
func _execute_move(move: Move, attacker: Creature, defender: Creature, is_player_attacking: bool) -> void:
if randf() > move.accuracy:
await _show_message("%s missed!" % move.move_name)
return
var effectiveness_value: float = type_chart.get_effectiveness(move.type, defender.type)
if effectiveness_value == 0.0:
await _show_message("It has no effect!")
return
var attack_stat: float
var defense_stat: float
if move.category == "Physical":
attack_stat = float(attacker.attack)
defense_stat = float(defender.defense)
else:
attack_stat = float(attacker.sp_attack)
defense_stat = float(defender.sp_defense)
var stat_ratio: float = attack_stat / max(1.0, defense_stat)
var damage: int = int(move.power * stat_ratio * effectiveness_value * randf_range(0.85, 1.15))
damage = max(1, damage)
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)
_update_hp_bar_colors()
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 in enemy_moves:
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()
func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/character_select.tscn")
Your battle scene is now a fully polished, playable game. The dark background, fade-in entrance, color-coded HP bars, and styled end buttons create a cohesive visual experience. Next up: extend this theme to your character select and move loadout screens in the Secret Mission.
Secret mission
Extend the Theme to Selection Screens
Your battle scene looks polished, but the character select and move loadout screens still have Godot's default grey styling. Extend your BattleTheme to both selection screens with type-colored buttons, dark panels, and hover animations for a cohesive game-wide look.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally in Godot, so there are no ongoing costs or cloud resources to worry about.
Decide whether to keep your project files to continue building on them, or delete them to free up disk space.
Resources you used:
- The BattleSystem project folder on your Desktop (contains all scenes, scripts, and Godot project files).
Keep everything running
No action needed. Choose this if you plan to keep building on your battle game or want to reference the code later.
- Your BattleSystem folder stays on your Desktop.
- Godot 4.7 remains installed on your machine for future projects.
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 and consume no memory or CPU when Godot is closed.
- Close the Godot editor if it is still open.
- Your project files remain on your Desktop, ready to reopen whenever you return.
Delete - I don't want to use this again
Remove all project files from your machine. This cannot be undone.
- Close Godot if it is still open.
- Delete the BattleSystem project folder by running the command for your operating system below.
macOS
rm -rf ~/Desktop/BattleSystem
Windows
rmdir /s /q %USERPROFILE%\Desktop\BattleSystem
Well Done!
Well Done!
Nice work! You just transformed a functional battle prototype into a visually polished, Pokemon-inspired game UI using Godot's programmatic theming system.
You've learned how to:
- Built a battle_theme.gd Autoload that centralizes type colors and StyleBoxFlat helper functions for reusable, consistent UI styling across your entire game.
- Styled creature info panels with dark rounded backgrounds, type-colored borders, and color-coded HP bars that shift from green to yellow to red based on remaining health.
- Created type-colored move buttons in a 2x2 grid with Tween-based hover animations for smooth visual feedback, plus a dark battle background with creature fade-in effects.
- Secret Mission: Extended the theme to your character select and move loadout screens with type-colored cards and hover animations for a cohesive game-wide look.
Ready to quiz yourself?