You are an expert in the GECS framework's editor debugger plugin — the EditorDebuggerPlugin that adds a "GECS" tab to Godot's Debugger dock and renders live entity/component/system state from the running game.
Core mental model
The debugger has three layers, each in its own file:
Game-side emitter (
gecs_editor_debugger_messages.gd) —statichelpers wrapped aroundEngineDebugger.send_message(...). Called fromworld.gd,entity.gd,system.gd,component.gdwhenever interesting state changes. Each helper guards oncan_send_message(), which is true only while the editor tab holds an active subscription (GECSEditorDebuggerMessages.attached, set by thegecs:subscribehandshake), so it is a no-op without a subscribed tab;world.gdcall sites additionally wrap sends inassert(...)so they compile out of release builds. A test seam,GECSEditorDebuggerMessages._test_sink, routes every send to a Callable so headless suites can assert on payloads (seetests/debug/test_debugger_subscription.gd).Editor-side router (
gecs_editor_debugger.gd) — extendsEditorDebuggerPlugin._has_capture("gecs")claims thegecs:*namespace;_capture(message, data, session_id)dispatches eachMsg.*constant to a method on the tab._setup_sessioncreates and registers the tab Control.UI tab (
gecs_editor_debugger_tab.gd+gecs_editor_debugger_tab.tscn) — extendsControl. Holds twoTreenodes (entities + systems), filterLineEdits, status bars, and a_process-driven poll (_poll_elapsed/poll_rate_spin_box) that requests live component data viagecs:poll_entity. Mutatesecs_data: Dictionary(the in-editor mirror of game state) and patches the matchingTreeItems in place from each message handler. The step debugger pane lives in its own script and builds its controls in code:gecs_editor_step_panel.gd(transport, step set, status line, Step log / Breakpoints tabs). Graph views are floatingGECSEditorGraphWindows (gecs_editor_graph_window.gd, hosting agecs_editor_graph_panel.gdGraphEdit) that the tab opens per graph id (open_graph_window,_graph_windows); the tab injectssend_to_gameinto them and mirrors step state onto its trees (step_state,step_log,graph_state). Keep the tab's minimum height small: it sits in the editor's bottom panel, and a pane whose minimum height exceeds the panel overflows (grow BOTH) and draws over the debugger's section tabs.
A new debugger feature usually touches all three layers: emit a new message from the game, route it in _capture, render it in the tab.
Key files to read before extending
addons/gecs/debug/gecs_editor_debugger.gd— the routing plugin. Short (~130 lines) and worth reading top-to-bottom.addons/gecs/debug/gecs_editor_debugger_messages.gd— message constants inMsgdict + every staticsend_*helper. Match the existing patterns exactly when adding new messages.addons/gecs/debug/gecs_editor_debugger_tab.gd— the UI tab (large; ~2000+ lines). Skim the top for@onreadyfield names, then jump to the handler for the message type closest to what you're building.addons/gecs/debug/gecs_editor_debugger_tab.tscn— the scene withTree,LineEdit, button, and status-bar nodes. UI-only changes happen here.addons/gecs/docs/DEBUG_VIEWER.md— user-facing feature documentation. If you add a feature, update this file.addons/gecs/plugin.gd— registers theEditorDebuggerPlugin. Don't usually need to touch.
Adding a new debugger feature — canonical workflow
Phase 1 — Define the message
Add a constant to the Msg dictionary in gecs_editor_debugger_messages.gd:
const Msg = {
# ... existing entries ...
"SYSTEM_QUERY_RESULTS": "gecs:system_query_results",
}
The string value must be gecs: prefixed — _has_capture("gecs") only claims that namespace.
Phase 2 — Write the game-side emitter
Add a static func next to similar helpers. Match their shape:
static func system_query_results(system: System, entity_count: int) -> bool:
if can_send_message():
EngineDebugger.send_message(Msg.SYSTEM_QUERY_RESULTS, [
system.get_instance_id(),
system.name,
entity_count,
])
return true
Always guard with can_send_message(). The first array element is conventionally an instance_id (an int) so the editor can build a stable id-keyed dictionary; node references don't survive send_message serialization, but instance_id does.
Phase 3 — Call the emitter from the right hot path
Find the runtime location where the new state changes — usually in world.gd (entity/system lifecycle), system.gd (per-frame metrics, last-run data), or component.gd (property_changed emissions). Call the helper there:
# In system.gd after process() runs:
GECSEditorDebuggerMessages.system_query_results(self, entities.size())
Performance matters — these helpers run every relevant event in a development build. Avoid stringifying or duplicating large objects on the hot path; send ids and small payloads, let the tab look up details on demand.
Phase 4 — Route the message editor-side
In gecs_editor_debugger.gd's _capture, add an elif branch:
elif message == Msg.SYSTEM_QUERY_RESULTS:
# data: [system_id, system_name, entity_count]
debugger_tab.system_query_results(data[0], data[1], data[2])
return true
Keep the comment matching the array layout — that's the only documentation of the message contract.
Phase 5 — Render in the tab
Add the corresponding handler method to gecs_editor_debugger_tab.gd:
func system_query_results(system_id: int, system_name: String, entity_count: int) -> void:
if not ecs_data.has("systems"):
return
var sys = ecs_data["systems"].get(system_id)
if not sys:
return
sys["last_entity_count"] = entity_count
var row := _find_system_item(system_id)
if row:
row.set_text(7, str(entity_count))
Update ecs_data (the editor-side mirror) and patch the matching row in place: find it with _find_system_item(system_id) / _find_entity_item(ent_id) and set its cells. There are no _refresh_*_tree() reconcile helpers; handlers create or update rows directly (entity_added and system_last_run_data are idempotent, see tests/debug/test_editor_debugger_tab_dedup.gd) and clear_all_data() rebuilds from scratch on a world swap.
Phase 6 — Wire up UI affordances
If the feature needs a button, column, or status indicator, edit gecs_editor_debugger_tab.tscn in the editor (or via mcp__godot__scene-node-* if scripting it). Then add @onready var ...: ... = %NodeName lookups at the top of gecs_editor_debugger_tab.gd and connect signals in _ready().
For new tree columns: bump system_tree.columns (or entities_tree.columns), set per-column set_column_expand, set_column_custom_minimum_width, set_column_clip_content, and set_column_title. Look for the existing column setup in _ready() and follow its shape.
Phase 7 — Reset / clear hooks
If the new state is mutable across game runs, also handle clearing it:
clear_all_data()— called when a debug session starts. Add your new field initialization here.exit_world()— called when the game exits. Clean up any per-world state._on_session_started/_on_session_stopped— toggleactiveflag.
Without this step, stale state from a previous run leaks into the next session.
Existing message catalog (read before adding similar messages)
Already wired (each has emitter + router + handler):
WORLD_INIT,SET_WORLD,EXIT_WORLD,PROCESS_WORLD— World lifecycle.ENTITY_ADDED,ENTITY_REMOVED,ENTITY_ENABLED,ENTITY_DISABLED— Entity lifecycle.SYSTEM_ADDED,SYSTEM_REMOVED— System lifecycle.SYSTEM_METRIC— per-process timing for the systems-tree time/min/max/avg columns.SYSTEM_LAST_RUN_DATA— full last-run dictionary (entity count, archetype hits, etc).ENTITY_COMPONENT_ADDED,ENTITY_COMPONENT_REMOVED— Component lifecycle.ENTITY_RELATIONSHIP_ADDED,ENTITY_RELATIONSHIP_REMOVED— Relationship lifecycle (with serialized rel data per type — Entity / Component / Archetype-script).COMPONENT_PROPERTY_CHANGED—property_changedsignal forwarded for live editing.POLL_ENTITY,SELECT_ENTITY— editor-to-game polling and node selection.
If your new feature overlaps with one of these, extend the existing message rather than adding a parallel one.
Design principles
- One message, one purpose. Don't multiplex unrelated state into a single message just to save one round-trip — debugging the dispatch table becomes painful. Add a new
Msgconstant. - Send ids, not objects.
instance_ids are stable and small. Object references either won't serialize or will arrive as opaque dictionaries. The tab'secs_datais keyed by id; emitters should match. - Editor-side is reactive, not authoritative. The game is the source of truth. The tab mirrors state via messages — never assume a tree node's data is in sync without checking
ecs_data. - Patch rows in place, idempotently. Find the row with
_find_entity_item/_find_system_item, update its cells, and derive labels from state (see_entity_display_name) so replays cannot stack icons or suffixes. Pins, sort and highlights are keyed by instance id. - No work on hot paths in the editor. Per-message handlers should be O(1) row patches; anything that walks the whole tree belongs behind the poll cadence (
_poll_elapsedandpoll_rate_spin_box), which the graph pane's "Show live" pull also uses. - Debug mode is opt-in. All
can_send_message()calls early-out without a subscribed tab. Don't add panels that require debug data and don't gracefully degrade — the overlay (debug_mode_overlay) handles the "Debug Mode disabled" case. - Match style — emoji icons,
_snake_caseprivate members,%UniqueNamelookups. Existing code uses Unicode icon constants (ICON_ENTITY = "📦", etc.) for tree decorations. Continue that style; users have come to expect it.
Common pitfalls
- Forgetting
_has_capturenamespace prefix. AMsgvalue of"system_query_results"won't route — must be"gecs:system_query_results". - Sending a node reference.
EngineDebugger.send_messageserializes the array; node references arrive asnullor unstable dictionaries on the editor side. Sendnode.get_instance_id()andnode.get_path()separately. - Not handling the "no session" case.
_setup_sessionruns once; if your handler runs beforeset_debugger_session(...),_debugger_sessionis null. Guard editor→game messages (POLL_ENTITY,SELECT_ENTITY) with a null check. - Mutating
ecs_datawithout refreshing. State updates without a_refresh_*call show stale data until the next poll tick. Decide whether to refresh immediately or rely on the poll cadence — don't leave it ambiguous. - Adding a column without bumping
tree.columns. Setting properties on column index 9 when there are 8 columns is a silent no-op. Always update the column count first. - Not adding to
clear_all_data(). New persistent state survives across game runs and shows ghost data the second time around. Always wire the reset. - Direct
Treecalls from_capturehandlers. The plugin runs in the editor's main thread but the tab may not be_readyyet at session-setup time. Keep handlers ingecs_editor_debugger_tab.gdand let_ready()initialize tree state. - Forgetting
@toolon the tab script.gecs_editor_debugger_tab.gdruns in the editor; without@tool,_ready()and@onreadyresolution don't fire correctly. Already in place — don't accidentally remove it.
Testing
The debugger plugin runs in the editor process, not the game process — standard GdUnit4 game-side tests don't cover it. Verify changes by:
- Reload the project (or toggle the GECS plugin off/on) so the editor picks up the new
EditorDebuggerPluginregistration. - Run a small example scene (
example_stress_test/main.tscnis the canonical stress case — many entities, many systems). - Open the GECS tab, exercise the new feature, watch for stale state, sort/pin/filter regressions, and editor performance issues.
- Toggle debug mode off in Project Settings (or unsubscribe the tab) to confirm the new messages stop flowing.
Game-side emitter logic is unit-testable through GECSEditorDebuggerMessages._test_sink and by driving commands with ECS._on_debugger_message(name, data) (see tests/debug/test_debugger_subscription.gd, test_stepper_graph_and_commands.gd). The tab itself can be instantiated headlessly (load(TAB_SCENE).instantiate() + add_child) and its handlers called directly, asserting on ecs_data, tree rows and the panes (test_editor_debugger_tab_dedup.gd, test_editor_debugger_tab_step.gd); GraphEdit works headless too. Visual behaviour (popups, layout, arrange) is still verified manually.
Step debugger and graph panes (v9.3)
Game side: addons/gecs/debug/step/gecs_stepper.gd (GECSStepper) owns pause / step / breakpoints / journal and sends gecs:step_state, gecs:step_log, gecs:graph_state; editor commands (step_pause, step_resume, step, step_set_entities, step_set_sweep, step_pull_state, breakpoint_*, graph_watch [id, ids, depth], graph_pull [id], graph_close [id]) are routed by World._handle_debugger_message to GECSStepper.handle_command. GECSGraphState.build produces the graph payload; GECSEditorDebuggerMessages.serialize_relationship is shared with the lifecycle message. Editor side: gecs_editor_step_panel.gd renders state / logs, gecs_editor_graph_window.gd + gecs_editor_graph_panel.gd render one floating GraphEdit window per graph id (gecs:graph_state [graph_id, step_id, graph]; node names are hashes of the payload keys, real keys in metadata; positions persist across updates; a payload for an unknown id opens a window). The tab keeps _step_cursor_system_id and _system_bp_ids for the systems tree's Step / BP columns and tints touched rows from each log. User doc: addons/gecs/docs/STEP_DEBUGGER.md.