neon-sprawl/client
VinPropane 6397d81ca3 NEO-122: Run gdformat on quest and economy HUD client scripts. 2026-06-07 13:37:58 -04:00
..
addons/gdUnit4 NS-21: track GdUnit4 bin/ scripts (unignore vs root bin/ rule) 2026-04-06 22:29:08 -04:00
assets/district NEO-99: add Blender-exported district GLB assets and import sidecars. 2026-05-29 20:03:42 -04:00
resources NEON-27: implement OcclusionPolicy — RayCast-based material fade 2026-04-08 23:34:00 -04:00
scenes NEO-122: Separate debug HUD scroll from economy panel. 2026-06-07 13:07:58 -04:00
scripts NEO-122: Run gdformat on quest and economy HUD client scripts. 2026-06-07 13:37:58 -04:00
test NEO-122: Run gdformat on quest and economy HUD client scripts. 2026-06-07 13:37:58 -04:00
README.md NEO-122: Address follow-up review doc drift and QA numbering. 2026-06-07 13:36:21 -04:00
icon.svg Update README and documentation to reflect project rebranding to "Neon Sprawl". Revise links to vision plan and adjust references in tech stack and decomposition documents. Enhance README with repository layout and instructions for running the server and client. 2026-03-29 17:23:00 -04:00
icon.svg.import fix(client): reliable NS-14 click-to-move without NavigationMesh 2026-03-29 22:08:25 -04:00
project.godot NEO-99: restore Godot window maximized display mode from editor. 2026-05-29 20:04:27 -04:00

README.md

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.

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.

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). CI runs them via .github/workflows/gdscript.yml.

Follow camera (NEO-15)

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 preNEO-15 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.

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) Occlusion policy demo
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; TerraceStepB_Approach / TSB_* — three treads per S/E/W (darker gold), 1.0 m run on NS arms
TerracePlatformB walkable Violet 6 × 6, h=0.6 m (15, 13) Upper NW terrace; TerracePlatformB_Approach / TPB_*six treads per N/E/W (~0.104 m rise) for 0.6 m to floor (south uses gold step, not violet treads)
TerracePlatformC walkable Green 8 × 8, h=0.3 m (14, 14) NE pad; TerracePlatformC_Approach / PCS_* — same tread pattern as teal (PAS_*), darker green albedo

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 steady WASD approach 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 (gold, 0.3 m) → TerracePlatformB (violet, +0.3 m). agent_max_climb = 0.35 applies to the nav mesh and to walk step assist: you cannot cheese the violet deck from the floor in one move — use the gold step (or the platform after you are already on the step), not the violet top from below. Approach treads on gold (TSB_*) and on violets north / east / west (TPB_*, six risers for 0.6 m) match the teal convention.
  • QA / thin props: PrototypeTerminal, MoveRejectPedestal, MoveRejectFarPad are unchanged (terminal kiosk, vertical reject demo, near-floor pad). Add ramps or treads later if they need full walk-up treatment.
  • 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. Nav queries outside the NavigationRegion3D boundary (beyond the floor edge) fail as expected.
  • The MoveRejectPedestal (orange box) top is ~2.5 m above the floor and will be rejected by the server's MaxVerticalStep (2.2 m by default) — the only remaining move-rejection criterion with default server config. Horizontal distance is never a rejection reason when HorizontalStepEnabled is false.
  • New obstacles are tagged "occluder" so the NEON-27 occlusion policy applies automatically.

Authoritative movement (NEO-7, NEO-11, NEO-22)

With the game server running (server/README.md), WASD (input actions move_* in project.godot) drives camera-relative XZ wish direction on the Player, and main.gd periodically **POST**s /game/players/{id}/move-stream with the latest body-centre sample while you are moving (no stream while standing still, so idle anchor does not fight HTTP). PositionAuthorityClient coalesces to one target per request so a slow round-trip cannot apply a stale multi-step chain after the capsule has moved. The server validates each leg with the same rules as a single MoveCommand, applies all targets in order (reject = no partial apply), and returns PositionStateResponse. A successful move-stream response is treated as an ack only (no client teleport): the echoed position typically trails move_and_slide by roughly one RTT, so snapping it caused rubber-banding. authoritative_position_received is emitted only from boot GET so main.gd can snap_to_server. Left-click ground pick / POST /move has been removed from the client.

Boot sync_from_server() snaps once so spawn matches the server.

Tradeoff (prototype): Horizontal motion is a direct XZ bee-line toward the servers verified target. The baked NavigationMesh is still used when vertical routing is latched and the capsule is inside the nav column hysteresis band (NAV_COLUMN_STEER_ENTER_DIST / EXIT) and is_on_floor() — terraces / steps then steer along get_current_navigation_path() using the first waypoint at least NAV_PATH_STEER_MIN_LOOKAHEAD (~22 cm) away in XZ (unlike a 5 cm scan or raw get_next_path_position(), which both sat inside Jolts per-tick slide and could flip velocity 180° every tick). While airborne, column path steering is off so horizontal motion stays a bee-line toward the movement goal (avoids fall-time XZ oscillation). No descend bypass for gravity while on an upper deck (same as before). While walking, gravity still runs when is_on_floor() is false; if snap / lip contacts keep is_on_floor() true over open space (e.g. gold deck → gray floor), player.gd casts several short down rays under the foot disk (center + offsets along move direction so the leading edge off a deck is tested) and applies gravity when none hit upward-facing ground within WALK_SUPPORT_PROBE_DEPTH. In that case floor_snap_length is set to 0 for the tick so moving snap does not cancel velocity.y on every physics step. Automatic routing around tall obstacles in one straight push is still not guaranteed — steer around e.g. the gray Obstacle with WASD as needed.

NEO-10 / 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 (NEO-14 / 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. Uniform shallow ramps can latch stable idle; nav-column path steering is gated to flatter floors so ramp ribbons do not flip velocity each tick. Goal arrival and descend-bypass checks both use the capsule feet height rather than the body origin, so flat-floor arrivals 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/position_authority_client.gd (PositionAuthorityClient: boot GET, move-stream POST), scripts/player.gd (WASD locomotion + nav assist when a goal exists), scripts/isometric_follow_camera.gd + scripts/camera_state.gd (NEO-15 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 (NEO-7 + NEO-11 + NEO-22)

  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 servers default position (e.g. (-5, 0.9, -5) per Game:DefaultPosition / NEO-9 walk demo).
  4. WASD: walk on the floor; the HUD position label should update and the server should accept move-stream steps (watch server logs or use a proxy if needed). Release keys and confirm the capsule settles to idle without runaway drift.
  5. Walk to the orange reject pedestal and try to climb it with WASD; move-stream should return vertical_step_exceeded when a requested step exceeds MaxVerticalStep (watch the MoveRejectLabel or server logs). The MoveRejectFarPad (blue pad at (9, 9)) remains a normal walkable surface.
  6. After movement, stand idle 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). move-stream submissions are skipped while the HTTP client is busy, then resume.

Interaction + range preview (NEO-9 + NEO-25)

On boot, InteractablesCatalogClient GETs /game/world/interactables; interactable_world_builder.gd spawns walkable prototype props plus two small glow markers per row under World/InteractionMarkers. interaction_radius_indicators.gd compares the snapped CharacterBody3D global_position to each descriptors anchor on X/Z with the same interactionRadius and inclusive <= rule as the server — preview only; POST /game/players/{id}/interact remains authoritative.

  • InteractionRequestClient: interact (E) → prototype_terminal; interact_secondary (R) → nearest in-range resource_node from the interactables catalog (falls back to prototype_resource_node_alpha when none in range). Output prints allowed / reasonCode per response (lines prefixed with Interaction [<id>]: …). Only one HTTP POST runs at a time; if you press E then R while the first request is still in flight, the latest id is remembered and sent immediately after the first completes (last-wins coalescing — not a silent drop).
  • Inspector: match base_url on InteractablesCatalogClient, InteractionRequestClient, and PositionAuthorityClient to the running server.
  • No server: catalog GET fails → Output errors; props and glow markers do not spawn (expected prototype degradation).

Manual check (NEO-9 + NEO-25)

  1. Run the game server and client as in NEO-7.
  2. F5 in Godot: after catalog load, two interactable sites appear (center + +X / Z floor, anchor (12, 0.5, 6) — clear of the gray Obstacle near (6, 1, 5)). Default spawn (-5, 0.9, -5) is out of range of the center node (3 m); markers near origin stay dim until you WASD within horizontal 3 m of (0, 0.5, 0) on X/Z — then that pair brightens.
  3. Press E (interact): Output allowed=true when the terminal pair glows; allowed=false / out_of_range when dim. Walk toward (12, 6) until the second pair glows; press R (interact_secondary) for allowed=true on the resource node.
  4. E / R are handled in main.gd _unhandled_key_input and forwarded to InteractionRequestClient (same pattern as dev keys) so they still register in the embedded Game dock; click the game view if the editor had focus elsewhere.

Full checklist: docs/manual-qa/NEO-25.md.

Target lock + tab cycle (NEO-24)

scripts/target_selection_client.gd wires the server targeting API from NEO-23 to a tab-cycle input and a HUD overlay.

  • Bindings (in project.godot): target_tabTab cycles targets in the order defined by scripts/prototype_target_constants.gd (prototype_npc_elite, then prototype_npc_melee, then prototype_npc_ranged; matches PrototypeNpcRegistry.GetInstanceIdsInOrder() ascending id), wrapping from last → first. When the player reference is wired (via set_freshness_kick, which main.gd does), Tab skips client-computed out-of-range anchors and selects the first in-range id in cycle order so pressing Tab while visibly standing next to an anchor actually locks it instead of trying the next-in-order id that happens to be across the map. Falls back to the plain cycle when nothing is in range so the server still picks the denial reason. target_clearEsc POSTs with targetId omitted to clear the server lock.
  • HUD: UICanvas/TargetLockLabel shows the last server-acknowledged state: lockedTargetId (or when no lock), validity (none / ok / out_of_range / invalid_target), sequence, one display-only line per known target in the form <id>: <dist> m / <radius> (in|out) (computed client-side from the live capsule position against prototype_target_constants.gd), and (on denials) Denied: <reasonCode> on the last line. UI never shows an optimistic lock the server denied — it always paints from the authoritative targetState echoed by each 200 response. Distance lines are a lag diagnostic: if the HUD shows Validity: ok but alpha: 7.1 m / 6.0 (out), the server's position snapshot is lagging the visible capsule, and the next authoritative_ack refresh will reconcile. UICanvas/PlayerPositionLabel also shows the last server-acknowledged world position + horizontal delta from the visible capsule — when that Δ is large, targeting denials are being evaluated against a stale server snapshot.
  • positionHint on select (NEO-24 follow-up #5): when TargetSelectionClient.set_freshness_kick(authority, player) is wired (as main.gd does), every POST /target/select body also carries positionHint: {x,y,z} = the live capsule position. The server uses the hint for the radius check and for the validity field in the echoed targetState, eliminating the cross-request race between move-stream and target/select POSTs — a Tab pressed several seconds after stopping (with an old stored snap on the server) now still succeeds when you are visibly in range. The hint is advisory (server never writes it to the position store); move-stream remains the sole write path. Tests that omit set_freshness_kick(...) fall back to the no-hint code path.
  • Freshness kick (ancillary): alongside the hint, set_freshness_kick(...) also has the target client nudge the authority with submit_stream_targets([player.global_position]) before each select POST. That write is no longer load-bearing for denial correctness (the hint handles that), but it keeps the stored snap reasonably fresh so the movement-triggered validity GET that runs on the next authoritative_ack sees current data.
  • Visible anchors: scripts/prototype_target_markers.gd (under World/PrototypeTargetMarkers) spawns a prop mesh or capsule body, floating Label3D short name (Elite / Melee / Ranged), and a flat translucent lock ring at every ANCHORS entry. Gold = prototype_npc_elite at origin; orange = prototype_npc_melee west; purple = prototype_npc_ranged south-east — all near default spawn (-5, 0.9, -5) with overlapping 6 m rings. Tab locks an NPC; the locked marker brightens. All prototype NPCs share PrototypeTargetConstants.SHARED_LOCK_RADIUS = 6 m (mirrors PrototypeNpcRegistry.SharedLockRadius). Keep client constants and server registry in the same commit when anchors move.
  • Soft lock refresh: the client does not poll on a timer. It refreshes via GET …/target on boot, on every POST …/target/select response (body already carries targetState), and — while a lock is currently held — on authoritative_ack from PositionAuthorityClient, throttled to at most one GET per 250 ms. authoritative_ack fires on every server-confirmed position (boot GET 200 and successful move-stream 200), so a held lock revalidates during normal WASD locomotion without re-introducing snap rubber-banding. Boot is naturally skipped because no lock exists yet. Purely server-driven state flips (future PvP / combat categories) would require revisiting this policy; today NEO-23 has none.
  • Inspector: match base_url / dev_player_id on the TargetSelectionClient node to the other HTTP clients / Game:DevPlayerId.

SelectionEvent (NEO-26)

  • TargetSelectionClient emits selection_event(event: Dictionary) only when the server-acknowledged lockedTargetId changes (not on validity-only updates; those stay on target_state_changed).
  • Payload keys: previous ([String] or null), next (same), cause (tab | clear | server_correction), sequence (int from the new state). POST …/target/select from request_select_target_id (including the path used by Tab) uses cause tab; request_clear_target uses clear; every GET …/target completion uses server_correction (including boot sync).
  • Slice 3 telemetry hook (NEO-27): this selection transition maps to product event name target_changed; with log_selection_events enabled, Godot Output prints the target_changed token for manual verification. TODO(E9.M1): replace dev log with cataloged telemetry emit.
  • Ability cast hooks (NEO-27 + NEO-31 + NEO-28): digit keys hotbar_slot_1hotbar_slot_8 (defaults 18) issue a prototype cast via scripts/ability_cast_client.gd. ability_cast_requested is printed from main.gd only when request_cast returns true (HTTP POST successfully queued). ability_cast_denied is logged as push_warning from the client on deny responses, and NEO-28 mirrors the same outcome on the CastFeedbackLabel HUD line (ability_cast_denied: <reasonCode> or combat resolution copy on accept) via AbilityCastClient.cast_result_received (TODO(E9.M1): telemetry).
  • E1.M4+: connect to $TargetSelectionClient.selection_event (or your scenes path to that node) without reshaping the payload.

Full checklist: docs/manual-qa/NEO-26.md.

Hotbar loadout hydration (NEO-29)

scripts/hotbar_loadout_client.gd fetches server-owned HotbarLoadout state and applies it to scripts/hotbar_state.gd:

  • On main.gd startup, _setup_hotbar_loadout_sync() creates HotbarState + HotbarLoadoutClient nodes, mirrors base_url/dev_player_id from PositionAuthorityClient, and calls request_sync_from_server().
  • HotbarState keeps a fixed 8-slot local mirror (Array indexed 0..7) with nullable ability ids.
  • HotbarLoadoutClient.request_bind_slot(slot_index, ability_id) POSTs one slot update to /game/players/{id}/hotbar-loadout; denials emit loadout_update_denied(reason_code, loadout) so future UI can surface machine-readable errors directly.

This story hydrates bindings only; cast execution and cooldown behavior stay in later E1.M4 / E5.M1 stories.

Ability cast request (NEO-31 + NEO-28)

  • POST /game/players/{id}/ability-cast — JSON body schemaVersion, slotIndex (07), abilityId, targetId (must match server lock + prototype registry + range for accepted: true; see server/README.md and AbilityCastApi.cs).
  • Client: main.gd reads HotbarState.slots_snapshot() and TargetSelectionClient.cached_state() (lockedTargetId) so cast payloads use server-acknowledged target context, not camera guessing. Tab-lock a target before expecting a successful cast at the default spawn.

Full checklist: docs/manual-qa/NEO-31.md · cast deny/accept HUD: docs/manual-qa/NEO-28.md.

Combat feedback + target HP HUD (NEO-85)

  • POST /game/players/{id}/ability-cast accept responses include nested combatResolution (damageDealt, targetRemainingHp, targetDefeated, …) — see server README — Ability cast.
  • GET /game/world/combat-targets — authoritative dummy HP snapshot for the locked target label (NEO-83); no client-side damage math.
  • Scripts: scripts/ability_cast_client.gd (parses combatResolution on accept), scripts/combat_targets_client.gd; wired from main.gd in _setup_hotbar_loadout_sync() / _setup_combat_targets_sync().
  • HUD:
    • UICanvas/CastFeedbackLabel — on accept: Cast: {damage} dmg → {remaining} HP or … — defeated! when targetDefeated; denies unchanged from NEO-28.
    • UICanvas/CombatTargetHpLabel — locked target currentHp/maxHp (+ (defeated)); Target HP: — (Tab → elite/melee/ranged near spawn) when unlocked.
  • World markers: World/PrototypeTargetMarkersElite (gold, origin), Melee (orange, west), Ranged (purple, south-east); Tab locks and highlights the active marker (see Targeting section above).
  • Refresh (event-driven): combat-target GET after successful cast accept and when lockedTargetId changes (Tab lock / Esc clear / server correction). No periodic HP poll.
  • Dev hotbar bootstrap (NEO-85): on first hotbar sync, if slot 0 is empty the client POSTs a bind for prototype_pulse once (same preset as Bruno Post hotbar bind slot0 pulse) so digit 1 works without a manual bind step when the server is up.
  • Dev fixture reset: when the server exposes POST /game/__dev/combat-targets-fixture (Development/Testing), reset dummy HP between manual QA runs — see server README.

Full checklist: docs/manual-qa/NEO-85.md.

NPC runtime + telegraph HUD (NEO-97)

  • GET /game/world/npc-runtime-snapshot — authoritative NPC runtime rows + nested activeTelegraph with server-computed windupRemainingSeconds (NEO-94); poll advances server lazy tick.
  • GET /game/players/{id}/combat-health — session player HP after NPC telegraph damage (NEO-95).
  • Scripts: scripts/npc_runtime_client.gd, scripts/player_combat_health_client.gd, scripts/npc_runtime_hud_state.gd (local windup tick-down between polls); wired from main.gd in _setup_npc_combat_sync().
  • HUD:
    • UICanvas/PlayerCombatHpLabelPlayer HP: {current}/{max} (+ (defeated)).
    • UICanvas/NpcStateLabel — locked NPC state + Incoming: row when player is aggro holder on a different NPC.
    • UICanvas/TelegraphLabel — interpolated countdown from server snapshot (Telegraph: Elite … · 2.5s (elite)).
  • Poll gate (~1 Hz): runs while an NPC lock is held or any snapshot row lists the player as aggroHolderPlayerId; also fast-path sync on lock change + cast accept.
  • Tab cycle: prototype_npc_eliteprototype_npc_meleeprototype_npc_ranged (see prototype_target_constants.gd).

Full checklist: docs/manual-qa/NEO-97.md.

End-to-end NPC telegraph combat loop (NEO-98)

Epic 5 Slice 2 capstone — aggro, readable telegraph, incoming NPC damage, and defeat all three archetypes in Godot without Bruno.

Flow: fresh server restart → boot hotbar sync → Melee lock → cast → telegraph HUD → player HP drop → defeat → repeat Ranged + Elite.

Step Input / trigger HUD / server outcome
Boot Godot F5 + fresh server Hotbar slot 0 binds prototype_pulse; player combat HP 100/100 when poll starts
Melee lock Tab on prototype_npc_melee Marker brightens; CombatTargetHpLabel Target HP: prototype_npc_melee 100/100
Melee aggro 1 (damaging cast) NpcStateLabelaggro; combat poll ~1 Hz
Melee telegraph Wait ≥ 3 s telegraph_windup + TelegraphLabel countdown
Melee hit Wait ≥ 4.5 s from first cast; stay within ~2 m of melee marker PlayerCombatHpLabel85/100 (15 damage); back off during windup → whiff
Melee defeat 1 ×4 total (cooldown between) CombatTargetHpLabel includes 0/100 (defeated)
Ranged Lock + cast ×4; wait ≥ 4 s for telegraph Defeat at 80 HP; telegraph visible before damage
Elite Lock + cast ×8; wait ≥ 5 s for telegraph Defeat at 200 HP; telegraph visible before damage

Cross-links: NEO-92 aggro · NEO-94 runtime snapshot · NEO-95 player combat HP · NEO-97 telegraph HUD components · NEO-85 combat-target HP · NEO-28 cast deny HUD.

Scripts: npc_runtime_client.gd, player_combat_health_client.gd, npc_runtime_hud_state.gd, npc_combat_hud_helpers.gd, combat_targets_client.gd, ability_cast_client.gd — wired from main.gd.

Preconditions: Server restart before capstone run resets NPC HP, aggro/runtime rows, and player combat HP.

Full capstone checklist: docs/manual-qa/NEO-98.md.

End-to-end combat loop (NEO-86)

Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility in Godot without Bruno.

Flow: boot hotbar sync (auto prototype_pulse bind on slot 0 when empty) → walk to Melee marker → Tab lock → digit 1 cast until defeat → verify combat HUD + breach gig XP refresh.

Step Input / trigger HUD / server outcome
Boot Godot F5 + server running Hotbar slot 0 binds prototype_pulse; GigXpLabel hydrates breach: L1 · 0 xp
Lock Tab on prototype_npc_melee Marker brightens; CombatTargetHpLabel shows 100/100
Cast ×4 1 (respect ~3s cooldown) CastFeedbackLabel steps damage; HP label tracks server snapshot
Defeat 4th pulse accept Cast: … — defeated!; HP 0/100 (defeated); GigXpLabel25 xp
Deny 1 on defeated target ability_cast_denied: target_defeated
Clear Esc HP label — (no lock)

Cross-links: NEO-23 targeting · NEO-28 cast deny HUD · NEO-31 cast POST · NEO-85 combat feedback + HP · NEO-44 server gig grant.

Scripts: ability_cast_client.gd, combat_targets_client.gd, gig_progression_client.gd — wired from main.gd.

Economy HUD: GigXpLabel lives under EconomyHudSection/BodyScroll/Body/ (hidden when Economy HUD collapsed, NEO-75). Gig GET refreshes on boot and when cast accept carries targetDefeated: true only — not on every hit.

Preconditions: Server restart before capstone run resets in-memory dummy HP and gig XP baseline.

Full capstone checklist: docs/manual-qa/NEO-86.md.

Encounter progress + loot HUD (NEO-110)

  • GET /game/players/{id}/encounter-progress — server-authoritative per-encounter state, defeatedTargetIds, and rewardGrantSummary when completed (NEO-108); see server README — Per-player encounter progress.
  • Scripts: scripts/encounter_progress_client.gd; wired from main.gd in _setup_encounter_progress_sync().
  • HUD:
    • UICanvas/HudRootScroll/HudRoot/EncounterProgressLabel — frozen pocket prototype_combat_pocket: not started (0/3){n}/3 while activecompleted (3/3).
    • UICanvas/HudRootScroll/HudRoot/EncounterCompleteLabelLoot: — until completed; then one line per grant (displayName via ItemDefinitionsClient, fallback itemId).
  • Refresh: boot hydrate + GET after cast accept with targetDefeated: true (alongside gig/combat refreshes). No periodic poll or manual key.
  • Inventory: on completed row, InventoryLabel auto-refreshes (no I) so bag shows scrap_metal_bulk and contract_handoff_token.

Full checklist: docs/manual-qa/NEO-110.md.

Quest progress + accept HUD (NEO-122)

  • GET /game/players/{id}/quest-progress — server-authoritative per-quest status, currentStepIndex, objectiveCounters, optional completedAt (NEO-119); see server README — Per-player quest progress.
  • POST /game/players/{id}/quests/{questId}/accept — optional v1 body; accepted + optional reasonCode + optional quest row (NEO-120).
  • GET /game/world/quest-definitions — cached on boot for display names and eligible-quest iteration (NEO-115).
  • Scripts: scripts/quest_progress_client.gd, scripts/quest_definitions_client.gd; wired from main.gd in _setup_quest_progress_sync().
  • HUD:
    • UICanvas/HudRootScroll/HudRoot/QuestProgressLabel — all four prototype quests in catalog order with compact status/step/counter lines.
    • UICanvas/HudRootScroll/HudRoot/QuestAcceptFeedbackLabel — accept success/deny/failure copy.
  • Accept keys: Q = prototype_quest_gather_intro; Shift+Q = first catalog-order not_started quest with prerequisites completed in current snapshot.
  • Refresh: boot hydrate + GET after gather (interaction success), craft success, cast with targetDefeated: true, and after accept POST. No periodic poll.

Full checklist: docs/manual-qa/NEO-122.md.

End-to-end encounter clear loop (NEO-111)

Epic 5 Slice 3 capstone — defeat all three prototype NPCs, receive loot + quest token once, verify inventory in Godot without Bruno.

Flow: fresh server restart → boot encounter HUD 0/3 → defeat ×3 (any order) → progress 1/3 → 2/3 → completed → loot label + inventory → Godot restart idempotency.

Step Input / trigger HUD / server outcome
Boot Godot F5 + fresh server EncounterProgressLabel not started (0/3); Loot: —; empty bag
Engage Tab + 1 on first NPC Encounter activates on first damaging hit (NEO-106); progress 1/3 after defeat
Second defeat Lock + cast second NPC 2/3; loot still
Third defeat Lock + cast third NPC completed (3/3); EncounterCompleteLabel lists grants; InventoryLabel auto-refreshes
Idempotency Stop Godot + F5 (server still running) Encounter still completed; inventory counts unchanged
Re-cast defeated Tab + 1 on defeated NPC ability_cast_denied: target_defeated; no duplicate loot or gig XP

Cross-links: NEO-106 combat wiring · NEO-108 encounter-progress GET · NEO-110 HUD components · NEO-98 NPC combat · NEO-86 gig XP per defeat.

Scripts: encounter_progress_client.gd, inventory_client.gd, ability_cast_client.gd, combat_targets_client.gd — wired from main.gd.

Preconditions: Server restart before capstone run resets NPC HP, encounter progress, inventory, and gig XP.

Full capstone checklist: docs/manual-qa/NEO-111.md.

Inventory snapshot HUD (NEO-72)

  • GET /game/players/{id}/inventory — server-authoritative bag (24 slots) + equipment stub (1 slot); see server README — Player inventory.
  • GET /game/world/item-definitions — cached on boot for displayName labels (NEO-53).
  • Scripts: scripts/inventory_client.gd, scripts/item_definitions_client.gd; wired from main.gd on boot and on inventory_refresh (I).
  • HUD: UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel lists non-empty bag rows and equipment slot 0 (shows — empty when unoccupied). Failed GET paints error — … on the label and push_warning in Output. Economy HUD checkbox (NEO-75) collapses the economy block.
  • Refresh: press I (or call InventoryClient.request_sync_from_server() from main.gd helpers — entrypoint for NEO-73 gather/craft refresh).

Full checklist: docs/manual-qa/NEO-72.md.

Gather feedback on interact (NEO-73)

  • POST /game/players/{id}/interact on resource_node kinds — server gather engine (NEO-63); no separate gather HTTP on client.
  • GET /game/players/{id}/skill-progressionsalvage row on UICanvas/EconomyHudSection/BodyScroll/Body/SkillProgressionLabel; boot hydrate + refresh after successful gather.
  • Scripts: scripts/skill_progression_client.gd, scripts/prototype_interactable_picker.gd; interaction_request_client.gd emits interaction_result_received; wired from main.gd.
  • HUD: UICanvas/GatherFeedbackLabel — success delta +N {displayName} or deny Gather: denied — {reasonCode} (including node_depleted). UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel refreshes automatically on successful gather (no I).
  • R binding: nearest in-range resource_node by horizontal distance to catalog anchors (same radius rule as glow preview).

Full checklist: docs/manual-qa/NEO-73.md.

Craft UI + recipe list (NEO-74)

  • GET /game/world/recipe-definitions — eight prototype recipes on boot; scrollable panel inside EconomyHudSection (below SkillProgressionLabel).
  • POST /game/players/{id}/craft — JSON body schemaVersion, recipeId, quantity (default 1); see server README — Craft HTTP.
  • Scripts: scripts/recipe_definitions_client.gd, scripts/craft_client.gd, scripts/craft_recipe_panel.gd; wired from main.gd.
  • HUD: UICanvas/CraftFeedbackLabel — success Craft: +N {displayName} from POST outputsGranted; deny Craft: denied — {reasonCode}. UICanvas/EconomyHudSection/BodyScroll/Body/SkillProgressionLabel adds refine row (boot + post-craft refresh). UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel refreshes on craft success only (no I).
  • Panel: per-recipe Craft button (qty 1); input summary uses cached item displayName when available.

Full checklist: docs/manual-qa/NEO-74.md.

End-to-end economy loop (NEO-75)

Capstone proof that Epic 3 prototype gather→craft works in Godot without Bruno.

Flow (single session):

  1. Boot — server running; Godot F5. Clients hydrate item defs, inventory, skill progression, recipe defs (NEO-7274).
  2. GatherWASD to each resource anchor; R posts nearest in-range resource_node interact (NEO-63). On success: GatherFeedbackLabel delta, automatic inventory GET, salvage row refresh (NEO-73).
  3. Refine — scroll EconomyHudSection recipe panel; Craft refine_scrap_standard when ≥5 scrap (NEO-74). On success: CraftFeedbackLabel, inventory GET, refine row refresh.
  4. MakeCraft make_field_stim_mk0 when ≥2 refined_plate_stock and ≥1 scrap. Verify field_stim_mk0 in bag.

Inputs: R gather · Craft buttons (qty 1) · I manual inventory refresh (optional) · Economy HUD checkbox collapses inventory/skills/recipe block (NEO-75).

Material minimum: 11 scrap from one R at each of four prototype nodes (+1, +2, +3, +5) → two refine_scrap_standard → one make_field_stim_mk0.

HUD paths: feedback labels stay on UICanvas; economy block under UICanvas/EconomyHudSection/BodyScroll/Body/ (InventoryLabel, SkillProgressionLabel, CraftRecipePanel).

Full capstone checklist: docs/manual-qa/NEO-75.md.

Manual check (NEO-24)

  1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at (-5, 0.9, -5).
  2. Press Tab: HUD → Target: prototype_npc_elite, Validity: ok (spawn is inside elite's 6 m ring at origin).
  3. Press Tab again: cycles toward Melee / Ranged; server may deny when out of range — soft-lock policy keeps last-good lock with Denied: out_of_range when applicable.
  4. WASD away past ~6 m horizontally from locked anchor; HUD flips to Validity: out_of_range on the next movement-triggered refresh (≤250 ms cadence) while lockedTargetId stays set — soft lock from the server.
  5. Walk among overlapping rings near origin: distance lines show (in) for nearby NPCs. Press Tab to cycle elite → melee → ranged → wrap.
  6. Press Esc: HUD → Target: —, Validity: none, sequence advances.

Movement prototype (NEO-5 → NEO-11)

player.gd steers XZ toward the authoritative goal and uses NavigationAgent3Ds current path only for the vertical-routing case above (on sufficiently flat floor); move_and_slide() does the motion. 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.

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 repos intended demo—run the server for the demo path.

First run

  1. Install Godot 4.x.
  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 from the 4.6-stable release.
  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/:
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, NEO-12)

Framework: 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. On a fresh clone you may need a one-time import so .godot exists:

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, player_locomotion_wasd.gd (pure WASD seam helpers), position_authority_client.gd, inventory_client.gd, item_definitions_client.gd (NEO-72), skill_progression_client.gd, gig_progression_client.gd (NEO-86), quest_progress_client.gd, quest_definitions_client.gd (NEO-122), prototype_interactable_picker.gd, extended interaction_request_client_test.gd, gather_feedback_refresh_test.gd (NEO-73), craft_client.gd, recipe_definitions_client.gd, craft_recipe_panel_test.gd (NEO-122 follow-up), craft_feedback_refresh_test.gd (NEO-74), prototype_economy_hud_section.gd (NEO-75), combat_targets_client.gd, combat_feedback_refresh_test.gd (NEO-85), gig_feedback_refresh_test.gd (NEO-86), isometric_follow_camera.gd (eye math + effective zoom distance), camera_state.gd, and zoom_band_config.gd. main.gd 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. isometric_follow_camera.gd also maps macOS trackpad two-finger scroll and pinch to the same discrete zoom bands using accumulated delta (one band step per ~1.25 pan units, 150 ms cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where + requires Shift, remap or add a binding under Project → Project Settings → Input Map if needed.

Dev (NEO-18): 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 hooks (recommended): configure hooks from the repo root (once per clone). This sets core.hooksPath=scripts/git-hooks so Git runs the tracked pre-commit / pre-push scripts (bash 3.2+ compatible):

./scripts/install-git-hooks.sh

On Windows (PowerShell):

pwsh -File scripts/install-git-hooks.ps1

Then install gdtoolkit (same version as CI) when you work on GDScript:

python3 -m venv .venv-gd
.venv-gd/bin/pip install "gdtoolkit==4.5.0"

This enables:

  • pre-commit: blocks commits when client/scripts/*.gd changes lack matching client/test/*_test.gd updates, or when server route/contract files change without corresponding server/NeonSprawl.Server.Tests/ and bruno/neon-sprawl-server/**/*.bru updates.
  • pre-push: when client/scripts/*.gd or client/test/*.gd changed in the commits being pushed, runs gdlint client/scripts client/test and gdformat --check client/scripts client/test (same scope as CI). Otherwise skips lint/format. Still requires a clean working tree on every push.

The pre-push hook prefers .venv-gd/bin/ (Unix) or .venv-gd/Scripts/ (Windows venv) when present; without that venv or a global install, the hook fails when GDScript changed — same as CI. Pushes with no client .gd changes do not require gdtoolkit installed.

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.