# E1.M2 — IsometricCameraController

## Summary

| Field | Value |
|--------|--------|
| **Module ID** | E1.M2 |
| **Epic** | [Epic 1 — Core Player Runtime](../epics/epic_01_core_player_runtime.md) |
| **Stage target** | Prototype |
| **Status** | **Ready** (prototype slice) — follow + `CameraState` + **discrete zoom bands** + **occlusion** + pick-through ([NEO-15](https://linear.app/neon-sprawl/issue/NEO-15)–[NEO-17](https://linear.app/neon-sprawl/issue/NEO-17), [NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); contracts + hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)); see [dependency register](module_dependency_register.md) |
| **Linear** | Project [E1.M2 — IsometricCameraController](https://linear.app/neon-sprawl/project/e1m2-isometriccameracontroller-deac8ef10395); prototype backlog issues under [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016) — [Linear backlog](#linear-backlog) |

## Purpose

**Authority:** [Client vs server](client_server_authority.md#e1m2-isometriccameracontroller) — camera is client-local for prototype; server does not use camera pose for gameplay checks.

Delivers an isometric follow camera that keeps the player readable during motion: follow behavior, discrete zoom bands, occlusion handling, and **yaw held at a fixed default for players in early prototypes** so combat telegraphs and UI stay easy to reason about. **After release, the shipped camera model is settled** (fixed presentation vs product setting that allows yaw)—we do **not** plan to revisit that post-launch. **Before release**, we may still decide to expose **yaw orbit** for comfort; the implementation follows the [mid-project rotation policy](#mid-project-rotation-policy-practical-compromise) so that decision stays mid-project scope, not a rewrite.

## Responsibilities

- Locked isometric follow for **player-facing controls in prototype**: **yaw = 0** (no orbit bound in UI) while the rig and **`CameraState`** still model **yaw explicitly** so optional orbit can be enabled later via config and input binding.
- Zoom bands and configuration-driven limits.
- Occlusion policy so critical gameplay elements remain visible where possible.
- Pitch / roll remain fixed for the isometric presentation; only **yaw** is the optional future degree of freedom.

## Mid-project rotation policy (practical compromise)

**Intent:** Ship prototypes and early milestones **as if** the camera were fully fixed from the player’s perspective, without closing the door to **yaw orbit** before release.

| Topic | Direction |
|--------|-----------|
| **When this applies** | **Mid-project only.** We may enable or keep disabled **yaw orbit** while still in development. **Post-release**, treat the chosen shipped behavior as **final**—no planned pivot between “fixed” and “orbit” for live players. |
| **Implementation** | Single seam: e.g. **`allow_yaw`**, **`max_yaw_deg`**, or equivalent; default keeps **yaw at 0** and **no rotate input** until product + combat readability agree. `CameraState` carries **yaw** (default `0`) so consumers do not assume “camera yaw does not exist.” |
| **Gameplay semantics** | Prefer **world-anchored** movement, targeting, and server checks—not **camera-relative** aim—so turning yaw on later is mostly **presentation + UI**, not a combat authority redesign. |
| **Telegraphs & VFX** | Design **now** for a world where yaw might exist: ground shapes remain **world-accurate**; plan **screen-space or height** readability affordances where foreshortening under yaw would otherwise lie (outlines, rings, bars—exact art TBD). |
| **Migration risk** | If we add yaw mid-project, expect a **telegraph + minimap / compass** pass. **Fixed → yaw** is usually lower risk than **yaw → fixed** (content and players may already assume spin for sightlines). |

## Key contracts

| Contract | Role |
|----------|------|
| `CameraState` | Per-frame snapshot: follow path, effective distance, zoom band index, focus, **yaw** (orbit delta; prototype `0`). **Policy / presentation exports are not duplicated** in this type for the prototype — read the rig when needed ([Consumer contract](#consumer-contract-neo-18)). |
| `ZoomBandConfig` | Data-driven min/max or discrete zoom steps. |
| `OcclusionPolicy` | Rules for fading, dithering, or offset when geometry blocks the view. |

## Consumer contract (NEO-18)

Single place for dependents (e.g. **E6.M2**): what is on `CameraState` vs what stays on the rig. **Authority:** [E1.M2 — client vs server](client_server_authority.md#e1m2-isometriccameracontroller).

### `CameraState` fields (`client/scripts/camera_state.gd`)

Refreshed **every `_physics_process`** tick on `IsometricFollowCamera` after the follow target has moved (`process_physics_priority` after `Player`). Same object each tick (rig-owned).

| Field | Meaning |
|--------|---------|
| `yaw` | Orbit delta (rad) around the follow target **relative to** `presentation_yaw_deg` — **not** full compass. **0** while `allow_yaw` is false (prototype UX). |
| `follow_target_path` | `NodePath` to the follow anchor (e.g. player). |
| `distance` | Effective follow distance this tick (active band or fallback). |
| `zoom_band_index` | Active discrete band index, or **0** when zoom config is invalid / unused. |
| `focus_world` | World look-at focus last tick. |

### Rig-only reads (`client/scripts/isometric_follow_camera.gd`, `World/IsometricFollowCamera` in `main.tscn`)

| Export / node | Meaning |
|----------------|---------|
| `allow_yaw`, `max_yaw_deg` | Orbit seam (no rotate input bound in prototype). |
| `presentation_yaw_deg`, `pitch_elevation_deg`, `follow_distance`, `focus_vertical_offset` | Fixed isometric presentation (pitch/roll not exposed as free axes). |
| `zoom_band_config`, `occlusion_policy` | Resources driving bands and occlusion. |
| Child `Camera3D` | Active render camera (`ground_pick` uses viewport / `fallback_camera` from `main.gd`). |

**Telemetry:** Throttled `camera_zoom_changed` and occlusion/perf hooks are **TODO(E9.M1)** until an event schema exists (`zoom_band_changed` signal on the rig today).

## Module dependencies

- **E1.M1** — InputAndMovementRuntime: camera follows the player anchor derived from movement/position.

## Dependents (by design)

- **E6.M2** — Consent and risk UX may use camera-adjacent presentation; epic lists E1.M2 as a dependency for in-zone risk signaling and readability.

## Related implementation slices

See Epic 1 **Slice 2 — Locked isometric camera**: follow-center, zoom bands, occlusion policy, **yaw fixed for players in prototype** (implementation still models yaw per [mid-project rotation policy](#mid-project-rotation-policy-practical-compromise)); optional telemetry such as throttled `camera_zoom_changed` and perf stress markers for occluders.

## Implementation snapshot (NEO-15 + NEO-16 + NEO-17 + NEO-20 + NEO-18, 2026-04-08 / 2026-04-10)

- **Godot:** `client/scripts/isometric_follow_camera.gd` on **`World/IsometricFollowCamera`**; child **`Camera3D`** (current). **`scripts/camera_state.gd`** — `RefCounted` tick snapshot (`yaw` = orbit component, **0** while **`allow_yaw`** is false; **`distance`** = effective follow distance; **`zoom_band_index`**; **`focus_world`**, **`follow_target_path`**).
- **Seam:** **`allow_yaw`** / **`max_yaw_deg`** on the rig; orbit would adjust **`_orbit_yaw_rad`** (no input bound yet). **Presentation** compass uses **`presentation_yaw_deg`** separately from orbit **`yaw`** in state.
- **Zoom:** **`ZoomBandConfig`** resource (`client/scripts/zoom_band_config.gd`); default **`client/resources/isometric_zoom_bands.tres`** on main rig. Bands are **distance-only** (ascending near→far); **pitch / FOV** unchanged vs NEO-15. Input: **`camera_zoom_in`** / **`camera_zoom_out`** in `client/project.godot` (mouse wheel + `=` / `-` + keypad **+** / **−**). **`follow_distance`** remains **fallback** when config is null or has no bands; state **`zoom_band_index`** is **0** in that case. **`zoom_band_changed`** signal on rig; **TODO(E9.M1)** for throttled `camera_zoom_changed` telemetry.
- **Occlusion:** **`OcclusionPolicy`** resource (`client/scripts/occlusion_policy.gd`); default **`client/resources/isometric_occlusion_policy.tres`** on main rig. **Technique:** each **`_physics_process`** tick, iterative `intersect_ray` from `_smoothed_eye` to player focus (up to `max_occluder_cast_depth` calls). Bodies tagged `"occluder"` that intersect the ray have their `MeshInstance3D` surfaces overridden with a transparent `StandardMaterial3D` copy (`fade_alpha = 0.25`). Materials restored instantly when the body clears the ray. **Null-material surfaces** (no mesh or override material set) receive a plain transparent `StandardMaterial3D`; other non-`StandardMaterial3D` surfaces are skipped with `push_warning`. **Prototype district:** `Obstacle` node in `main.tscn` tagged `"occluder"`. **Perf marker:** `occluder_count_log_threshold` export (default 0 = disabled) emits `push_warning` for stress-test reference. See [risks and telemetry](#risks-and-telemetry) for readability gate.

  > **Prototype demo readability gate:** before shipping the prototype demo, the PR for NEO-17 must include a before/after screenshot or clip demonstrating that the player is no longer fully hidden by the `Obstacle`. This serves as the first data point for the occlusion-hiding-telegraphs risk documented below.

- **Click-through input (NEO-20):** `scripts/ground_pick.gd` unconditionally skips bodies in the `"occluder"` group during click-to-move ground-pick raycasts. When a cast hits an occluder the ray origin advances `OCCLUDER_PICK_THROUGH` meters past the hit point and continues, independent of whether `OcclusionPolicy` is currently fading that body. This keeps the `"occluder"` group as the sole tagging convention shared between the camera occlusion system and ground-pick input — no dedicated collision layer is added for this case.

- **Integration hardening (NEO-18):** [Consumer contract](#consumer-contract-neo-18) + `camera_state.gd` header; **E6.M2** adjacency notes ([E6_M2_ConsentAndRiskUxSignals.md](E6_M2_ConsentAndRiskUxSignals.md)); invalid / freed occluder bodies are **dropped** from `_occluder_overrides` before each occlusion ray pass and before full restore (`occluder_override_key_is_valid`, tests in `isometric_follow_camera_test.gd`) — **no** material restore on that path (freed subtree would error); live bodies still restore via `_restore_occluder` when they leave the ray. **Follow / state / occlusion** run in **`_physics_process`** with **`process_physics_priority = 1`** so the rig updates **after** `Player` (default **0**) and tracks **`move_and_slide`** without display-vs-physics jitter.

## Linear backlog

Parent epic: [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016).

| Key | Type | Summary |
|-----|------|---------|
| [E1.M2](https://linear.app/neon-sprawl/project/e1m2-isometriccameracontroller-deac8ef10395) | Project | E1.M2 — IsometricCameraController (module umbrella) |
| [NEO-15](https://linear.app/neon-sprawl/issue/NEO-15) | Story | Isometric follow camera (fixed yaw prototype; CameraState seam) |
| [NEO-19](https://linear.app/neon-sprawl/issue/NEO-19) | Story | Expand prototype client district for camera/nav stress QA |
| [NEO-16](https://linear.app/neon-sprawl/issue/NEO-16) | Story | ZoomBandConfig + discrete zoom input (clamped) |
| [NEO-17](https://linear.app/neon-sprawl/issue/NEO-17) | Story | OcclusionPolicy — keep player readable through geometry |
| [NEO-18](https://linear.app/neon-sprawl/issue/NEO-18) | Story | Camera integration hardening + dependent contract notes |

Suggested order: NEO-15 → NEO-16 → NEO-17 → NEO-18. [NEO-19](https://linear.app/neon-sprawl/issue/NEO-19) may run in parallel with or after NEO-15 (scene/nav geography; does not require zoom/occlusion).

## Risks and telemetry

- Occlusion hiding telegraphs: tune policy early and include readability checklist in prototype gates.
- Enabling **yaw orbit** mid-project without a telegraph/UI/minimap pass can undermine readability; design ground effects for **world accuracy** plus **screen-space or height** cues early (see [mid-project rotation policy](#mid-project-rotation-policy-practical-compromise)).

## Source anchors

- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Core Epic Map, Epic 1.
- [Module dependency register](module_dependency_register.md)
