# GDD: Match State Machine

**System ID:** SYS-22  
**Design Order:** 4 of 33  
**Priority:** MVP — Foundation Layer  
**Dependencies:** SYS-33 (FishNet Replication — NetworkBehaviour patterns), SYS-14 (Item Database — GlobalRules_SO timing values)  
**Dependents:** SYS-08 (Trap/Dread Economy), SYS-09 (Loot & Extraction Loop), SYS-03 (Minion Controller), SYS-13 (Overtime Collapse), SYS-18 (Minion System), SYS-21 (Lobby & Matchmaking), SYS-23 (Scoring & Results), SYS-26 (Runner HUD), SYS-27 (Eye HUD), SYS-31 (Replay System)  
**Status:** Designed  

---

## 1. Purpose

The Match State Machine is the **authoritative timeline** of every IStalk match. It defines what phase the game is in, fires the events that tell all other systems to activate or deactivate, checks win conditions every tick, and ultimately declares the match result.

Every system in this project that gates its behavior on match state subscribes to `GameManager.OnPhaseChanged`. This GDD locks those states, their triggers, and their contracts before any dependent system is written.

**Authority:** Runs server-side only. State is synced to all clients via `SyncVar<MatchPhase>`. Clients react to phase changes through the event; they never transition phases themselves.

---

## 2. Phase Model

### 2.1 Design Decision: Fewer Phases, Cleaner State Machine

The game concept document describes 6 time windows (Grace, Hunt Begins, Mid-Game, Threshold Race, Extraction Crisis, Overtime). These are **not** all distinct states — some are time windows within a single Hunt phase that affect tuning, not behavior.

The actual state machine has **5 states**:

| Phase | Enters When | Exits When |
|-------|------------|------------|
| `Grace` | Match starts | Grace timer expires (30s) |
| `Hunt` | Grace ends | Any runner hits threshold OR match time limit |
| `ExtractionCrisis` | Any runner hits extraction threshold | All living runners dead OR all living runners extracted OR overtime timer |
| `Overtime` | `matchTimeLimit` reached with no resolution | All runners dead OR forced-end timer |
| `PostMatch` | Match result declared | Scene unloads |

The concept's "Mid-Game" and "Threshold Race" descriptions are implemented as **`MatchTimeWindow`** — a HUD-only computed enum derived from the timer. They drive visual urgency in the Runner and Eye HUDs but do NOT change system behavior.

### 2.2 MatchPhase Enum

```csharp
public enum MatchPhase
{
    Grace,             // Eye vision obscured; Runners orient and grab early loot
    Hunt,              // Main gameplay; Eye has full vision; full Dread regeneration
    ExtractionCrisis,  // At least one Runner hit threshold; altars are live
    Overtime,          // Match time limit hit; map collapse begins
    PostMatch          // Match ended; controls frozen; results shown
}
```

### 2.3 MatchTimeWindow Enum (HUD Only — Not State Machine)

```csharp
// Computed from _matchTimer.Value — NOT a SyncVar, derived client-side in HUD scripts
public enum MatchTimeWindow
{
    Grace,          // 0:00-0:30
    EarlyHunt,      // 0:30-5:00
    MidGame,        // 5:00-10:00
    ThresholdRace,  // 10:00-14:00
    ExtractionCrisis, // altars live (phase-driven, not timer-driven)
    Overtime        // 14:00+
}

// Usage in HUD — purely cosmetic urgency display:
public static MatchTimeWindow GetTimeWindow(MatchPhase phase, float timer)
{
    if (phase == MatchPhase.Grace) return MatchTimeWindow.Grace;
    if (phase == MatchPhase.ExtractionCrisis) return MatchTimeWindow.ExtractionCrisis;
    if (phase == MatchPhase.Overtime) return MatchTimeWindow.Overtime;
    // Hunt phase sub-windows:
    if (timer < 300f) return MatchTimeWindow.EarlyHunt;
    if (timer < 600f) return MatchTimeWindow.MidGame;
    return MatchTimeWindow.ThresholdRace;
}
```

---

## 3. Phase Definitions

### 3.1 Grace Phase

**Duration:** `GlobalRules_SO.gracePeriodDuration` (default: 30 seconds)  
**Entry trigger:** `OnStartServer()` — begins automatically  
**Exit trigger:** Timer expires

**What it does:**
- Eye camera vision is obscured (dark overlay or blurred view — see SYS-02 Eye Camera GDD for implementation)
- Eye laser is **disabled** — cannot fire during grace
- Eye trap placement is **disabled**
- Dread regeneration is **paused**
- Runners can move freely, pick up items, learn the map
- Background music: ambient, low tension

**Why grace exists:** The asymmetry is brutal without it. Runners spawn at random points and have zero information — the Eye has full-map visibility from second one. Without 30 seconds to orient, the first match is an instant-kill tutorial that teaches nothing. Grace also gives Runners a small stockpile of loot before pressure begins, raising the weight tradeoff stakes immediately.

**Exit sequence:**
```
Timer hits 0
    → ObserversGraceEnding() fires (3-second warning)
    → After 3 seconds: TransitionTo(MatchPhase.Hunt)
```

### 3.2 Hunt Phase

**Duration:** Until extraction threshold or match time limit  
**Entry trigger:** Grace phase end  
**Exit triggers:**
- Any runner's `_lootValue >= GlobalRules_SO.extractionThreshold` → `ExtractionCrisis`
- `_matchTimer >= GlobalRules_SO.matchTimeLimit` → `Overtime`

**What it does:**
- Eye camera: full vision, no overlay
- Eye laser: **enabled**
- Eye traps: **enabled**
- Dread: regenerating at base rate
- Sound radius system: active (runner sounds feed Dread)
- Weight system: fully active

**Dread regeneration rate scales during Hunt** — this is tuning, not a state change:

| Time Window | Dread Regen Multiplier | Design intent |
|------------|----------------------|---------------|
| EarlyHunt (0:30-5:00) | 0.7x | Runners need time to pick up loot before trap spam |
| MidGame (5:00-10:00) | 1.0x | Full pressure |
| ThresholdRace (10:00-14:00) | 1.3x | Eye gets stronger as runners approach extraction |

These multipliers live in `GlobalRules_SO` as `dreadRegenByTimeWindow[]` — NOT hardcoded in the state machine. The state machine exposes `CurrentDreadRegenMultiplier` as a property that the Dread Economy system (SYS-08) reads.

### 3.3 Extraction Crisis Phase

**Entry trigger:** First runner hits extraction threshold  
**Exit triggers:**
- All living runners dead (Eye wins)
- All living runners extracted (Runner victory / partial)
- `_matchTimer >= GlobalRules_SO.matchTimeLimit` (no more extensions, go to Overtime)

**What it does:**
- **Altars activate** — broadcast `ObserversAltar Activated()` to all clients
- **Eye gets a global ping** — direction + room location of each active altar
- **All HUDs update** — extraction progress bars appear
- Dread regen multiplier: 1.5x (Eye at maximum power)
- Music transitions to high-tension extraction theme
- Runners can now begin channeling at altars

**Why this is a separate phase from Hunt:** The altar activation is the dramatic pivot of the match. Going from "hunt and gather" to "do-or-die extraction" needs to be a clear, loud event felt by all players. Having it as a named phase lets every system know to escalate behavior.

### 3.4 Overtime Phase

**Entry trigger:** `_matchTimer >= GlobalRules_SO.matchTimeLimit` (default: 18 minutes) with unresolved match  
**Exit triggers:**
- All remaining runners killed → Eye wins
- `OvertimeCollapse` seals all but 1 room → forced confrontation → last runner alive result

**What it does:**
- Notifies SYS-13 (Overtime Collapse) to begin room sealing sequence
- No new altars activate (already live from ExtractionCrisis, or if in Hunt still → altars activate NOW)
- Music: urgent, percussive, no mercy

**Design note:** Overtime only happens if runners are slow or the Eye is ineffective. It is not a primary flow — it's the game's safety valve against stalemates (anti-pillar: "We will NOT create stalemates"). Most matches should resolve in ExtractionCrisis.

### 3.5 PostMatch Phase

**Entry trigger:** Win condition detected  
**Duration:** Results display time (~10 seconds), then lobby transition

**What it does:**
- Freezes all player input (server signals all clients via `ObserversMatchEnd`)
- Server calculates match result (`MatchResult` enum)
- Scoring system (SYS-23) computes rewards
- Results screen displayed to all clients
- After delay: all clients return to lobby (`RelayClient.ReturnToLobby()`)

---

## 4. Transition Diagram

```
                    [Match starts]
                         │
                    ┌────▼─────┐
                    │  Grace   │  30s — Eye blind, no pressure
                    └────┬─────┘
                         │ timer expires
                    ┌────▼─────┐
                    │   Hunt   │  main game
                    └──┬──┬────┘
                       │  │
     runner hits threshold  match timer expires
                       │  │
               ┌───────▼┐ └────────┐
               │Extraction│        │
               │ Crisis   │      ┌─▼─────┐
               └────┬─────┘      │Overtime│
                    │            └───┬────┘
              ┌─────┘                │
              │   match timer/       │
              │   all rooms sealed   │
              └──────────────────────┘
                          │
                     win condition
                          │
                   ┌──────▼──────┐
                   │  PostMatch  │
                   └─────────────┘
```

---

## 5. Win Conditions

Win conditions are evaluated every server tick inside `Update()` on `GameManager`. The server is the ONLY authority on win conditions — clients never self-report wins.

### 5.1 MatchResult Enum

```csharp
public enum MatchResult
{
    None,           // Match in progress
    EyeVictory,     // All runners eliminated, none extracted
    RunnerVictory,  // All surviving runners extracted (or all runners extracted)
    PartialVictory, // Mix: some extracted, some eliminated
    Timeout         // Overtime ended with no resolution — Eye wins by default
}
```

### 5.2 Win Condition Logic (Server Tick)

```csharp
// Called in Update() on server only
[Server]
private void CheckWinConditions()
{
    if (_matchPhase.Value == MatchPhase.PostMatch) return;

    int alive     = _aliveRunnerCount.Value;
    int extracted = _extractedRunnerCount.Value;
    int total     = _totalRunners;
    int dead      = total - alive - extracted;

    // Condition: All runners accounted for (alive=0 means all dead or extracted)
    if (alive > 0) return;  // match still live

    MatchResult result;
    if (extracted == 0)
        result = MatchResult.EyeVictory;       // everyone dead, none escaped
    else if (extracted == total)
        result = MatchResult.RunnerVictory;    // everyone extracted (no deaths)
    else
        result = MatchResult.PartialVictory;   // mix

    DeclareResult(result);
}
```

### 5.3 Special Case: Overtime Timeout

If Overtime sealing completes with runners still alive (very rare — Overtime Collapse should force resolution):

```csharp
[Server]
private void OnOvertimeForceEnd()
{
    // Called by SYS-13 when last room seals
    DeclareResult(
        _extractedRunnerCount.Value > 0
            ? MatchResult.PartialVictory
            : MatchResult.Timeout
    );
}
```

### 5.4 DeclareResult

```csharp
[Server]
private void DeclareResult(MatchResult result)
{
    _matchResult = result;  // server-only field, read by SYS-23
    TransitionTo(MatchPhase.PostMatch);
    ObserversMatchEnd(result);
}

[ObserversRpc]
private void ObserversMatchEnd(MatchResult result)
{
    // Fires on all clients including host-client
    OnMatchEnded?.Invoke(result);
    // Each system reacts: controls freeze, music fades, HUD transitions to results
}
```

---

## 6. GameManager NetworkBehaviour

`GameManager` is the single NetworkObject that owns the Match State Machine. It is spawned by the server at match start and persists until the scene unloads.

### 6.1 Full SyncVar Set

```csharp
public class GameManager : NetworkBehaviour
{
    public static GameManager Instance { get; private set; }

    // ── Synced to all clients ──────────────────────────────────────
    private readonly SyncVar<MatchPhase> _matchPhase =
        new(MatchPhase.Grace);

    private readonly SyncVar<float> _matchTimer =
        new(0f);

    private readonly SyncVar<int> _aliveRunnerCount =
        new(0);

    private readonly SyncVar<int> _extractedRunnerCount =
        new(0);

    // ── Server-only ────────────────────────────────────────────────
    private int   _totalRunners;
    private float _graceTimer;
    private bool  _extractionCrisisTriggered;
    private MatchResult _matchResult = MatchResult.None;

    // ── Readable properties for other systems ──────────────────────
    public MatchPhase Phase           => _matchPhase.Value;
    public float      MatchTimer      => _matchTimer.Value;
    public bool       IsGrace         => _matchPhase.Value == MatchPhase.Grace;
    public bool       IsHunt          => _matchPhase.Value == MatchPhase.Hunt;
    public bool       IsExtractionCrisis => _matchPhase.Value == MatchPhase.ExtractionCrisis;
    public bool       IsOvertime      => _matchPhase.Value == MatchPhase.Overtime;
    public bool       IsPostMatch     => _matchPhase.Value == MatchPhase.PostMatch;

    // Dread regen multiplier — read by SYS-08 every tick
    public float CurrentDreadRegenMultiplier =>
        _rules.GetDreadMultiplierForTimer(_matchTimer.Value, _matchPhase.Value);

    // ── Events (all clients subscribe) ────────────────────────────
    public event Action<MatchPhase, MatchPhase> OnPhaseChanged;
    public event Action<MatchResult>             OnMatchEnded;
    public event Action                          OnGraceEnding;   // 3s warning
    public event Action                          OnAltarsActivated;

    [SerializeField] private GlobalRules_SO _rules;
}
```

### 6.2 Lifecycle

```csharp
public override void OnStartNetwork()
{
    base.OnStartNetwork();
    Instance = this;
    _matchPhase.OnChange += (prev, next, asServer) =>
        OnPhaseChanged?.Invoke(prev, next);
}

public override void OnStartServer()
{
    base.OnStartServer();
    _totalRunners = GetRegisteredRunnerCount();  // from PlayerSpawner
    _graceTimer   = _rules.gracePeriodDuration;
    // State machine begins — Update() handles the rest
}

[Server]
private void Update()
{
    if (_matchPhase.Value == MatchPhase.PostMatch) return;

    _matchTimer.Value += Time.deltaTime;

    switch (_matchPhase.Value)
    {
        case MatchPhase.Grace:           TickGrace();    break;
        case MatchPhase.Hunt:            TickHunt();     break;
        case MatchPhase.ExtractionCrisis: TickExtraction(); break;
        case MatchPhase.Overtime:        TickOvertime(); break;
    }

    CheckWinConditions();
}
```

### 6.3 Phase Tick Methods

```csharp
[Server]
private void TickGrace()
{
    _graceTimer -= Time.deltaTime;

    if (_graceTimer <= 3f && !_graceWarningSent)
    {
        _graceWarningSent = true;
        ObserversGraceEnding();
    }

    if (_graceTimer <= 0f)
        TransitionTo(MatchPhase.Hunt);
}

[Server]
private void TickHunt()
{
    // Check extraction threshold — polled here since RunnerController
    // fires a ServerRpc when threshold is hit, but we verify server-side too
    if (!_extractionCrisisTriggered && AnyRunnerAtThreshold())
    {
        _extractionCrisisTriggered = true;
        TransitionTo(MatchPhase.ExtractionCrisis);
        return;
    }

    if (_matchTimer.Value >= _rules.matchTimeLimit)
        TransitionTo(MatchPhase.Overtime);
}

[Server]
private void TickExtraction()
{
    if (_matchTimer.Value >= _rules.matchTimeLimit)
        TransitionTo(MatchPhase.Overtime);
    // Win conditions checked in CheckWinConditions()
}

[Server]
private void TickOvertime()
{
    // Overtime Collapse system (SYS-13) drives its own sealing logic.
    // GameManager just waits for DeclareResult() to be called,
    // either by win conditions or by OvertimeCollapse.OnForceEnd.
}
```

### 6.4 Runner Notification Methods (Called by RunnerController)

```csharp
// Called by RunnerController when a Runner extracts (SYS-09)
[Server]
public void NotifyRunnerExtracted(NetworkConnection conn)
{
    _aliveRunnerCount.Value--;
    _extractedRunnerCount.Value++;
}

// Called by GameManager when Runner is killed (SYS-06 Eye Laser)
[Server]
public void NotifyRunnerKilled(NetworkConnection conn)
{
    _aliveRunnerCount.Value--;
    // Minion spawn is handled by SYS-18 after this notification
}

// Called by PlayerSpawner when all Runners have spawned
[Server]
public void NotifyAllSpawned(int runnerCount)
{
    _aliveRunnerCount.Value = runnerCount;
    _totalRunners = runnerCount;
}
```

---

## 7. Phase Transition Contract

Every system that depends on SYS-22 must subscribe to `GameManager.OnPhaseChanged` in `OnStartNetwork()` and react accordingly. This is the **binding contract** — systems MUST NOT poll `GameManager.Phase` in Update for behavior gating. Use event subscriptions.

```csharp
// Correct pattern (used in SYS-08, SYS-09, etc.)
public override void OnStartNetwork()
{
    base.OnStartNetwork();
    GameManager.Instance.OnPhaseChanged += OnMatchPhaseChanged;
}

private void OnMatchPhaseChanged(MatchPhase prev, MatchPhase next)
{
    // React to the transition
    if (next == MatchPhase.Hunt)
        EnableDreadRegeneration();
    if (next == MatchPhase.PostMatch)
        DisableAllEconomySystems();
}

// Wrong pattern — polling every frame wastes CPU and misses transitions
private void Update()
{
    if (GameManager.Instance.Phase == MatchPhase.Hunt)  // ← DO NOT DO THIS
        RegenerateDread();
}
```

### 7.1 What Each Dependent System Does Per Transition

| Phase Change | System | Reaction |
|-------------|--------|---------|
| Grace→Hunt | SYS-02 Eye Camera | Remove vision obscuring overlay |
| Grace→Hunt | SYS-06 Eye Laser | Enable laser charge input |
| Grace→Hunt | SYS-08 Trap/Dread Economy | Begin Dread regeneration |
| Hunt→ExtractionCrisis | SYS-09 Loot & Extraction | Activate all altars |
| Hunt→ExtractionCrisis | SYS-27 Eye HUD | Show global altar pings |
| Hunt→ExtractionCrisis | SYS-26 Runner HUD | Show extraction progress bar |
| Any→Overtime | SYS-13 Overtime Collapse | Begin room sealing sequence |
| Any→PostMatch | SYS-23 Scoring & Results | Calculate and display results |
| Any→PostMatch | SYS-26/27 HUD | Freeze HUD, show results overlay |
| Any→PostMatch | SYS-01/02 (all controllers) | Disable player input |

---

## 8. Timing Values (GlobalRules_SO)

All timing is sourced from `GlobalRules_SO` — never hardcoded in GameManager. These are the default values:

```csharp
// In GlobalRules_SO:
public float gracePeriodDuration  = 30f;     // seconds
public float matchTimeLimit       = 1080f;   // 18 minutes
public float altarChannelTime     = 6f;      // seconds to channel (for SYS-09)
public float overtimeWarningTime  = 30f;     // seconds before Overtime begins, warn players

// Dread regen multipliers indexed by MatchTimeWindow (length 4, indices 0-3)
// [0]=EarlyHunt, [1]=MidGame, [2]=ThresholdRace, [3]=ExtractionCrisis/Overtime
public float[] dreadRegenByTimeWindow = { 0.7f, 1.0f, 1.3f, 1.5f };

// Convenience method used by GameManager.CurrentDreadRegenMultiplier
public float GetDreadMultiplierForTimer(float timer, MatchPhase phase)
{
    if (phase == MatchPhase.Grace)    return 0f;
    if (phase == MatchPhase.PostMatch) return 0f;
    if (phase == MatchPhase.ExtractionCrisis || phase == MatchPhase.Overtime)
        return dreadRegenByTimeWindow[3];

    // Hunt sub-windows
    if (timer < 300f)  return dreadRegenByTimeWindow[0];  // 0:30-5:00
    if (timer < 600f)  return dreadRegenByTimeWindow[1];  // 5:00-10:00
    return dreadRegenByTimeWindow[2];                      // 10:00+
}
```

---

## 9. Overtime Warning

30 seconds before `matchTimeLimit`, all players get a warning:

```csharp
[Server]
private void TickHunt()
{
    // ...existing code...

    float timeRemaining = _rules.matchTimeLimit - _matchTimer.Value;
    if (timeRemaining <= _rules.overtimeWarningTime && !_overtimeWarningSent)
    {
        _overtimeWarningSent = true;
        ObserversOvertimeWarning(Mathf.RoundToInt(timeRemaining));
    }
}

[ObserversRpc]
private void ObserversOvertimeWarning(int secondsRemaining)
{
    // HUD systems display "OVERTIME IN X SECONDS" alert
    OnOvertimeWarning?.Invoke(secondsRemaining);
}
```

---

## 10. Extraction Crisis — Altar Activation Sequence

When Hunt → ExtractionCrisis fires, the server activates all altar NetworkObjects and pings the Eye:

```csharp
[Server]
private void TransitionTo(MatchPhase next)
{
    var prev = _matchPhase.Value;
    _matchPhase.Value = next;
    ObserversPhaseTransition(prev, next);

    // Phase-specific side effects on the server
    if (next == MatchPhase.ExtractionCrisis)
        ActivateAllAltars();
}

[Server]
private void ActivateAllAltars()
{
    var altars = FindObjectsByType<ExtractionAltar>(FindObjectsSortMode.None);
    foreach (var altar in altars)
    {
        altar.Activate();  // sets altar._isActive SyncVar = true
        // All clients see the altar light up via SyncVar
    }

    // ObserversRpc fires the HUD event + Eye ping
    ObserversAltarsActivated(altars.Length);
}

[ObserversRpc]
private void ObserversAltarsActivated(int count)
{
    OnAltarsActivated?.Invoke();  // HUD systems display extraction UI
    AudioManager.Instance.PlayGlobal(SFX.AltarsActivated);
}
```

---

## 11. Player Input Freeze at PostMatch

When the match ends, all player input must stop immediately. This is done via `ObserversMatchEnd`:

```csharp
[ObserversRpc]
private void ObserversMatchEnd(MatchResult result)
{
    OnMatchEnded?.Invoke(result);

    // If this is the local player's controller, disable input
    // Each controller (RunnerController, EyeController) subscribes to OnMatchEnded
    // and calls _inputReader.Disable() on IsOwner check
}
```

Each controller handles its own input disable — GameManager doesn't reach into controller scripts directly.

---

## 12. Scene Transition After PostMatch

After the results screen, everyone returns to the lobby. This is handled by SYS-21 (Lobby & Matchmaking), not the Match State Machine. GameManager fires `OnMatchEnded`, and the Lobby system listens and calls `RelayClient.ReturnToLobby()` (host only) after the results delay:

```csharp
// In LobbyManager (SYS-21):
private IEnumerator ReturnToLobbyAfterDelay()
{
    yield return new WaitForSeconds(_rules.resultsDisplayTime);  // ~10 seconds
    if (RelayClient.Instance.IsHost)
        RelayClient.Instance.ReturnToLobby();
    // All clients transition back — relay broadcasts lobby_state reset
}
```

---

## 13. Edge Cases

| Scenario | Behaviour |
|----------|-----------|
| Eye-host disconnects mid-match | Relay removes them from room, broadcasts `lobby_state`. `GameManager.Update()` stops running — FishNet server is gone. All clients receive disconnect event. Lobby system returns remaining clients to main menu. No match result recorded. Post-launch: host migration. |
| Runner disconnects mid-match | `OnStopClient` fires. Server decrements `_aliveRunnerCount`. Win condition is re-evaluated. If last Runner disconnects, Eye wins by default. |
| All runners hit threshold simultaneously | First `ServerRpc` call to `NotifyRunnerAtThreshold()` wins the race — only one `TransitionTo(ExtractionCrisis)` fires. `_extractionCrisisTriggered` flag prevents double-trigger. |
| Runner extracts in Grace phase | Physically impossible — altars only activate at ExtractionCrisis. Runner cannot channel until altar is active. |
| Match timer hits limit during ExtractionCrisis | `TickExtraction()` catches it → `TransitionTo(Overtime)`. Altars remain active. Overtime Collapse continues alongside extraction attempts. |
| No runners survive but Eye also disconnects | Relay cleans up. No result recorded. Edge case logged server-side only. |

---

## 14. MVP Scope

- [x] MatchPhase enum (Grace, Hunt, ExtractionCrisis, Overtime, PostMatch) defined
- [x] SyncVar<MatchPhase> synced to all clients
- [x] Timer-driven Grace → Hunt transition
- [x] Event-based runner threshold detection → ExtractionCrisis
- [x] Match time limit → Overtime
- [x] Win condition detection (EyeVictory, RunnerVictory, PartialVictory, Timeout)
- [x] `OnPhaseChanged` event for all 9 dependent systems
- [x] `OnMatchEnded` event with result
- [x] Altar activation sequence at ExtractionCrisis
- [x] Dread regen multiplier table (read by SYS-08)
- [x] Player input freeze at PostMatch
- [x] Overtime warning (30s before limit)
- [x] Edge cases: disconnect handling, double-trigger guards
- [ ] MatchTimeWindow HUD display (owned by SYS-26 Runner HUD + SYS-27 Eye HUD — they read the helper function)
- [ ] Overtime Collapse sealing logic (owned by SYS-13)
- [ ] Results display + currency calculation (owned by SYS-23)
- [ ] Scene transition after results (owned by SYS-21)

---

*System: SYS-22 — Match State Machine*  
*GDD Version: 1.0*  
*Created: 2026-06-23*  
*Depends on: [sys-33-fishnet-replication.md](sys-33-fishnet-replication.md), [sys-01-item-database.md](sys-01-item-database.md)*  
*Status: Complete — ready for implementation*
