Build a Creature Roster and Selection System
Create a JSON-powered creature roster with selection screens for a turn-based battle game.
Introduction
30 Second Summary
Think about fighting games where you pick your character from a roster grid, customize their moveset, and jump into battle. That selection process transforms a simple combat system into a game that feels complete and replayable.
In this project, you will build a data-driven creature selection system that loads 30 creatures and 100 moves from JSON files, presents them in selection screens, and feeds player choices into your existing battle system with a full stat-based damage formula.
What You'll Build
A complete character select and move loadout system powered by JSON data files and a Godot Autoload singleton for seamless scene transitions.
By the end of this project, you'll have:
- A JSON data pipeline that loads 30 creatures and 100 moves at runtime without manually creating resource files.
- A character select screen and move loadout screen where players choose their creature and pick any 4 moves from 100.
- An updated battle system using 6 stats for damage calculation and Speed-based turn order.
- Secret Mission: Add type filtering and a real-time search bar to the move loadout screen.
Are there any prerequisites?
This project builds on the "Build a Turn-Based Battle System" project. You need a working BattleSystem Godot project with creature.gd, move.gd, type_chart.gd, and battle_manager.gd already in place.
Update Resource Scripts and Create JSON Data
Your battle system currently has a bare-bones creature model with just HP and type. To support meaningful combat choices, each creature needs a full stat profile that determines how hard it hits and how well it takes damage.
In this step, you will expand the resource scripts with 6 combat stats and a move category system. Then you will create two JSON data files containing 30 creatures and 100 moves that load at runtime.
In this step, get ready to:
- Update creature.gd and move.gd with the full 6-stat model and move categories.
- Create a creatures.json file with 30 unique creatures across 10 types.
- Create a moves.json file with 100 moves mixing Physical and Special categories.
Update creature.gd and move.gd with the full stat model
The original creature script only tracked HP and type. A real battle system needs stats that differentiate a glass cannon from a tank. You will add Attack, Defense, Sp. Attack, Sp. Defense, and Speed to every creature.
- In Godot's FileSystem dock (bottom-left panel), double-click scripts/creature.gd to open it in the script editor.
- Replace the entire contents of the file with the following code:
class_name Creature
extends Resource
@export var creature_name: String = ""
@export var max_hp: int = 100
@export var attack: int = 50
@export var defense: int = 50
@export var sp_attack: int = 50
@export var sp_defense: int = 50
@export var speed: int = 50
@export var type: String = "FLAME"
@export var moves: Array[Resource] = []
@export var sprite_color: Color = Color.WHITE
What does this code do?
Each @export line defines a stat the creature carries. Physical moves will use attack vs the defender's defense. Special moves will use sp_attack vs sp_defense.
The speed stat determines which creature acts first each turn. Higher speed means you attack before your opponent.
- Save the file with Cmd+S (macOS) or Ctrl+S (Windows).
- Check the Output panel at the bottom of the editor. You should see no error messages.
Seeing a parse error?
Make sure you replaced the entire file contents, not just added lines. The old version may have had different field names that now conflict.
Help me debug this parse error
- Now open scripts/move.gd by double-clicking it in the FileSystem dock.
- Replace the entire contents with the following 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"
What does this code do?
The new category field marks each move as either "Physical" or "Special". Physical moves hit with the attacker's raw Attack stat. Special moves use the Sp. Attack stat instead.
This split creates real strategy. A creature with high Attack but low Sp. Attack benefits from Physical moves, while a magic-focused creature wants Special moves.
- Save move.gd with Cmd+S (macOS) or Ctrl+S (Windows).
- Confirm the Output panel shows no errors.
Seeing "identifier already declared" errors?
This happens if the old file had a field with a different type but the same name. Make sure you deleted all old contents before pasting the new code.
Help me fix this identifier error
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
class_name Creature
extends Resource
@export var creature_name: String = ""
@export var max_hp: int = 100
@export var attack: int = 50
@export var defense: int = 50
@export var sp_attack: int = 50
@export var sp_defense: int = 50
@export var speed: int = 50
@export var type: String = "FLAME"
@export var moves: Array[Resource] = []
@export var sprite_color: Color = Color.WHITE
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"
Create the creatures JSON data file
Instead of manually creating 30 .tres resource files through the Inspector, you will define all creature data in a single JSON file. This is the same data-driven approach professional games use to manage large rosters.
- In the FileSystem dock, right-click the res:// root folder.
- Select New Folder from the context menu.
- Name the folder data and click OK.
Why use an external text editor for JSON?
Godot's built-in script editor handles GDScript and shader files, but JSON files need to be created externally. You will create these files in a separate text editor and save them into the data/ folder on disk. Godot detects new files automatically.
- Open your system file manager (Finder on macOS or File Explorer on Windows) and navigate to your BattleSystem project folder.
- Inside the project folder, open the data folder you just created.
- Create a new text file named creatures.json inside the data folder.
- Open creatures.json in any text editor and paste the following JSON data:
[
{"name": "Emberfox", "type": "FLAME", "hp": 85, "attack": 75, "defense": 50, "sp_attack": 90, "sp_defense": 55, "speed": 80, "color": "#FF6B35"},
{"name": "Blazeram", "type": "FLAME", "hp": 110, "attack": 95, "defense": 70, "sp_attack": 60, "sp_defense": 65, "speed": 45, "color": "#CC3300"},
{"name": "Cinderwisp", "type": "FLAME", "hp": 70, "attack": 45, "defense": 40, "sp_attack": 105, "sp_defense": 80, "speed": 95, "color": "#FF9966"},
{"name": "Aquashell", "type": "TIDE", "hp": 120, "attack": 60, "defense": 95, "sp_attack": 70, "sp_defense": 90, "speed": 35, "color": "#1E90FF"},
{"name": "Tidalfang", "type": "TIDE", "hp": 90, "attack": 95, "defense": 65, "sp_attack": 55, "sp_defense": 60, "speed": 75, "color": "#003366"},
{"name": "Mistral", "type": "TIDE", "hp": 80, "attack": 50, "defense": 55, "sp_attack": 100, "sp_defense": 85, "speed": 70, "color": "#66CCFF"},
{"name": "Thornvine", "type": "FLORA", "hp": 95, "attack": 85, "defense": 80, "sp_attack": 55, "sp_defense": 70, "speed": 50, "color": "#228B22"},
{"name": "Petalbloom", "type": "FLORA", "hp": 80, "attack": 45, "defense": 60, "sp_attack": 100, "sp_defense": 90, "speed": 65, "color": "#90EE90"},
{"name": "Mossguard", "type": "FLORA", "hp": 130, "attack": 55, "defense": 100, "sp_attack": 50, "sp_defense": 95, "speed": 25, "color": "#006400"},
{"name": "Galetalon", "type": "SKY", "hp": 75, "attack": 90, "defense": 50, "sp_attack": 60, "sp_defense": 55, "speed": 105, "color": "#87CEEB"},
{"name": "Stormraptor", "type": "SKY", "hp": 85, "attack": 70, "defense": 55, "sp_attack": 95, "sp_defense": 60, "speed": 90, "color": "#4682B4"},
{"name": "Zephyrhawk", "type": "SKY", "hp": 100, "attack": 80, "defense": 75, "sp_attack": 65, "sp_defense": 70, "speed": 55, "color": "#B0C4DE"},
{"name": "Sparkpaw", "type": "VOLT", "hp": 70, "attack": 55, "defense": 45, "sp_attack": 100, "sp_defense": 60, "speed": 110, "color": "#FFD700"},
{"name": "Thundermane", "type": "VOLT", "hp": 95, "attack": 90, "defense": 65, "sp_attack": 75, "sp_defense": 60, "speed": 70, "color": "#FFA500"},
{"name": "Voltcore", "type": "VOLT", "hp": 80, "attack": 60, "defense": 80, "sp_attack": 95, "sp_defense": 85, "speed": 50, "color": "#CCCC00"},
{"name": "Boulderback", "type": "EARTH", "hp": 130, "attack": 100, "defense": 110, "sp_attack": 35, "sp_defense": 55, "speed": 20, "color": "#8B4513"},
{"name": "Sandstalker", "type": "EARTH", "hp": 85, "attack": 85, "defense": 70, "sp_attack": 50, "sp_defense": 60, "speed": 90, "color": "#D2B48C"},
{"name": "Claymor", "type": "EARTH", "hp": 105, "attack": 70, "defense": 90, "sp_attack": 70, "sp_defense": 75, "speed": 40, "color": "#A0522D"},
{"name": "Steelclaw", "type": "IRON", "hp": 90, "attack": 95, "defense": 100, "sp_attack": 45, "sp_defense": 70, "speed": 50, "color": "#708090"},
{"name": "Chromewing", "type": "IRON", "hp": 75, "attack": 70, "defense": 85, "sp_attack": 80, "sp_defense": 90, "speed": 55, "color": "#C0C0C0"},
{"name": "Ironfist", "type": "IRON", "hp": 100, "attack": 110, "defense": 80, "sp_attack": 40, "sp_defense": 55, "speed": 65, "color": "#2F4F4F"},
{"name": "Mindweaver", "type": "PSYCHE", "hp": 75, "attack": 40, "defense": 50, "sp_attack": 110, "sp_defense": 95, "speed": 80, "color": "#9370DB"},
{"name": "Dreamcaster", "type": "PSYCHE", "hp": 90, "attack": 50, "defense": 65, "sp_attack": 95, "sp_defense": 100, "speed": 55, "color": "#BA55D3"},
{"name": "Psyblade", "type": "PSYCHE", "hp": 80, "attack": 90, "defense": 60, "sp_attack": 75, "sp_defense": 55, "speed": 85, "color": "#EE82EE"},
{"name": "Hammerfist", "type": "BRAWN", "hp": 110, "attack": 105, "defense": 75, "sp_attack": 35, "sp_defense": 50, "speed": 65, "color": "#B22222"},
{"name": "Grapplex", "type": "BRAWN", "hp": 95, "attack": 85, "defense": 90, "sp_attack": 40, "sp_defense": 65, "speed": 55, "color": "#CD853F"},
{"name": "Swiftstrike", "type": "BRAWN", "hp": 75, "attack": 95, "defense": 55, "sp_attack": 45, "sp_defense": 50, "speed": 105, "color": "#DC143C"},
{"name": "Nightshade", "type": "SHADOW", "hp": 80, "attack": 70, "defense": 55, "sp_attack": 100, "sp_defense": 75, "speed": 85, "color": "#2E0854"},
{"name": "Duskfang", "type": "SHADOW", "hp": 95, "attack": 100, "defense": 65, "sp_attack": 60, "sp_defense": 55, "speed": 70, "color": "#191970"},
{"name": "Voidmoth", "type": "SHADOW", "hp": 70, "attack": 45, "defense": 50, "sp_attack": 105, "sp_defense": 90, "speed": 95, "color": "#4B0082"}
]
What does this data contain?
Each creature entry defines a name, type (one of 10 element types), 6 combat stats, and a hex color for its sprite. The 30 creatures break down into 3 per type.
Notice how stat distributions vary. Emberfox is a glass cannon (high Sp. Attack, low Defense). Boulderback is a tank (massive HP and Defense, terrible Speed). These differences create meaningful team-building decisions.
- Save creatures.json in your text editor.
- Switch back to Godot. The FileSystem dock should now show data/creatures.json. If it does not appear, click somewhere in the FileSystem dock to refresh it.
File not showing in the FileSystem dock?
Make sure you saved the file inside the correct project folder. The data folder must be directly inside your BattleSystem project root (at the same level as scripts/).
Help me find my creatures.json file
Create the moves JSON data file
Each move needs a name, type, power level, accuracy, and category. The mix of weak/accurate and powerful/risky moves creates interesting loadout choices later.
- In the same data folder on disk, create a new text file named moves.json.
- Open moves.json in your text editor and paste the following JSON data:
[
{"name": "Flame Burst", "type": "FLAME", "power": 50, "accuracy": 0.95, "category": "Special"},
{"name": "Fire Fang", "type": "FLAME", "power": 55, "accuracy": 0.9, "category": "Physical"},
{"name": "Inferno Slam", "type": "FLAME", "power": 80, "accuracy": 0.8, "category": "Physical"},
{"name": "Heat Wave", "type": "FLAME", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Ember Claw", "type": "FLAME", "power": 40, "accuracy": 1.0, "category": "Physical"},
{"name": "Scorch Beam", "type": "FLAME", "power": 65, "accuracy": 0.95, "category": "Special"},
{"name": "Wildfire", "type": "FLAME", "power": 90, "accuracy": 0.75, "category": "Special"},
{"name": "Flame Tackle", "type": "FLAME", "power": 45, "accuracy": 1.0, "category": "Physical"},
{"name": "Magma Pulse", "type": "FLAME", "power": 75, "accuracy": 0.85, "category": "Special"},
{"name": "Burning Rush", "type": "FLAME", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Tidal Wave", "type": "TIDE", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Aqua Fang", "type": "TIDE", "power": 55, "accuracy": 0.95, "category": "Physical"},
{"name": "Hydro Cannon", "type": "TIDE", "power": 90, "accuracy": 0.75, "category": "Special"},
{"name": "Water Slash", "type": "TIDE", "power": 50, "accuracy": 1.0, "category": "Physical"},
{"name": "Torrent Blast", "type": "TIDE", "power": 80, "accuracy": 0.85, "category": "Special"},
{"name": "Splash Strike", "type": "TIDE", "power": 40, "accuracy": 1.0, "category": "Physical"},
{"name": "Riptide", "type": "TIDE", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Depth Charge", "type": "TIDE", "power": 75, "accuracy": 0.85, "category": "Physical"},
{"name": "Bubble Beam", "type": "TIDE", "power": 45, "accuracy": 1.0, "category": "Special"},
{"name": "Tsunami Crush", "type": "TIDE", "power": 85, "accuracy": 0.8, "category": "Physical"},
{"name": "Vine Whip", "type": "FLORA", "power": 40, "accuracy": 1.0, "category": "Physical"},
{"name": "Solar Beam", "type": "FLORA", "power": 90, "accuracy": 0.8, "category": "Special"},
{"name": "Leaf Blade", "type": "FLORA", "power": 70, "accuracy": 0.95, "category": "Physical"},
{"name": "Petal Storm", "type": "FLORA", "power": 75, "accuracy": 0.85, "category": "Special"},
{"name": "Root Slam", "type": "FLORA", "power": 60, "accuracy": 0.9, "category": "Physical"},
{"name": "Spore Burst", "type": "FLORA", "power": 50, "accuracy": 1.0, "category": "Special"},
{"name": "Thorn Lash", "type": "FLORA", "power": 55, "accuracy": 0.95, "category": "Physical"},
{"name": "Bloom Pulse", "type": "FLORA", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Nature Crush", "type": "FLORA", "power": 80, "accuracy": 0.8, "category": "Physical"},
{"name": "Seed Volley", "type": "FLORA", "power": 45, "accuracy": 1.0, "category": "Special"},
{"name": "Gust Slash", "type": "SKY", "power": 45, "accuracy": 1.0, "category": "Physical"},
{"name": "Air Cutter", "type": "SKY", "power": 55, "accuracy": 0.95, "category": "Special"},
{"name": "Dive Bomb", "type": "SKY", "power": 80, "accuracy": 0.85, "category": "Physical"},
{"name": "Cyclone", "type": "SKY", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Wing Strike", "type": "SKY", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Tornado Blast", "type": "SKY", "power": 85, "accuracy": 0.8, "category": "Special"},
{"name": "Feather Edge", "type": "SKY", "power": 50, "accuracy": 1.0, "category": "Physical"},
{"name": "Sky Burst", "type": "SKY", "power": 75, "accuracy": 0.85, "category": "Special"},
{"name": "Aerial Ace", "type": "SKY", "power": 65, "accuracy": 1.0, "category": "Physical"},
{"name": "Tempest Fury", "type": "SKY", "power": 90, "accuracy": 0.75, "category": "Special"},
{"name": "Thunder Bolt", "type": "VOLT", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Spark Punch", "type": "VOLT", "power": 55, "accuracy": 0.95, "category": "Physical"},
{"name": "Lightning Strike", "type": "VOLT", "power": 90, "accuracy": 0.75, "category": "Special"},
{"name": "Volt Tackle", "type": "VOLT", "power": 80, "accuracy": 0.85, "category": "Physical"},
{"name": "Static Pulse", "type": "VOLT", "power": 50, "accuracy": 1.0, "category": "Special"},
{"name": "Shock Fang", "type": "VOLT", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Discharge", "type": "VOLT", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Thunder Claw", "type": "VOLT", "power": 75, "accuracy": 0.85, "category": "Physical"},
{"name": "Zap Beam", "type": "VOLT", "power": 45, "accuracy": 1.0, "category": "Special"},
{"name": "Overcharge", "type": "VOLT", "power": 85, "accuracy": 0.8, "category": "Physical"},
{"name": "Earthquake", "type": "EARTH", "power": 80, "accuracy": 0.9, "category": "Physical"},
{"name": "Rock Throw", "type": "EARTH", "power": 45, "accuracy": 1.0, "category": "Physical"},
{"name": "Mud Blast", "type": "EARTH", "power": 55, "accuracy": 0.95, "category": "Special"},
{"name": "Landslide", "type": "EARTH", "power": 90, "accuracy": 0.75, "category": "Physical"},
{"name": "Sand Tomb", "type": "EARTH", "power": 50, "accuracy": 1.0, "category": "Special"},
{"name": "Boulder Crush", "type": "EARTH", "power": 75, "accuracy": 0.85, "category": "Physical"},
{"name": "Fissure Slam", "type": "EARTH", "power": 70, "accuracy": 0.9, "category": "Physical"},
{"name": "Dust Storm", "type": "EARTH", "power": 60, "accuracy": 0.9, "category": "Special"},
{"name": "Tremor Punch", "type": "EARTH", "power": 65, "accuracy": 0.95, "category": "Physical"},
{"name": "Terra Beam", "type": "EARTH", "power": 85, "accuracy": 0.8, "category": "Special"},
{"name": "Iron Slam", "type": "IRON", "power": 70, "accuracy": 0.9, "category": "Physical"},
{"name": "Metal Burst", "type": "IRON", "power": 60, "accuracy": 0.95, "category": "Special"},
{"name": "Steel Fang", "type": "IRON", "power": 55, "accuracy": 1.0, "category": "Physical"},
{"name": "Chrome Beam", "type": "IRON", "power": 80, "accuracy": 0.85, "category": "Special"},
{"name": "Anvil Drop", "type": "IRON", "power": 85, "accuracy": 0.8, "category": "Physical"},
{"name": "Alloy Edge", "type": "IRON", "power": 65, "accuracy": 0.95, "category": "Physical"},
{"name": "Magnetic Pulse", "type": "IRON", "power": 50, "accuracy": 1.0, "category": "Special"},
{"name": "Forge Strike", "type": "IRON", "power": 75, "accuracy": 0.9, "category": "Physical"},
{"name": "Titanium Rush", "type": "IRON", "power": 90, "accuracy": 0.75, "category": "Physical"},
{"name": "Shrapnel Shot", "type": "IRON", "power": 45, "accuracy": 1.0, "category": "Special"},
{"name": "Mind Blast", "type": "PSYCHE", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Psycho Cut", "type": "PSYCHE", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Brain Wave", "type": "PSYCHE", "power": 80, "accuracy": 0.85, "category": "Special"},
{"name": "Telekinetic Slam", "type": "PSYCHE", "power": 75, "accuracy": 0.9, "category": "Physical"},
{"name": "Mind Pulse", "type": "PSYCHE", "power": 45, "accuracy": 1.0, "category": "Special"},
{"name": "Dream Eater", "type": "PSYCHE", "power": 85, "accuracy": 0.8, "category": "Special"},
{"name": "Psi Blade", "type": "PSYCHE", "power": 55, "accuracy": 0.95, "category": "Physical"},
{"name": "Hypno Ray", "type": "PSYCHE", "power": 50, "accuracy": 1.0, "category": "Special"},
{"name": "Neural Strike", "type": "PSYCHE", "power": 90, "accuracy": 0.75, "category": "Physical"},
{"name": "Thought Crush", "type": "PSYCHE", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Power Punch", "type": "BRAWN", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Mega Kick", "type": "BRAWN", "power": 80, "accuracy": 0.85, "category": "Physical"},
{"name": "Body Slam", "type": "BRAWN", "power": 70, "accuracy": 0.9, "category": "Physical"},
{"name": "Focus Blast", "type": "BRAWN", "power": 90, "accuracy": 0.75, "category": "Special"},
{"name": "Quick Jab", "type": "BRAWN", "power": 40, "accuracy": 1.0, "category": "Physical"},
{"name": "Hammer Arm", "type": "BRAWN", "power": 85, "accuracy": 0.8, "category": "Physical"},
{"name": "Combat Surge", "type": "BRAWN", "power": 55, "accuracy": 1.0, "category": "Special"},
{"name": "Suplex", "type": "BRAWN", "power": 75, "accuracy": 0.85, "category": "Physical"},
{"name": "Aura Fist", "type": "BRAWN", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Iron Will", "type": "BRAWN", "power": 50, "accuracy": 1.0, "category": "Physical"},
{"name": "Shadow Claw", "type": "SHADOW", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Dark Pulse", "type": "SHADOW", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Nightmare", "type": "SHADOW", "power": 80, "accuracy": 0.85, "category": "Special"},
{"name": "Phantom Strike", "type": "SHADOW", "power": 75, "accuracy": 0.9, "category": "Physical"},
{"name": "Void Beam", "type": "SHADOW", "power": 85, "accuracy": 0.8, "category": "Special"},
{"name": "Dusk Fang", "type": "SHADOW", "power": 55, "accuracy": 1.0, "category": "Physical"},
{"name": "Shadow Sneak", "type": "SHADOW", "power": 40, "accuracy": 1.0, "category": "Physical"},
{"name": "Curse Wave", "type": "SHADOW", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Umbral Slash", "type": "SHADOW", "power": 90, "accuracy": 0.75, "category": "Physical"},
{"name": "Venom Strike", "type": "SHADOW", "power": 50, "accuracy": 1.0, "category": "Special"}
]
What does this data contain?
There are 100 moves total. Each of the 10 types (FLAME, TIDE, FLORA, SKY, VOLT, EARTH, IRON, PSYCHE, BRAWN, SHADOW) has exactly 10 moves. Each move is either Physical or Special.
Notice the accuracy/power tradeoff. Ember Claw has power 40 but 100% accuracy. Wildfire has power 90 but only 75% accuracy. This risk-vs-reward balance makes move selection meaningful.
- Save moves.json in your text editor.
- Switch back to Godot and confirm data/moves.json appears in the FileSystem dock alongside creatures.json.
File showing with a generic icon?
Godot does not natively open JSON files for editing, so it may show a generic icon. That is normal. What matters is that the file exists and can be read by GDScript at runtime.
If your text editor added a .txt extension (e.g. moves.json.txt), rename the file to remove the extra extension.
Help me troubleshoot my JSON files in Godot
Now verify that both JSON files parse correctly. You will add a quick test to confirm the data loads without errors.
- In Godot, click Script in the top menu to switch to the script editor.
- Open any existing script (such as battle_manager.gd) and temporarily add the following code inside the _ready() function to test parsing:
# Temporary test - remove after confirming it works
var file = FileAccess.open("res://data/creatures.json", FileAccess.READ)
var creatures_data = JSON.parse_string(file.get_as_text())
print("Creatures loaded: ", creatures_data.size())
var file2 = FileAccess.open("res://data/moves.json", FileAccess.READ)
var moves_data = JSON.parse_string(file2.get_as_text())
print("Moves loaded: ", moves_data.size())
What does this test do?
FileAccess.open reads the raw text from your JSON files. JSON.parse_string converts that text into a GDScript Array. If either file has a syntax error (missing comma, extra bracket), parse_string returns null and the .size() call will crash with a clear error.
- Run the scene by pressing F5 (or F6 to run the current scene).
- Check the Output panel at the bottom. You should see Creatures loaded: 30 and Moves loaded: 100.
- Once confirmed, delete the temporary test code you just added and save the file.
Seeing null or a crash?
This means your JSON has a syntax error. Common causes include a trailing comma after the last entry in the array, or a missing quotation mark around a field name.
Copy your JSON into an online JSON validator to find the exact line with the error. Fix it in your text editor and re-save.
Help me find my JSON syntax error
✔️ Awesome, I've got everything!
Great. Make sure you deleted the temporary test code and saved all files before moving on.
ⓧ I'd like to double check the full code
class_name Creature
extends Resource
@export var creature_name: String = ""
@export var max_hp: int = 100
@export var attack: int = 50
@export var defense: int = 50
@export var sp_attack: int = 50
@export var sp_defense: int = 50
@export var speed: int = 50
@export var type: String = "FLAME"
@export var moves: Array[Resource] = []
@export var sprite_color: Color = Color.WHITE
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"
[
{"name": "Emberfox", "type": "FLAME", "hp": 85, "attack": 75, "defense": 50, "sp_attack": 90, "sp_defense": 55, "speed": 80, "color": "#FF6B35"},
{"name": "Blazeram", "type": "FLAME", "hp": 110, "attack": 95, "defense": 70, "sp_attack": 60, "sp_defense": 65, "speed": 45, "color": "#CC3300"},
{"name": "Cinderwisp", "type": "FLAME", "hp": 70, "attack": 45, "defense": 40, "sp_attack": 105, "sp_defense": 80, "speed": 95, "color": "#FF9966"},
{"name": "Aquashell", "type": "TIDE", "hp": 120, "attack": 60, "defense": 95, "sp_attack": 70, "sp_defense": 90, "speed": 35, "color": "#1E90FF"},
{"name": "Tidalfang", "type": "TIDE", "hp": 90, "attack": 95, "defense": 65, "sp_attack": 55, "sp_defense": 60, "speed": 75, "color": "#003366"},
{"name": "Mistral", "type": "TIDE", "hp": 80, "attack": 50, "defense": 55, "sp_attack": 100, "sp_defense": 85, "speed": 70, "color": "#66CCFF"},
{"name": "Thornvine", "type": "FLORA", "hp": 95, "attack": 85, "defense": 80, "sp_attack": 55, "sp_defense": 70, "speed": 50, "color": "#228B22"},
{"name": "Petalbloom", "type": "FLORA", "hp": 80, "attack": 45, "defense": 60, "sp_attack": 100, "sp_defense": 90, "speed": 65, "color": "#90EE90"},
{"name": "Mossguard", "type": "FLORA", "hp": 130, "attack": 55, "defense": 100, "sp_attack": 50, "sp_defense": 95, "speed": 25, "color": "#006400"},
{"name": "Galetalon", "type": "SKY", "hp": 75, "attack": 90, "defense": 50, "sp_attack": 60, "sp_defense": 55, "speed": 105, "color": "#87CEEB"},
{"name": "Stormraptor", "type": "SKY", "hp": 85, "attack": 70, "defense": 55, "sp_attack": 95, "sp_defense": 60, "speed": 90, "color": "#4682B4"},
{"name": "Zephyrhawk", "type": "SKY", "hp": 100, "attack": 80, "defense": 75, "sp_attack": 65, "sp_defense": 70, "speed": 55, "color": "#B0C4DE"},
{"name": "Sparkpaw", "type": "VOLT", "hp": 70, "attack": 55, "defense": 45, "sp_attack": 100, "sp_defense": 60, "speed": 110, "color": "#FFD700"},
{"name": "Thundermane", "type": "VOLT", "hp": 95, "attack": 90, "defense": 65, "sp_attack": 75, "sp_defense": 60, "speed": 70, "color": "#FFA500"},
{"name": "Voltcore", "type": "VOLT", "hp": 80, "attack": 60, "defense": 80, "sp_attack": 95, "sp_defense": 85, "speed": 50, "color": "#CCCC00"},
{"name": "Boulderback", "type": "EARTH", "hp": 130, "attack": 100, "defense": 110, "sp_attack": 35, "sp_defense": 55, "speed": 20, "color": "#8B4513"},
{"name": "Sandstalker", "type": "EARTH", "hp": 85, "attack": 85, "defense": 70, "sp_attack": 50, "sp_defense": 60, "speed": 90, "color": "#D2B48C"},
{"name": "Claymor", "type": "EARTH", "hp": 105, "attack": 70, "defense": 90, "sp_attack": 70, "sp_defense": 75, "speed": 40, "color": "#A0522D"},
{"name": "Steelclaw", "type": "IRON", "hp": 90, "attack": 95, "defense": 100, "sp_attack": 45, "sp_defense": 70, "speed": 50, "color": "#708090"},
{"name": "Chromewing", "type": "IRON", "hp": 75, "attack": 70, "defense": 85, "sp_attack": 80, "sp_defense": 90, "speed": 55, "color": "#C0C0C0"},
{"name": "Ironfist", "type": "IRON", "hp": 100, "attack": 110, "defense": 80, "sp_attack": 40, "sp_defense": 55, "speed": 65, "color": "#2F4F4F"},
{"name": "Mindweaver", "type": "PSYCHE", "hp": 75, "attack": 40, "defense": 50, "sp_attack": 110, "sp_defense": 95, "speed": 80, "color": "#9370DB"},
{"name": "Dreamcaster", "type": "PSYCHE", "hp": 90, "attack": 50, "defense": 65, "sp_attack": 95, "sp_defense": 100, "speed": 55, "color": "#BA55D3"},
{"name": "Psyblade", "type": "PSYCHE", "hp": 80, "attack": 90, "defense": 60, "sp_attack": 75, "sp_defense": 55, "speed": 85, "color": "#EE82EE"},
{"name": "Hammerfist", "type": "BRAWN", "hp": 110, "attack": 105, "defense": 75, "sp_attack": 35, "sp_defense": 50, "speed": 65, "color": "#B22222"},
{"name": "Grapplex", "type": "BRAWN", "hp": 95, "attack": 85, "defense": 90, "sp_attack": 40, "sp_defense": 65, "speed": 55, "color": "#CD853F"},
{"name": "Swiftstrike", "type": "BRAWN", "hp": 75, "attack": 95, "defense": 55, "sp_attack": 45, "sp_defense": 50, "speed": 105, "color": "#DC143C"},
{"name": "Nightshade", "type": "SHADOW", "hp": 80, "attack": 70, "defense": 55, "sp_attack": 100, "sp_defense": 75, "speed": 85, "color": "#2E0854"},
{"name": "Duskfang", "type": "SHADOW", "hp": 95, "attack": 100, "defense": 65, "sp_attack": 60, "sp_defense": 55, "speed": 70, "color": "#191970"},
{"name": "Voidmoth", "type": "SHADOW", "hp": 70, "attack": 45, "defense": 50, "sp_attack": 105, "sp_defense": 90, "speed": 95, "color": "#4B0082"}
]
[
{"name": "Flame Burst", "type": "FLAME", "power": 50, "accuracy": 0.95, "category": "Special"},
{"name": "Fire Fang", "type": "FLAME", "power": 55, "accuracy": 0.9, "category": "Physical"},
{"name": "Inferno Slam", "type": "FLAME", "power": 80, "accuracy": 0.8, "category": "Physical"},
{"name": "Heat Wave", "type": "FLAME", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Ember Claw", "type": "FLAME", "power": 40, "accuracy": 1.0, "category": "Physical"},
{"name": "Scorch Beam", "type": "FLAME", "power": 65, "accuracy": 0.95, "category": "Special"},
{"name": "Wildfire", "type": "FLAME", "power": 90, "accuracy": 0.75, "category": "Special"},
{"name": "Flame Tackle", "type": "FLAME", "power": 45, "accuracy": 1.0, "category": "Physical"},
{"name": "Magma Pulse", "type": "FLAME", "power": 75, "accuracy": 0.85, "category": "Special"},
{"name": "Burning Rush", "type": "FLAME", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Tidal Wave", "type": "TIDE", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Aqua Fang", "type": "TIDE", "power": 55, "accuracy": 0.95, "category": "Physical"},
{"name": "Hydro Cannon", "type": "TIDE", "power": 90, "accuracy": 0.75, "category": "Special"},
{"name": "Water Slash", "type": "TIDE", "power": 50, "accuracy": 1.0, "category": "Physical"},
{"name": "Torrent Blast", "type": "TIDE", "power": 80, "accuracy": 0.85, "category": "Special"},
{"name": "Splash Strike", "type": "TIDE", "power": 40, "accuracy": 1.0, "category": "Physical"},
{"name": "Riptide", "type": "TIDE", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Depth Charge", "type": "TIDE", "power": 75, "accuracy": 0.85, "category": "Physical"},
{"name": "Bubble Beam", "type": "TIDE", "power": 45, "accuracy": 1.0, "category": "Special"},
{"name": "Tsunami Crush", "type": "TIDE", "power": 85, "accuracy": 0.8, "category": "Physical"},
{"name": "Vine Whip", "type": "FLORA", "power": 40, "accuracy": 1.0, "category": "Physical"},
{"name": "Solar Beam", "type": "FLORA", "power": 90, "accuracy": 0.8, "category": "Special"},
{"name": "Leaf Blade", "type": "FLORA", "power": 70, "accuracy": 0.95, "category": "Physical"},
{"name": "Petal Storm", "type": "FLORA", "power": 75, "accuracy": 0.85, "category": "Special"},
{"name": "Root Slam", "type": "FLORA", "power": 60, "accuracy": 0.9, "category": "Physical"},
{"name": "Spore Burst", "type": "FLORA", "power": 50, "accuracy": 1.0, "category": "Special"},
{"name": "Thorn Lash", "type": "FLORA", "power": 55, "accuracy": 0.95, "category": "Physical"},
{"name": "Bloom Pulse", "type": "FLORA", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Nature Crush", "type": "FLORA", "power": 80, "accuracy": 0.8, "category": "Physical"},
{"name": "Seed Volley", "type": "FLORA", "power": 45, "accuracy": 1.0, "category": "Special"},
{"name": "Gust Slash", "type": "SKY", "power": 45, "accuracy": 1.0, "category": "Physical"},
{"name": "Air Cutter", "type": "SKY", "power": 55, "accuracy": 0.95, "category": "Special"},
{"name": "Dive Bomb", "type": "SKY", "power": 80, "accuracy": 0.85, "category": "Physical"},
{"name": "Cyclone", "type": "SKY", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Wing Strike", "type": "SKY", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Tornado Blast", "type": "SKY", "power": 85, "accuracy": 0.8, "category": "Special"},
{"name": "Feather Edge", "type": "SKY", "power": 50, "accuracy": 1.0, "category": "Physical"},
{"name": "Sky Burst", "type": "SKY", "power": 75, "accuracy": 0.85, "category": "Special"},
{"name": "Aerial Ace", "type": "SKY", "power": 65, "accuracy": 1.0, "category": "Physical"},
{"name": "Tempest Fury", "type": "SKY", "power": 90, "accuracy": 0.75, "category": "Special"},
{"name": "Thunder Bolt", "type": "VOLT", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Spark Punch", "type": "VOLT", "power": 55, "accuracy": 0.95, "category": "Physical"},
{"name": "Lightning Strike", "type": "VOLT", "power": 90, "accuracy": 0.75, "category": "Special"},
{"name": "Volt Tackle", "type": "VOLT", "power": 80, "accuracy": 0.85, "category": "Physical"},
{"name": "Static Pulse", "type": "VOLT", "power": 50, "accuracy": 1.0, "category": "Special"},
{"name": "Shock Fang", "type": "VOLT", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Discharge", "type": "VOLT", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Thunder Claw", "type": "VOLT", "power": 75, "accuracy": 0.85, "category": "Physical"},
{"name": "Zap Beam", "type": "VOLT", "power": 45, "accuracy": 1.0, "category": "Special"},
{"name": "Overcharge", "type": "VOLT", "power": 85, "accuracy": 0.8, "category": "Physical"},
{"name": "Earthquake", "type": "EARTH", "power": 80, "accuracy": 0.9, "category": "Physical"},
{"name": "Rock Throw", "type": "EARTH", "power": 45, "accuracy": 1.0, "category": "Physical"},
{"name": "Mud Blast", "type": "EARTH", "power": 55, "accuracy": 0.95, "category": "Special"},
{"name": "Landslide", "type": "EARTH", "power": 90, "accuracy": 0.75, "category": "Physical"},
{"name": "Sand Tomb", "type": "EARTH", "power": 50, "accuracy": 1.0, "category": "Special"},
{"name": "Boulder Crush", "type": "EARTH", "power": 75, "accuracy": 0.85, "category": "Physical"},
{"name": "Fissure Slam", "type": "EARTH", "power": 70, "accuracy": 0.9, "category": "Physical"},
{"name": "Dust Storm", "type": "EARTH", "power": 60, "accuracy": 0.9, "category": "Special"},
{"name": "Tremor Punch", "type": "EARTH", "power": 65, "accuracy": 0.95, "category": "Physical"},
{"name": "Terra Beam", "type": "EARTH", "power": 85, "accuracy": 0.8, "category": "Special"},
{"name": "Iron Slam", "type": "IRON", "power": 70, "accuracy": 0.9, "category": "Physical"},
{"name": "Metal Burst", "type": "IRON", "power": 60, "accuracy": 0.95, "category": "Special"},
{"name": "Steel Fang", "type": "IRON", "power": 55, "accuracy": 1.0, "category": "Physical"},
{"name": "Chrome Beam", "type": "IRON", "power": 80, "accuracy": 0.85, "category": "Special"},
{"name": "Anvil Drop", "type": "IRON", "power": 85, "accuracy": 0.8, "category": "Physical"},
{"name": "Alloy Edge", "type": "IRON", "power": 65, "accuracy": 0.95, "category": "Physical"},
{"name": "Magnetic Pulse", "type": "IRON", "power": 50, "accuracy": 1.0, "category": "Special"},
{"name": "Forge Strike", "type": "IRON", "power": 75, "accuracy": 0.9, "category": "Physical"},
{"name": "Titanium Rush", "type": "IRON", "power": 90, "accuracy": 0.75, "category": "Physical"},
{"name": "Shrapnel Shot", "type": "IRON", "power": 45, "accuracy": 1.0, "category": "Special"},
{"name": "Mind Blast", "type": "PSYCHE", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Psycho Cut", "type": "PSYCHE", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Brain Wave", "type": "PSYCHE", "power": 80, "accuracy": 0.85, "category": "Special"},
{"name": "Telekinetic Slam", "type": "PSYCHE", "power": 75, "accuracy": 0.9, "category": "Physical"},
{"name": "Mind Pulse", "type": "PSYCHE", "power": 45, "accuracy": 1.0, "category": "Special"},
{"name": "Dream Eater", "type": "PSYCHE", "power": 85, "accuracy": 0.8, "category": "Special"},
{"name": "Psi Blade", "type": "PSYCHE", "power": 55, "accuracy": 0.95, "category": "Physical"},
{"name": "Hypno Ray", "type": "PSYCHE", "power": 50, "accuracy": 1.0, "category": "Special"},
{"name": "Neural Strike", "type": "PSYCHE", "power": 90, "accuracy": 0.75, "category": "Physical"},
{"name": "Thought Crush", "type": "PSYCHE", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Power Punch", "type": "BRAWN", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Mega Kick", "type": "BRAWN", "power": 80, "accuracy": 0.85, "category": "Physical"},
{"name": "Body Slam", "type": "BRAWN", "power": 70, "accuracy": 0.9, "category": "Physical"},
{"name": "Focus Blast", "type": "BRAWN", "power": 90, "accuracy": 0.75, "category": "Special"},
{"name": "Quick Jab", "type": "BRAWN", "power": 40, "accuracy": 1.0, "category": "Physical"},
{"name": "Hammer Arm", "type": "BRAWN", "power": 85, "accuracy": 0.8, "category": "Physical"},
{"name": "Combat Surge", "type": "BRAWN", "power": 55, "accuracy": 1.0, "category": "Special"},
{"name": "Suplex", "type": "BRAWN", "power": 75, "accuracy": 0.85, "category": "Physical"},
{"name": "Aura Fist", "type": "BRAWN", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Iron Will", "type": "BRAWN", "power": 50, "accuracy": 1.0, "category": "Physical"},
{"name": "Shadow Claw", "type": "SHADOW", "power": 60, "accuracy": 0.95, "category": "Physical"},
{"name": "Dark Pulse", "type": "SHADOW", "power": 70, "accuracy": 0.9, "category": "Special"},
{"name": "Nightmare", "type": "SHADOW", "power": 80, "accuracy": 0.85, "category": "Special"},
{"name": "Phantom Strike", "type": "SHADOW", "power": 75, "accuracy": 0.9, "category": "Physical"},
{"name": "Void Beam", "type": "SHADOW", "power": 85, "accuracy": 0.8, "category": "Special"},
{"name": "Dusk Fang", "type": "SHADOW", "power": 55, "accuracy": 1.0, "category": "Physical"},
{"name": "Shadow Sneak", "type": "SHADOW", "power": 40, "accuracy": 1.0, "category": "Physical"},
{"name": "Curse Wave", "type": "SHADOW", "power": 65, "accuracy": 0.9, "category": "Special"},
{"name": "Umbral Slash", "type": "SHADOW", "power": 90, "accuracy": 0.75, "category": "Physical"},
{"name": "Venom Strike", "type": "SHADOW", "power": 50, "accuracy": 1.0, "category": "Special"}
]
Your data foundation is locked in. You have 30 creatures with unique stat profiles and 100 moves split between Physical and Special categories. Next up, you will build the Autoload singleton that parses this JSON and makes it available across every scene in your game.
Build the Data Loader Autoload
You have 30 creatures and 100 moves sitting in JSON files. The next challenge is getting that data into your game at runtime so every scene can access it.
In Godot, an Autoload singleton is a node that loads once when the game starts and persists across scene changes. This makes it the perfect place to parse your JSON data and hold the player's selections as they move between the character select screen, move loadout, and battle.
In this step, get ready to:
- Create a game data script that loads creatures and moves from JSON.
- Register it as an Autoload singleton so all scenes can access the data.
- Delete old .tres files and remove @export creature slots from the battle scene.
Create the game data script
This script will be the central data hub for your entire game. It reads both JSON files, builds Resource objects from the data, and stores the player's creature and move selections.
- In the FileSystem dock at the bottom left, right-click the scripts/ folder.
- Select New Script.
- Name it game_data.gd and click Create.
- Clear any default content, then paste the following code to set up the class variables and _ready() function:
extends Node
# Holds all loaded creatures and moves, plus player selections
var creatures: Array[Creature] = []
var moves: Array[Move] = []
var selected_creature: Creature = null
var selected_moves: Array[Move] = []
func _ready() -> void:
_load_moves()
_load_creatures()
print("Loaded %d creatures and %d moves" % [creatures.size(), moves.size()])
What does this code do?
The script extends Node instead of Resource because Autoloads must be nodes that live in the scene tree.
- creatures and moves store all the data loaded from JSON.
- selected_creature and selected_moves hold the player's choices as they navigate between scenes.
- _ready() fires once when the game starts. It calls the two loader functions and prints a confirmation count.
- Below the _ready() function, add the _load_creatures() function:
func _load_creatures() -> void:
var file: FileAccess = FileAccess.open("res://data/creatures.json", FileAccess.READ)
var json_text: String = file.get_as_text()
var data: Array = JSON.parse_string(json_text)
for entry in data:
var creature: Creature = Creature.new()
creature.creature_name = entry["name"]
creature.max_hp = entry["hp"]
creature.attack = entry["attack"]
creature.defense = entry["defense"]
creature.sp_attack = entry["sp_attack"]
creature.sp_defense = entry["sp_defense"]
creature.speed = entry["speed"]
creature.type = entry["type"]
creature.sprite_color = Color.html(entry["color"])
creatures.append(creature)
What does this function do?
It opens creatures.json, reads the entire file into a string, then parses it with JSON.parse_string().
For each entry in the resulting array, it creates a new Creature resource and maps the JSON keys to the resource's properties. Color.html() converts hex strings like #FF6B35 into Godot Color values.
- Below _load_creatures(), add the _load_moves() function:
func _load_moves() -> void:
var file: FileAccess = FileAccess.open("res://data/moves.json", FileAccess.READ)
var json_text: String = file.get_as_text()
var data: Array = JSON.parse_string(json_text)
for entry in data:
var move: Move = Move.new()
move.move_name = entry["name"]
move.power = entry["power"]
move.type = entry["type"]
move.accuracy = entry["accuracy"]
move.category = entry["category"]
moves.append(move)
What does this function do?
This follows the same pattern as _load_creatures(). It opens moves.json, parses the JSON, and builds a Move resource for each entry. Each move gets its name, power, type, accuracy, and category assigned from the JSON data.
- Finally, add two helper functions at the bottom of the file for the enemy AI to use later:
# Helper functions for enemy creature/move selection
func get_random_creature() -> Creature:
return creatures[randi() % creatures.size()]
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 helpers do?
- get_random_creature() picks a random creature from the full roster. The battle scene will use this to assign the enemy.
- get_random_moves() shuffles the full move pool and returns the requested number. The enemy will get 4 random moves each fight.
- Save game_data.gd with Ctrl+S.
✔️ Awesome, I've got everything!
Great. Double check you have saved game_data.gd before moving on.
ⓧ I'd like to double check the full code
extends Node
var creatures: Array[Creature] = []
var moves: Array[Move] = []
var selected_creature: Creature = null
var selected_moves: Array[Move] = []
func _ready() -> void:
_load_moves()
_load_creatures()
print("Loaded %d creatures and %d moves" % [creatures.size(), moves.size()])
func _load_creatures() -> void:
var file: FileAccess = FileAccess.open("res://data/creatures.json", FileAccess.READ)
var json_text: String = file.get_as_text()
var data: Array = JSON.parse_string(json_text)
for entry in data:
var creature: Creature = Creature.new()
creature.creature_name = entry["name"]
creature.max_hp = entry["hp"]
creature.attack = entry["attack"]
creature.defense = entry["defense"]
creature.sp_attack = entry["sp_attack"]
creature.sp_defense = entry["sp_defense"]
creature.speed = entry["speed"]
creature.type = entry["type"]
creature.sprite_color = Color.html(entry["color"])
creatures.append(creature)
func _load_moves() -> void:
var file: FileAccess = FileAccess.open("res://data/moves.json", FileAccess.READ)
var json_text: String = file.get_as_text()
var data: Array = JSON.parse_string(json_text)
for entry in data:
var move: Move = Move.new()
move.move_name = entry["name"]
move.power = entry["power"]
move.type = entry["type"]
move.accuracy = entry["accuracy"]
move.category = entry["category"]
moves.append(move)
func get_random_creature() -> Creature:
return creatures[randi() % creatures.size()]
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
Register the Autoload in Project Settings
A script only becomes an Autoload when you register it in Project Settings. Until then, it is just a regular script file that nothing loads automatically.
- In the top menu bar, click Project.
- Click Project Settings.
- Select the Globals tab at the top of the dialog.
- Click Autoload in the left sidebar.
- In the Path field, click the folder icon and navigate to res://scripts/game_data.gd.
- The Node Name field should auto-fill as GameData. If it does not, type GameData manually.
- Click Add.
Why an Autoload instead of @export?
Your original battle scene used @export slots to assign creatures directly in the Inspector. That approach breaks when a selection screen in a different scene needs to pass data to the battle.
Autoloaded nodes persist across scene changes. When the player picks a creature on the select screen and transitions to battle, GameData keeps that selection alive.
- Close the Project Settings dialog.
- Press F5 to run the project (select any scene if prompted).
- Check the Output panel at the bottom of the editor. You should see Loaded 30 creatures and 100 moves printed.
Not seeing the print message?
- Make sure the Autoload entry shows GameData with a checkmark in the Enabled column under Project Settings > Globals > Autoload.
- If you see a parse error, confirm your creatures.json and moves.json files have valid JSON syntax (no trailing commas, all brackets closed).
- Check that the file paths in game_data.gd match exactly: res://data/creatures.json and res://data/moves.json.
Why isn't my GameData Autoload printing the creature and move counts?
Delete old resource files and update the battle scene
All creature and move data now comes from JSON at runtime. The old .tres files you created manually in the previous project are no longer needed.
- In the FileSystem dock, locate your old .tres resource files (such as emberfox.tres, aquashell.tres, flame_burst.tres, and any others you created).
- Select all of them, right-click, and choose Delete.
- Confirm the deletion when prompted.
Next, remove the old @export creature slots from your battle scene's script.
- Open battle_manager.gd (or whatever script is attached to your battle scene root).
- Find and delete any @export lines that reference creature or move resources (such as @export var player_creature: Creature or @export var enemy_creature: Creature).
- Save the file.
Why delete these now?
Leaving stale .tres files around causes confusion. The Inspector would still show them as assignable resources even though the battle scene will now read from GameData at runtime. Removing the @export slots ensures nothing accidentally references the old files.
- Press F5 again to confirm the project still runs without errors. The battle scene may not function fully yet (it still expects creature data that the selection screens will provide), but the Output panel should still show Loaded 30 creatures and 100 moves from GameData.
Seeing errors after deleting .tres files?
- If Godot reports missing resources, open the battle scene (battle.tscn) in the Inspector and clear any fields that still reference deleted .tres files.
- Make sure you removed all @export creature/move variables from the battle script. Any leftover export that expects a .tres will show a warning.
Help me fix errors after removing .tres resource files from my Godot project.
Your data pipeline is live. Every creature and move loads from JSON through a single Autoload that any scene can access. Next, you will build the character select screen that lets the player browse all 30 creatures and pick their fighter.
Build the Character Select Screen
Your data pipeline is working. 30 creatures and 100 moves load from JSON every time the project runs.
Now those creatures need a way to reach the player. You will build a character select screen that displays every creature in a scrollable grid and lets the player pick one before heading into battle.
In this step, get ready to:
- Create the character select scene with a scrollable grid layout and stat preview panel.
- Write a script that dynamically spawns a button for each creature in the roster.
- Wire up hover previews and click-to-select behavior that transitions to the move loadout screen.
Create the character select scene
The character select screen needs a title, a scrollable grid to hold 30 creature buttons, and a preview panel that shows the hovered creature's stats. You will build this node tree in the Godot editor.
- In the Godot editor, click Scene in the top menu bar.
- Click New Scene.
- In the Scene dock, click User Interface to create a Control root node.
- With the Control node selected, add a VBoxContainer child by pressing Ctrl+A (Windows) or Cmd+A (macOS), typing VBoxContainer, and clicking Create.
- Select the VBoxContainer. Add a Label child using the same method.
- Select the Label in the Scene dock. In the Inspector on the right, set its Text property to Choose Your Creature.
- Select the VBoxContainer again. Add a ScrollContainer child.
- Select the ScrollContainer. Add a GridContainer child inside it.
- Select the GridContainer. In the Inspector, set the Columns property to 6.
- Select the VBoxContainer again. Add a PanelContainer child and rename it to PreviewPanel.
- Select PreviewPanel. Add a VBoxContainer child inside it.
- Inside that inner VBoxContainer, add four children in order: a Label renamed to NameLabel, a Label renamed to TypeLabel, a Label renamed to StatsLabel, and a ColorRect.
Your final scene tree should look like this:
Control
└── VBoxContainer
├── Label ("Choose Your Creature")
├── ScrollContainer
│ └── GridContainer (columns = 6)
└── PreviewPanel (PanelContainer)
└── VBoxContainer
├── NameLabel (Label)
├── TypeLabel (Label)
├── StatsLabel (Label)
└── ColorRect
- Save the scene by pressing Ctrl+S (Windows) or Cmd+S (macOS).
- In the save dialog, navigate to the scenes folder and save as character_select.tscn.
What is a GridContainer?
A GridContainer arranges its child controls in a grid layout. The number of columns is set by the columns property, and the number of rows adjusts automatically based on how many children exist.
With 30 creature buttons and 6 columns, you get a clean 5-row by 6-column grid. The ScrollContainer wrapping it adds scrollbars if the grid grows beyond the visible area.
Don't see a scenes folder?
If your project does not have a scenes folder yet, right-click in the FileSystem dock, select New Folder, and name it scenes. Then save the scene inside it.
Help me create the character select scene tree in Godot.
Write the character select script
The scene structure is ready, but the grid is empty. You need a script that loops through every creature in GameData.creatures and spawns a clickable button for each one.
- Select the Control root node in the Scene dock.
- Attach a new script by clicking the scroll icon (Attach Script) at the top of the Scene dock.
- Set the path to res://scripts/character_select.gd and click Create.
- Replace all the default code with the following:
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
func _ready() -> void:
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_selected.bind(creature))
grid.add_child(button)
if GameData.creatures.size() > 0:
_update_preview(GameData.creatures[0])
func _on_creature_hovered(creature: Creature) -> void:
_update_preview(creature)
func _on_creature_selected(creature: Creature) -> void:
GameData.selected_creature = creature
GameData.selected_moves = []
get_tree().change_scene_to_file("res://scenes/move_select.tscn")
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 this script do?
The @onready variables grab references to the nodes you built in the scene tree. They resolve once the node enters the tree.
In _ready(), the script loops through all 30 creatures, creating a Button for each one. Each button displays the creature's name and type, and connects two signals: mouse_entered (for hover preview) and pressed (for selection).
When a creature is selected, the script stores it in GameData.selected_creature, clears any previously selected moves, and transitions to the move select scene.
- Save the script with Ctrl+S (Windows) or Cmd+S (macOS).
Seeing a node path error?
If you see an error about a null node reference when running, double-check that your scene tree node names match the paths in the script exactly. The GridContainer must be a direct child of ScrollContainer, and PreviewPanel must be a sibling of ScrollContainer under VBoxContainer.
Help me fix my character select node path errors.
Test the hover preview and selection
The script connects the mouse_entered signal on each button to _on_creature_hovered. When the player moves their cursor over a creature button, the preview panel at the bottom updates with that creature's stats, type, and color swatch.
- Run the character select scene by pressing F6 (Windows) or Cmd+R (macOS) to run the current scene.
- Hover over different creature buttons and confirm the preview panel updates with the creature's name, type, all 6 stats, and color.
Why mouse_entered instead of a focus signal?
The mouse_entered signal fires the moment the cursor moves over the button's bounding box. This gives instant visual feedback without requiring the player to click. It mirrors how character select screens in games like Super Smash Bros show a fighter's details as you browse the roster.
- Click any creature button.
The scene attempts to transition to res://scenes/move_select.tscn. Since that scene does not exist yet, you will see an error in the Output panel. That is expected and you will build it in the next step.
Grid shows up empty?
If no buttons appear in the grid, confirm that the GameData Autoload is still registered. Open Project > Project Settings > Globals > Autoload and verify GameData points to res://scripts/game_data.gd.
Also check the Output panel for the Loaded 30 creatures and 100 moves print statement. If that line is missing, the JSON files may not be loading correctly.
Help me debug why my creature buttons aren't appearing.
✔️ Awesome, I've got everything!
Great. Make sure you have saved both character_select.tscn and character_select.gd.
ⓧ I'd like to double check the full code
Here is the complete scripts/character_select.gd file:
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
func _ready() -> void:
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_selected.bind(creature))
grid.add_child(button)
if GameData.creatures.size() > 0:
_update_preview(GameData.creatures[0])
func _on_creature_hovered(creature: Creature) -> void:
_update_preview(creature)
func _on_creature_selected(creature: Creature) -> void:
GameData.selected_creature = creature
GameData.selected_moves = []
get_tree().change_scene_to_file("res://scenes/move_select.tscn")
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
Your character select screen is alive. Next up, you will build the move loadout screen where the player picks 4 moves from the full pool of 100.
Build the Move Loadout Screen
Your character select screen works. Players can browse 30 creatures, preview their stats, and lock in a choice.
Now you need the second half of the pre-battle flow: picking a moveset. In this step, you will build a move loadout screen that displays all 100 moves in a scrollable grid and lets the player pick exactly 4 to bring into battle.
In this step, get ready to:
- Create the move select scene with a 2-column scrollable grid and a slot panel.
- Write a script that spawns 100 move buttons and handles toggle selection.
- Add visual feedback with green highlights, a slot tracker, and a Fight button.
Create the move select scene
The move select screen needs a title showing which creature you picked, a scrollable grid of 100 move buttons, and a bottom panel tracking your 4 selected slots.
- In the Godot editor, click Scene in the top menu bar.
- Click New Scene.
- In the Scene dock, click User Interface to create a Control root node.
- Rename the root node to Control (leave the default name).
Now build the node tree. Add the following children one by one:
- Select the Control root and add a child node of type VBoxContainer.
- Select the VBoxContainer and add a child node of type Label. Rename it to TitleLabel.
- Select the VBoxContainer and add a child node of type ScrollContainer.
- Select the ScrollContainer and add a child node of type GridContainer. In the Inspector, set its Columns property to 2.
- Select the VBoxContainer again and add a child node of type PanelContainer. Rename it to SlotsPanel.
- Select SlotsPanel and add a child node of type VBoxContainer.
- Select the new VBoxContainer inside SlotsPanel and add three children:
- Save the scene as res://scenes/move_select.tscn by pressing Cmd+S (macOS) or Ctrl+S (Windows).
What is this scene structure doing?
The ScrollContainer wraps the GridContainer so that when 100 move buttons get added dynamically, the player can scroll through them.
The SlotsPanel at the bottom stays fixed below the scroll area. It shows the current selection state at all times, even while scrolling.
Your Scene dock should show this tree structure:
- Control > VBoxContainer > TitleLabel, ScrollContainer > GridContainer, SlotsPanel > VBoxContainer > SlotsLabel, CounterLabel, FightButton.
Scene tree look different?
Make sure the GridContainer is a child of ScrollContainer, not a sibling. If you added it to the wrong parent, drag it in the Scene dock to reparent it.
Double-check that the second VBoxContainer is inside SlotsPanel, not at the same level.
help me fix my move_select.tscn scene tree
Write the move population and selection script
With the scene tree ready, you need a script that reads all 100 moves from GameData and creates a clickable button for each one. Clicking a button toggles it in and out of your selection.
- Select the Control root node in your move_select.tscn scene.
- Click the Attach Script icon (scroll icon) at the top of the Scene dock.
- Set the path to res://scripts/move_select.gd and click Create.
- Replace the default script content with the following code:
extends Control
@onready var title_label: Label = $VBoxContainer/TitleLabel
@onready var grid: GridContainer = $VBoxContainer/ScrollContainer/GridContainer
@onready var slots_label: Label = $VBoxContainer/SlotsPanel/VBoxContainer/SlotsLabel
@onready var counter_label: Label = $VBoxContainer/SlotsPanel/VBoxContainer/CounterLabel
@onready var fight_button: Button = $VBoxContainer/SlotsPanel/VBoxContainer/FightButton
# Track all spawned buttons for reference
var move_buttons: Array[Button] = []
func _ready() -> void:
# Show which creature the player picked
title_label.text = "Pick 4 moves for %s" % GameData.selected_creature.creature_name
fight_button.hide()
fight_button.pressed.connect(_on_fight_pressed)
_populate_moves()
_update_slots_display()
func _populate_moves() -> void:
# Spawn a button for every move in the game
for move in GameData.moves:
var button: Button = Button.new()
button.text = "%s\n%s | Pow:%d | %s" % [move.move_name, move.type, move.power, move.category]
button.custom_minimum_size = Vector2(250, 50)
button.pressed.connect(_on_move_pressed.bind(move, button))
grid.add_child(button)
move_buttons.append(button)
func _on_move_pressed(move: Move, button: Button) -> void:
# Toggle selection: remove if already selected, add if under 4
if move in GameData.selected_moves:
GameData.selected_moves.erase(move)
button.modulate = Color.WHITE
elif GameData.selected_moves.size() < 4:
GameData.selected_moves.append(move)
button.modulate = Color.GREEN
_update_slots_display()
What does this code do?
The @onready variables grab references to each node in your scene tree using the exact paths you built.
The _populate_moves() function loops through all 100 moves stored in GameData.moves and creates a button for each one. Each button displays the move's name, type, power, and whether it is Physical or Special.
The _on_move_pressed() function toggles a move in or out of the selection. If the move is already selected, clicking removes it and resets the button color to white. If under 4 moves are selected, clicking adds it and turns the button green.
Seeing an error about null instance?
This means an @onready path does not match your scene tree. Double-check that TitleLabel is a direct child of VBoxContainer, and that SlotsPanel has a VBoxContainer child containing SlotsLabel, CounterLabel, and FightButton.
help me debug my @onready node path errors in move_select.gd
Add the slots display and fight transition
The toggle logic is in place, but the player has no way to see which moves they have picked or start the battle. You need a display function that updates the slot panel and reveals the Fight button when 4 moves are selected.
- In scripts/move_select.gd, add the following two functions at the bottom of the file, below _on_move_pressed():
func _update_slots_display() -> void:
# Build the text for all 4 move slots
var slot_texts: Array[String] = []
for move in GameData.selected_moves:
slot_texts.append("%s (%s)" % [move.move_name, move.type])
while slot_texts.size() < 4:
slot_texts.append("---")
slots_label.text = " | ".join(slot_texts)
counter_label.text = "%d/4 moves selected" % GameData.selected_moves.size()
# Show the Fight button only when exactly 4 moves are picked
if GameData.selected_moves.size() == 4:
fight_button.show()
else:
fight_button.hide()
func _on_fight_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/battle.tscn")
What does this code do?
The _update_slots_display() function builds a string showing your current picks (or --- for empty slots) and writes it to SlotsLabel. It also updates the counter text and toggles the Fight button's visibility.
The _on_fight_pressed() function transitions to the battle scene. Because GameData is an Autoload singleton, it persists across scene changes. The battle scene can read GameData.selected_creature and GameData.selected_moves directly.
- Save move_select.gd.
- Run the move_select.tscn scene by pressing F6 (or Cmd+R on macOS) to play the current scene.
What should I see?
If you run move_select.tscn directly without selecting a creature first, you will get an error because GameData.selected_creature is null. To test properly, run character_select.tscn instead. Click any creature, then the move select screen appears with 100 move buttons in a 2-column grid.
Click any 4 moves. Each selected move turns green, the slot panel fills in with move names, the counter reads 4/4 moves selected, and the Fight button appears.
Buttons not appearing in the grid?
Make sure your GridContainer is a direct child of ScrollContainer in the scene tree. If it is a sibling instead, the scroll will not contain the buttons.
Also verify that GameData is still registered as an Autoload. Check Project > Project Settings > Globals > Autoload to confirm game_data.gd is listed.
help me fix my move loadout screen buttons not showing up
I've got everything!
Great. Double check you have saved both move_select.tscn and move_select.gd.
I'd like to double check the full code
extends Control
@onready var title_label: Label = $VBoxContainer/TitleLabel
@onready var grid: GridContainer = $VBoxContainer/ScrollContainer/GridContainer
@onready var slots_label: Label = $VBoxContainer/SlotsPanel/VBoxContainer/SlotsLabel
@onready var counter_label: Label = $VBoxContainer/SlotsPanel/VBoxContainer/CounterLabel
@onready var fight_button: Button = $VBoxContainer/SlotsPanel/VBoxContainer/FightButton
var move_buttons: Array[Button] = []
func _ready() -> void:
title_label.text = "Pick 4 moves for %s" % GameData.selected_creature.creature_name
fight_button.hide()
fight_button.pressed.connect(_on_fight_pressed)
_populate_moves()
_update_slots_display()
func _populate_moves() -> void:
for move in GameData.moves:
var button: Button = Button.new()
button.text = "%s\n%s | Pow:%d | %s" % [move.move_name, move.type, move.power, move.category]
button.custom_minimum_size = Vector2(250, 50)
button.pressed.connect(_on_move_pressed.bind(move, button))
grid.add_child(button)
move_buttons.append(button)
func _on_move_pressed(move: Move, button: Button) -> void:
if move in GameData.selected_moves:
GameData.selected_moves.erase(move)
button.modulate = Color.WHITE
elif GameData.selected_moves.size() < 4:
GameData.selected_moves.append(move)
button.modulate = Color.GREEN
_update_slots_display()
func _update_slots_display() -> void:
var slot_texts: Array[String] = []
for move in GameData.selected_moves:
slot_texts.append("%s (%s)" % [move.move_name, move.type])
while slot_texts.size() < 4:
slot_texts.append("---")
slots_label.text = " | ".join(slot_texts)
counter_label.text = "%d/4 moves selected" % GameData.selected_moves.size()
if GameData.selected_moves.size() == 4:
fight_button.show()
else:
fight_button.hide()
func _on_fight_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/battle.tscn")
Your move loadout screen is complete. Players can browse all 100 moves, toggle their picks, and launch into battle. Next up, you will update the battle manager to actually use these selections and calculate damage with the full stat model.
Update the Battle Manager
Your selection screens feed the player's choices into GameData. The battle scene still uses the old hardcoded creature assignment and a simple damage formula that ignores stats entirely.
In this step, you'll rewire the battle manager to pull creatures and moves from GameData, implement a damage formula that distinguishes Physical and Special moves, and close the game loop with a back-to-select option.
In this step, get ready to:
- Rewrite battle_manager.gd to load creatures and moves from GameData.
- Implement stat-based damage using Physical/Special categories and Speed-based turn order.
- Set character_select.tscn as the main scene for a complete game loop.
Rewrite battle_manager.gd to use GameData
The new script dynamically creates move buttons and references a BackButton node that doesn't exist in the scene yet. You need to update the battle scene tree before replacing the script.
- Open battle.tscn in the Godot editor.
- In the Scene dock on the left, expand ActionPanel > VBoxContainer > MoveGrid.
- Select all the child Button nodes inside MoveGrid (Button1, Button2, Button3, Button4), right-click, and choose Delete Node(s).
- Right-click the root Control node and select Add Child Node.
- Search for Button, select it, and click Create.
- Rename the new node to BackButton in the Scene dock.
- In the Inspector on the right, set the Text property to Back to Select.
- Press Ctrl+S (Windows) or Cmd+S (macOS) to save the scene.
Why delete the old buttons?
The original battle scene had 4 pre-placed Button nodes in the MoveGrid. The new script clears all children from MoveGrid and dynamically creates buttons from the player's selected moves. Leaving the old nodes would cause duplicates.
Now replace the entire contents of battle_manager.gd with the updated version. The changes touch every function, so a full replacement is cleaner than patching individual sections.
- Open scripts/battle_manager.gd in the script editor.
- Select all the existing code with Ctrl+A (Windows) or Cmd+A (macOS) and delete it.
- Paste the following complete script:
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:
# Load player's selections from GameData
player_creature = GameData.selected_creature
player_moves = GameData.selected_moves
# Pick a random enemy creature and 4 random 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
# 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()
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()
func _setup_move_buttons() -> void:
# Clear any existing buttons and create from selected moves
for child in move_grid.get_children():
child.queue_free()
for i in range(player_moves.size()):
var move: Move = player_moves[i]
var button: Button = Button.new()
button.text = "%s (%s)" % [move.move_name, move.type]
button.pressed.connect(_on_move_selected.bind(i))
move_grid.add_child(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()
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:
# Check if the move misses
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
# Pick stats based on move category
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)
# Calculate damage with stat ratio and random variance
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)
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")
What changed in this script?
The top of _ready() now pulls the player's creature and moves directly from GameData instead of reading @export slots. The enemy is picked randomly from the full roster with get_random_creature() and get_random_moves(4).
The _setup_move_buttons() function clears any existing children from MoveGrid and dynamically creates one button per selected move. This handles any number of moves rather than relying on pre-placed nodes.
The _on_back_pressed() function transitions back to character_select.tscn so the player can pick a different creature after winning or losing.
- Save battle_manager.gd with Ctrl+S (Windows) or Cmd+S (macOS).
Seeing a node path error?
If the Output panel shows an error about BackButton not being found, make sure you added the BackButton node as a direct child of the root Control node in battle.tscn, not nested inside another container.
If you see errors about missing Button nodes in MoveGrid, confirm you deleted the old Button1-Button4 nodes from the scene tree and saved the scene.
Help me debug a node path error in my battle scene.
Walk through the stat-based damage formula
The biggest gameplay change is inside the _execute_move() function. Instead of a flat damage number, the formula now factors in each creature's individual stats.
How does the Physical vs Special split work?
Every move in moves.json has a category field set to either Physical or Special. Physical moves compare the attacker's attack stat against the defender's defense stat. Special moves compare sp_attack against sp_defense.
This means a creature like Boulderback (100 attack, 35 sp_attack) hits hard with Physical moves but barely scratches with Special ones. A creature like Mindweaver (40 attack, 110 sp_attack) is the opposite.
What is the damage formula doing?
The formula calculates: move.power * (attack_stat / defense_stat) * effectiveness * randf_range(0.85, 1.15). The stat ratio means a creature with twice the attack deals roughly double damage. The random range adds a 15% variance so battles feel less predictable.
The max(1.0, defense_stat) guard prevents division by zero. The max(1, damage) at the end ensures every hit deals at least 1 HP of damage.
How does Speed-based turn order work?
At the bottom of _ready(), the script compares player_creature.speed against enemy_creature.speed. The faster creature takes the first turn. If speeds are equal, the player goes first.
This makes Speed a meaningful stat. A glass cannon like Sparkpaw (speed 110) will almost always strike first, potentially knocking out a slower enemy before it can retaliate.
Set character_select.tscn as the main scene
The game loop starts at the character select screen, so you need to tell Godot to launch there when you press Play.
- Click Project in the top menu bar.
- Click Project Settings.
- In the left sidebar, navigate to General > Application > Run.
- Find the Main Scene field and click the folder icon next to it.
- Select res://scenes/character_select.tscn and click Open.
- Close the Project Settings window.
- Press F5 (or click the Play button in the top right) to run the project.
- The character select grid should appear. Pick a creature, select 4 moves, and click Fight! to enter battle.
- Battle until one creature faints. You should see both a Play Again button and a Back to Select button on the end screen.
- Click Back to Select to confirm it returns you to the character grid.
What should I see?
Damage numbers should vary based on stats. A Physical move from a high-attack creature like Boulderback should deal noticeably more damage than the same power move from a low-attack creature like Petalbloom.
If the enemy has higher speed, you'll see it attack first before your move buttons appear. The full loop (select, fight, rematch or re-select) should work seamlessly.
Battle crashes on launch?
Make sure you ran the full flow from character select, not the battle scene directly. Running battle.tscn alone will crash because GameData.selected_creature is null without going through the selection screens first.
If damage seems identical every turn, check that your creatures.json has distinct stat values for each creature. Creatures with similar attack and defense will produce similar damage numbers.
Help me troubleshoot my battle system not working correctly.
✔️ Awesome, I've got everything!
Great. Double check you've saved both battle.tscn and battle_manager.gd.
ⓧ I'd like to double check the full code
Here is the complete battle_manager.gd file:
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:
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
player_name_label.text = player_creature.creature_name
player_hp_bar.max_value = player_creature.max_hp
player_hp_bar.value = player_hp
player_hp_label.text = "%d/%d" % [player_hp, player_creature.max_hp]
player_sprite.color = player_creature.sprite_color
enemy_name_label.text = enemy_creature.creature_name
enemy_hp_bar.max_value = enemy_creature.max_hp
enemy_hp_bar.value = enemy_hp
enemy_hp_label.text = "%d/%d" % [enemy_hp, enemy_creature.max_hp]
enemy_sprite.color = enemy_creature.sprite_color
_setup_move_buttons()
message_label.hide()
play_again_button.hide()
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 _setup_move_buttons() -> void:
for child in move_grid.get_children():
child.queue_free()
for i in range(player_moves.size()):
var move: Move = player_moves[i]
var button: Button = Button.new()
button.text = "%s (%s)" % [move.move_name, move.type]
button.pressed.connect(_on_move_selected.bind(i))
move_grid.add_child(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()
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)
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 system now uses real stat-based combat with meaningful creature differences. The full game loop is complete. Next up, you'll extend the move loadout screen with filtering and search in the Secret Mission.
Secret mission
Add Move Filtering and Search
With 100 moves in the pool, scrolling through every single one to find what you want is tedious. Can you add a search bar and type filter buttons so players can narrow down their options in real time?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The BattleSystem project folder on your Desktop (contains all scripts, scenes, and JSON data files).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with creature stats and move balancing.
- Your project is saved locally in the BattleSystem folder.
- You can reopen it anytime by launching Godot and selecting the project from the Project Manager.
Pause - I'll come back to this later
Close Godot to free up memory. Your files remain on disk and you can reopen the project anytime.
- Close Godot by clicking the X button or pressing Cmd+Q (macOS) or Alt+F4 (Windows).
- Your BattleSystem folder stays on disk. Reopen by launching Godot and selecting the project from the list.
Delete - I don't want to use this again
Remove all project files and start fresh. This permanently deletes your creature roster, move data, and all scripts.
- Close Godot if it is still open.
- Delete the BattleSystem folder from your Desktop. On macOS, drag it to the Trash and then empty the Trash. On Windows, right-click the folder, select Delete, then empty the Recycle Bin.
- Alternatively, delete it from the terminal by running the appropriate command for your system:
macOS
rm -rf ~/Desktop/BattleSystem
Windows
Remove-Item -Recurse -Force ~\Desktop\BattleSystem
Nice Work!
Nice Work!
Nice work! You just transformed a basic battle prototype into a data-driven game with a full creature roster, selection screens, and a stat-based combat system.
You've learned how to:
- Build a JSON data pipeline that loads 30 creatures and 100 moves at runtime without manually creating .tres files.
- Create a character select grid with dynamically spawned buttons, hover previews, and an Autoload singleton that passes selections seamlessly between scenes.
- Implement a full 6-stat damage formula where Physical moves use Attack vs Defense, Special moves use Sp. Attack vs Sp. Defense, and Speed determines turn order.
- Secret Mission: Added real-time search and type filtering to the move selection screen so players can find moves instantly from a pool of 100.
Ready to quiz yourself?