# GDD: FishNet Replication Layer

**System ID:** SYS-33  
**Design Order:** 3 of 33  
**Priority:** MVP — Foundation Layer  
**Dependencies:** SYS-20 (Relay Transport — the relay IS the network pipe)  
**Dependents:** Every NetworkBehaviour in the codebase: SYS-01 (item sync), SYS-04 (weight sync), SYS-05 (LoS state), SYS-06 (laser), SYS-09 (loot/extraction), SYS-21 (lobby→game transition), SYS-22 (match state), SYS-18 (minions), and all others  
**Status:** Designed  

---

## 1. Purpose

This GDD defines the FishNet contracts every NetworkBehaviour in IStalk must follow. It answers three questions upfront, before any gameplay code is written:

1. **Who owns what?** Authority model — which machine runs the server, which data is server-authoritative vs. client-predicted
2. **How does data sync?** Patterns for SyncVar, RPC, NetworkTransform — when to use each
3. **How does Synapse connect through the relay?** The transport-relay integration problem

Getting this wrong cascades everywhere. A NetworkBehaviour written with the wrong authority assumption requires a full rewrite, not a tweak.

---

## 2. Authority Model

### 2.1 Host Identity

From SYS-20: the player who creates the room becomes `hostId`. That player runs **FishNet as a host (server + client simultaneously)**. All other players run as clients only.

**The Eye player is always the host.** `rooms.js` assigns the Eye role to the first joiner, who also becomes host. This is intentional: the Eye's server authority aligns with them being the source of truth for visibility, kills, and trap placement.

```
┌─────────────────────────────────────────────────────────┐
│  Eye Player Machine                                      │
│  ┌─────────────────────────────────────────────────┐   │
│  │  FishNet Server  (authoritative game state)     │   │
│  │  FishNet Client  (Eye's own local visuals)      │   │
│  └─────────────────────────────────────────────────┘   │
│  RelayClient  →  relay WebSocket                         │
└─────────────────────────────────────────────────────────┘
              ↕ relay forwards binary frames ↕
┌─────────────────────────────────────────────────────────┐
│  Runner 1 Machine                                        │
│  ┌──────────────────────────────────────────────────┐  │
│  │  FishNet Client only                              │  │
│  └──────────────────────────────────────────────────┘  │
│  RelayClient  →  relay WebSocket                         │
└─────────────────────────────────────────────────────────┘
```

### 2.2 What the Server Owns

The FishNet server (Eye's machine) is authoritative for:

| Data | Why server-owned |
|------|-----------------|
| Runner positions (ground truth) | Prevents cheating, resolves prediction errors |
| Weight totals | Cheating protection — clients never self-report weight |
| Loot item spawn positions | Deterministic world state |
| Loot pickup confirmation | Prevents two Runners claiming the same item |
| Eye laser hits | Server validates the SphereCast, not the Eye's client |
| Trap placement | Eye is the host, so this is local — but still goes through server path |
| Extraction altar channeling | Server owns the channel timer and extraction validation |
| Match phase transitions | Server owns the Match State Machine (SYS-22) |
| Runner death | Server calls `ServerManager.Despawn()` on vaporized Runners |
| Minion role assignment | Server re-spawns dead Runner as Minion NetworkObject |
| Dread economy | Server owns Dread values — client sends "spend Dread on trap" RPCs |

### 2.3 What Clients Predict (Client-Side Prediction)

Runner movement is the one area where waiting for server round-trip is unacceptable — parkour flow state (Pillar 1) requires sub-frame movement response. The Runner's **own movement** is client-predicted:

- Client applies input locally on the same frame (instant visual response)
- Client sends input to server via `ServerRpc`
- Server simulates the same physics and sends authoritative position
- If server position diverges from client prediction, client reconciles (snaps/blends)

FishNet v4 handles this via the **Prediction** system (`PredictionManager`, `ReplicateData`, `ReconcileData`). The Runner Movement Controller GDD (SYS-04) owns the implementation details — this GDD defines that prediction IS used and what the authority contract is.

**Other players' movement** is NOT predicted on remote clients — it is interpolated from server-replicated positions using `NetworkTransform`.

---

## 3. Transport-Relay Integration

### 3.1 The Problem

Synapse is a UDP transport (`SynapseManager` opens a `UdpClient` socket). The relay is a WebSocket server. These are incompatible without a bridge.

### 3.2 Chosen Approach: Relay-Routed WebSocket Transport

Rather than using Synapse's raw UDP path, the game traffic is routed through the relay WebSocket using a **custom transport wrapper** or **Synapse's WebSocket mode** (if available in this version).

**Option A — Synapse WebSocket mode (preferred):**  
Inspect `SynapseConfig` for a WebSocket endpoint option. If Synapse supports connecting to a WebSocket relay, configure `_clientAddress` in `Synapse.cs` to point to the relay URL and set the appropriate transport mode. The relay's binary frame forwarding then carries Synapse packets verbatim.

**Option B — Custom relay transport (fallback):**  
Write a `RelayTransport : Transport` that implements FishNet's `Transport` abstract class. Instead of opening UDP sockets, `RelayTransport` routes all FishNet data through `RelayClient.SendGamePacket()` (SYS-20) and feeds inbound relay packets to FishNet's ingress path.

**Option C — Synapse + NAT punch (alternative, no relay for game traffic):**  
Use Synapse's built-in NAT traversal (`NatTraversalConfig`, `FullConeNatConfig`) to establish a direct UDP connection between players, using the relay only for the lobby phase. This eliminates the relay bottleneck for game traffic but requires NAT punch to succeed (fails on symmetric NAT).

**MVP Decision:** Prototype with **Option A** first (check if Synapse supports WebSocket relay). If not, fall back to **Option B**. Option C is deferred to post-MVP as a performance optimisation.

### 3.3 Sender ID Offset

When the relay prepends a 2-byte sender ID to inbound binary frames (per SYS-20 Section 4.2), the FishNet/Synapse ingress path must skip those 2 bytes. Handle this at the `RelayTransport` receive boundary — strip the sender ID header before passing the `ArraySegment<byte>` to FishNet, OR use it to map relay `playerId` to FishNet `connectionId`.

**Relay playerId → FishNet connectionId mapping:**

| Relay `playerId` | FishNet `connectionId` |
|-----------------|----------------------|
| `hostId` (e.g. 1) | `0` (server's self-connection, or `-1` for host-client) |
| Runner 1 (e.g. 2) | `1` |
| Runner 2 (e.g. 3) | `2` |

The mapping is established during `game_start` processing (SYS-21) and stored in a `Dictionary<int, int>` on `RelayClient`.

---

## 4. NetworkObject Catalogue

All spawnable objects require a `NetworkObject` component. Server spawns with `ServerManager.Spawn()`, server despawns with `ServerManager.Despawn()`. Never call `Destroy()` on a networked object.

| NetworkObject | Spawned By | Owned By | Despawned When |
|---------------|-----------|----------|---------------|
| `GameManager` | Server at match start | Server | Match ends |
| `EyeController` | Server at match start | Eye connection | Match ends |
| `RunnerController` | Server per Runner | Individual Runner | Runner dies or extracts |
| `MinionController` | Server on Runner death | Dead Runner's connection | Match ends |
| `LootItem` | Server during map gen | Server (no owner) | Picked up |
| `DroppedLootItem` | Server on Runner drop | Server | Picked up by any Runner |
| `ExtractionAltar` | Server during map gen | Server | Match ends |
| `TrapObject` | Server on Eye placement | Eye connection | Duration expires |
| `ShrineObject` | Server during map gen | Server | Match ends |
| `PropObject` (dynamic) | Server during map gen | Server | Destroyed by laser |

---

## 5. SyncVar Patterns

### 5.1 Rule

Use `SyncVar<T>` for **state that all clients must always reflect accurately**. Do not use for high-frequency per-frame data (position, rotation — use `NetworkTransform` or prediction for those).

### 5.2 Examples by System

```csharp
// GameManager — match state (SYS-22)
private readonly SyncVar<MatchPhase> _matchPhase = new(MatchPhase.Grace);
private readonly SyncVar<float>      _matchTimer  = new(0f);

// RunnerController — weight (SYS-04)
// Synced so Eye HUD and other Runners can read it
private readonly SyncVar<float>  _currentWeight   = new(0f);
private readonly SyncVar<WeightTier> _weightTier  = new(WeightTier.Naked);

// RunnerController — loot value (SYS-09)
private readonly SyncVar<int> _lootValue = new(0);

// EyeController — Dread (SYS-08)
// Only Eye and server need this, use ReadPermission.OwnerAndServer
private readonly SyncVar<float> _dread = new(
    0f,
    new SyncTypeSettings(ReadPermission.OwnerAndServer)
);

// LootItem — is picked up (SYS-09)
private readonly SyncVar<bool> _isPickedUp = new(false);

// ExtractionAltar — is active, is channeling (SYS-09)
private readonly SyncVar<bool>  _isActive      = new(false);
private readonly SyncVar<int>   _channelingById = new(-1); // connectionId or -1
private readonly SyncVar<float> _channelProgress = new(0f);
```

### 5.3 Change Callbacks

```csharp
// Subscribe in Awake — do NOT subscribe in Start (may miss initial sync)
private void Awake()
{
    _weightTier.OnChange += OnWeightTierChanged;
    _matchPhase.OnChange += OnMatchPhaseChanged;
}

private void OnWeightTierChanged(WeightTier prev, WeightTier next, bool asServer)
{
    // asServer == false on clients → update local visuals
    if (!asServer)
        _movementController.ApplyWeightTier(next);
}
```

---

## 6. RPC Patterns

### 6.1 ServerRpc — Client → Server

Used when a client needs to request a state change. Server validates, then updates SyncVars or fires ObserversRpc.

```csharp
// Runner requests loot pickup (SYS-09)
[ServerRpc]
private void ServerPickupLoot(NetworkObject lootObject)
{
    if (!lootObject.TryGetComponent<LootItem>(out var item)) return;
    if (item.IsPickedUp) return;  // already taken — reject

    item.SetPickedUp(true);       // updates SyncVar
    _currentWeight.Value += item.Data.weight;
    _lootValue.Value      += item.Data.lootValue;
    ServerManager.Despawn(lootObject);
}

// Runner requests shrine purchase (SYS-15)
[ServerRpc]
private void ServerPurchaseShrineItem(string itemId)
{
    var item = ItemDatabase.Instance.GetShrineItem(itemId);
    if (item == null || _lootValue.Value < item.lootCost) return;

    _lootValue.Value      -= item.lootCost;
    _currentWeight.Value  += item.weightDelta;
    ObserversGrantItem(itemId);  // tell all clients this Runner now has the item
}

// Eye places a trap (SYS-08)
[ServerRpc(RequireOwnership = true)]
private void ServerPlaceTrap(string trapId, Vector3 position)
{
    var trap = ItemDatabase.Instance.GetTrap(trapId);
    if (trap == null || _dread.Value < trap.dreadCost) return;

    _dread.Value -= trap.dreadCost;
    var obj = Instantiate(trap.placedPrefab, position, Quaternion.identity);
    ServerManager.Spawn(obj);
}
```

### 6.2 ObserversRpc — Server → All Clients

Used for events all clients need to react to (sounds, VFX, UI alerts).

```csharp
// Notify all clients that an altar activated (SYS-09)
[ObserversRpc]
private void ObserversAltarActivated(int altarIndex)
{
    // Runs on all clients including host-client
    HUDManager.Instance.ShowAltarAlert(altarIndex);
    AudioManager.Instance.PlayGlobal(SFX.AltarActivated);
}

// Notify all clients of a Runner elimination
[ObserversRpc]
private void ObserversRunnerEliminated(int connectionId)
{
    SFXManager.Instance.PlayAt(SFX.Vaporize, transform.position);
    VFXManager.Instance.SpawnAt(VFX.Vaporize, transform.position);
}

// Grant a shrine item to this Runner's observers (SYS-15)
[ObserversRpc]
private void ObserversGrantItem(string itemId)
{
    _inventory.Add(itemId);  // local inventory on all clients
}
```

### 6.3 TargetRpc — Server → One Client

Used for private information: Dread penalties, sacrifice ping reward, personal altar notifications.

```csharp
// Tell one Runner they've been sacrificed (betrayal, SYS-17)
[TargetRpc]
private void TargetNotifySacrificed(NetworkConnection conn)
{
    HUDManager.Instance.ShowWarning("You've been betrayed!");
}

// Tell the Eye their Dread was reduced (sacrifice ping reward, SYS-17)
[TargetRpc]
private void TargetDreadReduced(NetworkConnection conn, float amount)
{
    // Eye-only: Dread SyncVar already has ReadPermission.OwnerAndServer
    HUDManager.Instance.ShowDreadReduction(amount);
}
```

---

## 7. FishNet Lifecycle — Correct Usage

### 7.1 Callback Order

```
OnStartNetwork    → Runs on both server and client. Use for shared init.
OnStartServer     → Server only. Spawn child objects, initialize server state.
OnStartClient     → Client only. Subscribe to SyncVar callbacks, set up local camera.
OnOwnershipClient → Called on the new owner when ownership changes.
OnStopServer      → Cleanup on server side.
OnStopClient      → Cleanup on client side.
```

### 7.2 Critical Rules (from CLAUDE.md global rules)

```csharp
public class RunnerController : NetworkBehaviour
{
    private Camera _camera;
    private CharacterController _cc;

    public override void OnStartNetwork()
    {
        base.OnStartNetwork();
        // Safe: runs on everyone. Subscribe to SyncVars here.
        _weightTier.OnChange += OnWeightTierChanged;
    }

    public override void OnStartClient()
    {
        base.OnStartClient();
        // RULE: Always guard camera/input with IsOwner
        if (!IsOwner) return;

        _camera = Camera.main;
        _inputReader.Enable();
        CameraManager.Instance.AttachToRunner(this);
    }

    public override void OnStartServer()
    {
        base.OnStartServer();
        // RULE: Never use IsOwner inside OnStartServer
        // Use Owner.IsLocalClient to check if owner is also the server (host case)
        if (Owner.IsLocalClient)
        {
            // This is the host running as Eye — shouldn't happen for Runners
            // but guarded here for correctness
        }
        InitializeServerState();
    }

    private void Update()
    {
        // RULE: Guard all input and camera code with IsOwner
        if (!IsOwner) return;
        ProcessMovementInput();
    }

    private void OnDestroy()
    {
        // RULE: Never call Destroy() on NetworkObjects — use ServerManager.Despawn()
        // This OnDestroy only fires on non-networked child objects
    }
}
```

### 7.3 Spawn Timing — Wait for `LoadedStartScenes()`

Do not spawn player NetworkObjects in `OnStartServer` directly. Wait for the scene to finish loading:

```csharp
public class PlayerSpawner : NetworkBehaviour
{
    public override void OnStartServer()
    {
        base.OnStartServer();
        SceneManager.OnLoadEnd += OnSceneLoaded;
    }

    private void OnSceneLoaded(SceneLoadEndEventArgs args)
    {
        // Safe to spawn now — scene is fully loaded
        SpawnAllPlayers();
    }
}
```

---

## 8. NetworkTransform Configuration

Runner and Eye positions are synced via `NetworkTransform`. Settings critical for parkour feel:

| Setting | Value | Reason |
|---------|-------|--------|
| **Synchronize Position** | X, Y, Z | Full 3D position |
| **Synchronize Rotation** | Y only for Runner body | Eye and head rotation via prediction |
| **Interpolation** | Client interpolation ON | Smooth remote Runner movement |
| **Extrapolation** | OFF for Runners | Parkour makes extrapolation wrong too often |
| **Send Rate** | 30Hz minimum | Matches GlobalRules_SO tick rate target |
| **Compress Small Values** | ON | Saves bandwidth for slow-moving Runners |

**Eye camera** does NOT use NetworkTransform — Eye's view position is not synced to clients. The Eye's location is only needed for LoS raycasts, which run server-side.

---

## 9. Connection ID ↔ Role Mapping

At `game_start`, `GameManager` builds a lookup used throughout the match:

```csharp
public class GameManager : NetworkBehaviour
{
    // Populated on server from game_start data
    private Dictionary<int, PlayerRole> _connectionRoles = new();
    private int _eyeConnectionId;

    public override void OnStartServer()
    {
        base.OnStartServer();
        // Read role assignments from RelayClient.LastGameStartMsg
        // populated by SYS-21 (Lobby & Matchmaking) before FishNet starts
        foreach (var player in RelayClient.Instance.LastGameStartMsg.players)
        {
            int connId = RelayClient.Instance.RelayIdToFishNetId(player.id);
            _connectionRoles[connId] = player.role == "Eye"
                ? PlayerRole.Eye : PlayerRole.Runner;
        }
        _eyeConnectionId = RelayClient.Instance.RelayIdToFishNetId(
            RelayClient.Instance.LastGameStartMsg.hostId
        );
    }

    public PlayerRole GetRole(NetworkConnection conn) =>
        _connectionRoles.GetValueOrDefault(conn.ClientId, PlayerRole.Runner);
}
```

---

## 10. Tick Rate and Performance Budget

| Parameter | Value | Notes |
|-----------|-------|-------|
| **Server tick rate** | 30 Hz | Set in FishNet `NetworkManager` |
| **Client tick rate** | 30 Hz | Matches server — prediction runs at same rate |
| **Max simultaneous NetworkObjects** | ~60 | 5 players + ~30 loot items + ~15 props + 10 traps |
| **SyncVar dirty check** | Per-tick | FishNet only sends changed SyncVars |
| **RPC flood limit** | No explicit limit in MVP | Monitor in playtesting |

**Runner movement prediction specifically:** At 30Hz with ~150ms relay RTT, clients predict ~4–5 frames ahead. This is acceptable for a parkour game (Mirror's Edge runs fine at similar latency). If RTT exceeds 300ms consistently, movement will feel detached — the relay server location (Frankfurt, `18.192.166.221`) keeps EU latency low.

---

## 11. Dead Runner → Minion Transition

This is the most complex networked state transition in the game. The server must:

1. Kill Runner: `ServerManager.Despawn(runnerNetworkObject)` → Runner disappears on all clients
2. Broadcast elimination VFX via `ObserversRpc`
3. Wait one frame (allow despawn to propagate)
4. Spawn Minion: `ServerManager.Spawn(minionPrefab, ownerConnection: deadRunnerConnection)`
5. New Minion `NetworkObject` has ownership transferred to the dead Runner's connection
6. Dead Runner's client receives `OnOwnershipClient` — switch local camera to ghost view

```csharp
[Server]
private void ServerKillRunner(NetworkConnection runnerConn, NetworkObject runnerObj)
{
    ObserversRunnerEliminated(runnerConn.ClientId);
    ServerManager.Despawn(runnerObj);

    // Spawn minion next tick
    StartCoroutine(SpawnMinionNextTick(runnerConn));
}

private IEnumerator SpawnMinionNextTick(NetworkConnection conn)
{
    yield return null;  // one frame
    var minionObj = Instantiate(_minionPrefab);
    ServerManager.Spawn(minionObj, conn);  // conn = owner
}
```

---

## 12. MVP Scope

- [x] Authority model documented and agreed (host = Eye = server)
- [x] Transport-relay integration approach selected (Option A/B, prototype first)
- [x] Sender ID offset handling defined
- [x] Relay playerId → FishNet connectionId mapping defined
- [x] SyncVar patterns documented for all MVP systems
- [x] RPC patterns (ServerRpc, ObserversRpc, TargetRpc) documented with examples
- [x] FishNet lifecycle rules documented (OnStartServer/Client/Network, IsOwner guards)
- [x] NetworkObject catalogue (all spawnable prefabs listed)
- [x] NetworkTransform settings for Runner/Eye
- [x] Dead Runner → Minion transition sequence
- [ ] Client-side prediction implementation (owned by SYS-01 Runner Movement Controller GDD)
- [ ] Full relay transport wrapper implementation (owned by SYS-20 + prototype phase)
- [ ] Anti-cheat validation hooks (post-launch)

---

*System: SYS-33 — FishNet Replication Layer*  
*GDD Version: 1.0*  
*Created: 2026-06-23*  
*Depends on: [sys-20-relay-transport.md](sys-20-relay-transport.md)*  
*Status: Complete — ready for implementation*
