| name | godot-addon |
| description | Working with the Godot addon GDScript layer in addons/stage/. Covers EditorPlugin patterns, autoload management, dock panels, input handling, and GDExtension integration from the GDScript side. |
Godot Addon — GDScript Layer
This skill covers addons/stage/ — the GDScript side of Stage. There are four GDScript files:
plugin.gd — @tool EditorPlugin (dock, autoload registration)
runtime.gd — Autoload singleton (instantiates GDExtension classes, input handling)
dock.gd — Dock panel script
debugger_plugin.gd — Editor debugger plugin for agent activity
Plugin Structure
addons/stage/
├── plugin.cfg # Required metadata
├── plugin.gd # @tool EditorPlugin
├── runtime.gd # Autoload singleton script
├── dock.tscn # Dock panel scene
├── dock.gd # Dock panel script
├── stage.gdextension # GDExtension manifest
└── bin/ # Compiled Rust libraries
├── linux/libstage_godot.so
├── windows/stage_godot.dll
└── macos/libstage_godot.dylib
plugin.cfg:
[plugin]
name="Stage"
description="Spatial debugging for AI agents"
author="Your Name"
version="0.1.0"
script="plugin.gd"
EditorPlugin — plugin.gd
@tool
extends EditorPlugin
var dock: Control
func _enter_tree() -> void:
# Dock setup goes in _enter_tree (not _enable_plugin)
dock = preload("res://addons/stage/dock.tscn").instantiate()
add_control_to_dock(DOCK_SLOT_RIGHT_BL, dock)
func _exit_tree() -> void:
if dock:
remove_control_from_docks(dock)
dock.queue_free()
dock = null
func _enable_plugin() -> void:
# Autoload + settings go in _enable_plugin (fires on explicit enable)
add_autoload_singleton("StageRuntime", "res://addons/stage/runtime.gd")
func _disable_plugin() -> void:
remove_autoload_singleton("StageRuntime")
CRITICAL: Use _enable_plugin / _disable_plugin for autoload management (fires on explicit enable, avoiding timing issues). Use _enter_tree / _exit_tree for dock setup (fires every time the plugin loads).
Dock slot options:
DOCK_SLOT_LEFT_UL # Left sidebar, upper-left tab
DOCK_SLOT_LEFT_BL # Left sidebar, bottom-left tab
DOCK_SLOT_RIGHT_UL # Right sidebar, upper-left tab
DOCK_SLOT_RIGHT_BL # Right sidebar, bottom-left tab (used by Stage)
# add_control_to_bottom_panel(control, title) for the bottom bar
Autoload — runtime.gd
The autoload is the runtime hub. It instantiates GDExtension classes and acts as the bridge between them and the scene tree:
extends Node
# GDExtension class instances (defined in stage-godot Rust crate)
var collector: StageCollector
var tcp_server: StageTCPServer
var recorder: StageRecorder
func _ready() -> void:
collector = StageCollector.new()
add_child(collector)
tcp_server = StageTCPServer.new()
add_child(tcp_server)
tcp_server.start(ProjectSettings.get_setting(
"theatre/stage/connection/port", 9077
))
recorder = StageRecorder.new()
add_child(recorder)
func _physics_process(_delta: float) -> void:
# Pump the TCP server each physics frame (non-blocking)
tcp_server.poll()
func _shortcut_input(event: InputEvent) -> void:
if not event is InputEventKey or not event.pressed:
return
match event.keycode:
KEY_F9: _drop_marker()
KEY_F11: _toggle_pause()
func _drop_marker() -> void:
# Flush dashcam clip — captures the ring buffer around this moment
recorder.flush_dashcam_clip("human")
func _toggle_pause() -> void:
get_tree().paused = not get_tree().paused
StageRuntime.marker() — Code Markers API
Game scripts can place markers directly in code using the StageRuntime autoload:
# System tier (default) — rate-limited, safe in loops
StageRuntime.marker("player_hit")
# Deliberate tier — always triggers a clip (use for rare, important events)
StageRuntime.marker("boss_defeated", "deliberate")
# Silent tier — annotates only, no clip trigger; attached to the next clip
StageRuntime.marker("entered_zone_b", "silent")
Signature: func marker(label: String, tier: String = "system") -> void
- No-op when Stage is not loaded (safe to leave in shipped builds)
- Delegates to
StageRecorder.add_code_marker(label, tier) (GDExtension export)
- Markers appear in clip data with
source: "code"
StageRecorder.add_code_marker(label: GString, tier: GString) — the underlying GDExtension export:
"system" tier: rate-limited dashcam trigger (2 s minimum interval)
"deliberate" tier: always triggers a clip, no rate limit
"silent" tier: stores in pending list; merged into the next clip whose frame range includes it; cap of 1000 pending entries with FIFO eviction
GDExtension Classes from GDScript
GDExtension classes defined in stage-godot (Rust) appear as regular GDScript classes after the .gdextension is loaded. No import needed — they're globally available by class name:
# These are Rust classes, used just like built-in Godot classes
var collector = StageCollector.new()
var result: Array = collector.get_visible_nodes()
var state: Dictionary = collector.get_node_state("enemies/scout_02")
GDExtension loads automatically from the .gdextension manifest file. Unlike regular plugins (which need Project Settings → Plugins → Enable), GDExtension libraries load whenever the .gdextension file is present in the project. There is no separate enable step.
The hybrid limitation (godot#85268): GDScript cannot extends a GDExtension-derived class that itself extends EditorPlugin. This is why plugin.gd is a pure GDScript EditorPlugin that uses GDExtension classes, rather than extending a Rust EditorPlugin. Never attempt:
# WRONG — will fail to load as editor plugin
extends StageRustEditorPlugin # if StageRustEditorPlugin extends EditorPlugin via GDExtension
Input Handling
Use _shortcut_input (preferred over _unhandled_input for keyboard shortcuts):
func _shortcut_input(event: InputEvent) -> void:
if not event is InputEventKey:
return
if not event.pressed:
return # ignore key release events
match event.keycode:
KEY_F8:
handle_f8()
get_viewport().set_input_as_handled() # consume event
Input method priority (earliest to latest):
_input — catches everything first
_shortcut_input — for keyboard shortcuts, after _input
_gui_input — for UI nodes
_unhandled_input — what's left after UI
Use _shortcut_input for Stage's hotkeys to intercept before the game does.
Consuming input: Call get_viewport().set_input_as_handled() if you don't want the game to also respond to the key.
Dock Panel — dock.gd
extends VBoxContainer
@onready var status_dot: ColorRect = $StatusBar/StatusDot
@onready var status_label: Label = $StatusBar/StatusLabel
@onready var port_label: Label = $Details/Port
@onready var tracking_label: Label = $Details/Tracking
@onready var watches_label: Label = $Details/Watches
@onready var frame_label: Label = $Details/Frame
@onready var activity_list: VBoxContainer = $Activity/ScrollContainer/List
@onready var activity_scroll: ScrollContainer = $Activity/ScrollContainer
@onready var collapse_btn: Button = $Activity/CollapseButton
func _ready() -> void:
record_btn.pressed.connect(_on_record_pressed)
stop_btn.pressed.connect(_on_stop_pressed)
marker_btn.pressed.connect(_on_marker_pressed)
# Update UI every second (not every frame — reduce overhead)
var timer = Timer.new()
timer.wait_time = 1.0
timer.timeout.connect(_update_ui)
add_child(timer)
timer.start()
func _update_ui() -> void:
# Access the autoload singleton by name
var runtime = get_node_or_null("/root/StageRuntime")
if not runtime:
connection_label.text = "No runtime"
return
var is_connected: bool = runtime.tcp_server.is_connected()
connection_label.text = "Connected" if is_connected else "Disconnected"
func _on_save_clip_pressed() -> void:
var runtime = get_node("/root/StageRuntime")
var clip_id: String = runtime.recorder.flush_dashcam_clip("manual save")
if not clip_id.is_empty():
_update_clip_ui(clip_id)
func add_activity_entry(text: String) -> void:
activity_list.add_item(text)
if activity_list.item_count > 20:
activity_list.remove_item(0) # keep most recent 20
activity_list.ensure_current_is_visible()
Accessing the autoload from the dock:
# Safe — returns null if not found
var runtime = get_node_or_null("/root/StageRuntime")
# Panics if not found — only use when certain it exists
var runtime = get_node("/root/StageRuntime")
# Or use the autoload global name directly (only works at runtime, not in editor)
StageRuntime.tcp_server.is_connected()
The dock runs inside the editor, so accessing StageRuntime only works when the game is running (Play mode). Use get_node_or_null and check for null.
Project Settings
Register custom project settings from the EditorPlugin:
func _enable_plugin() -> void:
_add_setting("theatre/stage/connection/port", TYPE_INT, 9077)
_add_setting("theatre/stage/connection/auto_start", TYPE_BOOL, true)
_add_setting("theatre/stage/display/show_agent_notifications", TYPE_BOOL, true)
func _add_setting(name: String, type: int, default_value) -> void:
if not ProjectSettings.has_setting(name):
ProjectSettings.set_setting(name, default_value)
ProjectSettings.set_initial_value(name, default_value)
# Makes it show in the Project Settings editor UI
ProjectSettings.add_property_info({
"name": name,
"type": type,
})
Read settings from any script:
var port: int = ProjectSettings.get_setting("theatre/stage/connection/port", 9077)
In-Game Overlay (CanvasLayer)
For F8/F9/F10 visual feedback and agent action notifications, add a CanvasLayer to the autoload:
# In runtime.gd _ready()
var overlay = CanvasLayer.new()
overlay.layer = 100 # Draw above everything
add_child(overlay)
var notification_label = Label.new()
overlay.add_child(notification_label)
notification_label.visible = false
func show_notification(text: String, duration: float = 3.0) -> void:
notification_label.text = text
notification_label.visible = true
await get_tree().create_timer(duration).timeout
notification_label.visible = false
Common Gotchas
_enter_tree autoload timing bug: When Godot starts and the plugin is already enabled, autoloads added in _enter_tree aren't immediately ready. Always use _enable_plugin for add_autoload_singleton.
GDExtension loads silently: If the .gdextension binary is missing or wrong platform, Godot shows an error in the Output panel and the classes are unavailable. The plugin won't crash — GDExtension classes just won't exist. Check ClassDB.class_exists("StageCollector") to detect this.
@tool is required: Without @tool at the top of plugin.gd, the EditorPlugin lifecycle methods (_enable_plugin, _disable_plugin) won't run in the editor.
Dock scenes must be freed: If you instantiate() a scene for the dock, you must queue_free() it in _disable_plugin. Forgetting leaks the Control node into the editor.
Autoload path: Autoloads live at /root/AutoloadName. Access via get_node("/root/StageRuntime") or just StageRuntime (global alias, only works at game runtime, not in @tool editor code).