# GDD: Eye Camera Controller

**System ID:** SYS-02  
**Design Order:** 7 of 33  
**Priority:** MVP — Core Layer  
**Dependencies:** SYS-33 (FishNet Replication — IsOwner guard, lifecycle), SYS-22 (Match State Machine — grace obscure, freeze at PostMatch)  
**Dependents:** SYS-06 (Eye Laser — fires from camera view), SYS-07 (Incarnation — camera transition), SYS-05 (Geometric LoS — server uses Eye position for occlusion), SYS-27 (Eye HUD — overlaid on this camera)  
**Status:** Designed  

---

## 1. Purpose

The Eye camera is the Eye player's entire world. Runners have a first-person physical body. The Eye has a rectangle of space and a cursor. Everything the Eye does — laser, traps, incarnation, minion commands — originates from where this camera is pointed and what its zoom level reveals.

Getting this camera wrong means the Eye role is either frustrating (can't see anything, always late) or trivially easy (perfect omniscience, no skill). The target feel is: **the Eye is powerful but not omniscient** — skilled panning and zoom management separates good Eye players from great ones.

This GDD defines:
- Projection type and the perspective choice
- Pan controls, pan bounds, pan speed
- Zoom levels, scroll behavior, zoom speed
- Grace period vision obscuring
- Incarnation camera transition (entry point — SYS-07 owns the full incarnation logic)
- Input model (all inputs, how they map to actions)
- Network isolation (camera is purely client-side on the Eye's machine)

---

## 2. Projection Type Decision

### 2.1 The Problem

The dollhouse is described as a "cutaway castle — front wall removed, all rooms visible from the Eye's exterior perspective" with "Z-axis depth (the dimension the Eye can't fully see into from the side)."

This creates a tension:
- **Pure orthographic** looks flat/clean but makes depth invisible — rooms at different Z depths look identical, and the Eye has no Z-information at all
- **Perspective** shows Z-depth as parallax and size falloff — but risks looking like a weird 3D game, not a stylized dollhouse

### 2.2 Decision: Perspective Camera at a Fixed Shallow Angle

**Use a `Camera` with perspective projection, positioned at a fixed X=0, Y=dollhouseCenterY, Z=-farDistance, looking toward +Z.** Not orthographic.

Rationale:
- Perspective gives the Eye subtle depth cues — a Runner hiding behind a prop in the back of a room is slightly smaller, slightly harder to target. This is the "Z-axis depth" the concept describes as the Eye's blind spot.
- The dollhouse's visual style (Dishonored-meets-Cult-of-the-Lamb) benefits from slight perspective — orthographic reads as a debug view.
- Laser aiming (SYS-06) needs a 3D SphereCast from camera to world — perspective is natural for this; orthographic requires extra math.
- Runners at the front of a room are slightly closer to camera → slightly larger → slightly easier to aim at. Runners at the back are slightly smaller. This is a natural skill expression for the Eye.

**Camera setup:**
```
Position: (0, dollhouseCenter.y, -zoomDistance)  — negative Z, looking toward +Z
Rotation: (0, 0, 0)  — looking straight down +Z axis
FOV: tunable per zoom level (see Section 5)
Near clip: 0.3
Far clip: zoomDistance + dollhouseDepth + 10
```

The camera stays at Z=constant (moves in X and Y for pan) — never moves on Z. Zoom is achieved by changing FOV, not dolly.

**Why FOV-zoom not dolly-zoom:** Dolly (moving camera on Z) changes the effective near/far relationship and can cause RunnerControllers to clip outside the frustum during zoom transitions. FOV zoom is purely visual, no physics change, and maps better to "scanning vs targeting" feel (wider view vs tighter view).

---

## 3. Camera Architecture

### 3.1 Component Structure

```
EyePlayer (NetworkObject, NetworkBehaviour)
    └── EyeCameraMount (Transform — panning moves this)
            └── EyeCamera (Camera component)
                    └── CinemachineCamera (Cinemachine 3)
                            └── EyeHUD Canvas (World Space or Screen Space Overlay)
```

`EyeCameraMount` is what pans. The actual camera always points +Z from the mount.

### 3.2 Network Isolation

**The Eye camera is 100% client-side visual.** It is NOT synced to other clients. The server only cares about Eye position for:
1. LoS raycasting (SYS-05 reads `EyeCameraMount.position.x` and `.y` to know which rooms the Eye is looking at)
2. Laser origin (SYS-06 reads camera world position + mouse cursor world position for the SphereCast)

Both are read server-side directly from the Eye's `NetworkTransform` on the `EyeCameraMount`. The camera's visual state (FOV, pan smoothing) is irrelevant to other players.

```csharp
public class EyeCameraController : NetworkBehaviour
{
    public override void OnStartClient()
    {
        base.OnStartClient();
        // Rule from SYS-33: ONLY activate camera for owner
        if (!IsOwner)
        {
            _camera.enabled = false;
            _audioListener.enabled = false;
            return;
        }
        _inputReader.Enable();
        _camera.enabled = true;
    }
}
```

---

## 4. Pan System

### 4.1 Pan Input Model

**WASD + mouse edge-scroll (optional).** Primary: WASD keys. Secondary: mouse reaching the screen edge triggers slow pan in that direction (configurable in settings, off by default — can feel intrusive).

Rationale for WASD over click-drag: click-drag conflicts with laser aiming (left-click fires laser in SYS-06). WASD keeps pan and combat inputs on separate hands.

```csharp
// In EyeInputReader:
public Vector2 PanInput { get; private set; }  // WASD → normalized direction

// W → (0, +1), S → (0, -1), A → (-1, 0), D → (+1, 0)
// Diagonal: normalize so diagonal pan == cardinal pan speed
```

### 4.2 Pan Physics

Pan uses a simple velocity model with acceleration and deceleration — not instant snap, not lerp-only. This gives the Eye kinetic feel: starting to pan is snappy; stopping has a small slide. The slide is crucial — the Eye should feel like they're controlling a camera crane, not a cursor.

```csharp
[SerializeField] private float _panAcceleration = 25f;   // m/s² — how fast it ramps
[SerializeField] private float _panDeceleration = 35f;   // m/s² — how fast it stops
[SerializeField] private float _maxPanSpeed     = 18f;   // m/s at current zoom (scales with zoom)

private Vector2 _panVelocity;

private void TickPan(Vector2 input, float delta)
{
    if (input.sqrMagnitude > 0.01f)
    {
        // Accelerate toward input direction
        _panVelocity += input.normalized * _panAcceleration * delta;
        _panVelocity = Vector2.ClampMagnitude(_panVelocity, _maxPanSpeed * _zoomPanScale);
    }
    else
    {
        // Decelerate
        float speed = _panVelocity.magnitude;
        float newSpeed = Mathf.Max(0f, speed - _panDeceleration * delta);
        _panVelocity = speed > 0.001f ? _panVelocity.normalized * newSpeed : Vector2.zero;
    }

    // Apply pan
    Vector3 newPos = _cameraMount.position
                   + new Vector3(_panVelocity.x, _panVelocity.y, 0) * delta;

    // Clamp to dollhouse bounds
    newPos.x = Mathf.Clamp(newPos.x, _panBounds.xMin, _panBounds.xMax);
    newPos.y = Mathf.Clamp(newPos.y, _panBounds.yMin, _panBounds.yMax);
    _cameraMount.position = newPos;
}
```

### 4.3 Pan Speed Scales with Zoom

At max zoom-out (scanning), panning at `_maxPanSpeed` covers the whole map quickly — the Eye is in overview mode, movement is fast and sweeping. At max zoom-in (targeting), panning speed scales DOWN so fine adjustments are possible without shooting across the map.

```csharp
// _zoomPanScale: 1.0 at max zoom-out, 0.35 at max zoom-in
// Interpolated based on current FOV:
_zoomPanScale = Mathf.Lerp(0.35f, 1.0f,
    (_currentFov - _minFov) / (_maxFov - _minFov));
```

### 4.4 Pan Bounds

Pan is bounded by the dollhouse extents, padded with one room-width of margin so the Eye can never pan to pure empty space:

```csharp
// Set by MapGenerator after map generation completes — computed from placed room bounds
public Rect _panBounds; // world-space XY rect
```

---

## 5. Zoom System

### 5.1 Zoom Model

Two named zoom modes with smooth interpolation between them. The Eye can be anywhere on the zoom spectrum, not just at the named stops — scroll wheel is continuous.

| Mode | FOV | Feel | Use |
|------|-----|------|-----|
| **Scanning (max out)** | 70° | Wide view, small Runners, full map visible | Finding Runners, planning traps |
| **Targeting (max in)** | 20° | Tight view, large Runners, 1–2 rooms visible | Laser aiming, reading Runner movement |

The named modes are the endpoints. The Eye scrolls between them continuously.

### 5.2 Zoom Input

```csharp
// Scroll wheel → zoom delta each frame
// Q key → hold for max zoom-in (quick targeting snap)
// E key → hold for max zoom-out (quick scanning snap)
public float ZoomDelta   { get; private set; }  // mouse scroll delta, -1 to +1
public bool  SnapZoomIn  { get; private set; }  // Q held
public bool  SnapZoomOut { get; private set; }  // E held
```

Q/E snap-to-max is quality-of-life — laser fights require quickly snapping to targeting mode and back. Without snap, scroll-to-zoom is too slow during time-critical aiming.

### 5.3 Zoom Implementation

```csharp
[SerializeField] private float _minFov     = 20f;   // max zoom in (targeting)
[SerializeField] private float _maxFov     = 70f;   // max zoom out (scanning)
[SerializeField] private float _zoomSpeed  = 60f;   // degrees/second continuous scroll
[SerializeField] private float _snapSpeed  = 200f;  // degrees/second for Q/E snap

private float _currentFov;
private float _targetFov;

private void TickZoom(float scrollDelta, bool snapIn, bool snapOut, float delta)
{
    if (snapIn)
        _targetFov = _minFov;
    else if (snapOut)
        _targetFov = _maxFov;
    else if (Mathf.Abs(scrollDelta) > 0.01f)
        _targetFov = Mathf.Clamp(_targetFov - scrollDelta * _zoomSpeed * delta,
                                  _minFov, _maxFov);

    float speed = (snapIn || snapOut) ? _snapSpeed : _zoomSpeed;
    _currentFov = Mathf.MoveTowards(_currentFov, _targetFov, speed * delta);
    _camera.fieldOfView = _currentFov;
}
```

### 5.4 Zoom → Pan Offset

When the Eye zooms in on a specific screen point (mouse cursor position), the camera should stay centered on that point — not on the camera mount center. This is the standard "zoom toward cursor" behavior in all RTS/strategy cameras.

```csharp
private void ZoomTowardCursor(float zoomDelta, float delta)
{
    if (Mathf.Abs(zoomDelta) < 0.01f) return;

    // World position under cursor before zoom
    Vector3 worldBefore = ScreenToWorld(_cursorScreenPos);

    // Apply zoom
    _currentFov = Mathf.Clamp(_currentFov - zoomDelta * _zoomSpeed * delta,
                               _minFov, _maxFov);
    _camera.fieldOfView = _currentFov;

    // World position under cursor after zoom
    Vector3 worldAfter = ScreenToWorld(_cursorScreenPos);

    // Offset mount to compensate
    Vector3 offset = worldBefore - worldAfter;
    offset.z = 0;  // don't move on Z
    Vector3 newPos = _cameraMount.position + offset;
    newPos.x = Mathf.Clamp(newPos.x, _panBounds.xMin, _panBounds.xMax);
    newPos.y = Mathf.Clamp(newPos.y, _panBounds.yMin, _panBounds.yMax);
    _cameraMount.position = newPos;
}

private Vector3 ScreenToWorld(Vector2 screenPos)
{
    Ray ray = _camera.ScreenPointToRay(screenPos);
    // Z-plane at dollhouse front face
    float t = (-_camera.transform.position.z) / ray.direction.z;
    return _camera.transform.position + ray.direction * t;
}
```

---

## 6. Grace Period Vision Obscuring

### 6.1 What Happens During Grace

During `MatchPhase.Grace` (first 30 seconds), the Eye's view is partially obscured. This is NOT a full blackout — the Eye can still orient themselves, but cannot see Runners clearly.

**Implementation: Screen-space texture overlay with noise pattern, high opacity.**

Options considered:
- **Full black:** Too extreme — Eye has zero information, boring
- **Blur:** Expensive post-processing, hard to tune for just Runners
- **Fog of war:** Complex per-object masking, too much for MVP
- **Noise overlay:** Cheap, visually interesting (static/interference), clearly communicates "impaired vision" without full blindness. ✓ Chosen.

```csharp
// A Canvas with a Screen Space Overlay mode
// Contains an Image component with a static noise texture
// Animated: two layered noise textures scrolling at different speeds
[SerializeField] private CanvasGroup _graceOverlay;
[SerializeField] private float       _graceOverlayAlpha = 0.75f;
// 0 = fully transparent (full vision), 1 = fully opaque (blind)

// On Grace→Hunt transition:
private IEnumerator FadeOutGraceOverlay()
{
    float elapsed = 0f;
    float duration = 1.5f;  // 1.5 second fade out on grace end
    while (elapsed < duration)
    {
        elapsed += Time.deltaTime;
        _graceOverlay.alpha = Mathf.Lerp(_graceOverlayAlpha, 0f, elapsed / duration);
        yield return null;
    }
    _graceOverlay.alpha = 0f;
    _graceOverlay.gameObject.SetActive(false);
}
```

### 6.2 Subscribe to Phase Change

```csharp
public override void OnStartClient()
{
    base.OnStartClient();
    if (!IsOwner) return;

    GameManager.Instance.OnPhaseChanged += OnPhaseChanged;
    _graceOverlay.alpha = _graceOverlayAlpha;
    _graceOverlay.gameObject.SetActive(true);
}

private void OnPhaseChanged(MatchPhase prev, MatchPhase next)
{
    if (prev == MatchPhase.Grace && IsOwner)
        StartCoroutine(FadeOutGraceOverlay());

    if (next == MatchPhase.PostMatch && IsOwner)
        _inputReader.Disable();
}
```

---

## 7. Incarnation Camera Transition (Entry Contract for SYS-07)

When the Eye incarnates (possesses an eye-decorated prop), the camera transitions from the dollhouse exterior view to a first-person interior view from the prop's position.

This GDD defines the **transition protocol**. SYS-07 (Incarnation System) owns the full incarnation gameplay logic.

### 7.1 Transition Sequence

```
1. Eye selects incarnation prop (right-click on glowing prop)
2. SYS-07 validates and triggers: EyeCameraController.BeginIncarnationTransition(prop)
3. Camera lerps from current dollhouse position → world position of prop over 0.4s
4. At transition midpoint (0.2s): FOV changes from current → 60° (first-person FOV)
5. At transition end: camera is now positioned at prop's eye socket, looking outward
6. EyeCameraController enters _incarnating = true — pan and zoom inputs disabled
7. SYS-07 is now controlling camera rotation (mouse look from prop position)

On exit (Eye releases incarnation):
1. SYS-07 triggers: EyeCameraController.EndIncarnationTransition()
2. Camera lerps from prop position → last dollhouse position over 0.4s
3. FOV lerps back to the zoom level before incarnation
4. _incarnating = false — pan and zoom inputs re-enabled
```

### 7.2 "Eye Goes Blank" Visual

When incarnating, the Eye's giant-eyeball visual on the dollhouse exterior should animate to a "blank stare" (pupil rolls back or irises close). This is cosmetic — SYS-07 drives the Eye model animator, not EyeCameraController. Mentioned here as a dependency contract.

---

## 8. Mouse Cursor World Position (Used by SYS-06)

SYS-06 (Eye Laser) needs to know the world-space position of the Eye's cursor to define the laser aim ray. `EyeCameraController` exposes this as a property:

```csharp
// Continuously computed each frame by EyeCameraController
// SYS-06 reads this when the player holds or releases laser charge
public Vector3 CursorWorldPosition { get; private set; }

private void UpdateCursorWorldPosition()
{
    Ray ray = _camera.ScreenPointToRay(Input.mousePosition);
    // Cast against the dollhouse's "aim plane" — a Z-plane at the center depth
    // of the dollhouse (midpoint of Z range)
    float aimPlaneZ = _dollhouseAimPlaneZ;
    if (ray.direction.z != 0f)
    {
        float t = (aimPlaneZ - ray.origin.z) / ray.direction.z;
        CursorWorldPosition = ray.origin + ray.direction * t;
    }
}
```

**The aim plane Z is the critical tuning parameter.** Too far back (deep in the dollhouse): laser hits back-wall Runners preferentially. Too far forward: laser overshoots Runners hiding near the back wall. Default: set to `dollhouseBounds.center.z` — midpoint of the dollhouse depth. Exposed in GlobalRules_SO.

---

## 9. Camera Position Sync (for SYS-05 LoS)

SYS-05 (Geometric LoS) needs the Eye's current pan position to determine which rooms are "in frame" — rooms outside the Eye's current view frustum can be culled from LoS raycasting. This is a server-side optimization.

`EyeCameraMount` has a `NetworkTransform` configured for **position only, no rotation** (rotation never changes). This syncs the pan position to the server at 30Hz. The server reads it in SYS-05 for LoS culling.

```csharp
// NetworkTransform on EyeCameraMount:
// - Sync Position: X, Y (Z never changes)
// - Sync Rotation: OFF
// - Sync Scale: OFF
// - Send Rate: 30Hz
```

---

## 10. Input Mapping Summary

| Input | Action | Notes |
|-------|--------|-------|
| WASD | Pan camera | Velocity-based, diagonal normalized |
| Mouse scroll | Zoom in/out | Continuous, zooms toward cursor |
| Q (hold) | Snap to max zoom-in (targeting) | Instant-ish, 200°/s FOV change |
| E (hold) | Snap to max zoom-out (scanning) | Instant-ish |
| Left Mouse | Laser aim / fire | Owned by SYS-06 — EyeCamera provides cursor world pos |
| Right Mouse | Select incarnation prop / trap placement | Owned by SYS-07/SYS-08 |
| Mouse position | CursorWorldPosition update | Continuous — SYS-06 reads this |
| (Optional) Mouse edge | Slow pan when cursor near screen edge | Setting, off by default |

---

## 11. Cinemachine 3 Integration

The camera uses Cinemachine 3's `CinemachineCamera` for smooth follow and post-processing hooks. However, since the Eye camera has custom pan physics (not follow-target), Cinemachine is used primarily for:
- Noise/shake when the Eye fires the laser (physical feedback)
- Smooth blend during incarnation transitions
- Screen shake on Runner extraction (world event feedback)

The `CinemachineCamera` is parented under `EyeCameraMount` and its body/aim modes are set to "Do Nothing" — custom pan code drives position directly. Cinemachine only adds impulse channels on top.

```csharp
// Shake on laser fire (called by SYS-06):
public void ApplyLaserFireShake()
{
    _cinemachineImpulseSource.GenerateImpulse(Vector3.right * 0.15f);
}
```

---

## 12. Tuning Values in GlobalRules_SO

```csharp
[System.Serializable]
public struct EyeCameraConfig
{
    public float panAcceleration;    // 25.0
    public float panDeceleration;    // 35.0
    public float maxPanSpeed;        // 18.0  (m/s at full zoom-out)
    public float minPanSpeedScale;   // 0.35  (pan speed at max zoom-in is 35% of max)
    public float minFov;             // 20.0  (targeting / max zoom-in)
    public float maxFov;             // 70.0  (scanning / max zoom-out)
    public float zoomScrollSpeed;    // 60.0  (deg/sec continuous scroll)
    public float zoomSnapSpeed;      // 200.0 (deg/sec Q/E snap)
    public float incarnationTransitionTime; // 0.4  seconds
    public float graceOverlayAlpha;  // 0.75
    public float graceOverlayFadeDuration; // 1.5 seconds
    public float dollhouseAimPlaneZ; // set to dollhouse center Z after map gen
}
```

---

## 13. MVP Scope

- [x] Perspective camera at fixed Z, pointing +Z decided
- [x] FOV-based zoom (not dolly) decided
- [x] Component structure (EyeCameraMount → EyeCamera) defined
- [x] Network isolation (camera owner-only, EyeCameraMount syncs position via NetworkTransform)
- [x] Pan system: WASD, velocity model, acceleration/deceleration, bounds clamp
- [x] Pan speed scales with zoom level
- [x] Zoom system: scroll wheel continuous, Q/E snap-to-max, zoom-toward-cursor
- [x] Grace period overlay: noise texture, alpha-fade on Hunt transition
- [x] Phase change subscription (grace out, PostMatch freeze)
- [x] Incarnation transition entry contract (SYS-07 interface)
- [x] CursorWorldPosition property (SYS-06 interface)
- [x] NetworkTransform on EyeCameraMount for LoS culling (SYS-05 interface)
- [x] Input mapping documented
- [x] Cinemachine 3 for impulse/shake
- [x] All tuning values in GlobalRules_SO.EyeCameraConfig
- [ ] Optional mouse edge pan (settings feature — not MVP)
- [ ] Post-processing effects per zoom level (e.g., vignette tightens at max zoom-in) — Polish tier
- [ ] Audio: subtle whoosh on snap zoom — SYS-19 dependency

---

*System: SYS-02 — Eye Camera Controller*  
*GDD Version: 1.0*  
*Created: 2026-06-23*  
*Depends on: [sys-33-fishnet-replication.md](sys-33-fishnet-replication.md), [sys-22-match-state-machine.md](sys-22-match-state-machine.md)*  
*Status: Complete — ready for implementation*
