# Phase 17b — Use spots: architecture and implementation specification > **Status:** Approved design; implementation is present and awaiting in-editor user verification. > **Product/UI contract:** [Phase 17 plan](../plans/phase_17_props_for_storytelling.md) and [approved Use spots UI/UX specification](phase_17b_use_spots_ui.md). > **Scope:** Placed-prop Use spots, a queued Use action, a completed-use rule trigger, and their EDIT/DIRECT/PLAY presentation in the Sandbox Stage. ## Decisions and boundaries 1. **Empty hands are required.** A stickman carrying a prop cannot begin Use. Using another prop while carrying is future work. A target currently held or reserved by any stickman is also unavailable for Use. 2. **Target physics remains normal.** A moving target is never frozen, teleported, or reserved for Use. Its local spot follows its live transform. The approach may be recalculated once within a bounded displacement; continued movement or an unreachable new position fails with a visible reason. 3. **Grounded locomotion only.** Use may walk, turn, reach, and crouch. Automatic jumping and climbing are future work. 4. Use does not move the target prop, fire an invented built-in effect, or treat a pickup grip as a Use spot. Only the successful touch emits `prop_used`; rules provide the story consequence. The reusable `.prop` asset schema and `PropAnchorData` remain unchanged. ### Shape of the design ```mermaid flowchart LR Card[StagePropSettingsView] -->|Add, rename, move, erase| Stage[SandboxStage] Stage -->|validated edit| Prop[PropBlock instance spots] Stage -->|save/load| Adapter[StageSceneAdapter + StageSceneIO 1.3] Editor[ActionEditor + RuleEditor] -->|draft target| Capture[StageTargetCaptureSession] Capture -->|preview| Solver[StickmanUseReachSolver] Runner[StickmanActionRunner] --> Interactor[StickmanPropInteractor Use phases] Interactor --> Solver Interactor -->|completed prop + spot ID| Event[SandboxStage event dispatch] Event --> Matcher[StageRuleEngine prop_used matcher] ``` `SandboxStage` remains the coordinator. Small data, overlay, and reach helpers carry their own logic; none becomes a global event bus or a new prop physics body. ## File responsibilities and typed contracts | File | Responsibility and intended API | | --- | --- | | `scripts/props/prop_use_spot_data.gd` (new), `class_name PropUseSpotData extends RefCounted` | Pure schema helper: `static func validate_saved(raw_spots: Variant, raw_next: Variant) -> String`, `static func decode(raw_spots: Array) -> Array[Dictionary]`, `static func encode(spots: Array[Dictionary]) -> Array[Dictionary]`, `static func fallback_name(number: int) -> String`, `static func find(spots: Array[Dictionary], spot_id: String) -> Dictionary`. No scene-tree or physics access. | | `scripts/props/prop_block.gd`, `class_name PropBlock extends RigidBody2D` | Own `use_spots: Array[Dictionary]` and `next_use_spot_number: int`; expose `get_use_spots() -> Array[Dictionary]`, `get_use_spot(id: String) -> Dictionary`, `get_use_spot_local_bounds() -> Rect2`, `add_use_spot(local_position: Vector2) -> String`, `rename_use_spot(id: String, name: String) -> bool`, `move_use_spot(id: String, local_position: Vector2) -> bool`, `erase_use_spot(id: String) -> bool`, `use_spot_world_position(id: String) -> Vector2`, and EDIT-only `use_spot_local_from_world(world_position: Vector2) -> Vector2`. Return copies from getters. No grip-anchor mutation. | | `scripts/editors/stage/stage_prop_settings_view.gd`, `class_name StagePropSettingsView extends Control` | Append the UI-spec Use spots section to the existing `StageInspectorPanel` body. Emit `spot_add_requested()`, `spot_move_requested(spot_id: String)`, `spot_rename_requested(spot_id: String, name: String)`, `spot_erase_requested(spot_id: String)`, and `spot_selected(spot_id: String)`. Update through `show_prop`/a dedicated `sync_spots`, without writing to `PropBlock`. Keep multi-selection notice and dismissed-card behavior. | | `scripts/editors/stage/stage_use_spot_overlay.gd`, `class_name StageUseSpotOverlay extends Control` | Screen-space numbered pins and name labels, EDIT cursor ghost, per-spot reach status, success pulse, and failure cue. Exposes `show_edit(...)`, `show_targeting(...)`, `show_success(prop, spot_id)`, `show_failure(prop, spot_id, reason)`, `hit_spots(screen_position)`, `closest_hit_spot(screen_position)`, `clear_session()`, and `set_paused(paused)`. It draws cached preview status beside the pin without approach or hand-path lines; it does not own spot data or run reach physics queries. Overlapping pin hits are disambiguated by the stage popup. | | `scripts/editors/stage/stage_use_target_capture_bar.gd`, `class_name StageUseTargetCaptureBar extends PanelContainer` | DIRECT Use and Use-related rule capture prompt and actions. `set_capture_state(prop_selected, prop_has_use_spots)` switches between prop, spot, and no-spots prompts; `hide_capture()` closes it. Emits `change_prop_requested` and `cancel_requested`; it does not commit a target. | | `scripts/editors/stage/stage_target_capture_session.gd`, `class_name StageTargetCaptureSession extends RefCounted` | Owns the active `USE_PROP` / `USE_SPOT` and EDIT `SPOT_ADD` / `SPOT_MOVE` capture states, provisional prop/spot IDs, actor/context, queue prefix, and cached preview. `begin_use_target(...)`, `select_use_target_prop(...)`, `reset_use_target_prop()`, `begin_spot_edit(...)`, and `finish()` maintain the single-capture invariant. Rule capture uses the same Use-target capture context; there is no separate `USED_RULE_PROP` kind. | | `scripts/characters/stickman/stickman_use_reach_solver.gd` (new), `class_name StickmanUseReachSolver extends RefCounted` | One nonmutating `solve(rig: StickmanRig, prop: PropBlock, spot_id: String, actor_root: Vector2, predicted_facing_left: bool, options: Dictionary = {}) -> Dictionary`. It uses rig arm geometry, grounded terrain probes, route/body sweep, and current prop transform. Both DIRECT preview and PLAY call it. Direct-space physics calls occur only in `_physics_process`/rig physics tick; UI draws cached results. | | `scripts/characters/stickman/stickman_prop_interactor.gd`, existing rig scene, `scripts/characters/stickman/stickman_rig.gd`, `scripts/characters/stickman/stickman_action_runner.gd` | Add explicit `USE_APPROACHING`, `USE_REACHING`, `USE_RECOVERING` phases under the existing one-action/one-hand ownership boundary. The interactor owns marker restoration, one replan count, completion and failure. Rig adds `preview_prop_use(...) -> Dictionary`; runner includes `use_prop` in the existing `PROP_INTERACTION` path and waits for `action_completed`. The existing interactor node path stays intact. | | `scripts/stage/action_registry.gd`, `scripts/stage/trigger_registry.gd`, `scripts/stage/stage_rule_engine.gd` | Register `use_prop` and `prop_used`; implement flat/rule conversion and summaries, explicit event matching, and only-success dispatch. A failed Use still safely completes its queue slot, but emits no `prop_used` event. | | `scripts/editors/stage/action_editor.gd`, `stage_action_queue_controller.gd`, `rule_editor.gd`, `stage_rule_authoring_controller.gd`, `queue_panel.gd`, `rule_panel.gd`, `sandbox_stage.gd` | Wire Use through the action menus, editors, capture callback, queue/rule lists, summaries, and repair state. Keep editor drafts separate from authored queue/rules until Done. Named spot labels are drawn on pins; overlapping hit regions open a named-choice popup. | | `scripts/editors/stage/stage_scene_adapter.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/stage_run_session.gd` | Collect/apply per-instance spots; migrate/validate scene v1.3; preserve missing Use references; clear Use animation/pulse on STOP while authored spots survive reset. | | `scripts/stage/sandbox_theme.gd`, `sandbox_theme.json` | Add `stage_panels.spots` and Use preview tokens in defaults and JSON. The UI spec owns exact copy and minimum target sizes. | `StageInspectorPanel` provides the fixed header, body scroll, safe fit, close, and action-button styling. `StagePanelToggleRow` and `StagePanelSwitchVisual` remain in the role section only. New `prop_used` rules target one named spot; the authoring flow has no **Any spot on this prop** option. Saved `spot_scope = "any"` rules remain readable and match any successfully used spot on their referenced prop for compatibility. Rule and action dialogs use `SandboxTheme.control_theme()` directly; they do not nest a `StageInspectorPanel`. ## Spot schema, identity, and coordinate contract An in-memory spot is `{ "id": String, "number": int, "name": String, "position": Vector2 }`. `id` is `spot_`; `number` is positive and unique within one placed prop. `next_use_spot_number` begins at 1, increments only on successful Add, and remains greater than every existing number. Erase never reduces it. Blank trimmed names normalize to `Spot `; duplicate names are valid, and the number is always displayed. Names are presentation only; queue/rule references use IDs. Scene version **1.3** adds the following keys to each real prop entry, with no `.prop` asset change: ```json { "file_id": "prop_1", "type": "prop", "stage_object_id": "prop_unchanging_instance_id", "use_spots": [ {"id": "spot_1", "number": 1, "name": "Button", "position": {"x": 24.0, "y": -18.0}} ], "next_use_spot_number": 2 } ``` `StageSceneIO.FILE_VERSION` becomes `"1.3"`. Migrate 1.0/1.1/1.2 through the existing 1.2 role/depth migration, then set missing `use_spots: []` and `next_use_spot_number: 1`. Current 1.3 documents lacking the additive keys receive the same defaults. A **present** malformed collection, duplicate/noncanonical ID or number, nonfinite point, or counter not greater than the highest stored number fails root validation before replacing the loaded world. Decode saved `{x,y}` dictionaries with `data_to_vector`, never by assigning dictionaries to typed vector arrays. Missing-prop placeholders retain their original entry, including spots and counter, through resave. `ContentPaths.canonicalize` continues to handle only path fields. `file_id` is regenerated on collection; it is not a persistent prop identity. `stage_object_id` survives save/load and is the action/rule prop key. A second placement of the same `.prop` begins with no Use spots and its own counter. If a future placement-copy command is added, it must allocate a new prop ID while deep-copying spots and preserving their local IDs within that new prop. Coordinates are authored in **unscaled prop asset-local space** and follow the prop's translation and rotation. A spot may be in a transparent gap inside the prop artwork bounds. `get_use_spot_local_bounds()` returns `get_visual_local_bounds()` for custom layered props, a radius rect for built-in circles, and the point AABB for built-in polygons; `get_visual_local_bounds()` alone is empty for the built-in primitives. On EDIT placement, first check that the prop transform is finite and invertible; then use `prop.to_local(world_position)` and those local bounds. During EDIT, root `global_transform` already contains the authored scale. During PLAY, `PropBlock.begin_play_instance_scale()` moves the signed authored scale into children and resets root scale. Thus the sole world conversion is: ```gdscript func use_spot_world_position(spot_id: String) -> Vector2: var spot: Dictionary = get_use_spot(spot_id) if spot.is_empty(): return Vector2.INF var local_point: Vector2 = spot["position"] var baked_scale: Vector2 = _play_instance_scale if _play_scale_active else Vector2.ONE return global_transform * (local_point * baked_scale) ``` Use the **signed** `_play_instance_scale`; `get_interaction_scale()` currently returns an absolute value for pickup grips and would lose mirrored spot positions. No caller multiplies by root scale again. Reset restores root authored scale through the existing `StageRunSession` path. Overlay hit regions convert the resulting world point to viewport pixels and maintain a minimum 44 × 44 screen-pixel target at any camera zoom. ## Authoring and capture transactions EDIT Add/Move starts only for one selected, live `PropBlock`. The capture stores its `stage_object_id`, mode, and (for Move) existing spot ID; it does not mutate the spot collection while following the cursor. A click is accepted only inside that prop's visual-local bounds after inverse transform. The selected prop remains visible; clicking elsewhere leaves the session active. A valid click commits one Add/Move, marks the scene dirty, refreshes the card/pins and summaries, and ends capture. Esc, right-click, visible Cancel, prop-selection change, or mode change discards the provisional position. Rename commits on Enter/focus exit; blank returns to the same fallback. The row/pin selection affects emphasis and camera reveal only. Erase shows the UI-spec confirmation, including the reference warning if any queue action, rule action, or specific `prop_used` trigger uses that exact prop/spot pair. On confirmation, erase only the spot. Keep the referring payloads and mark them as needing repair; do not retarget, broaden, or silently remove them. A prop deletion likewise retains Use-specific queue/rule references for repair, while the current behavior for unrelated rule types and deleted actor sources remains in place. Avoid the current `_cleanup_rules_for_nodes()` blanket pruning for a `prop_used` target by representing its target with stable IDs in trigger params. DIRECT Use target capture is two steps: select a prop through `StageSelection.hit_test_all()` / **Objects here**, then click one of its labeled pins. Overlapping hit regions open a named-choice popup instead of selecting arbitrarily. The persistent capture bar sits below the top toolbar and mode pill; it shows a step-aware prompt, visible **Cancel**, and, after prop selection, **Change prop**. It uses a compact dark rounded panel with a rounded-edge DIRECT amber accent; its shared visual contract is in the [Stage panel UI system spec](stage_panel_ui_system.md). A known-invalid target leaves capture active. An uncertain future actor state yields the UI-spec amber cue and permits a structurally valid target; PLAY checks it again. Choosing another prop clears only the provisional spot. Cancel restores the invoking editor and its prior draft. A DIRECT-mode selection queues the Use action immediately; a selection made inside an editor updates its draft, which is committed through that editor's Done flow. Rule capture selects the acting stickman, a prop with spots, and one specific spot ID. Rule capture does **not** run the reach solver. Changing prop clears the provisional spot so the child must choose a spot on the replacement prop. Existing saved rules with `spot_scope = "any"` remain supported for compatibility, but new rules are authored for one named spot and expose no Any-spot authoring button. A missing saved reference appears as **Choose a new spot** and cannot be recommitted until repaired. The current exclusive-popup host hide/restore convention applies to all new captures and their Cancel/resolve paths; see existing debt #19. The capture bar keeps a visible Cancel control available in both stages of action and rule targeting. The separate EDIT Add/Move spot-placement flow retains its existing stage instruction and Cancel control; the shared capture-bar pattern is the approved standard for future EDIT/DIRECT capture flows, as recorded in the [Stage panel UI system spec](stage_panel_ui_system.md) and [AGENTS.md](../../AGENTS.md). ## Action, rule, and event wire contracts Flat in-memory queue action: ```gdscript {"type": "use_prop", "target_prop_id": "prop_...", "target_spot_id": "spot_2"} ``` `StageSceneIO.encode_queue/decode_queue` saves the same strings, validating nonempty IDs and a known action type. It must **not** require the referenced prop/spot to exist during decode; the reference may be awaiting repair. `ActionRegistry.to_rule_action/from_rule_action` preserves both keys in the nested `params` of a rule consequence, and `_encode_rule_action/_decode_rule_action` use the same flat codec. `ActionRegistry.summarize()` provides a safe fallback, while Stage-owned presentation resolves live names and replaces missing ones with **Choose a new spot**. Queue list, action editor, rule consequence list, and rule summary refresh after rename/erase/load. `prop_used` trigger in memory and on disk: ```gdscript { "type": "prop_used", "source": actor_instance_id, "target": -1, "params": { "target_prop_id": "prop_...", "spot_scope": "specific", # or "any" "target_spot_id": "spot_2" # omitted for "any" } } ``` The saved rule uses the existing mapped `source_id` and null `target_id`; `target_prop_id` remains the stable string. Its matcher **must require** `target_prop_id` equality with the event's live `PropBlock.stage_object_id` even though `target == -1`; otherwise the current wildcard semantics could make a missing prop match every prop. For `specific`, require exact nonempty `target_spot_id` equality; `any` matches every successfully touched spot on that one prop. Validate the enum and conditional key in `StageSceneIO._encode_trigger/_decode_trigger`. Missing target objects/spots remain structurally valid saved references. Do not resolve them through `_unmap_ref`, which currently drops missing instance-ID rule targets. Existing source actor mapping and deletion semantics remain intact. At verified touch the interactor emits a **separate typed** signal, `use_completed(prop: PropBlock, spot_id: String, world_position: Vector2)`. `StickmanRig` forwards it; `SandboxStage` calls `_handle_event({"type": "prop_used", "source": rig, "target": prop, "spot_id": spot_id, "position": world_position, "action": ...})`, displays the pulse, then lets `StageRuleEngine` match rules. Keep the existing three-argument `prop_event` signal for pickup/drop/throw/Set Down so its consumers need no ambiguous reason/spot overloading. Failure emits `use_failed(reason_code: String, prop: PropBlock, spot_id: String)` for a local cue, with no `prop_used` or pulse. The runner still advances its queue safely; generic `action_finished` retains its existing meaning. ## Shared reach calculation and runtime behavior The solver returns a typed-key `Dictionary`: ```gdscript { "known": true, "valid": true, "reason_code": "", "spot_world": Vector2(420, -160), "approach_root": Vector2(330, -385), "approach_feet": Vector2(330, 0), "facing_left": false, "hand": StickmanPropInteractor.Hand.RIGHT, "reach_start": Vector2(355, -210), "reach_end": Vector2(420, -160) } ``` Invalid results retain relevant preview geometry and use one of `prop_missing`, `spot_missing`, `hands_full`, `prop_held`, `no_standing_room`, `route_blocked`, `out_of_reach`, or `target_moved`. The UI maps these to its short child-facing strings. A future-position preview returns `known = false` with `reason_code = "earlier_actions"`; it is amber, never green. Only a known invalid result blocks DIRECT commit. Invalid/unknown source actor or nonfinite transforms fail explicitly. The solver tests the current root first, then grounded standing candidates on both sides of the spot. It derives shoulder-to-wrist reach from live upper/lower arm lengths and rig facing, chooses an available hand only when empty handed, checks terrain support and route through the same navigation/actor-blocking sweeps as `StickmanLocomotion`, and checks standing torso/head clearance. It may choose a crouch pose for a low point. It does not invent a platform on a support prop, move a pinned point, or permit a jump. Hand-path obstruction checks exclude the target prop's own collider; configured blocking geometry along the reach still invalidates a path. Candidate ranking prefers the valid shortest grounded route with a reachable wrist. Validate the actual solved wrist against the live spot with a small contact tolerance at completion; the existing pickup `MAX_REACH = 390` wrist-distance heuristic alone does not prove a touch. DIRECT prediction receives the current actor and the unconsumed queue prefix from `StageTargetCaptureSession` (generalize `throw_preview_prefix_for` without breaking its current callers). Earlier Move To actions shift the predicted root from their ground target to the rig root; Speak and Wait preserve the predicted position. An earlier Use on an existing stationary prop shifts the predicted root to its known approach when valid, while a known failed Use leaves the actor in place. Active motion, ragdoll/recovery, pickup/drop/throw/set-down interactions, unknown actions, and moving target physics make the prediction conditional. Preview is advisory; runtime uses live positions and repeats the check. Perform physics queries in `_physics_process`, cache the result, and draw it in `_process`/overlay without querying the physics space there. ```text IDLE ── valid Use ──> USE_APPROACHING ── arrived ──> USE_REACHING ^ │ blocked/moved │ touch verified │ └─────────────── fail ──────────┤ │ v └──── restored marker/torso <── USE_RECOVERING <── prop_used once ``` At action start, resolve the stable prop and spot IDs, require empty hands and an animated/grounded actor, and solve. `USE_APPROACHING` walks toward the chosen feet point with normal prop collision policy. Keep the target's live spot world position under observation. If its displacement from the planned position exceeds a named threshold, recompute once from the current actor root and current prop transform; update the approach target. A second meaningful drift, blocked path, or newly invalid solution fails promptly. A small stationary jitter does not spend the replan. `USE_REACHING` drives one free IK hand marker toward the **current** spot; if it drifts beyond a separate contact tolerance after the approach, fail rather than teleport the arm. Recheck reach/obstruction and actual wrist contact before emitting success once. `USE_RECOVERING` eases hand/torso/legs back, then emits `action_completed` to the runner. Failures also restore the pose, emit a specific cue once, and finish safely. The target keeps its existing freeze/velocity/collision profile throughout. Pause stops the rig physics tick and procedural Use timer. The Stage explicitly pauses the overlay's completion-pulse timer too, since a `Control._process` clock otherwise continues while the rig is paused. Resume continues from the same phase, revalidates the live spot, and observes the normal event baseline gate. Takeover/ragdoll cancels Use, restores the saved marker/pose, and emits no success; a completed Use event is never replayed by resume. STOP/reset calls the existing interactor cleanup, cancels the pulse/preview, and restores the authored world through `StageRunSession`. Repeated PLAY runs start with zero Use replan count and no stale marker or success latch. ## Implementation order and verification boundaries 1. Add schema validation/normalization and `PropBlock` local/world APIs; migrate StageSceneIO/StageSceneAdapter to 1.3, including missing-prop placeholders and strict present-key validation. 2. Add action/trigger registry and codec branches, runtime matching, stable-reference repair checks, and name-resolving summaries. Protect `prop_used` from deletion pruning and decode-time loss. 3. Add shared reach solver and interactor phases/signals; wire runner, rig facade, stage event dispatch, pause/reset cleanup, and failure reasons. 4. Extend the existing prop card and Stage UI theme, then build EDIT and DIRECT capture/overlay/dialog flows, overlap choices, preview, and keyboard list alternatives. 5. Review the completed implementation against the UI acceptance criteria in the editor. This document records the intended and current interaction contract; it does not claim that every visual or runtime behavior has been user-verified. Automated results, where available, are maintained separately in the architecture log. ## Trade-offs and open debt - Persistent spot IDs on the placed `PropBlock` fit the current JSON scene model. New `Resource` files or child `Marker2D` nodes per spot would add scene-tree/persistence machinery without improving this small authored collection. - `prop_used` uses stable prop identity in its params while keeping actor source mapping in the existing rule system. This targeted exception preserves missing references without changing every rule type; broader session-ID limitations remain tracked in architecture debt #24. - Existing popup host visibility, broad AABB object targeting, and the large root stage controller remain tracked under debt #19, #12, and #67. This phase reuses their boundaries and adds no duplicate issue for them. - Preview cannot simulate every earlier rule or moving rigid body. It reports uncertainty and rechecks in PLAY; a future predictive timeline can improve this without changing saved action targets. This distinct limitation is logged as debt #68; existing throw-prefix behavior remains tracked by resolved #52.