Overview
Tested with: Godot 4.3+
The Godot editor is highly extensible -- using the EditorPlugin class, you can add custom docks and inspector panels. However, without a proper understanding of how the @tool annotation behaves and the plugin lifecycle works, you may encounter editor crashes or unexpected behavior.
This article walks through the process from basic plugin structure to implementing custom docks and inspector plugins for practical use.
@tool Annotation Basics
When implementing editor extensions in Godot, the first thing to understand is the @tool annotation. For example, imagine you want to visualize an attack range as a circle right in the editor -- that's where @tool comes in.
Adding @tool at the top of a script makes it run inside the editor as well. However, it's important to separate editor behavior from gameplay logic.
@tool
extends Node2D
func _process(delta):
if Engine.is_editor_hint():
# Runs only in the editor
queue_redraw()
else:
# Runs during gameplay
move_character(delta)
Important: Without branching via Engine.is_editor_hint(), game logic will execute inside the editor as well.
Here's a practical example combining @tool setters with _draw(). When you change radius in the inspector, the circle updates in real time in the editor.
Common uses for @tool:
| Use Case | Description |
|---|---|
| Custom drawing | Display guides or previews in the editor with _draw() |
| Property preview | Update preview in real time when @export values change |
| EditorPlugin | Required for the plugin's main script |
@tool
extends Sprite2D
@export var radius: float = 100.0:
set(value):
radius = value
queue_redraw() # Redraw when the value changes
func _draw():
if Engine.is_editor_hint():
draw_circle(Vector2.ZERO, radius, Color(0, 1, 0, 0.3))
plugin.cfg and EditorPlugin Setup
With @tool basics under your belt, let's move on to creating an actual plugin. To create a plugin, you need to follow a specific directory structure. Godot automatically detects this structure and displays it in the plugin settings panel.
Directory Structure
addons/
└── my_plugin/
├── plugin.cfg # Plugin configuration file
├── my_plugin.gd # EditorPlugin script
└── dock/
└── my_dock.tscn # Custom dock scene (optional)
plugin.cfg
[plugin]
name="My Plugin"
description="A plugin that adds a custom dock"
author="Your Name"
version="1.0.0"
script="my_plugin.gd"
EditorPlugin Basics
@tool
extends EditorPlugin
func _enter_tree():
# Called when the plugin is activated
print("Plugin activated")
func _exit_tree():
# Called when the plugin is deactivated
# Always remove added UI elements here
print("Plugin deactivated")
How to enable a plugin:
- Go to "Project" -> "Project Settings" -> "Plugins" tab
- Find your plugin in the list and check the enable checkbox
Adding a Custom Dock
With the plugin skeleton in place, let's add some real functionality. The most common use case is adding a custom panel (Dock) to the editor. For example, you could build a debug tool that lists all objects in a scene or a texture preview UI.
The following code adds a custom dock to the upper-left panel:
@tool
extends EditorPlugin
var dock: Control
func _enter_tree():
dock = preload("res://addons/my_plugin/dock/my_dock.tscn").instantiate()
add_control_to_dock(DOCK_SLOT_LEFT_UL, dock)
func _exit_tree():
if dock:
remove_control_from_docks(dock)
dock.queue_free()
Dock Placement Slots
| Slot Constant | Position |
|---|---|
DOCK_SLOT_LEFT_UL | Left panel, upper |
DOCK_SLOT_LEFT_BL | Left panel, lower |
DOCK_SLOT_RIGHT_UL | Right panel, upper |
DOCK_SLOT_RIGHT_BL | Right panel, lower |
Creating a Dock Scene
The content displayed in a dock can be created as a regular scene.
Use a Control node as the root and add the UI elements you need.
# dock/my_dock.gd
@tool
extends VBoxContainer
@onready var label = $StatusLabel
@onready var button = $RunButton
func _ready():
button.pressed.connect(_on_run_pressed)
func _on_run_pressed():
label.text = "Running..."
# EditorInterface is directly accessible in EditorPlugin scripts.
# For dock scripts, pass a reference from the EditorPlugin
# or use signals to communicate back to the plugin.
label.text = "Done"
Adding Toolbar Buttons
Docks are always-visible panels, but if you just need to trigger a specific action with a single click, a toolbar button is more lightweight. For example, a button that runs validation across all nodes in a scene is a great fit.
@tool
extends EditorPlugin
var button: Button
func _enter_tree():
button = Button.new()
button.text = "My Tool"
button.pressed.connect(_on_button_pressed)
add_control_to_container(CONTAINER_TOOLBAR, button)
func _exit_tree():
if button:
remove_control_from_container(CONTAINER_TOOLBAR, button)
button.queue_free()
func _on_button_pressed():
print("Toolbar button clicked")
Common Container Constants
| Constant | Location |
|---|---|
CONTAINER_TOOLBAR | Main toolbar |
CONTAINER_SPATIAL_EDITOR_MENU | 3D editor menu |
CONTAINER_CANVAS_EDITOR_MENU | 2D editor menu |
CONTAINER_INSPECTOR_BOTTOM | Bottom of the inspector |
Custom Inspector Plugins
Following docks and toolbar buttons, the inspector is another powerful extension point. The inspector normally displays node properties automatically, but you can also add dedicated UI when specific nodes are selected. For instance, you could add a widget that displays speed values in a user-friendly way when a CharacterBody2D is selected.
First, register the inspector plugin from the EditorPlugin, then define the actual plugin class:
# my_plugin.gd
@tool
extends EditorPlugin
var inspector_plugin: MyInspectorPlugin
func _enter_tree():
inspector_plugin = MyInspectorPlugin.new()
add_inspector_plugin(inspector_plugin)
func _exit_tree():
if inspector_plugin:
remove_inspector_plugin(inspector_plugin)
# my_inspector_plugin.gd
@tool
class_name MyInspectorPlugin
extends EditorInspectorPlugin
func _can_handle(object: Object) -> bool:
# Determine which objects this plugin handles
return object is CharacterBody2D
func _parse_begin(object: Object):
# Add UI elements at the top of the inspector
var label = Label.new()
label.text = "== Character Info =="
add_custom_control(label)
func _parse_property(object, type, name, hint_type, hint_string, usage_flags, wide):
if name == "speed":
var label = Label.new()
label.text = "Speed: %s" % str(object.get(name))
add_custom_control(label)
return true
return false
Best Practices
Now that you've seen the key features, let's go over common pitfalls in plugin development. Plugin development has different considerations than regular GDScript. In particular, poor resource management can cause editor crashes or memory leaks.
| Topic | Recommendation |
|---|---|
| Null checks | Always check for null before removing elements in _exit_tree() |
| Editor/runtime separation | Use Engine.is_editor_hint() to branch editor-only logic |
| Resource cleanup | Call queue_free() on all added UI elements in _exit_tree() |
| Use preload | Preload scenes and resources with preload() |
| Error handling | Use push_warning() to notify users of issues |
Common mistakes:
# BAD: Forgetting to free UI elements in _exit_tree()
func _exit_tree():
pass # Causes memory leaks or editor crashes
# GOOD: Always clean up
func _exit_tree():
if dock:
remove_control_from_docks(dock)
dock.queue_free()
if inspector_plugin:
remove_inspector_plugin(inspector_plugin)
Summary
- Adding @tool at the top of a script makes it run inside the editor
- Use Engine.is_editor_hint() to branch editor-only logic
- Implement
_enter_tree()/_exit_tree()in the EditorPlugin class to set up your plugin - Add custom docks to left/right panels with
add_control_to_dock() - Extend the inspector for specific nodes with EditorInspectorPlugin
- Always clean up all added elements in
_exit_tree()to prevent memory leaks