diff --git a/godot-addon/addons/godotiq/godotiq_server.gd b/godot-addon/addons/godotiq/godotiq_server.gd
index 45e3437..b817d75 100644
--- a/godot-addon/addons/godotiq/godotiq_server.gd
+++ b/godot-addon/addons/godotiq/godotiq_server.gd
@@ -183,6 +183,8 @@ func _dispatch(peer_id: int, id: String, method: String, params: Dictionary) ->
 			_handle_editor_screenshot(peer_id, id, params)
 		"camera":
 			_handle_camera(peer_id, id, params)
+		"build_scene":
+			_handle_build_scene(peer_id, id, params)
 		_:
 			_send_error(peer_id, id, "UNKNOWN_METHOD", "Unknown method: %s" % method)
 
@@ -1132,6 +1134,276 @@ func _handle_camera(peer_id: int, id: String, params: Dictionary) -> void:
 			_send_error(peer_id, id, "UNKNOWN_ACTION", "Unknown camera action: '%s'. Use get_position, look_at, or focus_node." % action)
 
 
+# --- Build scene handler ---
+
+const BUILD_SCENE_MAX_NODES: int = 256
+
+
+func _handle_build_scene(peer_id: int, id: String, params: Dictionary) -> void:
+	var scene_root := EditorInterface.get_edited_scene_root()
+	if scene_root == null:
+		_send_error(peer_id, id, "NO_SCENE", "No scene is currently open in the editor")
+		return
+
+	if undo_redo == null:
+		_send_error(peer_id, id, "NO_UNDO_REDO", "UndoRedo manager not available")
+		return
+
+	# Resolve parent node
+	var parent_str: String = str(params.get("parent", ""))
+	var parent: Node = scene_root
+	if parent_str != "":
+		parent = _find_node_by_name_or_path(parent_str, scene_root)
+		if parent == null:
+			_send_error(peer_id, id, "PARENT_NOT_FOUND", "Parent node not found: %s" % parent_str)
+			return
+
+	# Expand pattern into flat node specs
+	var specs: Array = _expand_build_pattern(params)
+
+	# Safety cap
+	var truncated := false
+	if specs.size() > BUILD_SCENE_MAX_NODES:
+		specs = specs.slice(0, BUILD_SCENE_MAX_NODES)
+		truncated = true
+
+	if specs.size() == 0:
+		_send_error(peer_id, id, "NO_NODES", "Pattern expansion produced no nodes")
+		return
+
+	# Create single undo action
+	var action_name := "GodotIQ: build %d nodes" % specs.size()
+	undo_redo.create_action(action_name)
+
+	var created: int = 0
+	var errors: Array = []
+
+	for spec in specs:
+		if not (spec is Dictionary):
+			errors.append({"error": "Invalid spec format"})
+			continue
+		var result: Dictionary = _create_build_node(spec, parent, scene_root, undo_redo)
+		if result.get("ok", false):
+			created += 1
+		else:
+			errors.append(result)
+
+	if created > 0:
+		undo_redo.commit_action()
+		_godotiq_action_history.append({
+			"action": action_name,
+			"operations": created,
+			"timestamp": Time.get_ticks_msec(),
+		})
+		if _godotiq_action_history.size() > MAX_HISTORY_SIZE:
+			_godotiq_action_history = _godotiq_action_history.slice(
+				_godotiq_action_history.size() - MAX_HISTORY_SIZE
+			)
+
+	send_response(peer_id, id, {
+		"created": created,
+		"errors": errors,
+		"total_requested": specs.size(),
+		"parent": parent.name,
+		"truncated": truncated,
+	})
+
+
+func _expand_build_pattern(params: Dictionary) -> Array:
+	if params.has("grid"):
+		return _expand_build_grid(params["grid"])
+	if params.has("line"):
+		return _expand_build_line(params["line"])
+	if params.has("scatter"):
+		var scatter: Dictionary = params["scatter"]
+		return scatter.get("items", [])
+	if params.has("nodes"):
+		return params["nodes"]
+	return []
+
+
+func _expand_build_grid(grid: Dictionary) -> Array:
+	var scene_path: String = str(grid.get("scene", ""))
+	var prefix: String = str(grid.get("prefix", "Node"))
+	var rows: int = int(grid.get("rows", 1))
+	var cols: int = int(grid.get("cols", 1))
+	var spacing: float = float(grid.get("spacing", 1.0))
+	var origin: Array = grid.get("origin", [0, 0, 0])
+	var axis: String = str(grid.get("axis", "xz"))
+	var overrides: Dictionary = grid.get("overrides", {})
+	var type_name: String = str(grid.get("type", ""))
+
+	var ox: float = float(origin[0]) if origin.size() > 0 else 0.0
+	var oy: float = float(origin[1]) if origin.size() > 1 else 0.0
+	var oz: float = float(origin[2]) if origin.size() > 2 else 0.0
+
+	var result: Array = []
+	for row in range(rows):
+		for col in range(cols):
+			var cell_key := "%d,%d" % [row, col]
+			var spec: Dictionary = {}
+
+			if overrides.has(cell_key):
+				spec = overrides[cell_key].duplicate()
+				if not spec.has("scene") and scene_path != "":
+					spec["scene"] = scene_path
+				if not spec.has("type") and type_name != "":
+					spec["type"] = type_name
+			else:
+				if scene_path != "":
+					spec["scene"] = scene_path
+				if type_name != "":
+					spec["type"] = type_name
+
+			if not spec.has("name"):
+				spec["name"] = "%s_%d_%d" % [prefix, row, col]
+
+			if not spec.has("position"):
+				if axis == "xy":
+					spec["position"] = [ox + col * spacing, oy + row * spacing, oz]
+				else:  # "xz" default
+					spec["position"] = [ox + col * spacing, oy, oz + row * spacing]
+
+			result.append(spec)
+	return result
+
+
+func _expand_build_line(line: Dictionary) -> Array:
+	var scene_path: String = str(line.get("scene", ""))
+	var prefix: String = str(line.get("prefix", "Node"))
+	var points: Array = line.get("points", [])
+	var spacing: float = float(line.get("spacing", 1.0))
+	var align_to_path: bool = line.get("align_to_path", false)
+	var type_name: String = str(line.get("type", ""))
+
+	if points.size() < 2 or spacing <= 0:
+		return []
+
+	var result: Array = []
+	var index: int = 0
+
+	# Place first node at start point
+	var first_pt: Array = points[0]
+	var first_spec: Dictionary = {"name": "%s_%d" % [prefix, index], "position": first_pt}
+	if scene_path != "":
+		first_spec["scene"] = scene_path
+	if type_name != "":
+		first_spec["type"] = type_name
+	result.append(first_spec)
+	index += 1
+
+	var remainder: float = 0.0
+
+	for i in range(points.size() - 1):
+		var start := Vector3(float(points[i][0]), float(points[i][1]), float(points[i][2]))
+		var end := Vector3(float(points[i + 1][0]), float(points[i + 1][1]), float(points[i + 1][2]))
+		var seg_dir := (end - start).normalized()
+		var seg_len := (end - start).length()
+
+		var y_rot: float = 0.0
+		if align_to_path and seg_dir.length() > 0.001:
+			y_rot = rad_to_deg(atan2(seg_dir.x, seg_dir.z))
+
+		var dist_along := spacing - remainder
+		while dist_along <= seg_len:
+			var pos := start + seg_dir * dist_along
+			var spec: Dictionary = {
+				"name": "%s_%d" % [prefix, index],
+				"position": [pos.x, pos.y, pos.z],
+			}
+			if scene_path != "":
+				spec["scene"] = scene_path
+			if type_name != "":
+				spec["type"] = type_name
+			if align_to_path:
+				spec["rotation"] = [0, y_rot, 0]
+			result.append(spec)
+			index += 1
+			dist_along += spacing
+
+		# Remainder = how far past the segment end the next placement would be
+		remainder = dist_along - seg_len
+
+	# Endpoint inclusion: check if last placed node is near the final point
+	var final_pt: Array = points[points.size() - 1]
+	var final_pos := Vector3(float(final_pt[0]), float(final_pt[1]), float(final_pt[2]))
+	var place_endpoint := true
+	if result.size() > 0:
+		var last_spec: Dictionary = result[result.size() - 1]
+		var last_pos_arr: Array = last_spec.get("position", [0, 0, 0])
+		var last_pos := Vector3(float(last_pos_arr[0]), float(last_pos_arr[1]), float(last_pos_arr[2]))
+		if last_pos.distance_to(final_pos) < spacing * 0.1:
+			place_endpoint = false
+
+	if place_endpoint:
+		var end_spec: Dictionary = {
+			"name": "%s_%d" % [prefix, index],
+			"position": [final_pos.x, final_pos.y, final_pos.z],
+		}
+		if scene_path != "":
+			end_spec["scene"] = scene_path
+		if type_name != "":
+			end_spec["type"] = type_name
+		result.append(end_spec)
+
+	return result
+
+
+func _create_build_node(spec: Dictionary, parent: Node, scene_root: Node, ur) -> Dictionary:
+	var new_node: Node = null
+	var scene_path: String = str(spec.get("scene", ""))
+	var type_name: String = str(spec.get("type", ""))
+
+	if scene_path != "":
+		var packed = ResourceLoader.load(scene_path)
+		if packed == null or not (packed is PackedScene):
+			return {"ok": false, "error": "Failed to load scene: %s" % scene_path}
+		new_node = packed.instantiate()
+	elif type_name != "":
+		if not ClassDB.class_exists(type_name) or not ClassDB.can_instantiate(type_name):
+			return {"ok": false, "error": "Cannot instantiate type: %s" % type_name}
+		new_node = ClassDB.instantiate(type_name)
+	else:
+		return {"ok": false, "error": "Spec must have 'scene' or 'type'"}
+
+	# Set name
+	var node_name: String = str(spec.get("name", "BuildNode"))
+	new_node.name = node_name
+
+	# Register with undo/redo (deferred)
+	ur.add_do_method(parent, "add_child", new_node)
+	ur.add_do_method(self, "_set_owner_recursive", new_node, scene_root)
+	ur.add_do_reference(new_node)
+	ur.add_undo_method(parent, "remove_child", new_node)
+
+	# Set transform properties (deferred)
+	if new_node is Node3D:
+		var pos = spec.get("position", null)
+		if pos is Array and pos.size() >= 3:
+			ur.add_do_property(new_node, "position", Vector3(float(pos[0]), float(pos[1]), float(pos[2])))
+		var rot = spec.get("rotation", null)
+		if rot is Array and rot.size() >= 3:
+			ur.add_do_property(new_node, "rotation_degrees", Vector3(float(rot[0]), float(rot[1]), float(rot[2])))
+		var scl = spec.get("scale", null)
+		if scl is Array and scl.size() >= 3:
+			ur.add_do_property(new_node, "scale", Vector3(float(scl[0]), float(scl[1]), float(scl[2])))
+	elif new_node is Node2D or new_node is Control:
+		var pos = spec.get("position", null)
+		if pos is Array and pos.size() >= 2:
+			ur.add_do_property(new_node, "position", Vector2(float(pos[0]), float(pos[1])))
+		var scl = spec.get("scale", null)
+		if scl is Array and scl.size() >= 2:
+			ur.add_do_property(new_node, "scale", Vector2(float(scl[0]), float(scl[1])))
+
+	# Set custom properties
+	var properties = spec.get("properties", null)
+	if properties is Dictionary:
+		for prop_name in properties:
+			ur.add_do_property(new_node, prop_name, properties[prop_name])
+
+	return {"ok": true, "name": str(new_node.name)}
+
+
 # --- Game-side forwarding ---
 
 func _handle_game_forward(peer_id: int, id: String, msg_type: String, params: Dictionary, timeout_ms: int) -> void:
