# GDD: Geometric Line of Sight

**System ID:** SYS-05  
**Design Order:** 8 of 33  
**Priority:** MVP — Core Layer  
**Dependencies:** SYS-33 (FishNet Replication — authority model), SYS-02 (Eye Camera — Eye position for frustum cull), SYS-22 (Match State Machine — disable during Grace/PostMatch)  
**Dependents:** SYS-06 (Eye Laser — can only fire at visible Runners), SYS-07 (Incarnation — visibility from prop cameras), SYS-08 (Trap/Dread Economy — hidden Runners don't feed passive Dread on noise ticks), SYS-11 (Prop System — props are occluders), SYS-16 (Usable Items — Smoke Bomb blocks LoS), SYS-27 (Eye HUD — highlight detected Runners)  
**Status:** Designed  

---

## 1. Purpose

Geometric LoS is the stealth system. Every moment of tension in IStalk — "is the Eye looking at me? can I cross this gap?" — lives here. It is binary: the Runner is either **visible** (the Eye has line of sight) or **hidden** (occluded by geometry). No fog of war, no dynamic lighting, no stealth meters.

The concept specifies: "Pure physics raycast: if a collider blocks the vector between Eye and Runner, Runner is hidden. Multi-point raycasting (head, torso, extremities) prevents 'hidden arm' exploits."

This GDD defines:
- Authority model (server-authoritative, client visuals follow)
- Raycasting strategy (anatomical sockets, layer masks, pre-culling)
- Performance budget and profiling constraints
- The visibility state sync (SyncVar → all clients see Eye reactions)
- Cover destruction integration (Eye laser destroys props → new LoS)
- Smoke Bomb integration (SYS-16)
- Eye HUD feedback (which Runners are visible)

---

## 2. Authority Model

### 2.1 LoS is Server-Authoritative

The server computes all LoS. Clients do NOT compute LoS for themselves. This is non-negotiable:

- Runners must not be able to manipulate their own visibility state
- The Eye (server+client host) must not be able to fabricate visibility on remote Runners
- All systems that depend on visibility (laser firing, Dread feed, HUD highlights) must agree on the same state

**Server computes → SyncVar replicates → Clients react.**

### 2.2 Client-Side Visual Prediction

There is ONE exception: the owning Runner client shows a **local prediction** of whether they are being seen (the "Eye contact" indicator). If the server says Hidden but the client's local raycast says Visible, the client shows the "danger" indicator slightly early. This prevents the jarring experience of "Eye is looking right at me but my screen shows safe."

Local prediction runs only on the owning Runner's client, only for their own Runner. It is purely cosmetic — it does not affect any gameplay state.

```
Server (authoritative): computes real LoS, syncs _isVisible SyncVar
Runner owner client: also runs a light local LoS check for their own "I'm being seen" UI
```

---

## 3. Anatomical Raycasting

### 3.1 Why Multi-Point

A single chest-to-chest ray fails in two ways:
- **False positive (wrongly hidden):** Runner's body clips around a corner — one ray from the center misses the visible arm/leg. Runner is hidden but arm is showing.
- **False negative (wrongly visible):** Runner's center is fully inside a prop hitbox but head peaks out. Single ray from center says "blocked" even though the head is clear.

Five anatomical points handle this correctly with minimal extra cost.

### 3.2 Socket Layout

Each Runner has 5 `LoSSocket` Transforms as children:

```
LoSSocket_Head     — at top of head (1.75m from ground)
LoSSocket_Chest    — at center of torso (1.1m from ground)
LoSSocket_HipLeft  — left hip (0.8m, offset -0.25m X)
LoSSocket_HipRight — right hip (0.8m, offset +0.25m X)
LoSSocket_Feet     — at feet (0.1m from ground, center)
```

**Visibility definition:** A Runner is VISIBLE if **any 1 of 5 sockets** has an unobstructed ray to the Eye. A Runner is HIDDEN only if ALL 5 sockets are obstructed.

This means:
- A Runner whose head pokes above a crate is VISIBLE — the Eye can see (and shoot) them
- A Runner fully behind a pillar is HIDDEN — all 5 sockets blocked
- A Runner whose arm clips through thin cover is VISIBLE — one socket is clear

### 3.3 Eye Origin Point

Rays are cast **from the Eye's EyeCameraMount world position** toward each Runner socket. The Eye is a single point source (not a frustum — frustum is only used for pre-culling, not for LoS rays themselves).

```csharp
Vector3 eyeOrigin = GameManager.Instance.EyeCameraMount.position;
// Or more precisely: the center of the Eye NetworkObject's world position
// (EyeCameraMount tracks pan position, which is the correct origin)
```

### 3.4 Raycast Layer Mask

```csharp
// Layers that BLOCK LoS:
//   "Wall"        — room walls, floors, ceilings
//   "Prop"        — all prop objects (static and dynamic)
//   "SmokeVolume" — active Smoke Bomb volumes (SYS-16)
//
// Layers that DO NOT block LoS:
//   "Runner"      — Runner colliders themselves (rays pass through them to reach sockets)
//   "EyeBeam"     — laser beam visual
//   "Trigger"     — all trigger volumes
//   "Water"       — none in MVP
//   "UI"          — HUD elements

private static readonly int _losMask = LayerMask.GetMask("Wall", "Prop", "SmokeVolume");
```

Runners are NOT on the blocking mask — rays are cast FROM the Eye TO a socket ON the Runner. If Runner colliders blocked, every ray would hit the Runner's own collider first.

---

## 4. Pre-Culling Strategy

### 4.1 The Problem

At max player count (1 Eye + 4 Runners), running 5 raycasts × 4 Runners = 20 raycasts per frame × 30Hz = 600 raycasts/second. Each physics raycast in Unity is approximately 0.01–0.05ms depending on scene complexity. At a complex dollhouse with many props: up to 30ms/frame just for LoS — that's 2 full frame budgets blown.

Pre-culling reduces this to only the Runners who could plausibly be visible.

### 4.2 Cull Pass 1: Distance Cutoff

Any Runner further than `_maxLoSDistance` (60m) from the Eye origin is HIDDEN without raycasting. In a dollhouse of 8–30 rooms, 60m covers the full map. This is a safety valve for procgen maps that happen to be very long.

```csharp
if (Vector3.SqrMagnitude(eyeOrigin - runnerPos) > _maxLoSDistanceSq)
    continue; // skip — too far
```

**Use `SqrMagnitude` not `Distance`** — avoids a sqrt per Runner.

### 4.3 Cull Pass 2: Eye Camera Frustum

The Eye only cares about what's inside their camera's view frustum (with padding). Runners outside the frustum are candidates for hidden — but NOT automatically hidden, because the Eye may have panned away from a Runner who is still "technically" in a room the Eye WAS looking at. Frustum cull only skips the raycast; it does NOT override the previous frame's state.

```csharp
// Recalculate frustum planes each tick (cheap — 6 plane equations)
GeometryUtility.CalculateFrustumPlanes(_eyeCamera, _frustumPlanes);

// Padded bounds — use a slightly larger sphere per Runner than their actual bounds
// so panning slightly away doesn't instantly hide them
Bounds paddedBounds = new Bounds(runnerPos, Vector3.one * 2.5f);
if (!GeometryUtility.TestPlanesAABB(_frustumPlanes, paddedBounds))
{
    // Runner is outside frustum — skip raycast THIS tick
    // Retain last known state (_isVisible remains whatever it was)
    // NOTE: if Runner was visible last tick, they remain visible for up to _maxStaleFrames
    _staleFrameCount[runnerId]++;
    if (_staleFrameCount[runnerId] > _maxStaleFrames)
        SetVisibility(runner, false);  // gone for too long, declare hidden
    continue;
}
_staleFrameCount[runnerId] = 0;
```

**Stale frame tolerance:** A Runner who was visible last frame but is now outside the frustum stays "visible" for up to `_maxStaleFrames` (default: 3 frames = 100ms at 30Hz) before being declared hidden. This prevents flickering when the Eye pans quickly.

### 4.4 Cull Pass 3: Room Visibility (Post-MVP)

Each generated room has an AABB (from `RoomBounds` component). The Eye's frustum can be tested against room AABBs to skip entire rooms at once. Runners in rooms not in the frustum skip all raycasts. This is the most impactful optimization at high room counts. The map generator (SYS-10) exposes `GetAllPlacedRooms()` with bounds. Implement when profiling shows raycast budget is tight.

---

## 5. Per-Tick LoS Computation

### 5.1 Schedule

LoS runs on the **server** at **30Hz** (matches the server tick rate). It does NOT run every Unity frame — it runs in `TimeManager_OnTick()`, synchronized with the FishNet server tick.

```csharp
public class LoSManager : NetworkBehaviour
{
    private List<RunnerController> _activeRunners = new();

    public override void TimeManager_OnTick()
    {
        if (!IsServer) return;
        if (GameManager.Instance.IsGrace || GameManager.Instance.IsPostMatch) return;

        ComputeAllLoS();
    }
}
```

### 5.2 Full Computation Flow

```csharp
[Server]
private void ComputeAllLoS()
{
    Vector3 eyeOrigin = _eyePosition.Value;  // synced via SYS-02 NetworkTransform

    foreach (var runner in _activeRunners)
    {
        if (runner == null || !runner.IsAlive) continue;

        Vector3 runnerPos = runner.transform.position;

        // Cull Pass 1: distance
        if (Vector3.SqrMagnitude(eyeOrigin - runnerPos) > _maxLoSDistanceSq)
        {
            HandleCulled(runner);
            continue;
        }

        // Cull Pass 2: frustum
        if (!IsInFrustum(runnerPos))
        {
            HandleFrustumCulled(runner);
            continue;
        }

        // Full raycast — check all 5 sockets
        bool visible = false;
        foreach (var socket in runner.LoSSockets)
        {
            Vector3 dir = socket.position - eyeOrigin;
            float dist = dir.magnitude;
            if (dist < 0.1f) { visible = true; break; }  // Eye is right on top of Runner

            if (!Physics.Raycast(eyeOrigin, dir / dist, dist, _losMask))
            {
                visible = true;
                break;  // early exit — first clear socket = visible
            }
        }

        SetVisibility(runner, visible);
    }
}

[Server]
private void SetVisibility(RunnerController runner, bool visible)
{
    if (runner.IsVisible == visible) return;  // no state change — don't fire SyncVar write
    runner.IsVisible = visible;  // this is a SyncVar on RunnerController
}
```

**Early exit on first visible socket** — don't raycast all 5 if the first one is clear. In practice the chest ray (most direct) is checked first.

---

## 6. Visibility State Sync

### 6.1 SyncVar on RunnerController

```csharp
// In RunnerController.cs:
// ReadPermission: everyone — Eye HUD, minion targeting, and other Runners all need this
private readonly SyncVar<bool> _isVisible = new(false);
public bool IsVisible
{
    get => _isVisible.Value;
    [Server] set => _isVisible.Value = value;
}
```

### 6.2 Callback Uses Per System

| System | Callback Action |
|--------|----------------|
| **SYS-06 Eye Laser** | Laser can only fire if `runner.IsVisible == true` at release |
| **SYS-27 Eye HUD** | Highlight visible Runners on dollhouse view (outline glow) |
| **SYS-08 Dread Economy** | Visible Runners with noise events generate extra Dread |
| **SYS-16 Smoke Bomb** | Smoke volume sets all Runners inside to HIDDEN forcibly (server-side, overrides raycast result) |
| **Runner owner client** | Local "I'm being seen" indicator (cosmetic prediction) |

### 6.3 "I'm Being Seen" Indicator (Client Prediction)

```csharp
// On the owning Runner's client — runs in Update(), purely cosmetic
private void UpdateBeingSeenIndicator()
{
    if (!IsOwner) return;

    // Quick single-ray check from this client's perspective
    Vector3 eyePos = LoSManager.Instance.EyePositionLastKnown;
    Vector3 toEye = eyePos - _chestSocket.position;
    bool locallyVisible = !Physics.Raycast(
        _chestSocket.position, toEye.normalized, toEye.magnitude, _losMask
    );

    // Show indicator if server says visible OR local check says visible
    bool showIndicator = _isVisible.Value || locallyVisible;
    _beingSeenUI.SetActive(showIndicator);
}
```

The local check runs on ONE socket (chest only) — not all 5. It's a fast hint for UX, not authoritative.

---

## 7. Cover Destruction (Prop System Integration)

When the Eye's laser destroys a prop (SYS-06 + SYS-11), that prop's collider is removed. LoS recalculates on the next tick naturally — no special handling needed. The runner who was behind the prop will register as visible on the tick AFTER the prop is destroyed.

**Delay concern:** At 30Hz, there is at most 33ms between prop destruction and the next LoS tick. The laser cannot fire and hit the Runner in that same tick (laser has a charge duration of ~1.5s per SYS-06). The delay is invisible to players.

---

## 8. Smoke Bomb Integration (SYS-16)

A `SmokeVolume` is a Trigger collider on the `SmokeVolume` layer. LoS rays cast through it are blocked (smoke is on the `_losMask`).

Alternatively — and more robustly — the Smoke Bomb system registers its volume with `LoSManager` directly:

```csharp
// SYS-16 calls this on server when Smoke Bomb activates:
[Server]
public void RegisterSmokeVolume(Collider volume, float duration)
{
    _smokeVolumes.Add(volume);
    StartCoroutine(RemoveSmokeAfterDuration(volume, duration));
}
```

Runners INSIDE an active smoke volume are forcibly set to `IsVisible = false` regardless of raycast results — smoke doesn't just block rays from outside, it makes the Runner invisible even if the Eye "looks through" it from the right angle:

```csharp
// In ComputeAllLoS(), before raycast:
if (IsRunnerInsideSmokeVolume(runner))
{
    SetVisibility(runner, false);
    continue;
}
```

---

## 9. Incarnation LoS (SYS-07 Integration)

When the Eye incarnates a prop (SYS-07), LoS continues to run from the Eye's `EyeCameraMount` position. However, SYS-07 may also request LoS checks from the incarnated prop's position for the mini-laser aim validation. This is handled by a separate one-shot method:

```csharp
// Called by SYS-07 when Eye aims mini-laser from incarnated prop:
[Server]
public bool CheckLoSFromPoint(Vector3 origin, RunnerController runner)
{
    foreach (var socket in runner.LoSSockets)
    {
        Vector3 dir = socket.position - origin;
        float dist = dir.magnitude;
        if (!Physics.Raycast(origin, dir / dist, dist, _losMask))
            return true;
    }
    return false;
}
```

This is a one-shot synchronous check, not part of the per-tick loop. It's called at most once per incarnation laser fire event.

---

## 10. Performance Budget

### 10.1 Target

**Max 2ms per LoS tick on target hardware** (mid-range PC, 2022 era — ~GTX 1070 equivalent CPU/single-thread).

At worst case (4 Runners, all in frustum, all behind complex props):
- 4 Runners × 5 sockets × 1 raycast each = 20 raycasts
- Plus 4 × 1 pre-pass distance checks + 4 × frustum plane tests
- Estimated: ~0.5–1.5ms at 30Hz on mid-range hardware (Physics.Raycast against a 30-room dollhouse)

If profiling shows > 2ms:
1. **Reduce socket count from 5 to 3** (head, chest, feet — drop hips)
2. **Increase stale frame tolerance** — skip more raycasts when Runner hasn't been visible recently
3. **Enable room AABB culling** (Alpha feature pulled forward)
4. **Reduce LoS tick rate to 20Hz** (barely noticeable at 20Hz — 50ms resolution is acceptable for a stealth indicator)

### 10.2 Profiling Markers

```csharp
private static readonly ProfilerMarker _losMarker =
    new ProfilerMarker("LoSManager.ComputeAllLoS");

private void ComputeAllLoS()
{
    using (_losMarker.Auto())
    {
        // ... all computation ...
    }
}
```

Always-on profiling marker — shows in Unity Profiler under "CPU" → "LoSManager.ComputeAllLoS". Check this in week 3 when the first test map is in.

---

## 11. Layer Architecture

Correct layer assignment is critical for performance and correctness. These must be set up in the Unity project before any LoS code is written:

| Layer Name | Layer Index | Purpose |
|------------|-------------|---------|
| `Default` | 0 | Legacy — avoid |
| `Wall` | 8 | Room walls, floors, ceilings — blocks LoS |
| `Prop` | 9 | All prop colliders — blocks LoS, blocks laser |
| `Runner` | 10 | Runner CharacterController — passes through LoS rays |
| `Eye` | 11 | Eye model — ignored in all raycasts |
| `SmokeVolume` | 12 | Active Smoke Bomb volumes — blocks LoS |
| `GrappleAnchor` | 13 | Grapple attachment points — trigger only |
| `Trigger` | 14 | All gameplay triggers — ignored by LoS |
| `EyeBeam` | 15 | Laser visual — ignored by LoS |

**Rule:** Never put gameplay objects on `Default`. Every object must be explicitly assigned to a named layer.

---

## 12. LoSSocket Setup

### 12.1 Auto-Setup Component

```csharp
// Attach to RunnerController prefab root — auto-finds or creates sockets
public class LoSSocketsSetup : MonoBehaviour
{
    public Transform SocketHead;
    public Transform SocketChest;
    public Transform SocketHipLeft;
    public Transform SocketHipRight;
    public Transform SocketFeet;

    public Transform[] AllSockets => new[]
    {
        SocketChest,    // first — most common clear socket, early-exit optimization
        SocketHead,
        SocketHipLeft,
        SocketHipRight,
        SocketFeet
    };
}
```

**Chest is first in the array** — most raycasts early-exit at chest because it's the Runner's visible center. Head is second because the Eye most commonly spots Runners by their head above cover.

---

## 13. Invariants and Edge Cases

| Scenario | Behavior |
|----------|---------|
| Runner is Dead (`IsAlive == false`) | Skipped — dead Runners are not tracked for LoS |
| Runner is Minion | Minions have their own LoS (they can pass through walls) — tracked separately in SYS-18 |
| Eye disconnects | `LoSManager.Update()` stops (server gone). All Runners declared Hidden. |
| All 5 sockets in props (clipped) | Runner is HIDDEN — all raycasts blocked. (Runner is using cover correctly) |
| Runner behind transparent glass | Glass is on `Prop` layer → blocks LoS. If glass should be see-through for LoS, put it on a `TransparentProp` layer excluded from `_losMask`. Decision: MVP has no transparent props — all props block LoS. |
| Multiple Runners stacked | Each Runner is checked independently — stacking doesn't share a LoS result |
| LoS during Grace phase | Disabled — `if (GameManager.Instance.IsGrace) return` |
| Smoke Bomb activated mid-raycast | Smoke collider added to physics scene. LoS next tick will naturally block through it. One-tick lag is acceptable. |
| Runner extracts (leaves match) | Removed from `_activeRunners` list in `LoSManager.OnRunnerExtracted()` |

---

## 14. Grace Phase LoS Behavior

During Grace (first 30s), LoS is **not computed**. All Runners are treated as HIDDEN from the Eye's perspective. This matches the Eye's visual overlay (SYS-02 — grace obscures vision) with the actual system state.

```csharp
// Also in SYS-22 contract table — Eye Laser subscribes to OnPhaseChanged
// and disables itself during Grace (SYS-06 ownership)
// LoSManager enforces at computation level:
if (GameManager.Instance.IsGrace || GameManager.Instance.IsPostMatch) return;
```

---

## 15. MVP Scope

- [x] Server-authoritative LoS with SyncVar<bool> _isVisible on RunnerController
- [x] 5 anatomical sockets (Head, Chest, HipLeft, HipRight, Feet)
- [x] Visibility: any 1 clear socket = VISIBLE; all 5 blocked = HIDDEN
- [x] Raycast layer mask (Wall, Prop, SmokeVolume block; Runner, Eye pass through)
- [x] Pre-cull Pass 1: distance cutoff (SqrMagnitude, 60m)
- [x] Pre-cull Pass 2: camera frustum with stale frame tolerance (3 frames)
- [x] Early-exit on first visible socket (chest first)
- [x] Runs at 30Hz server tick, not per Unity frame
- [x] Disabled during Grace and PostMatch phases
- [x] Cover destruction integration (automatic — no special handling needed)
- [x] Smoke Bomb integration (SmokeVolume layer + forced-hidden for Runners inside volume)
- [x] Incarnation LoS one-shot check (`CheckLoSFromPoint`)
- [x] Client-side prediction for "I'm being seen" indicator (one socket, owner only)
- [x] Layer architecture table (8 named layers)
- [x] Performance budget (2ms target, profiling marker, fallback plan)
- [x] LoSSocket auto-setup component
- [x] Edge cases: dead Runners, all sockets in props, grace phase, multi-Runner stacking
- [ ] Room AABB culling (Pass 3) — Alpha tier, requires SYS-10/12
- [ ] Minion LoS tracking — owned by SYS-18
- [ ] Per-Runner LoS history for replay system — SYS-31

---

*System: SYS-05 — Geometric Line of Sight*  
*GDD Version: 1.0*  
*Created: 2026-06-23*  
*Depends on: [sys-33-fishnet-replication.md](sys-33-fishnet-replication.md), [sys-02-eye-camera.md](sys-02-eye-camera.md), [sys-22-match-state-machine.md](sys-22-match-state-machine.md)*  
*Status: Complete — ready for implementation*
