# Neon Sprawl — Godot client Open this **`client/`** directory as a project in **Godot 4.6** (4.x compatible). Use the **standard** Godot build (not the .NET build)—client code is **GDScript** (`.gd`). - Main scene: `scenes/main.tscn` (bootstrap `scripts/main.gd`). - Networking will use **WebSocket** or **TCP** to the C# server; authoritative logic stays on the server per [`docs/architecture/tech_stack.md`](../docs/architecture/tech_stack.md). ## Script organization (repo policy) Do not grow an all-in-one **`main.gd`**. The main scene root should **compose** child nodes and scripts; put picking, server sync, and similar features in **dedicated scripts** (typically under `scripts/`) and connect via **signals** or small APIs. Use **Autoloads** only when several scenes truly need the same global. Full guidance for contributors and tooling: [`.cursor/rules/godot-client-script-organization.md`](../.cursor/rules/godot-client-script-organization.md). **Tests with script changes:** Any PR that adds or changes GDScript under **`scripts/`** should **add or update** tests under **`test/`** in the same change set (see [`.cursor/rules/testing-expectations.md`](../.cursor/rules/testing-expectations.md)). CI runs them via **`.github/workflows/gdscript.yml`**. ## Follow camera (NEON-25) The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow_camera.gd`): client-local **isometric follow** for **`Player`** (`NodePath` **`../Player`**), with damped eye motion (`follow_smoothness`, exponential lerp) and **teleport snap** when the eye is farther than **`snap_distance`** from the desired pose (e.g. after **`snap_to_server`**). Framing exports **`follow_distance`**, **`pitch_elevation_deg`**, and **`presentation_yaw_deg`** match the pre–NEON-25 static camera at spawn. **Orbit is off in the prototype:** **`allow_yaw`** is false, **`CameraState.yaw`** stays **0**, and no rotate input is read—enable **`allow_yaw`** later and drive **`_orbit_yaw_rad`** from input on the rig (see **`docs/decomposition/modules/E1_M2_IsometricCameraController.md`**). **`CameraState`** (`scripts/camera_state.gd`) is refreshed every **`_physics_process`** tick on the rig (**`process_physics_priority`** after **`Player`**) so framing tracks **`move_and_slide`** without display-vs-physics jitter. **`ground_pick`** uses the viewport camera; **`main.gd`** sets **`fallback_camera`** to **`World/IsometricFollowCamera/Camera3D`**. ## Prototype district (NEON-29) The default play space is a **45 × 45** unit flat floor (~2 000 sq units, ~5× the pre-NEON-29 footprint). All geometry lives under `World/NavigationRegion3D` in `scenes/main.tscn`; `main.gd` rebakes the nav mesh at startup so the scene-file vertices are intentionally stale. ### Props at a glance Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screenshots and discussion can name the right prop (`Mat_terrace_platform_a`, `Mat_terrace_b_step`, etc.). The main **floor** slab uses neutral gray (`Mat_floor_slab`). **Occluders** keep the default mesh gray. | Node | Group | Tint | Size (m) | World XZ | Purpose | |------|-------|------|----------|----------|---------| | `Floor` | `walkable` | Neutral gray | 45 × 45 | (0, 0) | Main play surface | | `PrototypeTerminal` | `walkable` | Slate blue | 0.9 × 0.4 | (0, 0) | Interaction target | | `MoveRejectPedestal` | `walkable` | Orange | 1.5 × 1.5 | (7.5, −6.5) | NEON-7 vertical reject | | `MoveRejectFarPad` | `walkable` | Blue-gray | 2.5 × 2.5 | (9, 9) | Near-floor walkable pad; no longer a reject target (horizontal limit removed NEON-29) | | `Obstacle` | `occluder` | Default gray | 2 × 2 | (6, 5) | Original occlusion / click-through target | | `ObstacleB` | `occluder` | Default gray | 3 × 3 | (−10, 5) | Second occluder (wider angle from spawn) | | `ObstacleC` | `occluder` | Default gray | 2 × 4 | (5, −12) | Tall occluder, south quadrant | | `ObstacleD` | `occluder` | Default gray | 3 × 2 | (−8, −16) | Far-south occluder | | `TerracePlatformA` | `walkable` | **Teal** | 10 × 10, h=0.3 m | (13, −14) | Large SE pad; **three darker-teal approach treads per cardinal** (`TerracePlatformA_Approach` / `PAS_*`) — **1.0 m run** along the climb axis (~0.104 m rise each), wider than the **0.8 m** capsule diameter so the body is not wider than each tread | | `TerraceStepB` | `walkable` | **Gold** | 6 × 3, h=0.3 m | (−15, 8.5) | NW approach step; bridges floor → TerracePlatformB | | `TerracePlatformB` | `walkable` | **Violet** | 6 × 6, h=0.6 m | (−15, 13) | Upper NW terrace; reached via TerraceStepB | | `TerracePlatformC` | `walkable` | **Green** | 8 × 8, h=0.3 m | (14, 14) | NE pad; single-step ascent from floor | ### Height variation notes - **Single-step terraces (A, C):** 0.3 m rise — within `agent_max_climb = 0.35`, so the nav mesh routes directly from floor onto the pad; one click suffices. **`TerracePlatformA`** (teal) sits **12 mm** above the main floor slab and has **cardinal walkable treads** (`TerracePlatformA_Approach`) so movement does not rely on a bare vertical box lip; nav rebakes at startup. - **Two-level terrace B:** floor → `TerraceStepB` (0.3 m) → `TerracePlatformB` (additional 0.3 m). Each transition is within climb limit, so the agent can route the full ascent in a single click-to-move target anywhere on the platform. - **Occluders** are plain `StaticBody3D` with no `walkable` tag; the nav bake excludes their top surfaces, so they remain true obstacles for routing. ### Designer / QA limits - Floor bounds: **±22.5 m** in X and Z from world origin. Clicks outside the `NavigationRegion3D` boundary (beyond the floor edge) will be rejected by `NavigationAgent3D` path queries. - The `MoveRejectPedestal` (orange box) top is ~2.5 m above the floor and will be rejected by the server's `MaxVerticalStep = 1.25 m` check — the only remaining move-rejection criterion. Horizontal distance is never a rejection reason. - New obstacles are tagged `"occluder"` so the NEON-27 occlusion policy and NEON-30 click-through both apply automatically. ## Authoritative movement (NEON-4, NEON-8) With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NEON-4/19); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NEON-8). **Boot** `sync_from_server()` **snaps** once so spawn matches the server. **Tradeoff (prototype):** With a valid nav map, horizontal motion follows **`NavigationAgent3D.get_next_path_position()`** (or direct approach near the goal). **No descend bypass:** bee-lining in xz whenever the pick **Y** is below the capsule **feet** applied gravity for the entire crossing of a raised pad toward a lower target (e.g. **TerracePlatformA** or **TerracePlatformB**) and broke movement; **terraces and stepped QA bumps rely on the baked mesh + step assist** instead. **Automatic routing around tall obstacles on one click is still not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed. **NEON-7 / movement QA bumps:** On **run**, **`spawn_short_random_bumps`** adds **two** green cylinders, each on its own **`StaticBody3D`** **sibling** of **`Floor`** under **`NavigationRegion3D`** (**`walkable`** on bump roots — avoids compound **internal-edge** jitter vs floor+cylinder on one body). Bump meshes use Godot group **`random_floor_bump_mesh`**. **Collision radius** = mesh **+ `COLLISION_RADIUS_EXTRA`** (see **`scripts/random_floor_bump_collision_constants.gd`**, capped by **`COLLISION_RADIUS_MAX`**). **`bake_navigation_mesh(false)`** after spawn. **Idle stability (NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **Flat idle hardening:** when support is already effectively level (floor normal almost **`Vector3.UP`** and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an **idle x/z anchor**, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. Goal arrival uses the capsule **feet height** rather than the body origin, so flat floor clicks clear and route normally instead of lingering in a straight-line obstacle push. **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** now short-circuits when the basis is already identity. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**. - **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`; occluder bodies tagged `"occluder"` are passed through unconditionally so clicks reach the ground behind them — NEON-30), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), `scripts/isometric_follow_camera.gd` + `scripts/camera_state.gd` (NEON-25 follow), thin `scripts/main.gd` (nav bake on first frame, then wiring). - **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` waits one **`process_frame`**, spawns **random test bumps** on **`Floor`**, then **`bake_navigation_mesh(false)`** (main-thread bake), then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. The baked **`NavigationMesh`** asset in the scene file is **stale** until you run or re-bake in the editor. - **Inspector:** select **`PositionAuthorityClient`** on the main scene to set **`base_url`** (default `http://127.0.0.1:5253`) and **`dev_player_id`** (default `dev-local-1`, must match server `Game:DevPlayerId`). ### Manual check (NEON-4 + NEON-8) 1. From repo root: `cd server/NeonSprawl.Server && dotnet run` (note the URL/port, usually `http://localhost:5253`). 2. If the port differs, set **`base_url`** on **`PositionAuthorityClient`** accordingly (e.g. `http://127.0.0.1:5253`). 3. Open the client in Godot and run the main scene (**F5**). The player should **snap** to the server’s default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NEON-6 walk demo). 4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** toward the authoritative target (follows **`NavigationAgent3D`** waypoints when the map is ready, or direct approach when near the goal). Server-rejected clicks show the reject label and do **not** start a path. 5. Click the **pedestal top** (orange box at ~(7.5, 0, −6.5)) to confirm **`vertical_step_exceeded`** rejection — the top is ~2.5 m above floor, which exceeds `MaxVerticalStep`. The `MoveRejectFarPad` (blue pad at (9, 9)) is now a normal walkable surface; clicking it from any distance should succeed. 6. After a move (or at spawn), **stand idle** with no click goal for **10+ seconds** and confirm the capsule does **not** visibly vibrate or drift in **x/z** on flat floor, then repeat on the **random green bumps** (positions change each run). If the server is **down**, boot **`GET`** fails silently (check Output for warnings); clicks while a request is in flight are ignored until it finishes. ## Interaction + range preview (NEON-6) The main scene includes a **prototype terminal** at the map center (same world **X/Z** as the server’s static registry) and **two glowing markers** driven by `scripts/interaction_radius_indicators.gd`. That script compares the **`CharacterBody3D`** **`global_position`** (after server snap) to the anchor in **`scripts/prototype_interaction_constants.gd`** using **horizontal distance on X/Z** and the same **`interaction_radius`** as **`PrototypeInteractableRegistry.cs`** — **preview only**; the server **`POST /game/players/{id}/interact`** is authoritative. - **`InteractionRequestClient`** (child of the main scene root): press **E** to POST interact for the prototype id; **Output** prints `allowed` / `reasonCode` (or HTTP errors). While a request is in flight, further **E** presses are ignored (same pattern as **`PositionAuthorityClient`**). - **Inspector:** match **`base_url`** / **`dev_player_id`** to **`PositionAuthorityClient`** and the server `Game:DevPlayerId`. ### Manual check (NEON-6) 1. Run the game server and client as in NEON-4. 2. **F5** in Godot: default spawn is out of range of the terminal; markers should stay **dim**. **Click-move** toward the center until markers **brighten** (within **3** m on the floor plane). 3. Press **E** (input action **`interact`** in `project.godot`): Output should show **`allowed=true`** when markers glow, **`allowed=false`** with **`reasonCode=out_of_range`** when dim (if you walk back out). Interaction uses **`_input`**, not `_unhandled_input`, so keys register reliably in the embedded **Game** dock; click the game view if the editor had focus elsewhere. ### Manual check (NEON-30 — occluder click-through) 1. Run the main scene (**F5**) with the server running. 2. **While `Obstacle` is opaque** (player not behind it): left-click the ground directly behind `World/NavigationRegion3D/Obstacle` (roughly `(6, 0, 5)` + a few units further). The player should receive a valid move target and walk toward that position — not stop or ignore the click. 3. **While `Obstacle` is faded** (player positioned so the occlusion policy is actively fading the obstacle): repeat the same click. Behavior should be identical — the occluder fade state must not affect pick success. 4. **Regression — normal floor:** click a clear area of the floor; confirm the player walks there as before. 5. **Regression — far pad / pedestal:** confirm click-rejection behavior on `MoveRejectPedestal` and `MoveRejectFarPad` is unchanged. ## Movement prototype (NEON-2 → NEON-8) **`player.gd`** uses **`NavigationAgent3D.get_next_path_position()`** + **`move_and_slide()`** for horizontal motion when following the mesh (NEON-8). **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile). - The avatar is on **physics layer 2** with **collision_mask** **3** (scans layers **1** and **2** so all walkables/occluders pair reliably). Environment **StaticBody3D** nodes stay on **collision_layer** **1** with **collision_mask** **3** as well. The pick ray uses **mask 1** only, so clicks pass through the avatar and hit the floor. ### Manual check (remnants) 1. Run the main scene (**F5**) **with server running** for movement behavior above. 2. Without the server, startup sync does not apply authoritative position; behavior is undefined for this repo’s intended demo—**run the server** for the demo path. ### Clicks still ignored? In the **Game** dock, **Input** must be active (not the **2D** / **3D** scene-picking tools) so events go to the game. If Input is on and clicks still do nothing, pick rays must use the **viewport’s current camera** and **mouse position** (the script does this so the embedded Game view matches the ray). ## First run 1. Install [Godot 4.x](https://godotengine.org/download). 2. In the project manager, **Import** and select `client/project.godot`. 3. Press **F5** to run the main scene. ## Godot CLI (Linux smoke test) For agents and CI, use the **official Linux x86_64 editor binary** (matches **`config/features`** `4.6` in `project.godot`): 1. Download [Godot_v4.6-stable_linux.x86_64.zip](https://github.com/godotengine/godot/releases/download/4.6-stable/Godot_v4.6-stable_linux.x86_64.zip) from the [4.6-stable release](https://github.com/godotengine/godot/releases/tag/4.6-stable). 2. Unzip and put the binary on your **`PATH`**, e.g. `~/.local/opt/godot-4.6-stable/` and `ln -s …/Godot_v4.6-stable_linux.x86_64 ~/.local/bin/godot`. 3. Ensure **`~/.local/bin`** is on **`PATH`**, then from **`client/`**: ```bash godot --version godot --headless --path . --quit-after 5 ``` A clean checkout has **`client/.godot/`** gitignored, so scripts intentionally avoid **`class_name`** global types that require a full editor import before headless can resolve them. ## Automated tests (GDScript, NEON-14) **Framework:** **[GdUnit4](https://github.com/godot-gdunit-labs/gdUnit4)** **v6.1.2** is vendored under **`addons/gdUnit4/`** (plugin enabled in `project.godot`). Test suites live in **`test/`** (`*_test.gd` files extending `GdUnitTestSuite`). **Editor:** Open the project in Godot 4.6; use the **GdUnit** dock to run tests, or run headless (below). **Headless (Linux, from `client/`):** Use the same **4.6-stable** binary as in [Godot CLI](#godot-cli-linux-smoke-test). On a fresh clone you may need a one-time import so `.godot` exists: ```bash godot --headless --import --path . --quit-after 8 godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test ``` On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a capital **`U`**; `gdunit4` does not resolve. **CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that. **Scope:** Unit tests cover **`player.gd`**, **`position_authority_client.gd`**, **`ground_pick.gd`** (walkable collider check + occluder ancestry check), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`**, full **`_input` / ray pick** flows, and scene wiring are **not** automated here—use manual checks above. **Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. **Dev (NEON-28):** **`dev_toggle_occluder_obstacle`** (default **Ctrl+Shift+K**) toggles the prototype `Obstacle` visibility/collision in **`main.gd`**. **F9** / **F10** are used by the embedded debugger; use this action instead when the game is running in the editor. **Git hook (recommended):** install the repo’s local **pre-push** GDScript lint hook from the repo root (once per clone): ```bash ./scripts/install-git-hooks.sh ``` On **Windows** (PowerShell), same hook: ```powershell pwsh -File scripts/install-git-hooks.ps1 ``` It runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`** before each push. It prefers **`.venv-gd/bin/`** (Unix) or **`.venv-gd/Scripts/`** (Windows venv) when present; without that venv or a global install, the hook **fails** and blocks the push — same as CI. **If CI fails gdlint but your push succeeded:** the hook was not installed, **`gdlint`/`gdformat` were missing** when the hook ran, or push bypassed hooks (`--no-verify`). **Reports:** GdUnit writes under **`reports/`** (gitignored); ignore locally generated HTML/XML when committing.