# GDD: Loot & Extraction Loop

**System ID:** SYS-09  
**Design Order:** 12 of 33  
**Priority:** MVP — Feature Layer  
**Dependencies:** SYS-04 (Weight/Burden — pickup adds weight, drop reduces it), SYS-14 (Item Database — item SO definitions), SYS-22 (Match State Machine — extraction disabled in Grace/PostMatch, win condition triggers match end)  
**Dependents:** SYS-26 (Runner HUD — inventory display, extraction progress bar), SYS-15 (Shrine/Shop — spending loot at shrines), SYS-17 (Betrayal — loot theft between Runners)  
**Status:** Designed  

---

## 1. Purpose

The loot-and-extract loop is the **Runner win condition**. Every other Runner system exists to support this: movement is how you reach loot and escape, weight is the cost of carrying it, LoS is the Eye's ability to stop you. Without this system, the game has no Runner goal.

The design tension is weight vs. value: every item is a tradeoff. Heavier loot extracts more value but slows you to a crawl and extends the altar channel time. The ideal play is to read the room, grab what you can carry efficiently, and reach the altar before the Eye can position to interrupt.

**Feel target:** The extraction channel should feel like defusing a bomb under sniper fire. The Runner is locked in place, watching the bar fill, knowing the Eye's beam could end it at any second. The longer you channel (heavier loot), the more tension the moment carries.

This GDD defines:
- Item spawn system (placed at loot slots by MapGenerator)
- Pickup and drop mechanics
- PlayerInventory component
- Extraction altar channeling (weight-scaled time, interruption rules)
- Win condition resolution and match end signals
- Network authority for all state

---

## 2. Item World State

### 2.1 WorldItem (NetworkObject)

Items that exist in the world are `WorldItem` NetworkObjects:

```csharp
public class WorldItem : NetworkBehaviour
{
    [SerializeField] private string _itemId;  // references ItemDatabase SO
    public string ItemId => _itemId;

    // Only valid while item is in the world (not picked up)
    public ItemData Data => ItemDatabase.Instance.Get(_itemId);

    // Called by server when a Runner picks this up
    [Server]
    public void ServerPickUp()
    {
        // Despawn removes from all clients — item is now in a player's inventory
        ServerManager.Despawn(gameObject);
    }
}
```

`WorldItem` has a `Collider` (trigger) on the `Trigger` layer for proximity detection. Items do NOT block LoS — they are on `Trigger`, not `Prop`.

### 2.2 Item Spawn Points

Items spawn at `LootSlot` markers within generated room prefabs. The MapGenerator populates them during content population:

```csharp
public class ItemSpawnPoint : MonoBehaviour
{
    [SerializeField] private string _itemId;      // what spawns here
    [SerializeField] private bool   _alwaysSpawn; // false = random chance

    [Server]
    public void ServerSpawnItem()
    {
        if (!_alwaysSpawn && Random.value > _spawnChance) return;
        var go = ServerManager.Instantiate(_worldItemPrefab, transform.position, transform.rotation);
        go.GetComponent<WorldItem>().ItemId = _itemId;
        ServerManager.Spawn(go);
    }
}
```

`ItemSpawner.cs` (a singleton NetworkBehaviour on the GameManager) calls `ServerSpawnItem()` on all spawn points at match start (after `MatchPhase.Grace` begins). Items spawn in-world once and are not replenished mid-match in MVP.

### 2.3 Spawn Point Layout (MVP)

MVP map spawns:
- **High-value items** (2–5 kg each): placed deep in the map, away from extraction altar, near the Eye's preferred viewing angles — risk/reward positioning
- **Low-value items** (0.1–1 kg): scattered through accessible areas — safe pickups for conservative Runners
- **Total spawn count**: ~12–15 items for a 4-Runner match on the MVP map

Exact placement is a level design decision, not a system rule. The system enforces the spawn-once logic.

---

## 3. Pickup Mechanic

### 3.1 Interaction Window

A Runner can pick up a `WorldItem` when:
1. Within 1.5m of the item (sphere overlap check — no facing requirement)
2. They press the `Interact` key (default: E)
3. The item has not already been picked up (server validates)

No "carry limit" by item count — the only limit is the weight tier (SYS-04). A Runner can hold any number of items as long as total weight permits movement.

### 3.2 Pickup Sequence

```
Runner presses E near WorldItem
    → [Client] RunnerInteractor checks proximity: finds closest WorldItem in 1.5m
    → [Client] Shows pickup prompt: "Pick up [item name] ([weight]kg)"
    → [Client] On E hold-complete: sends ServerRpc
    → [Server] Validates: item still exists, player within 1.5m (anti-cheat)
    → [Server] WorldItem.ServerPickUp() → ServerManager.Despawn(worldItem)
    → [Server] PlayerInventory.ServerAddItem(itemId, itemWeight, itemValue)
        → WeightComponent.AddWeight(itemWeight)         // SYS-04
        → _carriedItems SyncList updated                // all clients see inventory
        → _personalLootValue SyncVar updated             // tally, never decrements
    → [Clients] WorldItem disappears from world (auto via Despawn)
    → [Client: Runner] HUD updates (SYS-26)
```

**Anti-cheat:** Server re-validates proximity at the moment the `ServerRpc` is processed. If the Runner moved away (e.g., network delay), the pickup is rejected silently.

### 3.3 Pickup Prompt (HUD Contract)

`PlayerInteractor` exposes:
```csharp
public WorldItem NearestInteractable { get; private set; }  // null if none in range
```

The Runner HUD (SYS-26) reads this each frame to show/hide the prompt. No events needed — polling is fine for a HUD element.

---

## 4. Drop Mechanic

### 4.1 Drop Rules

A Runner can drop any item from their inventory at any time:
- Press `G` (default drop key) → drops the heaviest item in inventory (auto-select)
- Press `G + [1–4]` to select by slot → drops a specific item

Dropping is always permitted, even mid-air or during extraction channeling. Dropping during channeling cancels the channel (Runner left empty-handed at the altar — no value to extract).

### 4.2 Loot Value is Permanent

**Critical rule from SYS-04:** When an item is dropped, `WeightComponent.RemoveWeight()` is called, but `_personalLootValue` is NOT decremented. The Runner keeps the loot value tally for their personal score/progression even if they drop or lose the item.

**Rationale:** This prevents a "loot exploit" (pick up everything to maximize score, drop before dying) while still rewarding aggressive looting behavior. The extraction value (what Runners extract = currency earned) DOES require carrying items to the altar — the personal value tally is a separate post-match metric.

### 4.3 Drop Sequence

```
Runner presses G (or G+slot)
    → [Client] Sends ServerRpc: ServerDropItem(itemId)
    → [Server] Validates: Runner does have this item
    → [Server] PlayerInventory.ServerRemoveItem(itemId)
        → WeightComponent.RemoveWeight(itemWeight)  // SYS-04
        → _carriedItems SyncList updated
        → _personalLootValue stays unchanged
    → [Server] Spawn WorldItem at Runner's feet + small forward impulse
    → [Clients] WorldItem appears in world (auto via Spawn)
```

Dropped items can be picked up by other Runners (for weight transfer) or re-picked-up by the same Runner. Betrayal mechanic (SYS-17) builds on this contract.

---

## 5. PlayerInventory Component

```csharp
public class PlayerInventory : NetworkBehaviour
{
    // Synced carried item list — clients read this for HUD
    private readonly SyncList<InventoryEntry> _carriedItems = new();

    // Cumulative score — never decrements, survives drops
    private readonly SyncVar<float> _personalLootValue = new(0f);

    public float PersonalLootValue => _personalLootValue.Value;
    public IReadOnlyList<InventoryEntry> CarriedItems => _carriedItems;

    // Sum of weights currently carried (derived, but cached for perf)
    private float _carriedWeight;
    public float CarriedWeight => _carriedWeight;

    [Server]
    public void ServerAddItem(string itemId, float weight, float value)
    {
        _carriedItems.Add(new InventoryEntry { ItemId = itemId, Weight = weight });
        _personalLootValue.Value += value;
        _carriedWeight += weight;
        GetComponent<WeightComponent>().AddWeight(weight);
    }

    [Server]
    public void ServerRemoveItem(string itemId)
    {
        var entry = _carriedItems.FirstOrDefault(e => e.ItemId == itemId);
        if (entry == null) return;
        _carriedItems.Remove(entry);
        _carriedWeight -= entry.Weight;
        // Note: _personalLootValue is NOT decremented here
        GetComponent<WeightComponent>().RemoveWeight(entry.Weight);
    }

    // Returns total VALUE of items currently in inventory (not personal tally)
    // This is what the extraction altar uses to calculate channel time
    public float GetCarriedValue()
    {
        return _carriedItems.Sum(e => ItemDatabase.Instance.Get(e.ItemId).LootValue);
    }
}

[System.Serializable]
public struct InventoryEntry : IEquatable<InventoryEntry>
{
    public string ItemId;
    public float  Weight;
    public bool Equals(InventoryEntry other) => ItemId == other.ItemId;
}
```

`PlayerInventory` is a separate component on the Runner prefab (not inside `RunnerController`) — same split rationale as `WeightComponent` in SYS-04.

---

## 6. Extraction Altar

### 6.1 Altar Location

One extraction altar per map for MVP. The altar is a static scene object (NOT a NetworkObject — no networked state on the altar itself). Its world position is a scene reference read by `ExtractionManager` at Start.

Post-MVP: multiple altars (Runners choose their exit route), or altars that unlock progressively.

### 6.2 Channel Activation

A Runner initiates extraction by:
1. Standing within 2.5m of the altar center
2. Pressing `F` (interact key — same as general interact, but altar-specific context)
3. Having at least 1 item in inventory (weight > 0)

If these conditions aren't met, the altar interaction is rejected with a prompt: "Nothing to extract" or "Move closer."

### 6.3 Channel Time Formula

Channel time scales with carried weight — heavier loads take longer but extract more value:

```
channelTime = baseChannelTime + (carriedWeight × weightChannelFactor)
```

| Carried Weight | Channel Time | Context |
|---------------|-------------|---------|
| 0 kg | N/A (rejected) | No items to extract |
| 0.1 kg (tiny) | 3.04s | Barely worth it, very fast |
| 1 kg (light) | 3.4s | Quick escape with a small score |
| 5 kg (moderate) | 5.0s | Balanced risk — a few seconds of exposure |
| 12 kg (heavy) | 7.8s | Greedy run — over 7 seconds at the altar |
| 20 kg (overloaded) | 11.0s | Exhausting channel, massive payout |

Constants: `baseChannelTime = 3.0s`, `weightChannelFactor = 0.4s/kg`. All in `GlobalRules_SO.ExtractionConfig`.

**Design note:** A 7–11 second channel for heavy loads is intentionally long. The Eye, upon seeing a Runner reach the altar, has a real window to charge the laser (1.5s) and fire before the channel completes. This is the final act of the game — a race between the Runner's channel completing and the Eye's laser reaching them.

### 6.4 Channel Interruption Rules

| Interruption Cause | Result |
|-------------------|--------|
| Runner moves > 2.5m from altar center | Channel cancelled, progress reset to 0 |
| Runner eliminated by Eye laser | Channel cancelled, items dropped at altar |
| Runner presses F again (cancel) | Channel cancelled, progress reset to 0 |
| MatchPhase → PostMatch | Channel cancelled (match ended from another cause) |

No partial progress for MVP — the channel is all-or-nothing. The Runner must re-initiate.

### 6.5 Channel Sequence (Server-Authoritative)

```
Runner presses F at altar
    → [Client] Sends ServerRpc: ServerBeginExtraction()
    → [Server] ExtractionManager.ServerBeginExtraction(conn)
        → Validates: phase == Hunt or ExtractionCrisis, runner has items, within 2.5m
        → Sets RunnerController._isChanneling = true (SyncVar)
        → Starts coroutine: WatchChannelProgress(conn)
    → [Clients] All see "Runner X is channeling" via SyncVar (HUD shows to Eye!)
    → [Server coroutine] Ticks every 0.1s
        → Checks: Runner still within 2.5m?
        → Checks: Runner still alive?
        → If either fails: cancel (ObserversChannelCancelled)
        → If elapsed >= channelTime: ExtractionSuccess(conn)

ExtractionSuccess(conn)
    → ObserversRunnerExtracted(conn.ClientId)  // VFX + SFX on all clients
    → PlayerInventory.ServerClearInventory()   // items consumed (not dropped)
    → GameManager.NotifyRunnerExtracted(conn)  // SYS-22: may trigger match end
    → ServerManager.Despawn(runner.NetworkObject)
    → Runner client transitions to spectator view
```

### 6.6 Channel Visibility (Critical Design Decision)

**The Eye SEES when a Runner is channeling.** `_isChanneling` is a `SyncVar<bool>` on `RunnerController`. The Eye HUD (SYS-27) shows an "EXTRACTION ALERT" indicator when any Runner's `_isChanneling` becomes true. This is the Eye's "GET THERE NOW" signal.

The Runner knows this — they know their channel is a beacon. This creates the core asymmetric tension: Runners want to channel quietly (heavy load = slow approach + long channel = more risk), Eyes want to position to interrupt.

---

## 7. Win Condition Resolution

All win logic lives in `GameManager` (SYS-22). This system only fires the events.

### 7.1 Runner Victory Conditions

`GameManager.NotifyRunnerExtracted(conn)` is called each time a Runner extracts. Win condition check:

```csharp
[Server]
public void NotifyRunnerExtracted(NetworkConnection conn)
{
    _extractedRunners.Value++;
    CheckRunnerWinCondition();
}

[Server]
private void CheckRunnerWinCondition()
{
    int totalRunners  = _startingRunnerCount.Value;
    int extracted     = _extractedRunners.Value;
    int dead          = totalRunners - _aliveRunnerCount.Value - extracted;

    // Win if: all alive Runners have either extracted or died — and at least 1 extracted
    if (_aliveRunnerCount.Value == 0 && extracted > 0)
        EndMatch(MatchResult.RunnersWin);
}
```

**MVP win condition:** At least 1 Runner extracts AND no Runners are still alive (everyone resolved their fate). This allows partial wins: in a 3-Runner game, 2 extract and 1 dies → Runners win (2/3 extracted).

Post-MVP: tunable threshold — require majority (2/3), all, etc. Or partial-win scoring tiers.

### 7.2 Eye Victory Conditions (for reference — lives in SYS-22)

- All Runners dead and 0 extracted → Eye wins cleanly
- Match timer expires (Overtime collapse) → Eye wins by survival

These are triggered by `GameManager.NotifyRunnerKilled()` (SYS-06) and the Overtime phase respectively — not by this system.

### 7.3 Match Result Data (used by SYS-23 Scoring)

```csharp
public struct MatchResult
{
    public MatchOutcome Outcome;            // RunnersWin | EyeWin
    public int ExtractedRunnerCount;
    public int KilledRunnerCount;
    public float[] RunnerPersonalLootValues;  // from PlayerInventory._personalLootValue
    public float TotalExtractedValue;         // sum of all successfully extracted loot
}
```

This struct is passed to `GameManager.OnMatchEnded` listeners (SYS-23, SYS-29 Results Screen).

---

## 8. Extraction Crisis Phase Integration

When the first Runner begins channeling (or when match timer crosses the threshold — SYS-22 decides which), the match enters `MatchPhase.ExtractionCrisis`. From `ExtractionManager`:

```csharp
[Server]
private void ServerBeginExtraction(NetworkConnection conn)
{
    // Notify GameManager that extraction is happening
    // GameManager decides whether to trigger ExtractionCrisis phase
    GameManager.Instance.NotifyExtractionAttemptStarted();
    // ...begin channel logic...
}
```

`GameManager.NotifyExtractionAttemptStarted()` can use this to flip the match into ExtractionCrisis (accelerating Eye Dread regen, changing music — SYS-22 contract). This system just fires the signal.

---

## 9. Networked State Summary

| SyncVar / SyncList | Component | Purpose | Authority |
|-------------------|-----------|---------|-----------|
| `_carriedItems` (SyncList) | PlayerInventory | All clients see Runner's inventory | Server-set |
| `_personalLootValue` | PlayerInventory | Cumulative score tally | Server-set |
| `_isChanneling` | RunnerController | Channel in progress — Eye alert trigger | Server-set |
| `_extractedRunners` | GameManager | Count for win condition check | Server-set |

No WorldItem needs a SyncVar — its existence in the scene IS its state. When despawned by server, it disappears everywhere.

---

## 10. Tuning Values in GlobalRules_SO

```csharp
[System.Serializable]
public struct ExtractionConfig
{
    public float interactRadius;          // 2.5m — altar proximity required
    public float pickupRadius;            // 1.5m — item pickup proximity
    public float baseChannelTime;         // 3.0s — minimum channel time
    public float weightChannelFactor;     // 0.4s per kg carried
    public float channelTickRate;         // 0.1s — channel validation frequency
    public float dropForwardImpulse;      // 1.5 m/s — dropped item forward toss
}
```

---

## 11. Integration Contracts

| System | Contract |
|--------|----------|
| **SYS-04 Weight/Burden** | `WeightComponent.AddWeight(kg)` on pickup; `RemoveWeight(kg)` on drop. Weight drives movement (speed multipliers, ability gating) — no extra calls needed from this system |
| **SYS-14 Item Database** | `ItemDatabase.Instance.Get(itemId)` returns `ItemData` with `LootValue`, `WeightKg`, `DisplayName`, `WorldItemPrefab`. This system reads but never writes the SO |
| **SYS-22 Match State Machine** | `GameManager.NotifyRunnerExtracted(conn)` → triggers win check; `GameManager.NotifyExtractionAttemptStarted()` → may trigger ExtractionCrisis phase; listen to `OnPhaseChanged` to disable extraction during Grace/PostMatch |
| **SYS-26 Runner HUD** | Reads `PlayerInventory._carriedItems`, `_personalLootValue`; reads `RunnerInteractor.NearestInteractable` for prompt; reads `ExtractionManager.ChannelProgress` for extraction bar |
| **SYS-27 Eye HUD** | Reads any `RunnerController._isChanneling == true` → shows EXTRACTION ALERT |

---

## 12. Prototype Test Checklist

| Test | Pass Condition |
|------|---------------|
| Item spawns at match start | WorldItem NetworkObjects appear at all ItemSpawnPoints |
| Pickup: proximity triggers prompt | E prompt appears when Runner within 1.5m |
| Pickup: adds weight | WeightComponent.CurrentWeight increases by item.WeightKg |
| Pickup: despawns world item | WorldItem gone from all clients' views |
| Drop: reduces weight | WeightComponent.CurrentWeight decreases |
| Drop: personal loot value unchanged | _personalLootValue unchanged after drop |
| Drop: item re-spawns in world | WorldItem appears at Runner's feet on all clients |
| Altar reject: no items | F press at altar with empty inventory → rejected |
| Altar reject: too far | F press > 2.5m from altar → rejected |
| Channel starts | _isChanneling SyncVar becomes true; Eye HUD shows alert |
| Channel cancel: move away | Walk beyond 2.5m during channel → cancelled, resets |
| Channel complete: Runner extracts | Runner despawned, extracted count increments |
| Win condition: last Runner extracts | Match ends in RunnersWin |
| Win condition: all killed, 0 extracted | Eye win (SYS-22 owns this, but verify NotifyRunnerKilled path) |
| Heavy loot = longer channel | 20kg load takes ~11s vs 3s for minimal load |
| Eye sees channel alert | Eye HUD extraction alert fires on channel start |
| Grace phase: extraction blocked | F press during Grace → no channel, phase gate enforced |

---

## 13. MVP Scope

- [x] WorldItem NetworkObject (ItemId field, ServerPickUp)
- [x] ItemSpawnPoint + ItemSpawner (MapGenerator populates loot slots at generation time)
- [x] Pickup: proximity check (1.5m), E key, ServerRpc validation, inventory add
- [x] Drop: G key, ServerRpc, inventory remove, re-spawn WorldItem at feet
- [x] PlayerInventory: SyncList carriedItems, SyncVar personalLootValue, AddItem/RemoveItem
- [x] Loot value permanent-on-drop rule (weight decreases, value tally does not)
- [x] Extraction altar: F key, 2.5m radius, requires items, weight-scaled channel time
- [x] Channel time formula: 3.0s + (0.4s × kg) — range 3–11s
- [x] Channel interruption: move away, eliminated, cancel
- [x] _isChanneling SyncVar on RunnerController → Eye HUD alert trigger
- [x] ExtractionSuccess: inventory clear, GameManager notify, Runner despawn
- [x] Win condition: NotifyRunnerExtracted → check all Runners resolved → RunnersWin
- [x] ExtractionCrisis phase signal: NotifyExtractionAttemptStarted
- [x] All tuning in GlobalRules_SO.ExtractionConfig
- [x] 17-test prototype checklist
- [ ] Pickup prompt UI (SYS-26 dependency)
- [ ] Extraction progress bar (SYS-26 dependency)
- [ ] Eye extraction alert HUD (SYS-27 dependency)
- [ ] Altar VFX + SFX (SYS-32 + SYS-19 dependencies)
- [ ] Shrine spending of loot (SYS-15 Alpha dependency)
- [ ] Loot theft between Runners (SYS-17 Beta dependency)
- [ ] Multiple altars / unlockable altars (Alpha or Beta)

---

*System: SYS-09 — Loot & Extraction Loop*  
*GDD Version: 1.0*  
*Created: 2026-06-23*  
*Depends on: [sys-04-weight-burden.md](sys-04-weight-burden.md), [sys-14-item-database.md](sys-14-item-database.md), [sys-22-match-state-machine.md](sys-22-match-state-machine.md)*  
*Status: Complete — ready for implementation*
