|
|
||
|---|---|---|
| .. | ||
| addons/gdUnit4 | ||
| assets/district | ||
| resources | ||
| scenes | ||
| scripts | ||
| test | ||
| README.md | ||
| icon.svg | ||
| icon.svg.import | ||
| project.godot | ||
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(bootstrapscripts/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 pre–NEO-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.35applies 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 violet’s north / east / west (TPB_*, six risers for 0.6 m) match the teal convention. - QA / thin props:
PrototypeTerminal,MoveRejectPedestal,MoveRejectFarPadare unchanged (terminal kiosk, vertical reject demo, near-floor pad). Add ramps or treads later if they need full walk-up treatment. - Occluders are plain
StaticBody3Dwith nowalkabletag; 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
NavigationRegion3Dboundary (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'sMaxVerticalStep(2.2 m by default) — the only remaining move-rejection criterion with default server config. Horizontal distance is never a rejection reason whenHorizontalStepEnabledis 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 server’s 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 Jolt’s 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: bootGET,move-streamPOST),scripts/player.gd(WASD locomotion + nav assist when a goal exists),scripts/isometric_follow_camera.gd+scripts/camera_state.gd(NEO-15 follow), thinscripts/main.gd(nav bake on first frame, then wiring). - Scene:
scenes/main.tscn— walkableStaticBody3Dgeometry lives underWorld/NavigationRegion3D.main.gdwaits oneprocess_frame, spawns random test bumps onFloor, thenbake_navigation_mesh(false)(main-thread bake), then waits two **physics_frame**s soNavigationServer3Dhas a map before agents query paths. The bakedNavigationMeshasset in the scene file is stale until you run or re-bake in the editor. - Inspector: select
PositionAuthorityClienton the main scene to setbase_url(defaulthttp://127.0.0.1:5253) anddev_player_id(defaultdev-local-1, must match serverGame:DevPlayerId).
Manual check (NEO-7 + NEO-11 + NEO-22)
- From repo root:
cd server/NeonSprawl.Server && dotnet run(note the URL/port, usuallyhttp://localhost:5253). - If the port differs, set
base_urlonPositionAuthorityClientaccordingly (e.g.http://127.0.0.1:5253). - 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/ NEO-9 walk demo). - WASD: walk on the floor; the HUD position label should update and the server should accept
move-streamsteps (watch server logs or use a proxy if needed). Release keys and confirm the capsule settles to idle without runaway drift. - Walk to the orange reject pedestal and try to climb it with WASD;
move-streamshould returnvertical_step_exceededwhen a requested step exceedsMaxVerticalStep(watch theMoveRejectLabelor server logs). TheMoveRejectFarPad(blue pad at (9, 9)) remains a normal walkable surface. - 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 descriptor’s 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-rangeresource_nodefrom the interactables catalog (falls back toprototype_resource_node_alphawhen none in range). Output printsallowed/reasonCodeper response (lines prefixed withInteraction [<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_urlonInteractablesCatalogClient,InteractionRequestClient, andPositionAuthorityClientto 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)
- Run the game server and client as in NEO-7.
- 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.
- Press E (
interact): Outputallowed=truewhen the terminal pair glows;allowed=false/out_of_rangewhen dim. Walk toward (12, −6) until the second pair glows; press R (interact_secondary) forallowed=trueon the resource node. - E / R are handled in
main.gd_unhandled_key_inputand forwarded toInteractionRequestClient(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_tab→ Tab cycles targets in the order defined byscripts/prototype_target_constants.gd(prototype_npc_elite, thenprototype_npc_melee, thenprototype_npc_ranged; matchesPrototypeNpcRegistry.GetInstanceIdsInOrder()ascending id), wrapping from last → first. When the player reference is wired (viaset_freshness_kick, whichmain.gddoes), 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_clear→ Esc POSTs withtargetIdomitted to clear the server lock. - HUD:
UICanvas/TargetLockLabelshows 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 againstprototype_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 authoritativetargetStateechoed by each200response. Distance lines are a lag diagnostic: if the HUD showsValidity: okbutalpha: 7.1 m / 6.0 (out), the server's position snapshot is lagging the visible capsule, and the nextauthoritative_ackrefresh will reconcile.UICanvas/PlayerPositionLabelalso 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. positionHinton select (NEO-24 follow-up #5): whenTargetSelectionClient.set_freshness_kick(authority, player)is wired (asmain.gddoes), everyPOST /target/selectbody also carriespositionHint: {x,y,z}= the live capsule position. The server uses the hint for the radius check and for thevalidityfield in the echoedtargetState, eliminating the cross-request race betweenmove-streamandtarget/selectPOSTs — 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-streamremains the sole write path. Tests that omitset_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 withsubmit_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 nextauthoritative_acksees current data. - Visible anchors:
scripts/prototype_target_markers.gd(underWorld/PrototypeTargetMarkers) spawns a prop mesh or capsule body, floating Label3D short name (Elite / Melee / Ranged), and a flat translucent lock ring at everyANCHORSentry. Gold =prototype_npc_eliteat origin; orange =prototype_npc_meleewest; purple =prototype_npc_rangedsouth-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 sharePrototypeTargetConstants.SHARED_LOCK_RADIUS= 6 m (mirrorsPrototypeNpcRegistry.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 …/targeton boot, on everyPOST …/target/selectresponse (body already carriestargetState), and — while a lock is currently held — onauthoritative_ackfromPositionAuthorityClient, throttled to at most one GET per 250 ms.authoritative_ackfires on every server-confirmed position (bootGET200 and successfulmove-stream200), 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_idon theTargetSelectionClientnode to the other HTTP clients /Game:DevPlayerId.
SelectionEvent (NEO-26)
TargetSelectionClientemitsselection_event(event: Dictionary)only when the server-acknowledgedlockedTargetIdchanges (not onvalidity-only updates; those stay ontarget_state_changed).- Payload keys:
previous([String] ornull),next(same),cause(tab|clear|server_correction),sequence(int from the new state).POST …/target/selectfromrequest_select_target_id(including the path used by Tab) uses causetab;request_clear_targetusesclear; everyGET …/targetcompletion usesserver_correction(including boot sync). - Slice 3 telemetry hook (NEO-27): this selection transition maps to product event name
target_changed; withlog_selection_eventsenabled, Godot Output prints thetarget_changedtoken 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_1…hotbar_slot_8(defaults 1–8) issue a prototype cast viascripts/ability_cast_client.gd.ability_cast_requestedis printed frommain.gdonly whenrequest_castreturnstrue(HTTP POST successfully queued).ability_cast_deniedis logged aspush_warningfrom the client on deny responses, and NEO-28 mirrors the same outcome on theCastFeedbackLabelHUD line (ability_cast_denied: <reasonCode>or combat resolution copy on accept) viaAbilityCastClient.cast_result_received(TODO(E9.M1): telemetry). - E1.M4+: connect to
$TargetSelectionClient.selection_event(or your scene’s 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.gdstartup,_setup_hotbar_loadout_sync()createsHotbarState+HotbarLoadoutClientnodes, mirrorsbase_url/dev_player_idfromPositionAuthorityClient, and callsrequest_sync_from_server(). HotbarStatekeeps a fixed 8-slot local mirror (Arrayindexed0..7) with nullable ability ids.HotbarLoadoutClient.request_bind_slot(slot_index, ability_id)POSTs one slot update to/game/players/{id}/hotbar-loadout; denials emitloadout_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 bodyschemaVersion,slotIndex(0–7),abilityId,targetId(must match server lock + prototype registry + range foraccepted: true; seeserver/README.mdandAbilityCastApi.cs).- Client:
main.gdreadsHotbarState.slots_snapshot()andTargetSelectionClient.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-castaccept responses include nestedcombatResolution(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(parsescombatResolutionon accept),scripts/combat_targets_client.gd; wired frommain.gdin_setup_hotbar_loadout_sync()/_setup_combat_targets_sync(). - HUD:
UICanvas/CastFeedbackLabel— on accept:Cast: {damage} dmg → {remaining} HPor… — defeated!whentargetDefeated; denies unchanged from NEO-28.UICanvas/CombatTargetHpLabel— locked targetcurrentHp/maxHp(+(defeated));Target HP: — (Tab → elite/melee/ranged near spawn)when unlocked.
- World markers:
World/PrototypeTargetMarkers— Elite (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
lockedTargetIdchanges (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_pulseonce (same preset as BrunoPost 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 + nestedactiveTelegraphwith server-computedwindupRemainingSeconds(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 frommain.gdin_setup_npc_combat_sync(). - HUD:
UICanvas/PlayerCombatHpLabel—Player 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_elite→prototype_npc_melee→prototype_npc_ranged(seeprototype_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) | NpcStateLabel → aggro; 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 | PlayerCombatHpLabel → 85/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); GigXpLabel → 25 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/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-encounterstate,defeatedTargetIds, andrewardGrantSummarywhencompleted(NEO-108); see server README — Per-player encounter progress.- Scripts:
scripts/encounter_progress_client.gd; wired frommain.gdin_setup_encounter_progress_sync(). - HUD:
UICanvas/HudRoot/EncounterProgressLabel— frozen pocketprototype_combat_pocket:not started (0/3)→{n}/3whileactive→completed (3/3).UICanvas/HudRoot/EncounterCompleteLabel—Loot: —untilcompleted; then one line per grant (displayNameviaItemDefinitionsClient, fallbackitemId).
- Refresh: boot hydrate +
GETafter cast accept withtargetDefeated: true(alongside gig/combat refreshes). No periodic poll or manual key. - Inventory: on
completedrow,InventoryLabelauto-refreshes (no I) so bag showsscrap_metal_bulkandcontract_handoff_token.
Full checklist: docs/manual-qa/NEO-110.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 fordisplayNamelabels (NEO-53).- Scripts:
scripts/inventory_client.gd,scripts/item_definitions_client.gd; wired frommain.gdon boot and oninventory_refresh(I). - HUD:
UICanvas/EconomyHudSection/Body/InventoryLabellists non-empty bag rows and equipment slot 0 (shows— emptywhen unoccupied). Failed GET paintserror — …on the label andpush_warningin Output.Economy HUDcheckbox (NEO-75) collapses the economy block. - Refresh: press I (or call
InventoryClient.request_sync_from_server()frommain.gdhelpers — 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}/interactonresource_nodekinds — server gather engine (NEO-63); no separate gather HTTP on client.GET /game/players/{id}/skill-progression—salvagerow onUICanvas/EconomyHudSection/Body/SkillProgressionLabel; boot hydrate + refresh after successful gather.- Scripts:
scripts/skill_progression_client.gd,scripts/prototype_interactable_picker.gd;interaction_request_client.gdemitsinteraction_result_received; wired frommain.gd. - HUD:
UICanvas/GatherFeedbackLabel— success delta+N {displayName}or denyGather: denied — {reasonCode}(includingnode_depleted).UICanvas/EconomyHudSection/Body/InventoryLabelrefreshes automatically on successful gather (no I). - R binding: nearest in-range
resource_nodeby 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 insideEconomyHudSection(belowSkillProgressionLabel).POST /game/players/{id}/craft— JSON bodyschemaVersion,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 frommain.gd. - HUD:
UICanvas/CraftFeedbackLabel— successCraft: +N {displayName}from POSToutputsGranted; denyCraft: denied — {reasonCode}.UICanvas/EconomyHudSection/Body/SkillProgressionLabeladdsrefinerow (boot + post-craft refresh).UICanvas/EconomyHudSection/Body/InventoryLabelrefreshes on craft success only (no I). - Panel: per-recipe Craft button (qty 1); input summary uses cached item
displayNamewhen 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):
- Boot — server running; Godot F5. Clients hydrate item defs, inventory, skill progression, recipe defs (NEO-72–74).
- Gather — WASD to each resource anchor; R posts nearest in-range
resource_nodeinteract (NEO-63). On success:GatherFeedbackLabeldelta, automatic inventory GET, salvage row refresh (NEO-73). - Refine — scroll
EconomyHudSectionrecipe panel; Craftrefine_scrap_standardwhen ≥5 scrap (NEO-74). On success:CraftFeedbackLabel, inventory GET, refine row refresh. - Make — Craft
make_field_stim_mk0when ≥2refined_plate_stockand ≥1 scrap. Verifyfield_stim_mk0in 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/Body/ (InventoryLabel, SkillProgressionLabel, CraftRecipePanel).
Full capstone checklist: docs/manual-qa/NEO-75.md.
Manual check (NEO-24)
- Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at
(-5, 0.9, -5). - Press Tab: HUD →
Target: prototype_npc_elite,Validity: ok(spawn is inside elite's 6 m ring at origin). - 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_rangewhen applicable. - WASD away past ~6 m horizontally from locked anchor; HUD flips to
Validity: out_of_rangeon the next movement-triggered refresh (≤250 ms cadence) whilelockedTargetIdstays set — soft lock from the server. - Walk among overlapping rings near origin: distance lines show
(in)for nearby NPCs. Press Tab to cycle elite → melee → ranged → wrap. - Press Esc: HUD →
Target: —,Validity: none, sequence advances.
Movement prototype (NEO-5 → NEO-11)
player.gd steers XZ toward the authoritative goal and uses NavigationAgent3D’s 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)
- Run the main scene (F5) with server running for movement behavior above.
- 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.
First run
- Install Godot 4.x.
- In the project manager, Import and select
client/project.godot. - 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):
- Download Godot_v4.6-stable_linux.x86_64.zip from the 4.6-stable release.
- Unzip and put the binary on your
PATH, e.g.~/.local/opt/godot-4.6-stable/andln -s …/Godot_v4.6-stable_linux.x86_64 ~/.local/bin/godot. - Ensure
~/.local/binis onPATH, then fromclient/:
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), 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_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/*.gdchanges lack matchingclient/test/*_test.gdupdates, or when server route/contract files change without correspondingserver/NeonSprawl.Server.Tests/andbruno/neon-sprawl-server/**/*.bruupdates. - pre-push: when
client/scripts/*.gdorclient/test/*.gdchanged in the commits being pushed, runsgdlint client/scripts client/testandgdformat --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.