# NEON-29 Implementation Plan — Expand prototype client district for camera/nav stress QA

> **Historical — Jira archive.** This file preserved the **Atlassian / NEON-*** story record. **Active tracker:** Linear **NEO-***, team Neon Sprawl. The same work is described for implementation kickoff in [**NEO-19** — implementation plan](NEO-19-implementation-plan.md) (same title and scope). Do not open new work against **NEON-*** keys.

## Story reference

- **Key:** [NEON-29](https://neon-sprawl.atlassian.net/browse/NEON-29)
- **Title:** E1.M2: Expand prototype client district for camera/nav stress QA
- **Parent epic:** [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1)
- **Labels:** E1, E1.M2, client-qa-district, decomposition

---

## Goal, scope, and out-of-scope

### Goal

Provide a **~5× footprint** (order-of-magnitude) prototype play space versus the current district, with **more obstacles** and **height-changing regions** (steps, terraces, bumps) so day-to-day client work can stress **motion, framing, and readability** for the camera, navigation, ground pick, and interaction systems.

### Scope

- `client/scenes/main.tscn`: expand walkable floor / nav source geometry; add props that participate in `NavigationRegion3D` bake and `walkable` picking as today; include elevated terrace/step platforms for vertical variation.
- `client/README.md`: short runbook note covering district scale, intent, and any new designer-facing limits.
- Optional: pointer in `docs/decomposition/modules/E1_M2_IsometricCameraController.md` that this geography is the default stress playground for client stories.

### Out of scope

- New camera features (zoom bands NEON-26, occlusion NEON-27) — geography may help test them, but this story does not implement them.
- Server schema or MoveCommand contract changes unless existing NEON-7 step/bounds defaults visibly break on the larger map (in that case: document + minimal config tune only).
- Production art pass or zone graph / Epic 4 content.

---

## Acceptance criteria checklist

- [ ] Playable area is **materially larger** (~5× order of magnitude vs pre-change footprint) with **additional obstacles** and **vertical variation** usable with click-to-move.
- [ ] **NavigationRegion3D** bakes successfully at startup; long clicks behave per existing NEON-8 tradeoffs (document if new geometry exposes new edge cases).
- [ ] **Ground pick** and `walkable` grouping remain correct on new surfaces.
- [ ] README updated for anyone running F5 + server.

---

## Technical approach

### Current baseline

The current `main.tscn` district is a single **20 × 20** unit flat floor (`BoxMesh_floor`, placed at Y = -0.1), giving an approximate playable area of ~400 sq units. Static props:

- `PrototypeTerminal` (0.9 × 1 × 0.4) at origin
- `MoveRejectPedestal` (1.5 × 2.5 × 1.5) at (7.5, 0, -6.5)
- `MoveRejectFarPad` (2.5 × 0.12 × 2.5) at (9, 0.06, 9)
- `Obstacle` (2 × 2 × 2, `occluder` group) at (6, 0, 5)
- Player spawn at (-5, 0.9, -5)

The `NavigationMesh` stored in the scene file is intentionally stale; `main.gd` calls `bake_navigation_mesh(false)` at startup so the live mesh always reflects actual collision geometry.

### Expansion plan

**Floor size:** Replace the 20 × 20 floor with a **45 × 45** unit floor, giving ~2025 sq units (~5× of 400). Keep floor mesh center at origin (X=0, Z=0) so spawn position and existing props stay roughly central.

**Additional obstacles (nav stress + camera occlusion):** Add 3–4 extra `StaticBody3D` nodes in the `occluder` group, distributed across the expanded area — positioned so the follow camera must work around varying angles.

**Vertical variation (terraces / steps):** Add 2–3 raised terrace platforms as `StaticBody3D` nodes in the `walkable` group, elevated ~0.5–2.5 m above the main floor, with ramp/step approach geometry. These are `StaticBody3D` siblings of `Floor` under `NavigationRegion3D` so the runtime bake includes them as nav sources. Each terrace:
  - Has its own `CollisionShape3D` (BoxShape3D).
  - Is in the `walkable` group so ground-pick raycasts hit it correctly.
  - Has a small `StaticBody3D` ramp or step approach (also `walkable`) so `NavigationAgent3D` can route onto it within the NEON-8 step/climb parameters (`agent_max_climb = 0.35` in existing NavMesh settings — ramp grade must respect this, or use multi-step approach).

**MoveReject props:** Keep `MoveRejectPedestal` and `MoveRejectFarPad` in place — they exercise NEON-7 reject behavior and must remain reachable. Shift `MoveRejectFarPad` outward if needed to stay within the expanded floor bounds and remain "far."

**NavigationMesh settings:** No changes to agent parameters (height=1.0, radius=0.4, max_climb=0.35, cell_size=0.15) — they remain aligned with the player capsule per NEON-8. The stale baked mesh in the scene will update at runtime. The `geometry_collision_mask = 1` stays, so all collision-layer-1 bodies under the region contribute.

**NEON-7 edge case check:** With a larger floor the server's `step_size` / `MaxDistance` defaults may now allow clicks that would previously have been out-of-range. Document in the README if this changes observed reject behavior; no server code change unless a new reject case is broken (not just shifted in range).

---

## Files to add

None. All geometry lives in the existing scene file; no new GDScript logic is required.

---

## Files to modify

| File | Rationale |
|------|-----------|
| `client/scenes/main.tscn` | Replace 20×20 floor with 45×45; add terrace/step platforms and extra obstacle props; update pre-baked `NavigationMesh` vertices (will be overwritten at runtime, but keeping a reasonable approximation avoids editor warnings). |
| `client/README.md` | Add district runbook note: new footprint, vertical variation, intent as stress playground, updated manual-check geometry references. |
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | Optional lightweight pointer that this expanded geography is the default QA map for E1.M2 camera stories. |

---

## Tests

No new automated GDScript tests are added for this story — there is no new GDScript logic. This story is pure scene geometry.

**Manual verification replaces automated tests:**

1. **F5** with server running: confirm player spawns and nav bake completes without errors in Output.
2. Click-to-move across the expanded floor, up/down each terrace, and through obstacles — confirm `NavigationAgent3D` routes and `move_and_slide` behaves per NEON-8 tradeoffs.
3. Ground-pick regression: click behind each new `occluder`-tagged obstacle with player in and out of occlusion fade range (per NEON-30 manual check in README).
4. Confirm `MoveRejectPedestal` and `MoveRejectFarPad` still reject correctly.
5. Follow camera: walk to far corners of the expanded floor — confirm `IsometricFollowCamera` tracks smoothly without snapping or losing the player (snap_distance not tripped by longer walks).
6. Interaction radius: walk near `PrototypeTerminal` to confirm range indicators still work correctly.

---

## Decisions

- **No horizontal step limit (resolved):** The server's `MaxHorizontalStep = 18 m` was too restrictive for the expanded 45 × 45 m floor (diagonal ≈ 63 m). Added `HorizontalStepEnabled` bool to `MovementValidationOptions` (defaults `false`), matching the `DistrictBoundsEnabled` pattern. The horizontal check is now skipped by default; the property remains available to opt back in via config. `MoveRejectFarPad` no longer exercises a horizontal range reject; it retains its walkable surface for vertical-step regression only.
- **Step assist guard/lift bug fixed (resolved):** `_try_walk_step_assist` in `player.gd` compared the goal's surface Y against the **capsule centre** (`global_position.y ≈ 0.9`) instead of the actual capsule bottom (`global_position.y − CAPSULE_HALF_HEIGHT − PLAYER_CAPSULE_RADIUS = 0.0`). This disabled the assist for any surface below ≈ 0.925 m — including all three terrace platforms. The lift formula used offsets of 0.45/0.55 instead of the full total half-height (0.9), giving zero or negative lift. Fixed: guard now uses `actual_feet_y`; lift targets `goal.y + CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` via `clampf`.
- **Arrival check using wrong capsule reference (resolved):** `vertical_arrival_error` was called with `CAPSULE_HALF_HEIGHT = 0.5`, giving code-feet at `body_y − 0.5 = 0.4` at floor level. Step surfaces (Y = 0.3) are only 0.1 m from that reference. When the Jolt physics engine nudges the capsule body down to ~0.8 m (bottom hemisphere contacts the step edge), code-feet becomes 0.3 — matching the step surface Y and making `vert_err = 0 ≤ VERT_ARRIVE_EPS`. If the player is also within `ARRIVE_EPS = 0.35 m` horizontally, the arrival check fires immediately, clearing the nav goal before the capsule has moved. Fixed: pass `CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` (= 0.9) to `vertical_arrival_error` so actual feet = `body_y − 0.9`. Step goal vert_err is now 0.3 >> 0.055 from floor height; arrival fires correctly only once the step assist has lifted the capsule onto the surface (body_y = 1.2, vert_err ≈ 0). Two regression tests added.
- **snap_to_server places capsule underground (resolved):** The server stores the raw surface Y from the client's click (e.g. 0.6 for a terrace platform), not the capsule body-centre Y (which should be 0.6 + 0.9 = 1.5). `snap_to_server` was setting `global_position.y` directly to this surface Y, putting the capsule bottom at −0.3 m. With `_floor_angle_loose_ticks = 0` and a stale `is_on_floor() = true`, the idle path's `_stable_idle_support()` check passed and held the player without calling `move_and_slide()`, preventing Jolt from resolving the penetration. Fixed: clamp `global_position.y ≥ CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` (= 0.9 m) in `snap_to_server`; also set `_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP` (96 ticks) to force corrective idle physics before the next walk goal.
- **Step assist undone by floor snap (resolved):** Each `_try_walk_step_assist()` cycle lifts the capsule 0.11 m. The prior `floor_snap_length = FLOOR_SNAP_MOVING = 0.32 m` meant the snap always reached the lower floor (0.11 m below the lifted bottom), pulling the capsule straight back down every tick. The player "nudged and stopped" because each lift was immediately cancelled. Fixed: added `WALK_STEP_ASSIST_SNAP = 0.09 m` constant (below 0.11 m so the snap cannot reach the previous floor, but above 0.03 m so it catches the step surface once cleared) and a `_step_assist_active` boolean flag. During active climbing, `floor_snap_length` is set to `WALK_STEP_ASSIST_SNAP` in both `_after_walk_move_and_slide()` and the main physics loop. The flag is cleared when the player lands cleanly on a floor without wall contact (`is_on_floor() and not is_on_wall()`), or when the nav goal is cleared/snapped.
- **Terrace B descend / climb (resolved, iterated):** Scene lifts and **descend-bypass** bee-lines caused regressions (stuck climbs, lip oscillation, wrong-direction slides). **Removed the descend bypass:** vertical moves (e.g. platform ↔ floor) use the **baked nav path + step assist** only. **Walk gravity** is **`!is_on_floor()`** again — never “goal below feet” while still walking on an upper surface (that applied gravity across the whole **TerracePlatformB** deck and destroyed horizontal motion). **`velocity.y`** is cleared by the horizontal steer each tick as before. 
- **Lip stuck (floor→platform / platform→floor):** QA screenshots for this were on **`TerracePlatformA`** (10 × 10 large SE pad, **0.3 m** single step — not the NW **Terrace B** step/platform pair). Step assist required **`vel_h.dot(want) ≤ 0.52`**, so a **head-on** push into the vertical face (dot ≈ 1) **never** triggered the lift. **Removed** that gate. For **platform→floor**, **`floor_block_on_wall = false`** only when the nav goal is **below** the feet **and** **`is_on_wall()`** or a **wall-ish slide normal** exists, so open deck walking stays stable.
- **Teal pad still stuck (follow-up):** Step assist used **slide normals only**; Jolt often exposes the lip via **`is_on_wall()`** / **`get_wall_normal()`** instead — re-added a **directional** wall check (`wnh.dot(want) < -0.02`). Descend relax now also triggers on **shallow floor normal** (`get_floor_normal().dot(UP) < 0.992`) and slightly wider vertical-ish slide band. **`TerracePlatformA`** root **Y = 0.012 m** breaks **coplanarity** with the main floor without the larger gap that hurt **TerraceStepB** approach.
- **Nav waypoint steering dropped (movement):** Following **`get_next_path_position()`** by default caused **near-zero** horizontal progress under **Jolt** + **120 Hz** (waypoint advance / finished edge cases), read as universal **stuck** movement even with the server up. **`player.gd`** now **bee-lines in XZ** toward **`_auth_walk_goal`** and uses path-based steer only when **`needs_vertical_routing`** (Schmitt **`WALK_VERT_ROUTE_LATCH_*`**) **and** **`NAV_COLUMN_STEER_*`** hysteresis **and** **`is_on_floor()`** (mid-air column steer caused **180° XZ flips** while falling). **`_set_horizontal_velocity_from_nav_path_or_goal`** scans **`get_current_navigation_path()`** for the first waypoint **≥ `NAV_PATH_STEER_MIN_LOOKAHEAD` (0.22 m)** in XZ, with a **goal-direction dot** guard — raw **`get_next_path_position()`** and a **5 cm** scan both sat inside Jolt’s per-tick slide and could flip velocity **180°** each tick.
- **Walk ledge / void gravity (iterated):** **`is_on_floor()`** + **`floor_snap_length`** could keep the capsule **glued** while crossing void to a lower click. **Fix:** **multi-sample** down rays under the **foot disk** (offsets along **move direction**; depth ~**0.32**); apply gravity when **none** hit; **`floor_snap_length = 0`** that tick so snap does not cancel **`velocity.y`**. Dropped a **floor-normal** gate that skipped the probe on **lip** contacts.
- **Step assist vs `agent_max_climb`:** Nav **`agent_max_climb = 0.35`** only constrained mesh links; **bee-line + chained step assist** could still climb **TerracePlatformB** (~**0.6 m** from floor) in one click. **`_try_walk_step_assist`** now returns false when **`_auth_walk_goal.y > feet + WALK_STEP_ASSIST_MAX_SURFACE_DELTA`** (**0.35**, same as the mesh).
- **Teal cardinal / tread shake (rollback):** Aggressive tweaks (**0.16** lift, **1**-tick cooldown, **`descending_stall`** toggling **`floor_block_on_wall`**, **seam-based** step-assist clear, **walk-stall nav replan**) caused **oscillation** on the small approach treads. **Reverted** to **`WALK_STEP_ASSIST_DELTA = 0.11`**, **`WALK_STEP_ASSIST_COOLDOWN_TICKS = 8`**, **no** stall replan, **no** `descending_stall` branch, **no** seam-based assist clear — only the original clear (clean floor without wall, or airborne with no slides). **Climb** wallish snap cap applies only when **`goal.y > feet_y + 0.12`** so shallow tread-to-tread motion is not capped to assist snap every frame.
- **Teal pad — geometry wins:** Script-only mitigations still failed in practice; **`TerracePlatformA_Approach`** adds **twelve** `walkable` **`StaticBody3D`** treads (three per cardinal) with **~0.104 m** rise each, **darker teal** albedo, flush to the platform lip — nav + `CharacterBody3D` use **sloped contact** instead of fighting a **single vertical face**. **Follow-up:** tread **run** was **0.45 m** along the climb axis while the capsule **diameter is 0.8 m** — the body was wider than each tread, causing straddle / stuck motion when **entering** and blocking horizontal progress toward the lip when **exiting**. Treads widened to **1.0 m** run with positions re-centered. **`NavigationAgent3D`** `path_desired_distance` / `target_desired_distance` were briefly lowered to **0.22** but that caused **floor** movement to **stall**; **reverted to 0.35**.

## Approach treads (district convention)

- **Shipped in scene:** **`TerracePlatformC_Approach`** (`PCS_*`, teal-style **3×4** cardinals, **7.2 m** NS / **WE** span), **`TerraceStepB_Approach`** (`TSB_*`, **S/E/W** only — north meets violet), **`TerracePlatformB_Approach`** (`TPB_*`, **N/E/W**, **six** ~**0.104 m** risers for **0.6 m** deck height; south remains **gold step**). **Smooth ramps** for other props can follow the same rise/run idea without new `player.gd` logic.
- **Idle jitter on thin treads / flat stops:** (1) **`idle_support_is_stable`**: post-stop **`loose_ticks` + ridged** blocks stable idle only when **`floor_normal.dot(UP) < IDLE_RIM_MIN_FLOOR_UP_DOT`** — flat tread + vertical riser (e.g. **TerraceStepB_Approach** corner) no longer spends ~0.8 s in corrective idle. (2) **`use_loose_floor_angle`** not tied to **`loose_ticks` alone**. (3) **`STABLE_IDLE_FLOOR_MIN_UP_DOT` 0.998** (was 0.999 — Jolt flat-floor normals often ~0.9991+ so the anchor rarely latched and **`move_and_slide`** + nudges drifted HUD XZ); **`STABLE_IDLE_ENTER_STREAK_FRAMES` 2**; **`STABLE_IDLE_UNLATCH_TICKS` 10**; ridged **wallish ny < 0.35**; **stable latch + Y anchor**. (4) **Arrival** stable shortcut (immediate latch, no enter streak). (5) **Nav column steer** hysteresis. (6) **`needs_vertical_routing` Schmitt latch**; **`debug_idle_trace`** walking extras.
- **Not in this pass:** **`PrototypeTerminal`**, **MoveReject** props, **runtime random bumps** — add geometry when those need climbable access.

## Open questions / risks

- **NavMesh bake time:** A 45×45 floor with several terraces will bake more polygons than the 20×20 baseline. Expected to be negligible for `bake_navigation_mesh(false)` on desktop, but monitor Output for a perceptible pause.
- **Agent climb on terraces:** `agent_max_climb = 0.35` limits single-step height. Each terrace transition is 0.30 m; verify in Godot editor nav mesh debug overlay before shipping.
