Build a Party Battle System with Doubles
Add party switching and a Doubles mode with spread moves to a turn-based battle game.
Introduction
30 Second Summary
Think about any strategy game where you command a whole team instead of a single fighter. The moment you can swap in a fresh creature or slam both opponents at once, every turn becomes a real decision.
In this project, you will build a two-mode battle system in Godot where players choose Singles or Doubles, watch a coin flip decide who goes first, then fight with a full party of creatures that can be switched in and out mid-battle.
What You'll Build
You run the game, pick your team, and watch a coin rapidly flicker before landing on a result. Then you dive into a full party battle where every voluntary swap and spread move targeting choice feels like a real tactical call.
By the end of this project, you'll have:
- A mode select screen where you pick Singles or Doubles, then watch an animated coin flip that slows to a dramatic stop and decides who attacks first.
- A Singles party battle where you command a team of 3 creatures, voluntarily swap mid-fight at the cost of your turn, and get forced to send in the next creature when one faints.
- A Doubles party battle where two creatures fight per side, single-target moves require picking which enemy to hit, and spread moves automatically slam both opponents at 75% power each.
- Secret Mission: Add stat-boosting setup moves that raise Attack or Sp. Attack for the rest of the battle.
Are there any prerequisites?
This project builds on the "Build a Creature Roster and Selection System" project. You need a working BattleSystem Godot project containing creature.gd, move.gd, type_chart.gd, battle_manager.gd, game_data.gd, and the character_select.tscn and battle.tscn scenes.
Before We Start
Before diving in, let's lock in what you're about to build and why it matters.
Add Target Field and Extend Game Data
Your battle system currently treats every move the same way. A fire punch and a sweeping earthquake both hit a single target because there is no data telling the system otherwise.
Without a target field on each move, the system cannot distinguish a single-target attack from a spread move that hits multiple creatures. This step adds that distinction and prepares the data structures for full party battles.
In this step, get ready to:
- Add a target field to the Move resource script.
- Update all 100 moves in the JSON data with their target type.
- Extend GameData with party arrays, battle mode tracking, and helper functions.
Add the target field to move.gd
Every move needs a target property that tells the battle system who it can hit. The three values are "Single" (one opponent), "Spread" (all opponents at reduced power), and "All" (all creatures on the field).
- Open scripts/move.gd in the Script Editor and replace its contents with the following:
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
@export var category: String = "Physical"
@export var target: String = "Single"
What does this change?
The new target property defaults to "Single" so existing moves work without modification. Only spread and area moves need an explicit override in the JSON data.
- Save move.gd.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before continuing.
ⓧ 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
@export var category: String = "Physical"
@export var target: String = "Single"
Update moves.json with target data
Now that the script can store a target value, the JSON data needs to supply one for every move. Most moves are "Single". Fourteen powerful area moves are tagged "Spread" or "All".
- Open data/moves.json and replace the entire file contents with the following blocks pasted in order. Start with the first group:
[
{"name": "Flame Burst", "type": "FLAME", "power": 50, "accuracy": 0.95, "category": "Special", "target": "Single"},
{"name": "Fire Fang", "type": "FLAME", "power": 55, "accuracy": 0.9, "category": "Physical", "target": "Single"},
{"name": "Inferno Slam", "type": "FLAME", "power": 80, "accuracy": 0.8, "category": "Physical", "target": "Single"},
{"name": "Heat Wave", "type": "FLAME", "power": 70, "accuracy": 0.9, "category": "Special", "target": "Spread"},
{"name": "Ember Claw", "type": "FLAME", "power": 40, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Scorch Beam", "type": "FLAME", "power": 65, "accuracy": 0.95, "category": "Special", "target": "Single"},
{"name": "Wildfire", "type": "FLAME", "power": 90, "accuracy": 0.75, "category": "Special", "target": "Spread"},
{"name": "Flame Tackle", "type": "FLAME", "power": 45, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Magma Pulse", "type": "FLAME", "power": 75, "accuracy": 0.85, "category": "Special", "target": "Single"},
{"name": "Burning Rush", "type": "FLAME", "power": 60, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Tidal Wave", "type": "TIDE", "power": 70, "accuracy": 0.9, "category": "Special", "target": "Spread"},
{"name": "Aqua Fang", "type": "TIDE", "power": 55, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Hydro Cannon", "type": "TIDE", "power": 90, "accuracy": 0.75, "category": "Special", "target": "Single"},
{"name": "Water Slash", "type": "TIDE", "power": 50, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Torrent Blast", "type": "TIDE", "power": 80, "accuracy": 0.85, "category": "Special", "target": "Single"},
{"name": "Splash Strike", "type": "TIDE", "power": 40, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Riptide", "type": "TIDE", "power": 65, "accuracy": 0.9, "category": "Special", "target": "Single"},
{"name": "Depth Charge", "type": "TIDE", "power": 75, "accuracy": 0.85, "category": "Physical", "target": "Single"},
{"name": "Bubble Beam", "type": "TIDE", "power": 45, "accuracy": 1.0, "category": "Special", "target": "Single"},
{"name": "Tsunami Crush", "type": "TIDE", "power": 85, "accuracy": 0.8, "category": "Physical", "target": "Spread"},
{"name": "Vine Whip", "type": "FLORA", "power": 40, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Solar Beam", "type": "FLORA", "power": 90, "accuracy": 0.8, "category": "Special", "target": "Single"},
{"name": "Leaf Blade", "type": "FLORA", "power": 70, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Petal Storm", "type": "FLORA", "power": 75, "accuracy": 0.85, "category": "Special", "target": "Spread"},
{"name": "Root Slam", "type": "FLORA", "power": 60, "accuracy": 0.9, "category": "Physical", "target": "Single"},
- Continue pasting directly after the last line above:
{"name": "Spore Burst", "type": "FLORA", "power": 50, "accuracy": 1.0, "category": "Special", "target": "Single"},
{"name": "Thorn Lash", "type": "FLORA", "power": 55, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Bloom Pulse", "type": "FLORA", "power": 65, "accuracy": 0.9, "category": "Special", "target": "Single"},
{"name": "Nature Crush", "type": "FLORA", "power": 80, "accuracy": 0.8, "category": "Physical", "target": "Single"},
{"name": "Seed Volley", "type": "FLORA", "power": 45, "accuracy": 1.0, "category": "Special", "target": "Single"},
{"name": "Gust Slash", "type": "SKY", "power": 45, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Air Cutter", "type": "SKY", "power": 55, "accuracy": 0.95, "category": "Special", "target": "Single"},
{"name": "Dive Bomb", "type": "SKY", "power": 80, "accuracy": 0.85, "category": "Physical", "target": "Single"},
{"name": "Cyclone", "type": "SKY", "power": 70, "accuracy": 0.9, "category": "Special", "target": "Single"},
{"name": "Wing Strike", "type": "SKY", "power": 60, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Tornado Blast", "type": "SKY", "power": 85, "accuracy": 0.8, "category": "Special", "target": "Spread"},
{"name": "Feather Edge", "type": "SKY", "power": 50, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Sky Burst", "type": "SKY", "power": 75, "accuracy": 0.85, "category": "Special", "target": "Single"},
{"name": "Aerial Ace", "type": "SKY", "power": 65, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Tempest Fury", "type": "SKY", "power": 90, "accuracy": 0.75, "category": "Special", "target": "Spread"},
{"name": "Thunder Bolt", "type": "VOLT", "power": 70, "accuracy": 0.9, "category": "Special", "target": "Single"},
{"name": "Spark Punch", "type": "VOLT", "power": 55, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Lightning Strike", "type": "VOLT", "power": 90, "accuracy": 0.75, "category": "Special", "target": "Single"},
{"name": "Volt Tackle", "type": "VOLT", "power": 80, "accuracy": 0.85, "category": "Physical", "target": "Single"},
{"name": "Static Pulse", "type": "VOLT", "power": 50, "accuracy": 1.0, "category": "Special", "target": "Single"},
{"name": "Shock Fang", "type": "VOLT", "power": 60, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Discharge", "type": "VOLT", "power": 65, "accuracy": 0.9, "category": "Special", "target": "All"},
{"name": "Thunder Claw", "type": "VOLT", "power": 75, "accuracy": 0.85, "category": "Physical", "target": "Single"},
{"name": "Zap Beam", "type": "VOLT", "power": 45, "accuracy": 1.0, "category": "Special", "target": "Single"},
{"name": "Overcharge", "type": "VOLT", "power": 85, "accuracy": 0.8, "category": "Physical", "target": "Single"},
- Continue pasting the next group directly below:
{"name": "Earthquake", "type": "EARTH", "power": 80, "accuracy": 0.9, "category": "Physical", "target": "All"},
{"name": "Rock Throw", "type": "EARTH", "power": 45, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Mud Blast", "type": "EARTH", "power": 55, "accuracy": 0.95, "category": "Special", "target": "Single"},
{"name": "Landslide", "type": "EARTH", "power": 90, "accuracy": 0.75, "category": "Physical", "target": "Spread"},
{"name": "Sand Tomb", "type": "EARTH", "power": 50, "accuracy": 1.0, "category": "Special", "target": "Single"},
{"name": "Boulder Crush", "type": "EARTH", "power": 75, "accuracy": 0.85, "category": "Physical", "target": "Single"},
{"name": "Fissure Slam", "type": "EARTH", "power": 70, "accuracy": 0.9, "category": "Physical", "target": "Single"},
{"name": "Dust Storm", "type": "EARTH", "power": 60, "accuracy": 0.9, "category": "Special", "target": "Spread"},
{"name": "Tremor Punch", "type": "EARTH", "power": 65, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Terra Beam", "type": "EARTH", "power": 85, "accuracy": 0.8, "category": "Special", "target": "Single"},
{"name": "Iron Slam", "type": "IRON", "power": 70, "accuracy": 0.9, "category": "Physical", "target": "Single"},
{"name": "Metal Burst", "type": "IRON", "power": 60, "accuracy": 0.95, "category": "Special", "target": "Single"},
{"name": "Steel Fang", "type": "IRON", "power": 55, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Chrome Beam", "type": "IRON", "power": 80, "accuracy": 0.85, "category": "Special", "target": "Single"},
{"name": "Anvil Drop", "type": "IRON", "power": 85, "accuracy": 0.8, "category": "Physical", "target": "Single"},
{"name": "Alloy Edge", "type": "IRON", "power": 65, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Magnetic Pulse", "type": "IRON", "power": 50, "accuracy": 1.0, "category": "Special", "target": "Single"},
{"name": "Forge Strike", "type": "IRON", "power": 75, "accuracy": 0.9, "category": "Physical", "target": "Single"},
{"name": "Titanium Rush", "type": "IRON", "power": 90, "accuracy": 0.75, "category": "Physical", "target": "Single"},
{"name": "Shrapnel Shot", "type": "IRON", "power": 45, "accuracy": 1.0, "category": "Special", "target": "Spread"},
{"name": "Mind Blast", "type": "PSYCHE", "power": 70, "accuracy": 0.9, "category": "Special", "target": "Single"},
{"name": "Psycho Cut", "type": "PSYCHE", "power": 60, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Brain Wave", "type": "PSYCHE", "power": 80, "accuracy": 0.85, "category": "Special", "target": "Spread"},
{"name": "Telekinetic Slam", "type": "PSYCHE", "power": 75, "accuracy": 0.9, "category": "Physical", "target": "Single"},
{"name": "Mind Pulse", "type": "PSYCHE", "power": 45, "accuracy": 1.0, "category": "Special", "target": "Single"},
- Paste the final group and close the array:
{"name": "Dream Eater", "type": "PSYCHE", "power": 85, "accuracy": 0.8, "category": "Special", "target": "Single"},
{"name": "Psi Blade", "type": "PSYCHE", "power": 55, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Hypno Ray", "type": "PSYCHE", "power": 50, "accuracy": 1.0, "category": "Special", "target": "Single"},
{"name": "Neural Strike", "type": "PSYCHE", "power": 90, "accuracy": 0.75, "category": "Physical", "target": "Single"},
{"name": "Thought Crush", "type": "PSYCHE", "power": 65, "accuracy": 0.9, "category": "Special", "target": "Single"},
{"name": "Power Punch", "type": "BRAWN", "power": 60, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Mega Kick", "type": "BRAWN", "power": 80, "accuracy": 0.85, "category": "Physical", "target": "Single"},
{"name": "Body Slam", "type": "BRAWN", "power": 70, "accuracy": 0.9, "category": "Physical", "target": "Single"},
{"name": "Focus Blast", "type": "BRAWN", "power": 90, "accuracy": 0.75, "category": "Special", "target": "Single"},
{"name": "Quick Jab", "type": "BRAWN", "power": 40, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Hammer Arm", "type": "BRAWN", "power": 85, "accuracy": 0.8, "category": "Physical", "target": "Single"},
{"name": "Combat Surge", "type": "BRAWN", "power": 55, "accuracy": 1.0, "category": "Special", "target": "Single"},
{"name": "Suplex", "type": "BRAWN", "power": 75, "accuracy": 0.85, "category": "Physical", "target": "Single"},
{"name": "Aura Fist", "type": "BRAWN", "power": 65, "accuracy": 0.9, "category": "Special", "target": "Single"},
{"name": "Iron Will", "type": "BRAWN", "power": 50, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Shadow Claw", "type": "SHADOW", "power": 60, "accuracy": 0.95, "category": "Physical", "target": "Single"},
{"name": "Dark Pulse", "type": "SHADOW", "power": 70, "accuracy": 0.9, "category": "Special", "target": "Spread"},
{"name": "Nightmare", "type": "SHADOW", "power": 80, "accuracy": 0.85, "category": "Special", "target": "Single"},
{"name": "Phantom Strike", "type": "SHADOW", "power": 75, "accuracy": 0.9, "category": "Physical", "target": "Single"},
{"name": "Void Beam", "type": "SHADOW", "power": 85, "accuracy": 0.8, "category": "Special", "target": "Single"},
{"name": "Dusk Fang", "type": "SHADOW", "power": 55, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Shadow Sneak", "type": "SHADOW", "power": 40, "accuracy": 1.0, "category": "Physical", "target": "Single"},
{"name": "Curse Wave", "type": "SHADOW", "power": 65, "accuracy": 0.9, "category": "Special", "target": "Single"},
{"name": "Umbral Slash", "type": "SHADOW", "power": 90, "accuracy": 0.75, "category": "Physical", "target": "Single"},
{"name": "Venom Strike", "type": "SHADOW", "power": 50, "accuracy": 1.0, "category": "Special", "target": "Single"}
]
What do the target values mean?
- "Single" hits one chosen opponent. Most moves use this.
- "Spread" hits all opponents simultaneously but at reduced power (75% in Doubles). Moves like Heat Wave and Tidal Wave use this.
- "All" also hits all opponents at reduced power. Earthquake and Discharge use this. In this project, All behaves the same as Spread.
- Save moves.json.
Before you run the project, what do you expect to see in the Output panel? Will the move count change, or should it stay the same?
- Run the project by pressing F5 (or click the Play button in the top-right toolbar).
The Output panel should still print Loaded 30 creatures and 100 moves. The count is the same because you updated existing entries rather than adding new ones. If you see 0 moves or an error, there is a JSON syntax issue.
Seeing 0 moves or a parse error?
- Check for a missing comma between entries. Every line except the very last entry must end with a comma.
- Make sure the file starts with [ and ends with ] on their own lines.
- Verify every target value is wrapped in double quotes: "Single", "Spread", or "All".
Help me debug a JSON parse error in my moves.json file.
✔️ Awesome, I've got everything!
Great. Confirm your Output panel shows Loaded 30 creatures and 100 moves with no errors before continuing.
ⓧ I'd like to double check the full code
The file has 100 entries total. Scroll through and confirm you have all four groups in order: FLAME and TIDE entries at the top, then FLORA and SKY and VOLT entries, then EARTH and IRON and first PSYCHE entries, then remaining PSYCHE and BRAWN and SHADOW entries. Every entry has a "target" field as its last key-value pair.
Extend game_data.gd for party battles
The existing GameData autoload only tracks a flat list of creatures and moves. For party battles, it needs to know the battle mode, store each player's selected party, and hold the moves assigned to every creature.
- In scripts/game_data.gd, find the existing variable declarations at the top of the file. Locate this line:
var moves: Array[Move] = []
- Add these five new variables directly below it:
# Battle configuration and party tracking
var battle_mode: String = "Singles"
var player_party: Array[Creature] = []
var enemy_party: Array[Creature] = []
var party_moves: Dictionary = {}
var coin_flip_winner: String = "player"
What do these variables store?
- battle_mode tracks whether the current game is Singles (3v3) or Doubles (2v2 with 4 creatures per side).
- player_party and enemy_party hold the full roster of creatures each side brings to battle.
- party_moves maps each Creature to its assigned array of 4 moves.
- coin_flip_winner records who gets the first turn after the coin flip animation.
- Next, scroll down to the _load_moves function. Find this line inside the for entry in data loop:
move.category = entry["category"]
- Add this line directly below it to read the target field from the JSON:
move.target = entry.get("target", "Single")
Why use entry.get instead of entry["target"]?
The .get() method returns a default value ("Single") if the key is missing. This provides a safety net in case any entry accidentally lacks the target field.
- Finally, add these two helper functions at the very bottom of game_data.gd, after the closing of _load_moves:
func get_random_creatures(count: int) -> Array[Creature]:
var shuffled: Array = creatures.duplicate()
shuffled.shuffle()
var result: Array[Creature] = []
for i in range(min(count, shuffled.size())):
result.append(shuffled[i] as Creature)
return result
func get_random_moves(count: int) -> Array[Move]:
var shuffled: Array = moves.duplicate()
shuffled.shuffle()
var result: Array[Move] = []
for i in range(min(count, shuffled.size())):
result.append(shuffled[i] as Move)
return result
What do these helper functions do?
Both functions shuffle a copy of the master list and return the requested number of items. get_random_creatures selects random creatures for the enemy team. get_random_moves assigns random movesets when the player picks their party.
- Save game_data.gd.
Think about what you expect to see in the Output panel. Did you change the print statement or just add new variables and functions?
- Run the project again with F5.
The Output panel should still show Loaded 30 creatures and 100 moves with no errors. The new variables and functions exist but nothing calls them yet.
Seeing an error about unknown identifiers?
- If you see an error mentioning Creature or Move as unknown types, confirm that creature.gd still has class_name Creature and move.gd has class_name Move at the top.
- If the error mentions target, make sure you saved move.gd with the new @export var target line before running.
- Check that game_data.gd still starts with extends Node and is set as an autoload in Project Settings.
Help me fix an error in my game_data.gd after adding party variables.
✔️ Awesome, I've got everything!
Great. Your data layer is now ready for party battles.
ⓧ I'd like to double check the full code
Scroll through game_data.gd and confirm the following pieces are in place, top to bottom:
- extends Node at the top, followed by var creatures, var moves, then the five new variables (battle_mode, player_party, enemy_party, party_moves, coin_flip_winner).
- The _ready function calling _load_moves() then _load_creatures(), followed by the print line.
- The _load_moves function with move.target = entry.get("target", "Single") after the category line.
- The get_random_creatures and get_random_moves functions at the bottom.
Your data layer now knows which moves hit one creature and which hit many. Next up, you will build the mode select screen where players choose Singles or Doubles and watch a coin flip decide who attacks first.
Build the Mode Select Screen and Coin Flip
Your data layer is ready, but the game still jumps straight to the character select screen with no way to pick a battle format.
In this step, you will build a mode selection screen where the player chooses Singles or Doubles, then watch a coin flip animation that dramatically decides who attacks first.
In this step, get ready to:
- Create the mode select scene with buttons and a coin flip display area.
- Write the coin flip animation script using Tweens with slowing intervals.
- Set mode_select.tscn as the project's main scene.
Create the mode select scene
The mode select screen needs two buttons for picking Singles or Doubles, plus labels and a button that stay hidden until the coin flip runs.
- In Godot, click Scene in the top menu bar.
- Click New Scene.
- In the Scene dock, click Other Node.
- Search for Control and select it, then click Create.
- With the Control root node selected, press Ctrl+A (Windows) or Cmd+A (macOS) to add a child node.
- Search for VBoxContainer and add it as a child of the root Control node.
- Select the VBoxContainer node and press Ctrl+A (Windows) or Cmd+A (macOS) to add a child Button node.
- Rename this button to SinglesButton by double-clicking its name in the Scene dock.
- With VBoxContainer still selected, add another Button child and rename it to DoublesButton.
- Add a Label child and rename it to CoinLabel.
- Add another Label child and rename it to ResultLabel.
- Add a final Button child and rename it to ContinueButton.
- Select SinglesButton and in the Inspector on the right, set its Text property to Singles.
- Select DoublesButton and set its Text to Doubles.
- Select ContinueButton and set its Text to Continue.
- Save the scene by pressing Ctrl+S (Windows) or Cmd+S (macOS).
- In the save dialog, navigate to the scenes/ folder and save it as mode_select.tscn.
Your Scene dock should now show a tree: Control > VBoxContainer > SinglesButton, DoublesButton, CoinLabel, ResultLabel, ContinueButton.
Don't see all 5 children?
Make sure you selected the VBoxContainer (not the root Control) before adding each child. If a node ended up in the wrong place, drag it in the Scene dock so it sits under VBoxContainer.
To rename a node, double-click its name in the Scene dock.
Help me fix my mode_select scene tree
Write the mode select script
Now you need a script that hides the coin flip UI on load, then reveals it when the player picks a mode. The coin flip uses a Tween with increasing delay intervals between each toggle, creating a convincing slowing-roulette effect without any sprite art.
- Select the root Control node in the Scene dock.
- Click the Attach Script button (the scroll icon) at the top of the Scene dock.
- Set the path to res://scripts/mode_select.gd and click Create.
- Replace the default script content with the following code:
extends Control
@onready var singles_button: Button = $VBoxContainer/SinglesButton
@onready var doubles_button: Button = $VBoxContainer/DoublesButton
@onready var coin_label: Label = $VBoxContainer/CoinLabel
@onready var result_label: Label = $VBoxContainer/ResultLabel
@onready var continue_button: Button = $VBoxContainer/ContinueButton
var flip_count: int = 0
var flip_result: String = ""
func _ready() -> void:
# Hide coin flip elements until a mode is chosen
coin_label.hide()
result_label.hide()
continue_button.hide()
singles_button.pressed.connect(_on_mode_selected.bind("Singles"))
doubles_button.pressed.connect(_on_mode_selected.bind("Doubles"))
continue_button.pressed.connect(_on_continue_pressed)
func _on_mode_selected(mode: String) -> void:
# Store mode choice and switch to coin flip display
GameData.battle_mode = mode
singles_button.hide()
doubles_button.hide()
coin_label.show()
coin_label.text = "Heads"
_start_coin_flip()
What does this code do?
- The @onready variables grab references to each node using their path under the VBoxContainer.
- In _ready(), the coin flip labels are hidden so only the mode buttons appear at first.
- Each button's pressed signal is connected to _on_mode_selected with the mode name bound as an argument.
- When the player clicks a mode button, the script stores the choice in GameData.battle_mode, hides the buttons, reveals the coin label, and calls _start_coin_flip().
- Add the following functions below _on_mode_selected in mode_select.gd:
func _start_coin_flip() -> void:
# Create tween with slowing intervals between toggles
var tween: Tween = create_tween()
var delays: Array[float] = [0.08, 0.08, 0.08, 0.1, 0.1, 0.12, 0.15, 0.18, 0.22, 0.28, 0.35, 0.45, 0.6]
for i in range(delays.size()):
tween.tween_callback(_toggle_coin)
tween.tween_interval(delays[i])
tween.tween_callback(_finish_flip)
func _toggle_coin() -> void:
if coin_label.text == "Heads":
coin_label.text = "Tails"
else:
coin_label.text = "Heads"
func _finish_flip() -> void:
var result: int = randi_range(0, 1)
if result == 0:
coin_label.text = "Heads"
GameData.coin_flip_winner = "player"
result_label.text = "Heads! Player goes first!"
else:
coin_label.text = "Tails"
GameData.coin_flip_winner = "enemy"
result_label.text = "Tails! Enemy goes first!"
result_label.show()
continue_button.show()
func _on_continue_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/character_select.tscn")
How does the coin flip animation work?
The delays array defines 13 intervals that grow from 0.08 seconds up to 0.6 seconds. Each interval triggers _toggle_coin which flips the label between "Heads" and "Tails".
Because the delays get longer, the flipping starts fast and slows down naturally. After the last toggle, _finish_flip uses randi_range(0, 1) to pick 0 or 1 at random, sets the winner in GameData, and shows the result.
- Save mode_select.gd by pressing Ctrl+S (Windows) or Cmd+S (macOS).
Seeing a parse error in the script editor?
Check that the @onready paths match the exact node names in your Scene dock. The path $VBoxContainer/SinglesButton only works if the VBoxContainer is a direct child of the root and the Button is named exactly SinglesButton.
Help me fix a path error in my mode_select.gd script
✔️ Awesome, I've got everything!
Great. Make sure you saved both mode_select.tscn and mode_select.gd.
ⓧ I'd like to double check the full code
Your mode_select.gd file should contain these sections in order. Scroll up and confirm each is present:
- The extends Control line and five @onready variable declarations.
- Two tracking variables: flip_count and flip_result.
- The _ready() function that hides labels and connects signals.
- The _on_mode_selected() function that stores mode and starts the flip.
- The _start_coin_flip() function with the tween and delays array.
- The _toggle_coin() function that swaps between Heads and Tails.
- The _finish_flip() function that uses randi_range(0, 1) and shows the result.
- The _on_continue_pressed() function that transitions to character_select.tscn.
Set mode_select.tscn as the main scene and test
Right now the project still launches into character_select.tscn. You need to point Godot at your new mode select scene instead.
- In the FileSystem dock at the bottom left, navigate to scenes/mode_select.tscn.
- Right-click mode_select.tscn and select Set As Main Scene.
What does setting the main scene do?
The main scene is the first scene Godot loads when you press Play. By changing it from character_select.tscn to mode_select.tscn, the game now starts at mode selection and the coin flip happens before anything else.
Before you run the project, what do you expect to see when you click one of the mode buttons?
- Press F5 (or click the Play button in the top right) to run the project.
- Click Singles or Doubles.
You should see the coin label flicker rapidly between "Heads" and "Tails" for about 2 seconds, slowing down before stopping on a final result. A message appears saying "Heads! Player goes first!" or "Tails! Enemy goes first!" and the Continue button appears.
- Click Continue to verify it transitions to the character select screen.
Coin label not flickering?
Make sure the CoinLabel node in your scene is a Label, not a Button. Also confirm your script is attached to the root Control node (you should see the script icon next to it in the Scene dock).
If nothing happens when you click Singles or Doubles, check the Output panel for errors about missing nodes or signal connections.
Help me debug my coin flip animation not working
Your mode select screen is alive. Players can now pick their battle format and watch a dramatic coin flip decide the first turn. Next up, you will build the party selection system so players can pick multiple creatures for their team.
Rebuild Party Selection
Your mode select and coin flip are working. The player picks Singles or Doubles, watches the coin land, and arrives at the character select screen.
The problem is that the old character select only picks one creature and sends the player to a move selection screen. A party battle needs 3 creatures (Singles) or 4 (Doubles), each with their own moveset, before the fight can begin.
In this step, get ready to:
- Rewrite the character select to support multi-creature party picks.
- Auto-assign moves and set up the enemy party when the player clicks Fight.
- Update the preview panel to show the current party count.
Update the scene tree for party selection
The new script expects a few nodes that the old scene does not have. You need to add a CounterLabel and a FightButton to the scene.
- Open character_select.tscn by double-clicking it in the FileSystem dock.
- In the Scene dock, confirm you have this structure under the root Control node: a VBoxContainer containing a ScrollContainer (with a GridContainer inside it), a PreviewPanel (PanelContainer with a VBoxContainer holding NameLabel, TypeLabel, StatsLabel, and a ColorRect), a CounterLabel, and a FightButton.
- If CounterLabel does not exist yet, select VBoxContainer in the Scene dock and click the Add Child Node button (the + icon at the top of the dock).
- Search for Label in the Create Node dialog and click Create.
- Rename the new node to CounterLabel by double-clicking its name in the Scene dock.
- Repeat the process to add a Button node as a child of VBoxContainer and rename it to FightButton.
- Save the scene with Cmd+S (macOS) or Ctrl+S (Windows).
You should now see CounterLabel and FightButton listed under the VBoxContainer in the Scene dock.
Nodes not showing in the tree?
Make sure you selected VBoxContainer (not the root Control) before adding child nodes. If they ended up in the wrong place, drag them under VBoxContainer in the Scene dock.
help me fix my character_select scene tree layout
Rewrite the selection script
The old script picked one creature and transitioned to move_select.tscn. You are replacing it entirely with a multi-pick system that reads the battle mode from GameData.
- Open scripts/character_select.gd and select all the existing code.
- Delete everything and replace it with this declarations section:
extends Control
@onready var grid: GridContainer = $VBoxContainer/ScrollContainer/GridContainer
@onready var preview_name: Label = $VBoxContainer/PreviewPanel/VBoxContainer/NameLabel
@onready var preview_type: Label = $VBoxContainer/PreviewPanel/VBoxContainer/TypeLabel
@onready var preview_stats: Label = $VBoxContainer/PreviewPanel/VBoxContainer/StatsLabel
@onready var preview_color: ColorRect = $VBoxContainer/PreviewPanel/VBoxContainer/ColorRect
@onready var counter_label: Label = $VBoxContainer/CounterLabel
@onready var fight_button: Button = $VBoxContainer/FightButton
var selected_creatures: Array[Creature] = []
var creature_buttons: Dictionary = {}
var max_picks: int = 3
What do these declarations do?
- The @onready variables grab references to the scene nodes you set up in the previous substep.
- selected_creatures is the array that tracks which creatures the player has picked for their party.
- creature_buttons maps each Creature to its Button so you can change the button color on selection.
- max_picks defaults to 3 but gets changed to 4 for Doubles in _ready().
- Directly below the declarations, add the _ready function that sets party size and builds the creature grid:
# Set party size based on mode and build the creature button grid
func _ready() -> void:
if GameData.battle_mode == "Doubles":
max_picks = 4
else:
max_picks = 3
fight_button.hide()
fight_button.pressed.connect(_on_fight_pressed)
_update_counter()
for creature in GameData.creatures:
var button: Button = Button.new()
button.text = "%s\n[%s]" % [creature.creature_name, creature.type]
button.custom_minimum_size = Vector2(120, 60)
button.mouse_entered.connect(_on_creature_hovered.bind(creature))
button.pressed.connect(_on_creature_pressed.bind(creature))
grid.add_child(button)
creature_buttons[creature] = button
if GameData.creatures.size() > 0:
_update_preview(GameData.creatures[0])
What does _ready do?
- Reads GameData.battle_mode to decide whether the player picks 3 (Singles) or 4 (Doubles) creatures.
- The for loop dynamically creates a button for every creature in the roster, wiring up hover and press signals.
- fight_button starts hidden. It only appears once the player selects the right number of creatures.
- Add the selection handler and helper functions below _ready:
# Handle creature hover and selection toggle
func _on_creature_hovered(creature: Creature) -> void:
_update_preview(creature)
func _on_creature_pressed(creature: Creature) -> void:
if creature in selected_creatures:
selected_creatures.erase(creature)
creature_buttons[creature].modulate = Color.WHITE
elif selected_creatures.size() < max_picks:
selected_creatures.append(creature)
creature_buttons[creature].modulate = Color.GREEN
_update_counter()
# Update counter label and preview panel display
func _update_counter() -> void:
counter_label.text = "Party: %d/%d selected" % [selected_creatures.size(), max_picks]
if selected_creatures.size() == max_picks:
fight_button.show()
else:
fight_button.hide()
func _update_preview(creature: Creature) -> void:
preview_name.text = creature.creature_name
preview_type.text = "Type: %s" % creature.type
preview_stats.text = "HP:%d ATK:%d DEF:%d SPA:%d SPD:%d SPE:%d" % [
creature.max_hp, creature.attack, creature.defense,
creature.sp_attack, creature.sp_defense, creature.speed
]
preview_color.color = creature.sprite_color
What does the toggle logic do?
Clicking a creature adds it to the party and turns the button green. Clicking the same creature again removes it and resets the button color to white. This gives the player full control over their picks before committing.
_update_counter refreshes the label text and shows or hides the Fight button depending on whether enough creatures are picked. _update_preview fills the stat panel whenever the player hovers over a creature button.
- Add the fight function at the very bottom of the file:
# Assign moves, generate enemies, and transition to battle
func _on_fight_pressed() -> void:
GameData.player_party = selected_creatures.duplicate()
GameData.party_moves = {}
for creature in GameData.player_party:
GameData.party_moves[creature] = GameData.get_random_moves(4)
var enemy_count: int = max_picks
GameData.enemy_party = GameData.get_random_creatures(enemy_count)
for creature in GameData.enemy_party:
GameData.party_moves[creature] = GameData.get_random_moves(4)
get_tree().change_scene_to_file("res://scenes/battle.tscn")
What does _on_fight_pressed do?
- Copies the player's selected creatures into GameData.player_party.
- Assigns 4 random moves to each player creature using GameData.get_random_moves(4).
- Picks random enemy creatures (same count as the player party) and assigns them random moves too.
- Transitions to battle.tscn. The old move_select.tscn transition is gone completely.
Why auto-assign moves instead of letting the player pick?
With 3 or 4 creatures each needing 4 moves, that is 12 to 16 manual selections before the fight even starts. Auto-assigning keeps the pre-battle flow fast and lets you focus on building the battle mechanics themselves.
- Save character_select.gd with Cmd+S (macOS) or Ctrl+S (Windows).
Test the party selection flow
Before you run this, think about what will happen when you click Fight. The scene transitions to battle.tscn. Do you think the battle will load successfully, or will something go wrong?
- Press F5 to run the project from the mode select screen.
- Click Singles and wait for the coin flip to finish.
- Click Continue to reach the character select.
- Click on 3 different creature buttons.
Each button you click turns green. The counter label updates to show your progress (e.g. "Party: 2/3 selected"). After the third pick, the Fight button appears.
- Click one of the green buttons again to deselect it.
- Confirm it turns white and the counter drops back down.
- Re-select a creature so you have 3 picks, then click Fight.
The game crashes with an error in the Output panel. That is expected. The battle manager still reads the old single-creature data that no longer exists.
Why does the crash prove things work?
The crash only happens AFTER the scene transition fires. That means your selection logic, party data storage, move assignment, and enemy generation all completed successfully. The failure is in battle_manager.gd trying to read the old selected_creature variable that no longer exists.
Scene not loading at all?
If you get a "Node not found" error before clicking any creatures, a node in your scene tree does not match the path in the script. Double-check that CounterLabel and FightButton are direct children of VBoxContainer. Also confirm the GridContainer is inside a ScrollContainer inside VBoxContainer.
help me debug my character_select.tscn node paths
✔️ Awesome, I've got everything!
Great. Make sure you have saved both character_select.gd and character_select.tscn.
ⓧ I'd like to double check the full code
Your character_select.gd should contain these four chunks in order. Scroll up and confirm each one is present:
- Chunk 1: extends Control with all @onready vars and the three tracking variables (selected_creatures, creature_buttons, max_picks).
- Chunk 2: The full _ready() function that sets max_picks based on mode, hides the fight button, and builds buttons for every creature in a loop.
- Chunk 3: _on_creature_hovered(), _on_creature_pressed() (toggle with green/white modulate), _update_counter() (shows/hides Fight), and _update_preview() (fills the stat panel).
- Chunk 4: _on_fight_pressed() that assigns moves, generates enemies, and transitions to battle.tscn.
Make sure there is no leftover code from the old single-creature selection or any reference to move_select.tscn.
Your party selection is solid. The player can pick their team, deselect mistakes, and commit with Fight. Next up, you will rebuild the battle manager so it knows what to do with a full party.
Build the Singles Party Battle
At the end of the last step, clicking Fight crashed the game. That happened because the old battle manager expected a single creature, but your party selection now sends an entire team.
In this step, you will rebuild the battle manager from scratch to track a full party of creatures with independent HP values. The player can switch creatures voluntarily or be forced to swap when one faints.
In this step, get ready to:
- Rewrite the battle scene and script to display active creature panels with HP bars and a move grid.
- Implement voluntary switching (costs your turn) and forced switching (creature fainted).
- Add win/loss conditions that trigger when an entire party is knocked out.
Rebuild the battle scene
The old battle scene had nodes for a single creature per side. The new version needs panels for two creatures per side (for Doubles later), plus switch and target panels.
- Switch back to the Godot editor.
- Open scenes/battle.tscn from the FileSystem panel.
- Delete all existing child nodes under the root Control node (select them, right-click, Delete Node).
- Build the following node tree under the root Control node. Right-click the parent and select Add Child Node for each:
- PlayerPanel1 (PanelContainer) with a child VBoxContainer containing: NameLabel (Label), HPBar (ProgressBar), HPLabel (Label), SpriteRect (ColorRect).
- EnemyPanel1 (PanelContainer) with the same child structure as PlayerPanel1.
- PlayerPanel2 (PanelContainer) with the same child structure as PlayerPanel1.
- EnemyPanel2 (PanelContainer) with the same child structure as PlayerPanel1.
- ActionPanel (PanelContainer) with a child VBoxContainer containing: MoveGrid (GridContainer) and SwitchButton (Button, text "Switch").
- TargetPanel (PanelContainer) with a child VBoxContainer containing: TargetButton1 (Button) and TargetButton2 (Button).
- SwitchPanel (PanelContainer) with a child VBoxContainer containing: SwitchGrid (VBoxContainer).
- MessageLabel (Label) directly under the root.
- MessageTimer (Timer) directly under the root. Set its Wait Time to 1.5 in the Inspector and enable One Shot.
- PlayAgainButton (Button, text "Play Again") directly under the root.
- BackButton (Button, text "Back") directly under the root.
- Save the scene with Ctrl+S (Windows) or Cmd+S (macOS).
Why build panels for Doubles now?
PlayerPanel2 and EnemyPanel2 will be hidden in Singles mode. Building them now means the scene is already prepared for Step 5 when you add Doubles, so you won't need to rebuild the scene later.
Write the battle manager declarations and initialization
Now you will completely replace the old battle manager script. The new version uses a state machine enum to track whose turn it is, and parallel arrays to track each creature's current HP.
- Open scripts/battle_manager.gd and delete all existing code.
- Add the state enum and variable declarations by pasting this code:
extends Control
enum BattleState { PLAYER_TURN, ENEMY_TURN, ANIMATING, SWITCHING, FORCED_SWITCH, TARGET_SELECT, VICTORY, DEFEAT }
var player_party: Array[Creature] = []
var enemy_party: Array[Creature] = []
var player_hp: Array[int] = []
var enemy_hp: Array[int] = []
var party_moves: Dictionary = {}
var player_active: int = 0
var enemy_active: int = 0
var player_active_2: int = 1
var enemy_active_2: int = 1
var state: BattleState = BattleState.PLAYER_TURN
var is_doubles: bool = false
var type_chart: TypeChart = TypeChart.new()
var pending_move: Move = null
var turn_queue: Array[Dictionary] = []
var turn_queue_index: int = 0
What does this code do?
The BattleState enum defines every possible state the battle can be in. The system checks this before allowing actions.
The player_hp and enemy_hp arrays track remaining health for each creature independently, using the same index as the party arrays.
- Add the scene node references below the variables by pasting this code:
@onready var player_name_label: Label = $PlayerPanel1/VBoxContainer/NameLabel
@onready var player_hp_bar: ProgressBar = $PlayerPanel1/VBoxContainer/HPBar
@onready var player_hp_label: Label = $PlayerPanel1/VBoxContainer/HPLabel
@onready var player_sprite: ColorRect = $PlayerPanel1/VBoxContainer/SpriteRect
@onready var enemy_name_label: Label = $EnemyPanel1/VBoxContainer/NameLabel
@onready var enemy_hp_bar: ProgressBar = $EnemyPanel1/VBoxContainer/HPBar
@onready var enemy_hp_label: Label = $EnemyPanel1/VBoxContainer/HPLabel
@onready var enemy_sprite: ColorRect = $EnemyPanel1/VBoxContainer/SpriteRect
What are @onready references?
Each @onready variable grabs a node from the scene tree when the script loads. The path after $ must match your node hierarchy exactly.
- Add the remaining node references for the second panels and UI controls:
@onready var player_panel_2: PanelContainer = $PlayerPanel2
@onready var player_name_label_2: Label = $PlayerPanel2/VBoxContainer/NameLabel
@onready var player_hp_bar_2: ProgressBar = $PlayerPanel2/VBoxContainer/HPBar
@onready var player_hp_label_2: Label = $PlayerPanel2/VBoxContainer/HPLabel
@onready var player_sprite_2: ColorRect = $PlayerPanel2/VBoxContainer/SpriteRect
@onready var enemy_panel_2: PanelContainer = $EnemyPanel2
@onready var enemy_name_label_2: Label = $EnemyPanel2/VBoxContainer/NameLabel
@onready var enemy_hp_bar_2: ProgressBar = $EnemyPanel2/VBoxContainer/HPBar
@onready var enemy_hp_label_2: Label = $EnemyPanel2/VBoxContainer/HPLabel
@onready var enemy_sprite_2: ColorRect = $EnemyPanel2/VBoxContainer/SpriteRect
- Add the action panel, target panel, switch panel, and message references:
@onready var action_panel: PanelContainer = $ActionPanel
@onready var move_grid: GridContainer = $ActionPanel/VBoxContainer/MoveGrid
@onready var switch_button: Button = $ActionPanel/VBoxContainer/SwitchButton
@onready var target_panel: PanelContainer = $TargetPanel
@onready var target_button_1: Button = $TargetPanel/VBoxContainer/TargetButton1
@onready var target_button_2: Button = $TargetPanel/VBoxContainer/TargetButton2
@onready var switch_panel: PanelContainer = $SwitchPanel
@onready var switch_grid: VBoxContainer = $SwitchPanel/VBoxContainer/SwitchGrid
@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
What do these panels control?
The ActionPanel holds the move buttons and switch button shown on the player's turn. SwitchPanel lists bench creatures. TargetPanel is for Doubles target selection (hidden for now).
- Add the _ready() function that initializes the battle state:
func _ready() -> void:
is_doubles = GameData.battle_mode == "Doubles"
player_party = GameData.player_party
enemy_party = GameData.enemy_party
party_moves = GameData.party_moves
player_hp = []
enemy_hp = []
for creature in player_party:
player_hp.append(creature.max_hp)
for creature in enemy_party:
enemy_hp.append(creature.max_hp)
player_active = 0
enemy_active = 0
player_panel_2.hide()
enemy_panel_2.hide()
_update_panel_player()
_update_panel_enemy()
_setup_move_buttons()
message_label.hide()
play_again_button.hide()
back_button.hide()
target_panel.hide()
switch_panel.hide()
play_again_button.pressed.connect(_on_play_again_pressed)
back_button.pressed.connect(_on_back_pressed)
switch_button.pressed.connect(_on_switch_pressed)
What does _ready do here?
It pulls the party data from GameData, builds the HP arrays (each creature starts at full health), hides unused panels, and wires up button signals.
- Add the coin flip turn logic at the bottom of _ready() (still inside the function, after the switch_button.pressed.connect line):
if GameData.coin_flip_winner == "player":
state = BattleState.PLAYER_TURN
action_panel.show()
else:
state = BattleState.ENEMY_TURN
action_panel.hide()
_enemy_turn_singles()
- Add the panel update functions that refresh the displayed creature info:
func _update_panel_player() -> void:
var creature: Creature = player_party[player_active]
player_name_label.text = creature.creature_name
player_hp_bar.max_value = creature.max_hp
player_hp_bar.value = player_hp[player_active]
player_hp_label.text = "%d/%d" % [player_hp[player_active], creature.max_hp]
player_sprite.color = creature.sprite_color
func _update_panel_enemy() -> void:
var creature: Creature = enemy_party[enemy_active]
enemy_name_label.text = creature.creature_name
enemy_hp_bar.max_value = creature.max_hp
enemy_hp_bar.value = enemy_hp[enemy_active]
enemy_hp_label.text = "%d/%d" % [enemy_hp[enemy_active], creature.max_hp]
enemy_sprite.color = creature.sprite_color
What do the panel updaters do?
Each function reads the creature at the current active index, then sets the name label, HP bar value, HP text, and sprite color to match. Calling them after any switch or damage keeps the display in sync.
- Add the function that builds move buttons dynamically from the active creature's moveset:
func _setup_move_buttons() -> void:
for child in move_grid.get_children():
child.queue_free()
var active_creature: Creature = player_party[player_active]
var moves: Array = party_moves[active_creature]
for i in range(moves.size()):
var move: Move = moves[i]
var button: Button = Button.new()
var target_tag: String = ""
if move.target == "Spread":
target_tag = " [SPREAD]"
elif move.target == "All":
target_tag = " [ALL]"
button.text = "%s (%s)%s" % [move.move_name, move.type, target_tag]
button.pressed.connect(_on_move_selected.bind(i))
move_grid.add_child(button)
Why clear the grid first?
When the player switches creatures, the moves change. Calling queue_free() on old buttons removes them before adding the new creature's moves. The [SPREAD] and [ALL] tags help the player identify multi-target moves at a glance.
The script won't run yet because we haven't added the functions that buttons and signals reference. You will add those next.
Implement move selection and creature switching
When a player picks a move in Singles, that move fires immediately against the enemy. Switching costs your entire turn, so the enemy gets a free attack afterward.
- Add the move selection handler below _setup_move_buttons:
func _on_move_selected(move_index: int) -> void:
if state != BattleState.PLAYER_TURN:
return
var active_creature: Creature = player_party[player_active]
var move: Move = party_moves[active_creature][move_index]
state = BattleState.ANIMATING
action_panel.hide()
await _execute_move(move, player_party[player_active], enemy_party[enemy_active], true, enemy_active)
if await _check_faint_enemy():
return
state = BattleState.ENEMY_TURN
await _enemy_turn_singles()
What happens in a turn?
The state is set to ANIMATING to block input while damage resolves. After the player's move, _check_faint_enemy() checks if the enemy's creature was knocked out. If not, the enemy takes its turn.
- Add the switch button handler and the panel that lists available bench creatures:
func _on_switch_pressed() -> void:
if state != BattleState.PLAYER_TURN and state != BattleState.FORCED_SWITCH:
return
_show_switch_panel()
func _show_switch_panel() -> void:
action_panel.hide()
switch_panel.show()
for child in switch_grid.get_children():
child.queue_free()
for i in range(player_party.size()):
if i == player_active:
continue
if is_doubles and i == player_active_2:
continue
if player_hp[i] <= 0:
continue
var creature: Creature = player_party[i]
var button: Button = Button.new()
button.text = "%s (HP: %d/%d)" % [creature.creature_name, player_hp[i], creature.max_hp]
button.pressed.connect(_on_switch_target_selected.bind(i))
switch_grid.add_child(button)
Why skip fainted creatures?
The loop skips the currently active creature and any creature with 0 HP. This ensures the player can only switch to a healthy bench creature. Each button shows remaining HP so the player can make an informed choice.
- Add the function that executes the switch when the player picks a bench creature:
func _on_switch_target_selected(index: int) -> void:
switch_panel.hide()
var was_forced: bool = state == BattleState.FORCED_SWITCH
state = BattleState.ANIMATING
var old_name: String = player_party[player_active].creature_name
player_active = index
_update_panel_player()
_setup_move_buttons()
await _show_message("%s, go! Swapped in %s!" % [player_party[player_active].creature_name, player_party[player_active].creature_name])
if was_forced:
if _check_battle_over():
return
state = BattleState.PLAYER_TURN
action_panel.show()
else:
state = BattleState.ENEMY_TURN
await _enemy_turn_singles()
What is forced vs voluntary switching?
A voluntary switch (player pressed Switch on their turn) costs the turn. The enemy attacks right after. A forced switch happens when the active creature fainted. There is no penalty because the player already lost a creature.
- Add the enemy's turn logic for Singles. The AI picks the most effective move and attacks:
func _enemy_turn_singles() -> void:
await get_tree().create_timer(0.5).timeout
var enemy_creature: Creature = enemy_party[enemy_active]
var moves: Array = party_moves[enemy_creature]
var move: Move = _ai_select_move(moves, player_party[player_active])
await _execute_move(move, enemy_creature, player_party[player_active], false, player_active)
if await _check_faint_player():
return
state = BattleState.PLAYER_TURN
action_panel.show()
Why the 0.5 second delay?
The timer gives a short pause before the enemy acts. Without it, the enemy's attack would appear instantly after the player's, making the battle feel rushed and hard to follow.
Add faint handling and win/loss conditions
The battle needs to detect when a creature's HP hits zero, send in a replacement, and eventually declare a winner when one side runs out of creatures entirely.
- Add the enemy faint checker that sends in the next bench creature or declares victory:
func _check_faint_enemy() -> bool:
if enemy_hp[enemy_active] <= 0:
await _show_message("%s fainted!" % enemy_party[enemy_active].creature_name)
var next: int = _find_next_alive(enemy_hp, enemy_active, enemy_active_2 if is_doubles else -1)
if next == -1:
state = BattleState.VICTORY
await _show_message("All enemy creatures fainted! You win!")
play_again_button.show()
back_button.show()
return true
else:
enemy_active = next
_update_panel_enemy()
await _show_message("Enemy sends out %s!" % enemy_party[enemy_active].creature_name)
return false
- Add the player faint checker that triggers forced switching or declares defeat:
func _check_faint_player() -> bool:
if player_hp[player_active] <= 0:
await _show_message("%s fainted!" % player_party[player_active].creature_name)
var next: int = _find_next_alive(player_hp, player_active, player_active_2 if is_doubles else -1)
if next == -1:
state = BattleState.DEFEAT
await _show_message("All your creatures fainted! You lose!")
play_again_button.show()
back_button.show()
return true
else:
state = BattleState.FORCED_SWITCH
_show_switch_panel()
return true
return false
Why does _check_faint_player return true for forced switch?
Returning true tells the calling function to stop the normal turn flow. The battle pauses until the player picks a replacement from the switch panel. After they choose, _on_switch_target_selected resumes the battle.
- Add the helper that finds the next alive creature in a party:
func _find_next_alive(hp_array: Array, exclude_1: int, exclude_2: int) -> int:
for i in range(hp_array.size()):
if i == exclude_1:
continue
if i == exclude_2:
continue
if hp_array[i] > 0:
return i
return -1
- Add the core damage function that calculates and applies move damage:
func _execute_move(move: Move, attacker: Creature, defender: Creature, is_player_attacking: bool, target_index: int, power_mult: float = 1.0) -> void:
if randf() > move.accuracy:
await _show_message("%s used %s! But it missed!" % [attacker.creature_name, move.move_name])
return
var effectiveness_value: float = type_chart.get_effectiveness(move.type, defender.type)
if effectiveness_value == 0.0:
await _show_message("%s used %s! It has no effect!" % [attacker.creature_name, move.move_name])
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)
What does the damage formula do?
First it checks accuracy. A random roll above the move's accuracy value means a miss. Then it calculates type effectiveness from the type chart. Physical moves use attack vs defense. Special moves use sp_attack vs sp_defense.
- Continue _execute_move by adding the damage calculation and HP update logic directly below the previous chunk:
var stat_ratio: float = attack_stat / max(1.0, defense_stat)
var damage: int = int(move.power * stat_ratio * effectiveness_value * power_mult * randf_range(0.85, 1.15))
damage = max(1, damage)
if is_player_attacking:
enemy_hp[target_index] = max(0, enemy_hp[target_index] - damage)
if target_index == enemy_active:
_animate_hp_bar(enemy_hp_bar, enemy_hp[target_index])
enemy_hp_label.text = "%d/%d" % [enemy_hp[target_index], defender.max_hp]
_flash_sprite(enemy_sprite)
else:
player_hp[target_index] = max(0, player_hp[target_index] - damage)
if target_index == player_active:
_animate_hp_bar(player_hp_bar, player_hp[target_index])
player_hp_label.text = "%d/%d" % [player_hp[target_index], defender.max_hp]
_flash_sprite(player_sprite)
- Finish _execute_move with the battle messages for move usage and effectiveness:
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...")
- Add the AI function that picks the most effective move against the target:
func _ai_select_move(moves: Array, target: Creature) -> Move:
var best_moves: Array = []
var best_effectiveness: float = 0.0
for move in moves:
var eff: float = type_chart.get_effectiveness(move.type, target.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
How does the AI choose?
The AI checks each move's type effectiveness against the player's creature and picks randomly among the most effective options. This creates a competent opponent without complex decision trees.
- Add the utility functions for HP animation, sprite flashing, and message display:
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()
What creates the hit feedback?
The _animate_hp_bar function uses a Tween to smoothly slide the bar to its new value over 0.5 seconds. The _flash_sprite turns the creature red for 0.15 seconds. Together they give clear visual feedback on each hit.
- Add the navigation functions and the _check_battle_over helper that both end-game buttons use:
func _check_battle_over() -> bool:
var player_alive: bool = false
for hp in player_hp:
if hp > 0:
player_alive = true
break
var enemy_alive: bool = false
for hp in enemy_hp:
if hp > 0:
enemy_alive = true
break
if not enemy_alive:
state = BattleState.VICTORY
_show_message("All enemy creatures fainted! You win!")
play_again_button.show()
back_button.show()
return true
if not player_alive:
state = BattleState.DEFEAT
_show_message("All your creatures fainted! You lose!")
play_again_button.show()
back_button.show()
return true
return false
- Add the play again and back button functions:
func _on_play_again_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/mode_select.tscn")
func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/mode_select.tscn")
- Save scripts/battle_manager.gd.
Seeing a "Node not found" error?
This means a node path in an @onready variable does not match your scene tree. Double check that each PanelContainer is named exactly as the path expects (e.g. PlayerPanel1, not PlayerPanel) and that each has a VBoxContainer child with the correct child node names.
help me fix my Node not found error in battle_manager.gd
Before you run the project, what do you expect to happen when you pick 3 creatures and click Fight?
- Run the project by pressing F5.
- Click Singles, watch the coin flip, then select 3 creatures and click Fight.
You should see the battle scene load with your active creature's name, HP bar, and colored sprite on the left, and the enemy's creature on the right. Four move buttons appear with type labels.
- Click a move button. You should see a damage message, the enemy's HP bar animate down, and then the enemy attacks you back.
- Click Switch. A panel listing your bench creatures with their HP should appear. Pick one. The enemy then takes a free attack.
- Keep battling until your active creature faints. The switch panel should appear automatically, forcing you to pick a replacement.
- Win or lose by knocking out all 3 creatures on one side. You should see "All enemy creatures fainted! You win!" or the equivalent loss message, with Play Again and Back buttons appearing.
Battle crashes when you click a move?
Check that type_chart.gd still exists in your project. The TypeChart.new() call requires it. Also confirm that _execute_move is defined with all 6 parameters including the default power_mult: float = 1.0.
help me debug why my battle crashes when I click a move
✔️ Awesome, I've got everything!
Great. Double check you have saved battle_manager.gd and that the full Singles flow works end to end.
ⓧ I'd like to double check the full code
Your scripts/battle_manager.gd should contain all of these sections in order. Scroll up and confirm each chunk is present:
- The extends Control line, BattleState enum, and all variable declarations.
- All @onready node references (PlayerPanel1, EnemyPanel1, PlayerPanel2, EnemyPanel2, ActionPanel, TargetPanel, SwitchPanel, MessageLabel, MessageTimer, PlayAgainButton, BackButton).
- The _ready() function ending with the coin flip if/else block.
- Panel updaters: _update_panel_player() and _update_panel_enemy().
- Move setup: _setup_move_buttons().
- Turn logic: _on_move_selected(), _enemy_turn_singles().
- Switching: _on_switch_pressed(), _show_switch_panel(), _on_switch_target_selected().
- Faint handling: _check_faint_enemy(), _check_faint_player(), _find_next_alive().
- Damage: _execute_move() (accuracy check, effectiveness, stat ratio, damage application, messages).
- AI: _ai_select_move().
- Utilities: _animate_hp_bar(), _flash_sprite(), _show_message().
- End-game: _check_battle_over(), _on_play_again_pressed(), _on_back_pressed().
Your Singles party battle is fully playable. Next up, you will add Doubles mode where two creatures fight side by side and spread moves hit both opponents at reduced power.
Build the Doubles Battle
Your Singles party battle works end-to-end with switching, fainting, and team-wipe win conditions. Singles is one creature at a time. Doubles is where spread moves and target selection create real strategic depth.
Two creatures active per side means every move choice involves picking who to hit. A spread move hits both opponents but at reduced power. This tradeoff is the core of Doubles strategy. In this step, you will build the full Doubles battle system on top of your existing Singles code.
In this step, get ready to:
- Add a doubles layout showing two creature panels per side with separate HP tracking.
- Implement a speed-based turn queue with target selection for single-target moves and spread moves at 75% power.
- Handle fainting in doubles with automatic bench replacements and team-wipe victory conditions.
Add the doubles layout to the battle scene
Doubles needs two creatures on screen per side instead of one. Your scene already has PlayerPanel2 and EnemyPanel2 nodes hidden. You need new variables to track the second active creature on each side, plus code to show and update those panels when the mode is Doubles.
- In battle_manager.gd, find the BattleState enum near the top of the file. It currently looks like this:
enum BattleState { PLAYER_TURN, ENEMY_TURN, ANIMATING, SWITCHING, FORCED_SWITCH, VICTORY, DEFEAT }
- Add TARGET_SELECT before VICTORY, so the line now looks like this:
enum BattleState { PLAYER_TURN, ENEMY_TURN, ANIMATING, SWITCHING, FORCED_SWITCH, TARGET_SELECT, VICTORY, DEFEAT }
- Find the line var enemy_active: int = 0. Add these new variables directly below it:
var player_active_2: int = 1
var enemy_active_2: int = 1
- Find the line var type_chart: TypeChart = TypeChart.new(). Add these variables directly below it:
var is_doubles: bool = false
var pending_move: Move = null
var turn_queue: Array[Dictionary] = []
var turn_queue_index: int = 0
What do these variables track?
- player_active_2 and enemy_active_2 are indices for each side's second creature slot.
- pending_move stores the player's chosen move while they pick a target.
- turn_queue holds the speed-sorted order for all 4 active creatures each round.
- Now add @onready references for the second panels and target panel. Find your existing enemy_sprite onready line and add this block below it:
@onready var player_panel_2: PanelContainer = $PlayerPanel2
@onready var player_name_label_2: Label = $PlayerPanel2/VBoxContainer/NameLabel
@onready var player_hp_bar_2: ProgressBar = $PlayerPanel2/VBoxContainer/HPBar
@onready var player_hp_label_2: Label = $PlayerPanel2/VBoxContainer/HPLabel
@onready var player_sprite_2: ColorRect = $PlayerPanel2/VBoxContainer/SpriteRect
@onready var enemy_panel_2: PanelContainer = $EnemyPanel2
@onready var enemy_name_label_2: Label = $EnemyPanel2/VBoxContainer/NameLabel
@onready var enemy_hp_bar_2: ProgressBar = $EnemyPanel2/VBoxContainer/HPBar
@onready var enemy_hp_label_2: Label = $EnemyPanel2/VBoxContainer/HPLabel
@onready var enemy_sprite_2: ColorRect = $EnemyPanel2/VBoxContainer/SpriteRect
- Find your existing switch_panel and switch_grid onready lines. Add the target panel references directly above them:
@onready var target_panel: PanelContainer = $TargetPanel
@onready var target_button_1: Button = $TargetPanel/VBoxContainer/TargetButton1
@onready var target_button_2: Button = $TargetPanel/VBoxContainer/TargetButton2
What do these references connect to?
Each @onready variable grabs a node from the scene tree when the script loads. PlayerPanel2 and EnemyPanel2 are the second creature displays. TargetPanel holds two buttons the player uses to pick which enemy to hit.
- Add the two panel update functions below your existing _update_panel_enemy function:
func _update_panel_2_player() -> void:
var creature: Creature = player_party[player_active_2]
player_name_label_2.text = creature.creature_name
player_hp_bar_2.max_value = creature.max_hp
player_hp_bar_2.value = player_hp[player_active_2]
player_hp_label_2.text = "%d/%d" % [player_hp[player_active_2], creature.max_hp]
player_sprite_2.color = creature.sprite_color
func _update_panel_2_enemy() -> void:
var creature: Creature = enemy_party[enemy_active_2]
player_name_label_2.text = creature.creature_name
enemy_hp_bar_2.max_value = creature.max_hp
enemy_hp_bar_2.value = enemy_hp[enemy_active_2]
enemy_hp_label_2.text = "%d/%d" % [enemy_hp[enemy_active_2], creature.max_hp]
enemy_sprite_2.color = creature.sprite_color
What do these functions do?
They mirror _update_panel_player and _update_panel_enemy but read from the second active index. Each one sets the name, HP bar, HP label, and sprite color for the second creature slot.
- Now modify your _ready function. Find the line player_active = 0 and add the doubles initialization block directly below enemy_active = 0:
if is_doubles:
player_active_2 = 1
enemy_active_2 = 1
player_panel_2.show()
enemy_panel_2.show()
_update_panel_2_player()
_update_panel_2_enemy()
else:
player_panel_2.hide()
enemy_panel_2.hide()
- At the very top of _ready, before player_party = GameData.player_party, add this line:
is_doubles = GameData.battle_mode == "Doubles"
- Also add target_panel.hide() alongside your other hide calls in _ready (near switch_panel.hide()).
- Save battle_manager.gd.
- Run the project from mode_select.tscn, click Doubles, wait for the coin flip, pick 4 creatures, and click Fight.
You should see four creature panels on screen with names and HP bars. The battle flow still uses Singles logic for now, so only the first panel is interactive. The layout is ready for Doubles turns.
Don't see four panels?
- Check that is_doubles = GameData.battle_mode == "Doubles" is the FIRST line inside _ready. If it comes after the panel code, the variable is still false when the show/hide runs.
- Confirm your @onready paths match the scene tree exactly. $PlayerPanel2/VBoxContainer/NameLabel requires those exact node names.
- If you see a crash referencing _update_panel_2_enemy, double-check that you use enemy_name_label_2 (not player_name_label_2) inside that function.
Help me debug my doubles panel layout in battle_manager.gd.
Implement the turn queue and target selection
In Singles, turns alternate between player and enemy. In Doubles, four creatures are active at once. Each round, all four take turns in speed order. You need a queue that sorts by speed and processes each creature's turn one at a time.
- Add the _start_doubles_round function below your Singles logic section (after _find_next_alive):
# --- DOUBLES LOGIC ---
func _start_doubles_round() -> void:
turn_queue = []
var active_creatures: Array[Dictionary] = [
{"creature": player_party[player_active], "side": "player", "slot": 0, "index": player_active},
{"creature": player_party[player_active_2], "side": "player", "slot": 1, "index": player_active_2},
{"creature": enemy_party[enemy_active], "side": "enemy", "slot": 0, "index": enemy_active},
{"creature": enemy_party[enemy_active_2], "side": "enemy", "slot": 1, "index": enemy_active_2},
]
active_creatures.sort_custom(func(a, b): return a["creature"].speed > b["creature"].speed)
turn_queue = active_creatures
turn_queue_index = 0
if GameData.coin_flip_winner == "enemy" and turn_queue[0]["side"] == "player":
# Swap first two if enemy won coin flip
var temp: Dictionary = turn_queue[0]
turn_queue[0] = turn_queue[1]
turn_queue[1] = temp
_process_next_doubles_turn()
What does this function do?
- It builds an array of all 4 active creatures with metadata (side, slot, party index).
- sort_custom orders them by speed so faster creatures act first.
- If the enemy won the coin flip and the fastest creature is the player's, the first two entries swap. This gives the coin flip winner a first-turn edge.
- Add _process_next_doubles_turn directly below:
func _process_next_doubles_turn() -> void:
if _check_battle_over():
return
if turn_queue_index >= turn_queue.size():
_replace_fainted_doubles()
_start_doubles_round()
return
var current: Dictionary = turn_queue[turn_queue_index]
var creature_index: int = current["index"]
var side: String = current["side"]
if side == "player" and player_hp[creature_index] <= 0:
turn_queue_index += 1
_process_next_doubles_turn()
return
if side == "enemy" and enemy_hp[creature_index] <= 0:
turn_queue_index += 1
_process_next_doubles_turn()
return
if side == "player":
state = BattleState.PLAYER_TURN
player_active = creature_index
_setup_move_buttons()
action_panel.show()
else:
state = BattleState.ENEMY_TURN
action_panel.hide()
_enemy_turn_doubles(creature_index)
What does this function do?
- It iterates through the turn queue one creature at a time.
- If a creature has fainted, it skips their turn.
- For player creatures, it shows move buttons. For enemy creatures, it calls the AI.
- When all turns are done, it replaces fainted creatures and starts a new round.
- Add the enemy AI turn handler for Doubles below:
func _enemy_turn_doubles(creature_index: int) -> void:
await get_tree().create_timer(0.5).timeout
var enemy_creature: Creature = enemy_party[creature_index]
var moves: Array = party_moves[enemy_creature]
var target_creature: Creature = player_party[player_active]
var move: Move = _ai_select_move(moves, target_creature)
if move.target == "Spread" or move.target == "All":
await _execute_spread_move(move, enemy_creature, false)
else:
var target_index: int = player_active
if player_hp[player_active_2] > 0 and randf() > 0.5:
target_index = player_active_2
await _execute_move(move, enemy_creature, player_party[target_index], false, target_index)
turn_queue_index += 1
_process_next_doubles_turn()
What does the enemy AI do in Doubles?
The AI picks the best move using type effectiveness (same as Singles). For single-target moves, it randomly picks one of the player's two active creatures. For spread moves, it calls _execute_spread_move which hits both targets.
- Save battle_manager.gd and confirm no script errors appear in the Output panel at the bottom of Godot.
- Now add the target selection functions that let the player pick which enemy to hit:
func _show_target_selection() -> void:
state = BattleState.TARGET_SELECT
action_panel.hide()
target_panel.show()
target_button_1.text = enemy_party[enemy_active].creature_name
target_button_2.text = enemy_party[enemy_active_2].creature_name
target_button_1.disabled = enemy_hp[enemy_active] <= 0
target_button_2.disabled = enemy_hp[enemy_active_2] <= 0
if not target_button_1.pressed.is_connected(_on_target_1_pressed):
target_button_1.pressed.connect(_on_target_1_pressed)
target_button_2.pressed.connect(_on_target_2_pressed)
func _on_target_1_pressed() -> void:
target_panel.hide()
state = BattleState.ANIMATING
await _execute_move(pending_move, player_party[player_active], enemy_party[enemy_active], true, enemy_active)
pending_move = null
turn_queue_index += 1
_process_next_doubles_turn()
func _on_target_2_pressed() -> void:
target_panel.hide()
state = BattleState.ANIMATING
await _execute_move(pending_move, player_party[player_active], enemy_party[enemy_active_2], true, enemy_active_2)
pending_move = null
turn_queue_index += 1
_process_next_doubles_turn()
How does target selection work?
- _show_target_selection displays the TargetPanel with each enemy's name on a button. If an enemy has fainted, their button is disabled.
- The is_connected check prevents connecting the signal multiple times (which would cause duplicate calls).
- When the player picks a target, the move executes against that specific enemy and the turn queue advances.
- Add the player spread-move handler below the target functions:
func _execute_doubles_player_move(move: Move, _target: int) -> void:
if move.target == "Spread" or move.target == "All":
await _execute_spread_move(move, player_party[player_active], true)
turn_queue_index += 1
_process_next_doubles_turn()
func _execute_spread_move(move: Move, attacker: Creature, is_player_attacking: bool) -> void:
if is_player_attacking:
if enemy_hp[enemy_active] > 0:
await _execute_move(move, attacker, enemy_party[enemy_active], true, enemy_active, 0.75)
if enemy_hp[enemy_active_2] > 0:
await _execute_move(move, attacker, enemy_party[enemy_active_2], true, enemy_active_2, 0.75)
else:
if player_hp[player_active] > 0:
await _execute_move(move, attacker, player_party[player_active], false, player_active, 0.75)
if player_hp[player_active_2] > 0:
await _execute_move(move, attacker, player_party[player_active_2], false, player_active_2, 0.75)
Why 0.75 for spread moves?
In doubles battles, spread moves that hit multiple targets deal 75% of their normal damage to each target. This makes them weaker per-hit than single-target moves but valuable when both enemies are alive. The power_mult parameter you built into _execute_move in the previous step handles this multiplication.
- Now update your _on_move_selected function to handle Doubles. Replace the entire function with this version:
func _on_move_selected(move_index: int) -> void:
if state != BattleState.PLAYER_TURN:
return
var active_creature: Creature = player_party[player_active]
var move: Move = party_moves[active_creature][move_index]
if is_doubles and move.target == "Single":
pending_move = move
_show_target_selection()
return
state = BattleState.ANIMATING
action_panel.hide()
if is_doubles:
await _execute_doubles_player_move(move, -1)
else:
await _execute_move(move, player_party[player_active], enemy_party[enemy_active], true, enemy_active)
if await _check_faint_enemy():
return
state = BattleState.ENEMY_TURN
await _enemy_turn_singles()
What changed in _on_move_selected?
- If the mode is Doubles and the move targets a single creature, it stores the move in pending_move and opens target selection.
- If the mode is Doubles and the move is Spread or All, it calls _execute_doubles_player_move which auto-targets both enemies.
- The else branch keeps Singles logic unchanged.
- Save battle_manager.gd and confirm no parse errors in the Output panel.
Seeing a parse error?
- Check that _on_move_selected has no duplicate definition. You should have replaced the old version entirely, not added a second copy.
- Verify every await keyword is present before _execute_move, _execute_spread_move, and _execute_doubles_player_move calls. Missing await causes a runtime error.
Help me fix a parse error in my doubles battle_manager.gd code.
Handle fainting in doubles
The turn queue processes each creature's turn, but it doesn't yet know what to do when a creature faints or when an entire team is eliminated. You need two more functions: one to replace fainted creatures between rounds, and one to detect when a battle is over.
- Add the _replace_fainted_doubles function below _execute_spread_move:
func _replace_fainted_doubles() -> void:
if enemy_hp[enemy_active] <= 0:
var next: int = _find_next_alive(enemy_hp, enemy_active, enemy_active_2)
if next != -1:
enemy_active = next
_update_panel_enemy()
if enemy_hp[enemy_active_2] <= 0:
var next: int = _find_next_alive(enemy_hp, enemy_active, enemy_active_2)
if next != -1:
enemy_active_2 = next
_update_panel_2_enemy()
if player_hp[player_active] <= 0:
var next: int = _find_next_alive(player_hp, player_active, player_active_2)
if next != -1:
player_active = next
_update_panel_player()
_setup_move_buttons()
if player_hp[player_active_2] <= 0:
var next: int = _find_next_alive(player_hp, player_active, player_active_2)
if next != -1:
player_active_2 = next
_update_panel_2_player()
What does this function do?
At the start of each new round, this function checks whether any active creature fainted during the previous round. If one did, it pulls the next alive creature from the bench using _find_next_alive (excluding both active slots to avoid duplicates) and updates the panel.
- Add the _check_battle_over function below _replace_fainted_doubles:
func _check_battle_over() -> bool:
var player_alive: bool = false
for hp in player_hp:
if hp > 0:
player_alive = true
break
var enemy_alive: bool = false
for hp in enemy_hp:
if hp > 0:
enemy_alive = true
break
if not enemy_alive:
state = BattleState.VICTORY
_show_message("All enemy creatures fainted! You win!")
play_again_button.show()
back_button.show()
return true
if not player_alive:
state = BattleState.DEFEAT
_show_message("All your creatures fainted! You lose!")
play_again_button.show()
back_button.show()
return true
return false
What does this function do?
It loops through every HP value on each side. If all HP values on one side are zero, that side has lost. Unlike the Singles faint check (which looks at one creature at a time), this checks the ENTIRE party at once.
- Update your _show_switch_panel function. Find the line that says if i == player_active: inside the for loop and add this block directly below the continue statement:
if is_doubles and i == player_active_2:
continue
- Also update both _check_faint_enemy and _check_faint_player. In _check_faint_enemy, find the _find_next_alive call. It currently looks like this:
var next: int = _find_next_alive(enemy_hp, enemy_active, -1)
- Replace -1 with enemy_active_2 if is_doubles else -1, so it becomes:
var next: int = _find_next_alive(enemy_hp, enemy_active, enemy_active_2 if is_doubles else -1)
- Make the same change in _check_faint_player. Find:
var next: int = _find_next_alive(player_hp, player_active, -1)
- Replace with:
var next: int = _find_next_alive(player_hp, player_active, player_active_2 if is_doubles else -1)
- Finally, modify the bottom of your _ready function. Find the block that starts with if GameData.coin_flip_winner == "player": and replace it with this:
if is_doubles:
_start_doubles_round()
else:
if GameData.coin_flip_winner == "player":
state = BattleState.PLAYER_TURN
action_panel.show()
else:
state = BattleState.ENEMY_TURN
action_panel.hide()
_enemy_turn_singles()
What changed in _ready?
The old code always used the Singles flow (coin flip deciding who acts first between two creatures). Now it branches: Doubles calls _start_doubles_round() which builds the speed-sorted queue. Singles keeps the original flow unchanged.
- Save battle_manager.gd.
Before you run this, what do you think will happen when you pick a single-target move in Doubles? Will it fire immediately or will something appear on screen first?
- Run the project, click Doubles, complete the coin flip and creature selection, then enter battle.
- When your creature's turn comes up, pick a single-target move from the move buttons.
You should see target buttons appear showing the two enemy creature names. Click one to hit that specific enemy. Then watch the turn queue cycle through the other creatures' turns.
- Now pick a move tagged [SPREAD] or [ALL] on your next turn.
The spread move should auto-target both enemies without showing target buttons. You should see damage messages for each enemy hit, and the numbers should be roughly 75% of what a single-target move of similar power would deal.
- Keep fighting until one enemy faints. At the start of the next round, their bench creature should automatically replace them in the second panel.
Doubles battle crashes on the first turn?
- If you see an error about _check_battle_over or _replace_fainted_doubles not existing, make sure you added both functions BEFORE changing the bottom of _ready to call _start_doubles_round().
- If target buttons don't appear for single-target moves, confirm the if is_doubles and move.target == "Single": check is at the top of _on_move_selected (before state = BattleState.ANIMATING).
- If the enemy never takes a turn, check that _process_next_doubles_turn calls _enemy_turn_doubles(creature_index) in the else branch.
Help me debug my doubles battle turn flow.
✔️ Awesome, I've got everything!
Great. Double check you have saved battle_manager.gd and that your Doubles battles run end-to-end with target selection, spread moves, and faint replacement.
ⓧ I'd like to double check the full code
Your battle_manager.gd is now a large file. Scroll through and confirm these sections are present in order:
- BattleState enum with TARGET_SELECT included.
- Variables: player_active_2, enemy_active_2, is_doubles, pending_move, turn_queue, turn_queue_index.
- Panel 2 @onready variables for player, enemy, and target panel.
- _update_panel_2_player and _update_panel_2_enemy functions.
- _ready with is_doubles panel show/hide and _start_doubles_round() call at the bottom.
- _on_move_selected with the is_doubles branches for Single-target and Spread.
- _show_switch_panel excluding player_active_2 in doubles.
- _check_faint_enemy and _check_faint_player passing enemy_active_2 if is_doubles else -1 and player_active_2 if is_doubles else -1.
- Doubles logic section: _start_doubles_round, _process_next_doubles_turn, _enemy_turn_doubles, _show_target_selection, _on_target_1_pressed, _on_target_2_pressed, _execute_doubles_player_move, _execute_spread_move, _replace_fainted_doubles, _check_battle_over.
Your Doubles battle is fully playable. Players pick targets for single-hit moves, spread moves slam both opponents at 75% power, and fainted creatures get replaced automatically. Next up, you will tackle the Secret Mission and add stat-boosting setup moves.
Secret mission
Add Setup Moves
Every move in your game deals damage. Real battle strategy involves spending a turn powering up to sweep later. Can you add stat-boosting setup moves that create a risk-reward decision between attacking now or investing in future power?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally. No cloud resources or paid services are involved, so there are no ongoing costs.
Resources you used:
- The BattleSystem project folder on your machine (contains all scripts, scenes, and JSON data files).
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Keep everything running
No action needed. Choose this if you are still actively building or want to continue experimenting with your battle system.
- Your project stays on disk exactly as you left it.
- Reopen the project in Godot anytime from the Project Manager.
Pause - I'll come back to this later
Close Godot to free up memory. Your project files remain on disk and you can pick up where you left off.
- Close the Godot editor.
- When you are ready to return, open Godot and select your BattleSystem project from the Project Manager list.
Delete - I don't want to use this again
Remove all project files from your system. This cannot be undone.
- Close the Godot editor if it is still open.
- Delete the BattleSystem folder from your Desktop (or wherever you created it).
On macOS or Linux, run this in your terminal:
rm -rf ~/Desktop/BattleSystem
On Windows, run this in PowerShell:
Remove-Item -Recurse -Force ~\Desktop\BattleSystem
That's a Wrap!
That's a Wrap!
Nice work! You just built a full two-mode party battle system from scratch, complete with animated coin flips, creature switching, and spread-move targeting.
You've learned how to:
- Build a mode selection screen with a Tween-based coin flip animation that uses slowing intervals to create dramatic randomization for first-turn advantage.
- Implement a full Singles party battle with voluntary switching that costs your turn, forced switching when a creature faints, and team-wipe win conditions across a 3v3 roster.
- Create a Doubles battle system with a speed-based turn queue, target selection for single-target moves, and spread moves that automatically hit both opponents at 75% damage each.
- Secret Mission: Added stat-boosting setup moves that create risk-reward decisions and modify the damage formula through stage multipliers capped at +3.
Ready to quiz yourself?