# Implementation Plan — `godotiq_build_scene`

## Overview

GodotIQ is an MCP server for AI-assisted Godot 4.x development. It communicates with a Godot editor plugin via WebSocket. The AI agent uses MCP tools to read, modify, and build Godot scenes.

**Problem:** The existing `node_ops` tool requires one operation dict per node, making it impractical for creating complex scenes (e.g., 64-tile grid). The AI agent resorts to writing runtime GDScript that generates nodes in `_ready()`, which are invisible in the editor. The MCP prompt also lacks guidance on scene creation workflows.

**Solution:** A new `godotiq_build_scene` tool that creates multiple nodes in one call using high-level patterns (grid, line, scatter, nodes), plus a "Creation Workflow" section in the MCP prompt.

## Architecture

### System Flow

```
AI Agent → MCP tool (Python) → WebSocket → Godot Addon (GDScript) → Editor Scene Tree
```

The Python side validates parameters and sends a single `build_scene` request over WebSocket. The GDScript addon expands patterns (grid → 64 node specs), creates nodes, and registers everything with the editor's undo/redo system.

### Why Pattern Expansion Happens in GDScript

The addon does pattern expansion (grid math, line interpolation) rather than the Python side. This keeps the WebSocket payload small (one grid dict vs. 64 node specs) and lets the addon use Godot's native types (Vector3, rotation math) directly. Note: for `nodes` and `scatter` modes, the full list is sent over WebSocket (up to ~50KB for 256 nodes).

## Section 1: Python-side Tool — `build_scene.py`

### File: `src/godotiq/tools/bridge/build_scene.py`

Create a new module with two functions:

```python
def validate_build_params(
    grid: dict | None,
    line: dict | None,
    scatter: dict | None,
    nodes: list[dict] | None,
) -> tuple[dict, dict | None]:
    """Validate build_scene parameters. Returns (params_dict, error_dict_or_None)."""
```

```python
async def build_scene(
    grid: dict | None = None,
    line: dict | None = None,
    scatter: dict | None = None,
    nodes: list[dict] | None = None,
    parent: str = "",
    target_scene: str = "",
) -> dict:
    """Send a build_scene request to the Godot addon."""
```

**Validation logic in `validate_build_params`:**

1. **Mode exclusivity**: Exactly one of grid, line, scatter, nodes must be non-None. If zero or multiple, return error.

2. **Required sub-fields per mode:**
   - Grid: `rows` must be present and > 0, `cols` must be present and > 0
   - Line: `points` must be an array with len >= 2
   - Scatter: must have `items` key with non-empty list
   - Nodes: must be non-empty list

3. **Node count limit (256):**
   - Grid: `rows * cols`
   - Line: precise calculation: `sum of segment lengths / spacing + 1` (for endpoint). Use the same formula as the GDScript expansion to avoid mismatches.
   - Scatter: `len(scatter.get("items", []))`
   - Nodes: `len(nodes)`

4. If all valid, return `(params_dict, None)` where params_dict contains the selected mode key and its value. If invalid, return `(None, error_dict)`.

The `build_scene()` function calls `validate_build_params`, then if valid, calls `get_bridge()` and `bridge.request("build_scene", params={mode_key: mode_value, "parent": parent, "target_scene": target_scene})`.

### File: `src/godotiq/tools/bridge/__init__.py`

Add a wrapper function registered with `@mcp.tool()`:

```python
@mcp.tool(
    name="godotiq_build_scene",
    annotations={
        "readOnlyHint": False,
        "destructiveHint": True,
        "idempotentHint": False,
        "openWorldHint": True,
    },
)
async def godotiq_build_scene(
    target_scene: str = "",
    parent: str = "",
    detail: str = "brief",
    grid: dict | None = None,
    line: dict | None = None,
    scatter: dict | None = None,
    nodes: list[dict] | None = None,
    ctx: Context = None,
) -> dict:
    """Create multiple nodes in a scene using high-level patterns. (full docstring)"""
```

Note: The parameter is `target_scene` (not `scene`) to avoid confusion with the `scene` key inside node specs which means "PackedScene to instantiate."

The wrapper follows the standard bridge tool pattern:
- Import `build_scene as _build_scene` from the implementation module
- Call `_build_scene(grid=grid, line=line, ...)` inside try/except
- Catch `BridgeConnectionError`, `BridgeTimeoutError`, `BridgeError`, `Exception`
- Apply `apply_output_limit(result, detail, list_keys=["errors"])` to the result
- The docstring should include detailed parameter docs for each mode so the AI agent sees them as tool descriptions

### File: `src/godotiq/bridge/protocol.py`

Add timeout entry:

```python
"build_scene": 30.0,  # Large batch creation may take time
```

## Section 2: GDScript Handler — `_handle_build_scene`

### File: `godot-addon/addons/godotiq/godotiq_server.gd`

#### Dispatch Registration

Add to the `_dispatch()` match statement:

```gdscript
"build_scene":
    _handle_build_scene(peer_id, id, params)
```

#### Main Handler: `_handle_build_scene`

The handler:
1. Gets `edited_scene_root` (error if null: "NO_SCENE")
2. Resolves `parent` node using `_find_node_by_name_or_path()`. If parent not found, return error "PARENT_NOT_FOUND" with the parent path. (Consistent with `node_ops` behavior — no auto-creation.)
3. Calls `_expand_build_pattern(params)` to get a flat Array of node specs
4. **GDScript-side safety cap**: If expanded array exceeds 256 items, truncate to 256 and add a warning to the response. This is defense-in-depth against Python-side estimation errors.
5. If empty, error "NO_NODES"
6. Creates one undo/redo action: `"GodotIQ: build N nodes"`
7. Iterates specs, calling `_create_build_node(spec, parent, scene_root, ur)` for each
8. If any succeeded, commits the undo action
9. Returns `{created, errors, total_requested, parent, truncated (bool if cap hit)}`

#### Pattern Dispatcher: `_expand_build_pattern`

```gdscript
func _expand_build_pattern(params: Dictionary) -> Array:
```

Checks for `grid`, `line`, `scatter` (returns `scatter.get("items", [])`), or `nodes` (returns directly). Returns empty array if none found.

#### Grid Expansion: `_expand_build_grid`

```gdscript
func _expand_build_grid(grid: Dictionary) -> Array:
```

Parameters: `scene`, `prefix` (default "Node"), `rows`, `cols`, `spacing` (default 1.0), `origin` (default [0,0,0]), `axis` (default "xz"), `overrides` (default {}).

For each (row, col):
- Check if `"row,col"` key exists in overrides
- If yes, use override spec (inherit `scene` from grid if not specified in override)
- If no, create spec with grid's scene
- Generate name: `{prefix}_{row}_{col}` (unless override provides name)
- Calculate position based on axis:
  - "xz": `[origin[0] + col*spacing, origin[1], origin[2] + row*spacing]`
  - "xy": `[origin[0] + col*spacing, origin[1] + row*spacing, origin[2]]`
- Override position is NOT overwritten if already in the spec

#### Line Expansion: `_expand_build_line`

```gdscript
func _expand_build_line(line: Dictionary) -> Array:
```

Parameters: `scene`, `prefix` (default "Node"), `points` (Array of [x,y,z]), `spacing` (default 1.0), `align_to_path` (default false).

Algorithm:
1. Walk each segment (points[i] → points[i+1])
2. Compute segment direction and length using Vector3
3. Place nodes at `spacing` intervals along the segment, carrying remainder to next segment
4. If `align_to_path`, compute Y rotation: `rad_to_deg(atan2(dir.x, dir.z))`
5. After all segments, check if the last placed node is within `spacing * 0.1` of the endpoint. If not, place a final node at the endpoint (endpoint inclusion). This epsilon check prevents near-duplicate nodes at the end.
6. Name each node `{prefix}_{index}`

**Rotation format:** All rotations are specified as `[x, y, z]` arrays in degrees. For 3D nodes, this sets `rotation_degrees`. For 2D nodes, rotation is not supported via build_scene (Node2D uses a single float in radians — too different for auto-detection).

#### Node Creation: `_create_build_node`

```gdscript
func _create_build_node(spec: Dictionary, parent: Node, scene_root: Node, ur) -> Dictionary:
```

Follows the SAME deferred undo/redo pattern as `_op_add_child`:

1. If `spec.scene` exists: `ResourceLoader.load()` → `PackedScene.instantiate()`
2. If `spec.type` exists: `ClassDB.can_instantiate()` → `ClassDB.instantiate()`
3. Otherwise: return error
4. Set node name from `spec.name` if provided
5. Register with undo/redo (deferred):
   - `ur.add_do_method(parent, "add_child", node)`
   - `ur.add_do_method(self, "_set_owner_recursive", node, scene_root)`
   - `ur.add_do_reference(node)`
   - `ur.add_undo_method(parent, "remove_child", node)`
6. Set transform properties (deferred via `ur.add_do_property`):
   - **3D nodes (Node3D)**: Position as Vector3, rotation_degrees as Vector3, scale as Vector3
   - **2D nodes (Node2D/Control)**: Position as Vector2, scale as Vector2. No rotation support.
   - Detection order: check `is Node3D` first, then `is Node2D` or `is Control`
7. Set custom properties: `ur.add_do_property(node, prop_name, value)` for each entry in `spec.properties`
8. Note on node name uniqueness: Godot auto-renames duplicate names (appends @N). This is expected behavior.

Returns `{"ok": true, "name": node_name}` or `{"ok": false, "error": "..."}`.

## Section 3: Creation Workflow Prompt

### File: `src/godotiq/prompts/godot_development_prompt_v2.md`

Add a new section after the "CRITICAL: How to Build 3D Scenes" section. The content should define a 6-phase workflow:

1. **Setup Scene Structure** — Use `nodes` mode to create container nodes (Terrain, Decorations, etc.) plus Camera and Light
2. **Build Terrain** — Use `grid` mode for tile-based ground, with overrides for path tiles
3. **Add Decorations** — Use `scatter` mode for trees, rocks, etc.
4. **Add Key Objects** — Use `placement` tool to find positions, then `scatter` to place
5. **Create Scripts** — Use `script_ops` for logic only (NOT scene construction)
6. **Verify** — Use `spatial_audit` + `signal_map` + `save_scene`, then ask user to check

Key principles to emphasize:
- Everything static goes in .tscn via `build_scene`
- One `build_scene` call per logical group
- Save and verify after each phase
- Grid for repetitive layouts, scatter for handpicked items, line for paths

Add `build_scene` error recovery entries to the Error Recovery section:
- `NO_SCENE` → Open a scene in the editor first
- `PARENT_NOT_FOUND` → Check parent node name/path, create parent first if needed
- `NO_NODES` → Ensure exactly one mode is specified with valid data
- Partial success → Check `errors` array, fix failed items, retry only those
- Node count limit → Split into multiple `build_scene` calls by parent group

## Section 4: Tests

### File: `tests/test_tools/test_build_scene.py`

#### Python-side Unit Tests (no bridge needed)

These test the `validate_build_params` function directly:

1. **test_mode_exclusivity_none** — Passing zero modes returns error.
2. **test_mode_exclusivity_multiple** — Passing two modes (grid + scatter) returns error.
3. **test_node_count_limit_grid** — Grid with rows=17, cols=16 (272 nodes) returns error mentioning 256 limit.
4. **test_node_count_limit_scatter** — Scatter with 257 items returns error.
5. **test_valid_grid_params** — Grid with rows=3, cols=3, spacing=2.0 passes validation.
6. **test_valid_scatter_params** — Scatter with 3 items passes validation.
7. **test_valid_nodes_params** — Nodes list with 4 items passes validation.
8. **test_grid_missing_rows** — Grid without rows returns error.
9. **test_grid_zero_cols** — Grid with cols=0 returns error.
10. **test_line_insufficient_points** — Line with 1 point returns error.
11. **test_scatter_missing_items** — Scatter without items key returns error.
12. **test_scatter_empty_items** — Scatter with empty items list returns error.
13. **test_single_node_grid** — Grid 1x1 passes validation (count=1).

#### Testing Strategy

Since bridge tools cannot be unit-tested without a running addon, the Python tests focus on the `validate_build_params` function. This function is extracted specifically to enable testing without a bridge connection.

#### Backward Compatibility

Run `pytest tests/ -v` to confirm all existing tests still pass. No existing tool files are modified (only `__init__.py` for registration and `protocol.py` for timeout).

## File Change Summary

| File | Action | Changes |
|------|--------|---------|
| `src/godotiq/tools/bridge/build_scene.py` | CREATE | `validate_build_params()` + `build_scene()` async function |
| `src/godotiq/tools/bridge/__init__.py` | MODIFY | Import + `@mcp.tool()` wrapper for `godotiq_build_scene` |
| `src/godotiq/bridge/protocol.py` | MODIFY | Add `"build_scene": 30.0` to TOOL_TIMEOUTS |
| `godot-addon/addons/godotiq/godotiq_server.gd` | MODIFY | Add `_handle_build_scene`, `_expand_build_pattern`, `_expand_build_grid`, `_expand_build_line`, `_create_build_node` + dispatch entry |
| `src/godotiq/prompts/godot_development_prompt_v2.md` | MODIFY | Add "Creation Workflow" section + error recovery entries |
| `tests/test_tools/test_build_scene.py` | CREATE | 13 unit tests for validation logic |

## Risks and Mitigations

| Risk | Mitigation |
|------|------------|
| Undo/redo with 256 deferred operations may be slow | Test with 256 nodes. GDScript-side cap at 256. |
| Line endpoint near-duplication | Epsilon check: skip endpoint if last node within spacing*0.1 |
| Scene files may not exist at runtime | Partial success: log error, continue with next node |
| Parent node not found | Return clear error (no auto-creation). Consistent with node_ops. |
| 2D auto-detection may miss some node types | Check `is Node3D` first, then `is Node2D or is Control`. |
| Python-side line count estimation mismatch | GDScript-side 256 cap as defense-in-depth |

## Decision Log

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Undo/redo pattern | Deferred (match existing) | Consistency with `_op_add_child` |
| Error handling | Partial success | More useful for large batches |
| Node limit | 256 per call | Supports 16×16 grid |
| 2D support | Yes, auto-detect | Node3D→Vector3, Node2D/Control→Vector2 |
| 2D rotation | Not supported | Node2D uses single float radians, too different |
| Line endpoint | Included with epsilon check | Prevents near-duplicates |
| Pattern expansion | GDScript-side | Minimal WebSocket payload for grid/line |
| Prompt file | godot_development_prompt_v2.md | Current active prompt |
| Timeout | 30s | Large batch creation takes time |
| Top-level param name | `target_scene` (not `scene`) | Avoids collision with per-node `scene` key |
| Parent not found | Error (no auto-create) | Consistent with node_ops, prevents typo bugs |
| GDScript safety cap | 256 nodes | Defense-in-depth against estimation errors |
| `apply_output_limit` | list_keys=["errors"] | Only `errors` array can grow large |
