17 KiB
NEO-19 Implementation Plan — Expand prototype client district for camera/nav stress QA
Story reference
- Key: NEO-19
- Title: E1.M2: Expand prototype client district for camera/nav stress QA
- Parent epic: Epic 1 — Core Player Runtime
- 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 inNavigationRegion3Dbake andwalkablepicking 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.mdthat this geography is the default stress playground for client stories.
Out of scope
- New camera features (zoom bands NEO-16, occlusion NEO-17) — geography may help test them, but this story does not implement them.
- Server schema or MoveCommand contract changes unless existing NEO-10 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 NEO-11 tradeoffs (document if new geometry exposes new edge cases).
- Ground pick and
walkablegrouping 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 originMoveRejectPedestal(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,occludergroup) 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
walkablegroup so ground-pick raycasts hit it correctly. - Has a small
StaticBody3Dramp or step approach (alsowalkable) soNavigationAgent3Dcan route onto it within the NEO-11 step/climb parameters (agent_max_climb = 0.35in existing NavMesh settings — ramp grade must respect this, or use multi-step approach).
MoveReject props: Keep MoveRejectPedestal and MoveRejectFarPad in place — they exercise NEO-10 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 NEO-11. 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.
NEO-10 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:
- F5 with server running: confirm player spawns and nav bake completes without errors in Output.
- Click-to-move across the expanded floor, up/down each terrace, and through obstacles — confirm
NavigationAgent3Droutes andmove_and_slidebehaves per NEO-11 tradeoffs. - Ground-pick regression: click behind each new
occluder-tagged obstacle with player in and out of occlusion fade range (per NEO-20 manual check in README). - Confirm
MoveRejectPedestalandMoveRejectFarPadstill reject correctly. - Follow camera: walk to far corners of the expanded floor — confirm
IsometricFollowCameratracks smoothly without snapping or losing the player (snap_distance not tripped by longer walks). - Interaction radius: walk near
PrototypeTerminalto confirm range indicators still work correctly.
Decisions
- No horizontal step limit (resolved): The server's
MaxHorizontalStep = 18 mwas too restrictive for the expanded 45 × 45 m floor (diagonal ≈ 63 m). AddedHorizontalStepEnabledbool toMovementValidationOptions(defaultsfalse), matching theDistrictBoundsEnabledpattern. The horizontal check is now skipped by default; the property remains available to opt back in via config.MoveRejectFarPadno 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_assistinplayer.gdcompared 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 usesactual_feet_y; lift targetsgoal.y + CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUSviaclampf. - Arrival check using wrong capsule reference (resolved):
vertical_arrival_errorwas called withCAPSULE_HALF_HEIGHT = 0.5, giving code-feet atbody_y − 0.5 = 0.4at 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 makingvert_err = 0 ≤ VERT_ARRIVE_EPS. If the player is also withinARRIVE_EPS = 0.35 mhorizontally, the arrival check fires immediately, clearing the nav goal before the capsule has moved. Fixed: passCAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS(= 0.9) tovertical_arrival_errorso 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_serverwas settingglobal_position.ydirectly to this surface Y, putting the capsule bottom at −0.3 m. With_floor_angle_loose_ticks = 0and a staleis_on_floor() = true, the idle path's_stable_idle_support()check passed and held the player without callingmove_and_slide(), preventing Jolt from resolving the penetration. Fixed: clampglobal_position.y ≥ CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS(= 0.9 m) insnap_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 priorfloor_snap_length = FLOOR_SNAP_MOVING = 0.32 mmeant 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: addedWALK_STEP_ASSIST_SNAP = 0.09 mconstant (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_activeboolean flag. During active climbing,floor_snap_lengthis set toWALK_STEP_ASSIST_SNAPin 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.yis 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 requiredvel_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 = falseonly when the nav goal is below the feet andis_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.TerracePlatformAroot 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.gdnow bee-lines in XZ toward_auth_walk_goaland uses path-based steer only whenneeds_vertical_routing(SchmittWALK_VERT_ROUTE_LATCH_*) andNAV_COLUMN_STEER_*hysteresis andis_on_floor()(mid-air column steer caused 180° XZ flips while falling)._set_horizontal_velocity_from_nav_path_or_goalscansget_current_navigation_path()for the first waypoint ≥NAV_PATH_STEER_MIN_LOOKAHEAD(0.22 m) in XZ, with a goal-direction dot guard — rawget_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_lengthcould 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 = 0that tick so snap does not cancelvelocity.y. Dropped a floor-normal gate that skipped the probe on lip contacts. - Step assist vs
agent_max_climb: Navagent_max_climb = 0.35only constrained mesh links; bee-line + chained step assist could still climb TerracePlatformB (~0.6 m from floor) in one click._try_walk_step_assistnow 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_stalltogglingfloor_block_on_wall, seam-based step-assist clear, walk-stall nav replan) caused oscillation on the small approach treads. Reverted toWALK_STEP_ASSIST_DELTA = 0.11,WALK_STEP_ASSIST_COOLDOWN_TICKS = 8, no stall replan, nodescending_stallbranch, no seam-based assist clear — only the original clear (clean floor without wall, or airborne with no slides). Climb wallish snap cap applies only whengoal.y > feet_y + 0.12so 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_Approachadds twelvewalkableStaticBody3Dtreads (three per cardinal) with ~0.104 m rise each, darker teal albedo, flush to the platform lip — nav +CharacterBody3Duse 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.NavigationAgent3Dpath_desired_distance/target_desired_distancewere 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 newplayer.gdlogic. - Idle jitter on thin treads / flat stops: (1)
idle_support_is_stable: post-stoploose_ticks+ ridged blocks stable idle only whenfloor_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_anglenot tied toloose_ticksalone. (3)STABLE_IDLE_FLOOR_MIN_UP_DOT0.998 (was 0.999 — Jolt flat-floor normals often ~0.9991+ so the anchor rarely latched andmove_and_slide+ nudges drifted HUD XZ);STABLE_IDLE_ENTER_STREAK_FRAMES2;STABLE_IDLE_UNLATCH_TICKS10; 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_routingSchmitt latch;debug_idle_tracewalking 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.35limits single-step height. Each terrace transition is 0.30 m; verify in Godot editor nav mesh debug overlay before shipping.