# GDD: Runner HUD

**System ID:** SYS-26  
**Design Order:** 13 of 33  
**Priority:** MVP — Presentation Layer  
**Dependencies:** SYS-01 (Runner Movement — ability availability, movement state), SYS-04 (Weight/Burden — current weight, weight tier), SYS-09 (Loot & Extraction — inventory, extraction channel progress), SYS-22 (Match State Machine — phase display, match timer)  
**Dependents:** None (leaf node — presentation only)  
**Status:** Designed  

---

## 1. Purpose

The Runner HUD is the only visual feedback channel between the Runner's gameplay systems and the player. It must communicate:
- **Am I safe right now?** (LoS-adjacent info — but the Runner HUD does NOT show LoS state directly. The Runner doesn't know if they're visible. This is intentional paranoia design.)
- **Can I run, dash, jump?** (ability availability)
- **What am I carrying? How heavy is it?** (weight tier + inventory)
- **Am I close to extracting?** (extraction progress)
- **What's the match state?** (phase, timer)

**HUD design principle**: Every element must answer a question the player is ACTIVELY asking during play. If a player never looks at an element mid-run, it shouldn't exist.

**Anti-patterns to avoid:**
- LoS visibility indicator — the Runner should NEVER know if the Eye can see them. Paranoia is Pillar 5.
- Health bar — Runners don't have HP. One laser hit = death. No gradual damage.
- Minimap — the map is the environment. No top-down shortcut.
- Damage numbers — there is no damage system on Runners.

This GDD defines the MVP Runner HUD: what elements exist, what data they read, where they sit on screen, and their implementation approach.

---

## 2. HUD Architecture

### 2.1 Owner-Only Rendering

The Runner HUD only renders for the **local Runner client** (`IsOwner == true`). There is no spectator HUD for MVP. The Eye has an entirely separate HUD (SYS-27).

```csharp
public class RunnerHUD : MonoBehaviour
{
    private RunnerController    _runner;
    private WeightComponent     _weight;
    private PlayerInventory     _inventory;
    private RunnerInteractor    _interactor;
    private ExtractionManager   _extraction;
    private GameManager         _gameManager;

    private void Awake()
    {
        // Deactivate until local runner is identified
        gameObject.SetActive(false);
    }

    // Called by RunnerController.OnStartClient() when IsOwner
    public void Initialize(RunnerController runner)
    {
        _runner    = runner;
        _weight    = runner.GetComponent<WeightComponent>();
        _inventory = runner.GetComponent<PlayerInventory>();
        _interactor = runner.GetComponent<RunnerInteractor>();
        _extraction = ExtractionManager.Instance;
        _gameManager = GameManager.Instance;
        gameObject.SetActive(true);
    }
}
```

The HUD `GameObject` lives in the scene as a child of a `Canvas` (Screen Space — Overlay). It's not a `NetworkBehaviour` — it reads from synced FishNet state but does not sync anything itself.

### 2.2 Implementation: uGUI Canvas (not UI Toolkit)

**Decision: Unity uGUI (Canvas-based), not UI Toolkit.**

Rationale: UI Toolkit is Unity 6's modern approach, but uGUI is simpler for real-time gameplay HUDs with frequent per-frame updates, animator-driven transitions, and well-understood FPS game patterns. For MVP, uGUI Canvas with anchored RectTransforms is the right choice. UI Toolkit migration can happen post-MVP if needed.

Canvas settings:
- `Render Mode`: Screen Space — Overlay
- `UI Scale Mode`: Scale With Screen Size, reference 1920×1080
- `Match`: Width Or Height at 0.5

---

## 3. HUD Element Inventory (MVP)

### Element Map — Screen Layout

```
┌────────────────────────────────────────────────────┐
│ [PHASE BANNER]     [MATCH TIMER]                   │  ← Top center
│                                                    │
│                                                    │
│                                                    │
│                         +  ← crosshair            │  ← Center
│                                                    │
│                                                    │
│ [PICKUP PROMPT]                [WEIGHT METER]      │  ← Lower left / lower right
│ [INVENTORY STRIP]              [ABILITY ICONS]     │  ← Bottom strip
│                 [EXTRACT BAR]                      │  ← Bottom center (shown only when channeling)
└────────────────────────────────────────────────────┘
```

### 3.1 Crosshair

**Position:** Screen center  
**Purpose:** FPS aiming reference  
**Data:** None — static  
**Design:** Small dot + 4 short lines. No dynamic spread (there's no "accuracy" mechanic). Simple `Image` component, no animation.

```csharp
// Crosshair hides during extraction channel (Runner is locked, no aiming)
_crosshair.gameObject.SetActive(!_runner.IsChanneling);
```

---

### 3.2 Weight Meter

**Position:** Bottom right  
**Purpose:** Primary mechanical feedback — the weight tier directly controls speed and abilities  
**Data reads:** `_weight.CurrentWeight`, `_weight.CurrentTier`

**Visual design:**
- A vertical bar (like a barometer) divided into 5 colored bands
- Current weight shown as a fill level with a tick mark
- Tier label above the bar: "LIGHT", "MODERATE", etc.
- Tier changes animate with a brief pulse (DOTween scale bounce)

```
  ██ OVERLOADED  ← red
  ██ HEAVY       ← orange
  ██ MODERATE    ← yellow  ← current (highlighted)
  ██ LIGHT       ← green
  ██ NAKED       ← grey
```

```csharp
private void UpdateWeightMeter()
{
    float t = _weight.CurrentWeight / _rules.maxCarryWeight;  // 0–1
    _weightFill.fillAmount = t;

    // Color tint by tier
    _weightFill.color = _tierColors[(int)_weight.CurrentTier];

    // Tier label
    _tierLabel.text = _weight.CurrentTier.ToString().ToUpper();
}
```

**Tier color map:**

| Tier | Color |
|------|-------|
| Naked | `#888888` (grey) |
| Light | `#55CC55` (green) |
| Moderate | `#DDDD00` (yellow) |
| Heavy | `#EE8800` (orange) |
| Overloaded | `#CC3333` (red) |

---

### 3.3 Ability Icons

**Position:** Bottom right, below/beside the weight meter  
**Purpose:** Communicate which movement abilities are currently available  
**Data reads:** `_runner.AirDashAvailable`, `_runner.DoubleJumpAvailable`, `_runner.IsDashing`, `_weight.CurrentTier`

Two icons for MVP:
- **Air Dash** icon: lightning bolt shape
- **Double Jump** icon: upward arrow × 2

Each icon has three states:
1. **Available** — full opacity, colored
2. **On cooldown** — dimmed, circular cooldown fill overlay
3. **Locked** — X overlay (weight too heavy to use — Air Dash disabled above 5kg)

```csharp
private void UpdateAbilityIcons()
{
    // Air Dash
    bool airDashLocked = _weight.CurrentWeight > _rules.airDashWeightCutoff;  // >5.0 kg
    _airDashIcon.SetState(
        locked:    airDashLocked,
        available: !airDashLocked && _runner.AirDashAvailable,
        cooldown:  !airDashLocked && !_runner.AirDashAvailable ? _runner.AirDashCooldownProgress : 0f
    );

    // Double Jump
    _doubleJumpIcon.SetState(
        locked:    false,  // Double Jump has no weight gate
        available: _runner.DoubleJumpAvailable,
        cooldown:  0f     // Double Jump resets on landing, no cooldown timer
    );
}
```

`AbilityIcon` is a small UI component: `Image` (icon) + `Image` (radial cooldown fill) + `Image` (lock X overlay). All driven by `SetState(bool locked, bool available, float cooldown)`.

---

### 3.4 Inventory Strip

**Position:** Bottom left  
**Purpose:** Show what items the Runner is carrying and their total weight  
**Data reads:** `_inventory.CarriedItems`, `_inventory.CarriedWeight`, `_inventory.PersonalLootValue`

The inventory strip is a horizontal row of item "chips" — one per item. Each chip shows:
- Item icon (from `ItemData.HudIcon` SO field)
- Item name (abbreviated, 8 chars max)
- Item weight in kg

Below the strip: a small text line showing totals: `3.4 kg  |  ★ 12.0`

```csharp
private void UpdateInventoryStrip()
{
    // Rebuild only when SyncList changes (subscribe to OnChange)
    _inventoryStrips.Clear();
    foreach (var entry in _inventory.CarriedItems)
    {
        var data = ItemDatabase.Instance.Get(entry.ItemId);
        var chip = Instantiate(_itemChipPrefab, _inventoryContainer);
        chip.Set(data.HudIcon, data.ShortName, entry.Weight);
        _inventoryStrips.Add(chip);
    }

    _weightTotalLabel.text = $"{_inventory.CarriedWeight:F1} kg";
    _lootValueLabel.text   = $"★ {_inventory.PersonalLootValue:F0}";
}
```

**SyncList.OnChange subscription** (not per-frame polling — only rebuild when inventory changes):
```csharp
_inventory.CarriedItems.OnChange += (op, index, prev, next, asServer) =>
{
    if (!asServer) RebuildInventoryStrip();
};
```

Maximum display: 6 chips before overflow. If Runner carries more than 6 items (unlikely at MVP weight limits but possible): show "…+2 more" overflow chip.

---

### 3.5 Pickup Prompt

**Position:** Bottom left, above inventory strip  
**Purpose:** Tell the Runner what they can interact with right now  
**Data reads:** `_interactor.NearestInteractable` (WorldItem or altar)

The prompt is context-sensitive:

| Nearest interactable | Prompt text |
|---------------------|------------|
| WorldItem within 1.5m | `[E] Pick up Ornate Vase  0.5 kg  ★ 3.0` |
| Extraction altar within 2.5m, has items | `[F] Extract  3.4 kg  →  8.2 sec` |
| Extraction altar within 2.5m, no items | `[F] Nothing to extract` (dimmed) |
| Nothing | *(hidden)* |

```csharp
private void UpdatePickupPrompt()
{
    var interactable = _interactor.NearestInteractable;
    if (interactable == null)
    {
        _promptRoot.SetActive(false);
        return;
    }

    _promptRoot.SetActive(true);

    if (interactable is WorldItem item)
    {
        var data = ItemDatabase.Instance.Get(item.ItemId);
        _promptText.text = $"[E] Pick up {data.DisplayName}  {data.WeightKg:F1} kg  ★ {data.LootValue:F0}";
        _promptText.color = Color.white;
    }
    else if (interactable is ExtractionAltar)
    {
        bool hasItems = _inventory.CarriedItems.Count > 0;
        if (hasItems)
        {
            float chanTime = _extraction.CalculateChannelTime(_inventory.CarriedWeight);
            _promptText.text = $"[F] Extract  {_inventory.CarriedWeight:F1} kg  →  {chanTime:F1} sec";
            _promptText.color = Color.white;
        }
        else
        {
            _promptText.text = "[F] Nothing to extract";
            _promptText.color = new Color(1f, 1f, 1f, 0.4f);
        }
    }
}
```

---

### 3.6 Extraction Progress Bar

**Position:** Bottom center, above the inventory strip  
**Visible:** Only during active extraction channel (`_runner.IsChanneling == true`)  
**Purpose:** Show channel progress and remaining time. Creates the "defuse bomb" tension moment.

```
  ████████████░░░░░░░░░░░  62%  4.2 sec remaining
         E X T R A C T I N G . . .
```

Animation:
- Slides UP from off-screen (DOTween `DOMoveY`) when channel starts
- Fills left-to-right over the channel duration
- Pulses color from gold → white at 90% (almost done!)
- Slides DOWN and hides when channel ends (success or cancel)

```csharp
private void UpdateExtractionBar()
{
    bool isChanneling = _runner.IsChanneling;

    if (isChanneling && !_extractBarVisible)
    {
        _extractBarVisible = true;
        _extractBarRoot.gameObject.SetActive(true);
        _extractBarRoot.DOAnchorPosY(_extractBarShowY, 0.2f).SetEase(Ease.OutBack);
    }
    else if (!isChanneling && _extractBarVisible)
    {
        _extractBarVisible = false;
        _extractBarRoot.DOAnchorPosY(_extractBarHideY, 0.15f).SetEase(Ease.InQuad)
            .OnComplete(() => _extractBarRoot.gameObject.SetActive(false));
    }

    if (isChanneling)
    {
        float progress = _extraction.GetChannelProgress(_runner.Owner.ClientId);
        _extractFill.fillAmount = progress;
        float remaining = _extraction.GetChannelTimeRemaining(_runner.Owner.ClientId);
        _extractTimeLabel.text = $"{remaining:F1}s";

        // Pulse gold → white at 90%+
        _extractFill.color = progress > 0.9f
            ? Color.Lerp(Color.yellow, Color.white, Mathf.PingPong(Time.time * 4f, 1f))
            : _extractNormalColor;
    }
}
```

`ExtractionManager` exposes:
```csharp
public float GetChannelProgress(int clientId);      // 0–1
public float GetChannelTimeRemaining(int clientId); // seconds
```

---

### 3.7 Phase Banner and Match Timer

**Position:** Top center  
**Purpose:** Context for what mode the match is in; urgency from the timer.

Phase banner: a small text label that shows the current phase name when it changes.

| Phase | Banner text | Color |
|-------|------------|-------|
| Grace | "GRACE PERIOD" | Soft blue |
| Hunt | *(hidden — no banner during Hunt, normal play)* | — |
| ExtractionCrisis | "EXTRACTION" | Gold |
| Overtime | "OVERTIME" | Orange-red, pulsing |
| PostMatch | "MATCH OVER" | White |

The banner fades in for 3s then fades out except during Overtime (stays visible, pulsing).

Match timer: `MM:SS` format. During Overtime, timer counts UP (time in Overtime) with red color. During Hunt, counts DOWN from the match duration limit.

```csharp
private void OnPhaseChanged(MatchPhase prev, MatchPhase next)
{
    if (next == MatchPhase.Hunt)
    {
        ShowPhaseBanner(null);  // hide
    }
    else
    {
        ShowPhaseBanner(_phaseNames[next], _phaseColors[next]);
    }
}
```

---

## 4. Update Strategy

Two update patterns, not one. Polling everything per-frame is wasteful for elements that rarely change.

| Element | Update trigger | Reason |
|---------|---------------|--------|
| Crosshair | Once (static) | Never changes |
| Weight Meter | Per-frame | Weight changes constantly during movement |
| Ability Icons | Per-frame | Air Dash/DoubleJump resets mid-air — need low latency |
| Inventory Strip | SyncList.OnChange event | Inventory changes are discrete events, not continuous |
| Pickup Prompt | Per-frame | Proximity changes as Runner moves |
| Extraction Bar | Per-frame (only when visible) | Smooth fill requires per-frame updates |
| Phase Banner | OnPhaseChanged event | Phase changes are rare |
| Match Timer | Once per second (`InvokeRepeating`) | Second-level precision is sufficient |

```csharp
private void Update()
{
    // Per-frame elements
    UpdateWeightMeter();
    UpdateAbilityIcons();
    UpdatePickupPrompt();
    if (_extractBarVisible) UpdateExtractionBar();
}
```

---

## 5. Death State

When `_runner.IsAlive` SyncVar becomes false (server confirmed kill):

1. Immediately hide all HUD elements except a full-screen fade to black
2. Play "vaporized" camera shake (SYS-02 impulse fires client-side before death confirmed)
3. After 1.5s black screen: show death text briefly
4. Minion spawn (SYS-18) will re-initialize a new HUD context — the Runner HUD itself despawns with the Runner NetworkObject

```csharp
// Subscribed to _runner._isAlive SyncVar OnChange
private void OnAliveStateChanged(bool prev, bool next, bool asServer)
{
    if (!next)  // died
    {
        StartCoroutine(DeathFade());
    }
}

private IEnumerator DeathFade()
{
    _fullscreenFade.DOFade(1f, 0.5f);  // fade to black
    yield return new WaitForSeconds(1.5f);
    _deathText.gameObject.SetActive(true);
    _deathText.text = "ELIMINATED";
}
```

---

## 6. Paranoia Design Constraint

The Runner HUD **deliberately omits**:

| Absent element | Why |
|---------------|-----|
| LoS indicator ("Eye can see you") | Pillar 5: Paranoia is a Feature. Not knowing if you're visible IS the gameplay. |
| Eye position / direction | Asymmetric — Runners are blind to Eye's view |
| Other Runners' positions | No minimap. You can only see teammates if in the same room. |
| Sound radius visualizer | Alpha — SYS-19. Not MVP. When added, it's subtle (not a radar). |
| HP / invincibility timer | No HP. Dash i-frames are instant feel, not a countdown. |

If a design discussion ever proposes adding any of the above for "clarity" — refuse it. The uncertainty IS the tension.

---

## 7. Visual Style Notes

The HUD should feel Gothic / institutional horror — not clean flat game UI. Reference direction:
- **Typography:** Monospace or slab-serif. Uppercase. No rounded buttons.
- **Color palette:** Desaturated base (dark grey), accents in muted gold/amber for loot, red/orange for danger states (overloaded weight, overtime).
- **Icons:** Simple outline style — readable at small sizes.
- **Opacity:** HUD elements at 85% max opacity. Don't occlude gameplay.
- **Animations:** Minimal. Transitions in 0.1–0.2s. No bouncy easing except the extraction bar reveal (which intentionally calls attention).

HUD font: `GlobalRules_SO.HudFont` (a single SO field pointing to a `TMP_FontAsset`). One font for all HUD text. Bold variant for numeric values, regular for labels.

---

## 8. Prototype Test Checklist

| Test | Pass Condition |
|------|---------------|
| HUD only visible on owner client | Other players' screens don't show this HUD |
| Weight meter fills correctly | 5kg carry → bar fills to Moderate band, yellow color |
| Weight tier label updates | Crossing tier threshold updates label text |
| Air Dash icon locks at >5kg | Icon shows X overlay when weight > airDashWeightCutoff |
| Air Dash icon cooldown fills | After using Air Dash, radial fill animates |
| Inventory strip adds on pickup | Pick up an item → chip appears in strip |
| Inventory strip removes on drop | Drop an item → chip disappears from strip |
| Pickup prompt shows near item | Walk within 1.5m of WorldItem → prompt with name/weight appears |
| Pickup prompt shows altar | Walk within 2.5m of altar with items → extract prompt with time appears |
| Altar prompt "nothing" when empty | Approach altar with no items → dimmed "nothing to extract" |
| Extraction bar appears on channel | Press F at altar → bar slides up from bottom |
| Extraction bar fills | Bar fills over channel duration |
| Extraction bar hides on cancel | Move away from altar → bar slides down and hides |
| Extraction bar hides on success | Channel completes → brief flash then hides |
| Phase banner "GRACE PERIOD" | Match start shows grace banner briefly |
| Phase banner "OVERTIME" pulses | Overtime phase shows pulsing orange banner |
| Death fade on elimination | Getting hit by laser fades to black |
| HUD hides on death | No stale HUD elements after elimination |

---

## 9. MVP Scope

- [x] Canvas setup (Screen Space Overlay, 1920×1080 reference)
- [x] RunnerHUD component initialized by RunnerController.OnStartClient() IsOwner check
- [x] Crosshair — static dot+lines, hides during channel
- [x] Weight Meter — vertical bar, 5-band color, tier label, DOTween pulse on tier change
- [x] Ability Icons — Air Dash + Double Jump, available/cooldown/locked states
- [x] Inventory Strip — SyncList.OnChange driven rebuild, item chips with icon/name/weight
- [x] Inventory totals line — total weight + personal loot value
- [x] Pickup Prompt — per-frame proximity polling, context-aware text (WorldItem or altar)
- [x] Extraction Bar — slide-in animation, radial fill, pulse at 90%, slide-out on end
- [x] Phase Banner — event-driven, per-phase color + text, fade out after 3s (except Overtime)
- [x] Match Timer — once-per-second update, countdown during Hunt, count-up during Overtime
- [x] Death fade — black screen on IsAlive SyncVar → false
- [x] Paranoia constraint documented (no LoS indicator, no Eye position, no minimap)
- [x] 18-test prototype checklist
- [ ] Actual art/icons for item chips (art dependency — placeholder icons for MVP)
- [ ] HUD font asset (art dependency — any monospace for prototype)
- [ ] Sound radius ring visualization (SYS-19 Alpha dependency)
- [ ] Usable item slots (SYS-16 Alpha dependency)
- [ ] Spectator/Minion HUD transition (SYS-28 Beta dependency)

---

*System: SYS-26 — Runner HUD*  
*GDD Version: 1.0*  
*Created: 2026-06-23*  
*Depends on: [sys-01-runner-movement.md](sys-01-runner-movement.md), [sys-04-weight-burden.md](sys-04-weight-burden.md), [sys-09-loot-extraction.md](sys-09-loot-extraction.md), [sys-22-match-state-machine.md](sys-22-match-state-machine.md)*  
*Status: Complete — ready for implementation*
