Modding System Theory - Godot 4
Overview
Godot 4 supports multiple approaches to modding, from in-game editors to standalone external tools. This guide covers the theory and implementation of mod systems.
Mod Creation Approaches
1. In-Game Editor (Built Into Your Game)
What it is: A mod creation tool built directly into your game's UI.
Advantages: - ✅ Players don't need any external software - ✅ Context-aware (can preview in actual game) - ✅ Instant testing and iteration - ✅ Streamlined workflow - ✅ Can use game's existing assets
Disadvantages: - ❌ Takes development time to build - ❌ UI space limited to game window - ❌ More complex to implement - ❌ Harder to update independently
Best For: - Simple mod types (character stats, items, basic levels) - Games where modding is a core feature - Mobile/console games (no file system access)
# ============================================
# IN-GAME WEAPON CREATOR EXAMPLE
# ============================================
extends Control
@onready var weapon_name_input: LineEdit = $Panel/WeaponName
@onready var damage_slider: HSlider = $Panel/Damage
@onready var speed_slider: HSlider = $Panel/Speed
@onready var preview_sprite: Sprite2D = $PreviewArea/Sprite
func _on_create_button_pressed() -> void:
var weapon = WeaponData.new()
weapon.weapon_name = weapon_name_input.text
weapon.damage = int(damage_slider.value)
weapon.attack_speed = speed_slider.value
weapon.icon = preview_sprite.texture
# Save to user directory
var save_path = "user://mods/weapons/" + weapon.weapon_name.to_snake_case() + ".tres"
DirAccess.make_dir_recursive_absolute("user://mods/weapons/")
if ResourceSaver.save(weapon, save_path) == OK:
show_success_popup("Weapon created successfully!")
# Immediately available in game
GameManager.reload_custom_weapons()
func _on_test_button_pressed() -> void:
# Spawn enemy to test weapon on
spawn_test_dummy()
2. External Standalone Editor (Separate Godot Project)
What it is: A completely separate Godot application dedicated to creating mods.
Advantages: - ✅ More screen space for complex editing - ✅ Can be updated independently from main game - ✅ More powerful tools/features - ✅ Can run on different platforms - ✅ Professional-grade features without bloating main game
Disadvantages: - ❌ Users need to download separate tool - ❌ More work to maintain two projects - ❌ Requires exporting another application
Best For: - Complex mod types (full campaigns, detailed levels) - Games with dedicated modding communities - Professional/advanced mod creation - PC games
# ============================================
# EXTERNAL LEVEL EDITOR (Separate Project)
# ============================================
# This runs as its own standalone application
extends Node2D
var current_level: LevelData
var selected_tool: String = "spawn"
var enemy_spawn_points: Array[Vector2] = []
var item_positions: Array[Vector2] = []
func _ready():
current_level = LevelData.new()
setup_tool_palette()
setup_preview_area()
func _input(event):
if event is InputEventMouseButton and event.pressed:
var click_pos = get_global_mouse_position()
match selected_tool:
"spawn":
add_enemy_spawn(click_pos)
"item":
add_item_position(click_pos)
"wall":
add_wall_segment(click_pos)
func add_enemy_spawn(pos: Vector2):
current_level.enemy_spawn_points.append(pos)
# Visual marker in editor
var marker = preload("res://editor/spawn_marker.tscn").instantiate()
marker.position = pos
$Markers.add_child(marker)
func save_level():
var dialog = FileDialog.new()
dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
dialog.filters = ["*.tres ; Level Resource"]
dialog.file_selected.connect(_on_save_location_selected)
add_child(dialog)
dialog.popup_centered()
func _on_save_location_selected(path: String):
current_level.level_name = path.get_file().get_basename()
if ResourceSaver.save(current_level, path) == OK:
print("Level saved: ", path)
show_notification("Level saved successfully!\nPlace in game's user://levels/ folder")
else:
show_error("Failed to save level!")
func export_for_game():
# Copy to a location users can easily find
var export_path = OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS) + "/MyGame/Mods/Levels/"
DirAccess.make_dir_recursive_absolute(export_path)
var filename = current_level.level_name + ".tres"
ResourceSaver.save(current_level, export_path + filename)
show_notification("Level exported to:\n" + export_path)
3. Hybrid Approach
What it is: Simple in-game editor + advanced external tool.
Example: - In-game: Quick weapon/character creator - External: Complex level editor, campaign builder
# ============================================
# HYBRID: Simple in-game + External editor
# ============================================
# IN-GAME: Quick item tweaker
func quick_modify_item(item: ItemData):
var popup = preload("res://ui/quick_item_editor.tscn").instantiate()
popup.load_item(item)
add_child(popup)
# Make quick stat changes, save back
# EXTERNAL EDITOR: Import and enhance
func import_from_advanced_editor():
var dir = DirAccess.open("user://mods/advanced/")
# Load complex mods created in external tool
# These have features not available in-game
Creating .tres Files Programmatically
The Core Concept
You don't need the Godot editor to create .tres files! They can be created entirely in code at runtime.
# ============================================
# CREATING RESOURCES IN CODE
# ============================================
# 1. Create the resource instance
var weapon = WeaponData.new()
# 2. Set properties
weapon.weapon_name = "Fire Sword"
weapon.damage = 50
weapon.element = "fire"
# 3. Save as .tres file
ResourceSaver.save(weapon, "user://mods/fire_sword.tres")
# 4. Load it back later
var loaded_weapon = load("user://mods/fire_sword.tres") as WeaponData
Advanced: Embedding User Images
# ============================================
# LOAD USER IMAGE AND EMBED IN .TRES
# ============================================
func create_character_with_custom_sprite():
var character = CharacterData.new()
character.name = "Custom Hero"
# Let user pick an image
var image_path = "user://uploads/hero_sprite.png"
var image = Image.new()
if image.load(image_path) == OK:
# Convert to texture and embed
character.sprite_texture = ImageTexture.create_from_image(image)
# Save everything (including image data)
ResourceSaver.save(character, "user://mods/characters/custom_hero.tres")
# The .tres file now contains the image data!
Complete Mod System Architecture
# ============================================
# MOD MANAGER - Game Side
# ============================================
class_name ModManager extends Node
signal mods_loaded
signal mod_failed(mod_name: String, error: String)
const MOD_DIR = "user://mods/"
var loaded_weapons: Array[WeaponData] = []
var loaded_levels: Array[LevelData] = []
var loaded_characters: Array[CharacterData] = []
func _ready():
create_mod_directories()
load_all_mods()
func create_mod_directories():
DirAccess.make_dir_recursive_absolute(MOD_DIR + "weapons/")
DirAccess.make_dir_recursive_absolute(MOD_DIR + "levels/")
DirAccess.make_dir_recursive_absolute(MOD_DIR + "characters/")
func load_all_mods():
loaded_weapons = load_mods_from_directory(MOD_DIR + "weapons/", WeaponData)
loaded_levels = load_mods_from_directory(MOD_DIR + "levels/", LevelData)
loaded_characters = load_mods_from_directory(MOD_DIR + "characters/", CharacterData)
print("Loaded %d weapon mods" % loaded_weapons.size())
print("Loaded %d level mods" % loaded_levels.size())
print("Loaded %d character mods" % loaded_characters.size())
mods_loaded.emit()
func load_mods_from_directory(dir_path: String, expected_type) -> Array:
var mods = []
var dir = DirAccess.open(dir_path)
if not dir:
return mods
dir.list_dir_begin()
var file_name = dir.get_next()
while file_name != "":
if file_name.ends_with(".tres"):
var full_path = dir_path + file_name
var resource = load(full_path)
# Validate type
if is_instance_of(resource, expected_type):
# Validate content
if validate_mod(resource):
mods.append(resource)
else:
mod_failed.emit(file_name, "Validation failed")
else:
mod_failed.emit(file_name, "Wrong resource type")
file_name = dir.get_next()
return mods
func validate_mod(mod_resource: Resource) -> bool:
# Prevent malicious or broken mods
if mod_resource is WeaponData:
var weapon = mod_resource as WeaponData
# Check for reasonable values
if weapon.damage < 0 or weapon.damage > 10000:
return false
if weapon.attack_speed < 0.1 or weapon.attack_speed > 100:
return false
return true
# Add validation for other types
return true
func get_weapon_by_name(weapon_name: String) -> WeaponData:
for weapon in loaded_weapons:
if weapon.weapon_name == weapon_name:
return weapon
return null
Resource Data Structures
# ============================================
# WEAPON RESOURCE
# ============================================
class_name WeaponData extends Resource
@export_group("Identity")
@export var weapon_name: String = "Unnamed Weapon"
@export_multiline var description: String = ""
@export var icon: Texture2D
@export_group("Stats")
@export var damage: int = 10
@export var attack_speed: float = 1.0
@export_range(0, 1) var critical_chance: float = 0.1
@export var element: Element = Element.NONE
@export_group("Requirements")
@export var required_level: int = 1
@export var cost: int = 100
enum Element { NONE, FIRE, ICE, LIGHTNING, POISON }
func get_dps() -> float:
return damage * attack_speed
# ============================================
# LEVEL RESOURCE
# ============================================
class_name LevelData extends Resource
@export_group("Info")
@export var level_name: String = "Untitled Level"
@export_multiline var description: String = ""
@export var author: String = ""
@export var thumbnail: Texture2D
@export_group("Layout")
@export var enemy_spawn_points: Array[Vector2] = []
@export var item_positions: Array[Vector2] = []
@export var player_start_position: Vector2 = Vector2.ZERO
@export var wall_segments: Array[PackedVector2Array] = []
@export_group("Gameplay")
@export var background_music: AudioStream
@export var time_limit: float = 0.0 # 0 = no limit
@export var difficulty: Difficulty = Difficulty.NORMAL
enum Difficulty { EASY, NORMAL, HARD, EXPERT }
# ============================================
# CHARACTER RESOURCE
# ============================================
class_name CharacterData extends Resource
@export_group("Identity")
@export var character_name: String = "Hero"
@export var sprite_texture: Texture2D
@export var portrait: Texture2D
@export_group("Base Stats")
@export var max_health: int = 100
@export var move_speed: float = 200.0
@export var jump_power: float = 500.0
@export_group("Starting Equipment")
@export var starting_weapon: WeaponData
@export var starting_items: Array[ItemData] = []
@export_group("Abilities")
@export var can_double_jump: bool = false
@export var can_dash: bool = false
@export var special_ability: String = ""
External Editor Structure
# ============================================
# EXTERNAL EDITOR PROJECT STRUCTURE
# ============================================
# MyGameEditor/
# ├── main.tscn # Main editor window
# ├── tool_palette.tscn # Tool selection UI
# ├── property_panel.tscn # Edit resource properties
# ├── preview_area.tscn # Preview the mod
# ├── export_dialog.tscn # Export settings
# └── scripts/
# ├── editor_main.gd
# ├── resource_creator.gd
# └── validation.gd
# editor_main.gd
extends Control
var current_resource: Resource
var resource_type: String = "weapon" # weapon, level, character
func _ready():
setup_ui()
setup_menus()
func new_mod(type: String):
match type:
"weapon":
current_resource = WeaponData.new()
"level":
current_resource = LevelData.new()
"character":
current_resource = CharacterData.new()
load_resource_into_editor(current_resource)
func load_mod(path: String):
current_resource = load(path)
load_resource_into_editor(current_resource)
func save_mod(path: String):
if validate_resource(current_resource):
ResourceSaver.save(current_resource, path)
show_notification("Mod saved!")
else:
show_error("Validation failed! Check your values.")
func export_to_game():
# Help users get mod to right location
var user_docs = OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS)
var export_path = user_docs + "/MyGame/Mods/"
# Create instructions file
var readme = FileAccess.open(export_path + "README.txt", FileAccess.WRITE)
readme.store_string("""
HOW TO INSTALL MODS:
1. Copy the .tres files to your game's mod folder:
Windows: %APPDATA%/Godot/app_userdata/MyGame/mods/
Linux: ~/.local/share/godot/app_userdata/MyGame/mods/
macOS: ~/Library/Application Support/Godot/app_userdata/MyGame/mods/
2. Launch the game
3. Your mods will appear in the custom content menu!
""")
readme.close()
# Copy the mod file
var filename = current_resource.get("level_name", "mod") + ".tres"
ResourceSaver.save(current_resource, export_path + filename)
OS.shell_show_in_file_manager(export_path)
Mod Distribution
Option 1: Manual File Sharing
Users share .tres files directly.
Instructions to users:
1. Download the .tres file
2. Place in [User Data Dir]/mods/weapons/
3. Launch game
Option 2: Auto-Install System
# ============================================
# MOD INSTALLER
# ============================================
extends Node
func install_mod_from_file(mod_file_path: String) -> bool:
# Detect mod type by loading and checking
var mod = load(mod_file_path)
var destination = ""
if mod is WeaponData:
destination = "user://mods/weapons/"
elif mod is LevelData:
destination = "user://mods/levels/"
elif mod is CharacterData:
destination = "user://mods/characters/"
else:
return false
# Copy to correct location
var filename = mod_file_path.get_file()
DirAccess.copy_absolute(mod_file_path, destination + filename)
# Reload mods
ModManager.load_all_mods()
return true
Option 3: Workshop Integration
For Steam Workshop or similar platforms:
# ============================================
# WORKSHOP INTEGRATION (Pseudocode)
# ============================================
func subscribe_to_mod(workshop_id: int):
# Steam handles download
Steam.subscribe_item(workshop_id)
func _on_item_installed(item_id: int):
var mod_path = Steam.get_item_install_info(item_id).folder
# Load .tres files from workshop folder
install_workshop_mods(mod_path)
Best Practices
For Game Developers
# ============================================
# VALIDATION IS CRITICAL
# ============================================
func validate_weapon(weapon: WeaponData) -> bool:
# Prevent exploits
if weapon.damage < 1 or weapon.damage > 999:
return false
if weapon.attack_speed < 0.1 or weapon.attack_speed > 10:
return false
# Prevent inappropriate content (if needed)
if contains_profanity(weapon.weapon_name):
return false
return true
# ============================================
# VERSION COMPATIBILITY
# ============================================
class_name WeaponData extends Resource
@export var mod_version: int = 1 # Track mod format version
func is_compatible_with_game() -> bool:
return mod_version <= GameVersion.MOD_FORMAT_VERSION
For Mod Creators
Document the resource structure:
# weapon_data.gd
class_name WeaponData extends Resource
## The displayed name of the weapon
@export var weapon_name: String = "Unnamed Weapon"
## Base damage per hit (1-999)
@export_range(1, 999) var damage: int = 10
## Attacks per second (0.1-10.0)
@export_range(0.1, 10.0) var attack_speed: float = 1.0
Comparison Table
| Approach | Complexity | User-Friendly | Power | Best For |
|---|---|---|---|---|
| In-Game Editor | 🟡 Medium | 🟢 Very Easy | 🟡 Limited | Simple mods, casual players |
| External Editor | 🔴 High | 🟡 Moderate | 🟢 Full | Complex mods, dedicated community |
| Hybrid | 🔴 Very High | 🟢 Flexible | 🟢 Full | Professional modding support |
| JSON Only | 🟢 Easy | 🟢 Easy | 🔴 Very Limited | Stats/config only |
Summary
Yes, you can create an external Godot editor!
✅ Separate Godot project = standalone mod editor
✅ Create .tres files programmatically (no main editor needed)
✅ Users don't need Godot to use mods
✅ Best of both worlds: simple in-game + powerful external
Recommended Workflow:
1. Define your Resource classes in main game
2. Export those scripts to editor project (or keep in sync)
3. Build editor UI with creation/preview tools
4. Save mods as .tres files
5. Users drop .tres files in game's mod folder
6. Game loads and validates mods at runtime
This is how professional modding tools work! 🎮🔧