A comprehensive guide for developing the OpenClaw Godot Plugin.
- Project Structure
- Development Environment Setup
- Architecture Overview
- Adding New Tools
- Debugging
- Code Style
openclaw-godot-plugin/
├── addons/
│ └── openclaw/
│ ├── plugin.cfg # Plugin metadata
│ ├── openclaw_plugin.gd # Main EditorPlugin
│ ├── connection_manager.gd # HTTP connection management
│ └── tools.gd # Tool execution logic
├── OpenClawPlugin~/ # Gateway extension (TypeScript)
│ ├── index.ts # Extension entry point
│ ├── package.json
│ └── tsconfig.json
├── Documentation~/ # Documentation (excluded from Godot)
│ ├── DEVELOPMENT.md
│ ├── TESTING.md
│ └── CONTRIBUTING.md
├── README.md
└── LICENSE
| File | Purpose |
|---|---|
openclaw_plugin.gd |
EditorPlugin entry point, UI creation, signal connections |
connection_manager.gd |
Gateway HTTP communication (register, poll, heartbeat) |
tools.gd |
40 tool execution implementations |
OpenClawPlugin~/index.ts |
Gateway extension, provides godot_execute tool |
- Godot 4.x (4.2+ recommended)
- Node.js 18+ (for building gateway extension)
- OpenClaw 2026.2.3+
git clone https://github.com/TomLeeLive/openclaw-godot-plugin.git
cd openclaw-godot-plugin# Create test project
mkdir -p ~/godot-dev-project
cp -r addons ~/godot-dev-project/Open in Godot:
Project → Project Settings → Plugins- Enable OpenClaw
# Copy extension files
cp -r OpenClawPlugin~/* ~/.openclaw/extensions/godot/
# Restart gateway
openclaw gateway restartCheck Godot Output panel:
[OpenClaw] Plugin loading...
[OpenClaw] Plugin loaded!
[OpenClaw] Registering with gateway...
[OpenClaw] Registered! Session: godot_xxxxx
┌──────────────────┐ HTTP ┌──────────────────┐
│ Godot Editor │◄────────────►│ OpenClaw Gateway │
│ │ │ (port 18789) │
│ ┌────────────┐ │ │ │
│ │ Connection │ │ /register │ ┌─────────────┐ │
│ │ Manager │──┼──────────────┼─►│ Sessions │ │
│ └────────────┘ │ │ └─────────────┘ │
│ │ │ /poll │ │ │
│ ▼ │◄─────────────┼────────┘ │
│ ┌────────────┐ │ │ │
│ │ Tools │ │ /result │ ┌─────────────┐ │
│ └────────────┘──┼──────────────┼─►│ Claude │ │
│ │ │ └─────────────┘ │
└──────────────────┘ └───────────────────┘
Plugin registers with Gateway on load:
# connection_manager.gd
func _register():
var body = {
"project": ProjectSettings.get_setting("application/config/name"),
"version": Engine.get_version_info().string,
"platform": "GodotEditor",
"tools": _get_tool_list()
}
_http_post("/godot/register", body, _on_register_complete)Long polling for commands (30s timeout):
func _poll():
if is_polling:
return # Prevent duplicate requests
is_polling = true
_http_get("/godot/poll?sessionId=" + session_id, _on_poll_complete)func _on_command_received(tool_call_id: String, tool_name: String, args: Dictionary):
var result = tools.execute(tool_name, args)
connection_manager.send_result(tool_call_id, result)PROCESS_MODE_ALWAYS setting maintains connection during Play mode:
func _ready():
process_mode = Node.PROCESS_MODE_ALWAYS # Critical!Let's add a tool that plays audio files.
# Add to TOOLS array at top of tools.gd
var TOOLS = [
# ... existing tools ...
# New tool
{
"name": "audio.play",
"description": "Play an audio file in the editor",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Resource path to audio file (e.g., res://sounds/bgm.ogg)"
},
"volume": {
"type": "number",
"description": "Volume in dB (default: 0)"
}
},
"required": ["path"]
}
}
]func execute(tool_name: String, args: Dictionary) -> Dictionary:
match tool_name:
# ... existing cases ...
"audio.play":
return _audio_play(args)
_:
return {"success": false, "error": "Unknown tool: " + tool_name}func _audio_play(args: Dictionary) -> Dictionary:
var path = args.get("path", "")
var volume = args.get("volume", 0.0)
# Check resource exists
if not ResourceLoader.exists(path):
return {"success": false, "error": "Audio file not found: " + path}
# Load audio stream
var stream = load(path) as AudioStream
if stream == null:
return {"success": false, "error": "Invalid audio file: " + path}
# Create and play AudioStreamPlayer
var player = AudioStreamPlayer.new()
player.stream = stream
player.volume_db = volume
# Add to editor root
editor_interface.get_base_control().add_child(player)
player.play()
# Auto-remove when finished
player.finished.connect(func(): player.queue_free())
return {
"success": true,
"path": path,
"duration": stream.get_length()
}Tool list is auto-sent from Godot, so no changes needed in OpenClawPlugin~/index.ts.
To improve tool description:
// index.ts - add to tools array (optional)
{
name: "audio.play",
description: "Play audio file in Godot Editor",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Audio resource path" },
volume: { type: "number", description: "Volume in dB" }
},
required: ["path"]
}
}# Restart gateway
openclaw gateway restartTest in OpenClaw:
You: Play the background music
OpenClaw:
[Executes audio.play {path: "res://audio/bgm.ogg", volume: -5}]
Playing bgm.ogg (duration: 180.5s)
Check plugin logs:
[OpenClaw] Plugin loading...
[OpenClaw] Command: scene.getCurrent
[OpenClaw] Error: Node not found
func _some_function():
print_debug("Debug: ", some_variable) # Includes filename/line# Watch gateway logs in real-time
tail -f ~/.openclaw/logs/gateway.log# Add to connection_manager.gd
func _http_post(endpoint: String, body: Dictionary, callback: Callable):
print("[OpenClaw] POST %s: %s" % [endpoint, JSON.stringify(body)])
# ... existing code ...| Issue | Cause | Solution |
|---|---|---|
| Plugin won't load | GDScript syntax error | Check Output panel, fix syntax |
| Connection failed | Gateway not running | openclaw gateway start |
| Tool not executing | Tool name mismatch | Check TOOLS array and match statement |
| Play mode disconnect | PROCESS_MODE not set | process_mode = PROCESS_MODE_ALWAYS |
| HTTP request duplicates | Flag not checked | Use is_polling flag |
# Class declaration
class_name MyClass
extends Node
## Doc comment (shown with Ctrl+Shift+D)
## @param value: The value to set
## @return: Success status
func my_function(value: String) -> bool:
# Constants are UPPER_SNAKE_CASE
const MAX_RETRIES = 3
# Variables are snake_case
var retry_count = 0
# Use explicit types
var result: Dictionary = {}
# Early return pattern
if value.is_empty():
return false
return true
# Private functions use _ prefix
func _internal_helper():
pass
# Signals are past tense
signal connection_changed(connected: bool)
signal command_received(tool_call_id: String, tool_name: String)func _safe_operation() -> Dictionary:
# Always include success field
if some_error_condition:
return {"success": false, "error": "Error message"}
return {
"success": true,
"data": some_data
}# Wait for HTTPRequest completion
func _make_request():
var http = HTTPRequest.new()
add_child(http)
http.request_completed.connect(_on_request_completed)
http.request("http://localhost:18789/endpoint")
func _on_request_completed(result: int, code: int, headers: PackedStringArray, body: PackedByteArray):
if result != HTTPRequest.RESULT_SUCCESS:
push_error("Request failed")
return
var json = JSON.parse_string(body.get_string_from_utf8())
# Process...- TESTING.md - Testing Guide
- CONTRIBUTING.md - Contribution Guide
Documentation Updated: 2026-02-08