# ADR-0005: FishNet v4 Prediction Implementation Pattern

**Status:** Accepted  
**Date:** 2026-06-23  
**Deciders:** Mahmoud Aglan  
**Engine:** Unity 6 + FishNet v4  
**Risk Level:** ⚡ MEDIUM — FishNet v4 prediction API partially post-training-cutoff. Verify interface signatures against the actual FishNet v4 package before implementation.

---

## Context

Runner movement in IStalk must feel instant and fluid — parkour flow state (Pillar 1) requires sub-frame movement response. But movement must also be server-authoritative (ADR-0004) to prevent cheating. Client-side prediction resolves this tension: the Runner sees their movement immediately, and the server reconciles any divergence.

FishNet v4 provides a prediction system via:
- `IReplicateData` — input struct sent from owner to server each tick
- `IReconcileData` — authoritative state struct sent from server back to owner
- `[Replicate]` attribute — marks the method that processes input
- `[Reconcile]` attribute — marks the method that applies authoritative state
- `TimeManager.OnTick` — 30Hz (configurable) tick event that drives the prediction loop

The patterns below are derived from GDD SYS-01 (Runner Movement) and SYS-33 (FishNet Replication). The exact API method signatures should be verified against the FishNet v4 package in the project.

---

## Decision

**Use FishNet v4's built-in prediction system with `IReplicateData` / `IReconcileData` structs. 30Hz tick rate. 0.1m divergence tolerance before position snap.**

---

## Implementation Pattern

### Data Structs

```csharp
public struct RunnerReplicateData : IReplicateData
{
    public Vector3 MoveDirection;       // normalized WASD input
    public bool JumpPressed;
    public bool DashPressed;
    public bool CrouchPressed;
    public bool DropItemPressed;
    public uint Tick;                   // FishNet tick number

    // IReplicateData requires this
    private uint _tick;
    public void Dispose() { }
    public uint GetTick() => _tick;
    public void SetTick(uint value) => _tick = value;
}

public struct RunnerReconcileData : IReconcileData
{
    public Vector3 Position;
    public Vector3 HorizontalVelocity;
    public float VerticalVelocity;
    public MovementState State;
    public float CoyoteTimer;
    public bool AirDashAvailable;
    public bool DoubleJumpAvailable;
    public WeightTier CurrentTier;

    // IReconcileData requires this
    private uint _tick;
    public void Dispose() { }
    public uint GetTick() => _tick;
    public void SetTick(uint value) => _tick = value;
}
```

### Tick Loop

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

    public override void OnStartNetwork()
    {
        base.OnStartNetwork();
        TimeManager.OnTick += TimeManager_OnTick;
    }

    public override void OnStopNetwork()
    {
        base.OnStopNetwork();
        TimeManager.OnTick -= TimeManager_OnTick;
    }

    private void TimeManager_OnTick()
    {
        // Step 1: Apply reconcile data (server correction)
        Reconcile(default);

        // Step 2: Build and send input
        BuildInput(out RunnerReplicateData input);
        Move(input);
    }

    private void BuildInput(out RunnerReplicateData input)
    {
        input = default;
        if (!IsOwner) return;

        input.MoveDirection = _inputReader.MoveDirection;
        input.JumpPressed = _inputReader.JumpPressed;
        input.DashPressed = _inputReader.DashPressed;
        input.CrouchPressed = _inputReader.CrouchPressed;
        input.DropItemPressed = _inputReader.DropItemPressed;
    }

    [Replicate]
    private void Move(RunnerReplicateData input, ReplicateState state = ReplicateState.Invalid, Channel channel = Channel.Unreliable)
    {
        // Same deterministic simulation on both owner (prediction) and server (authority)
        ProcessMovementState(input);
        ApplyGravity();
        ApplyHorizontalMovement(input);
        _cc.Move((_hVelocity + Vector3.up * _vVelocity) * (float)TimeManager.TickDelta);
    }

    [Reconcile]
    private void Reconcile(RunnerReconcileData data, Channel channel = Channel.Unreliable)
    {
        // Applied on owner when server state arrives
        transform.position = data.Position;
        _hVelocity = data.HorizontalVelocity;
        _vVelocity = data.VerticalVelocity;
        _movementState = data.State;
        _coyoteTimer = data.CoyoteTimer;
        _airDashAvailable = data.AirDashAvailable;
        _doubleJumpAvailable = data.DoubleJumpAvailable;
    }
}
```

### Reconcile Data Construction (Server-Side)

The server builds reconcile data after processing the Move:

```csharp
// Called by FishNet internally after Move() on server
private RunnerReconcileData BuildReconcileData()
{
    return new RunnerReconcileData
    {
        Position = transform.position,
        HorizontalVelocity = _hVelocity,
        VerticalVelocity = _vVelocity,
        State = _movementState,
        CoyoteTimer = _coyoteTimer,
        AirDashAvailable = _airDashAvailable,
        DoubleJumpAvailable = _doubleJumpAvailable,
        CurrentTier = _weightComponent.CurrentTier
    };
}
```

---

## Divergence Tolerance

**Threshold: 0.1m (10cm)**

When the server's authoritative position diverges from the client's predicted position by more than 0.1m, FishNet triggers a reconcile:
1. Client snaps to server position.
2. Client replays all unacknowledged inputs from that tick forward.
3. If the replayed position is within tolerance of the current visual position, no visible snap occurs.

**Why 0.1m:**
- Smaller (e.g., 0.01m) causes constant micro-corrections that feel jittery.
- Larger (e.g., 0.5m) allows visible desync before correction — Runner can appear to phase through walls from other players' perspectives.
- 0.1m is imperceptible to the owner in normal play but catches genuine desync (e.g., the server didn't register a wall slide that the client predicted).

**Configuration:**
```csharp
// On the RunnerController's PredictionManager or NetworkObject settings:
// OwnerSmoothing = true
// TeleportThreshold = 0.1f
```

---

## Determinism Requirements

For prediction to work without constant corrections, the `Move()` method must produce identical results on client and server given the same input and state. This means:

1. **No `Time.deltaTime` in Move().** Use `TimeManager.TickDelta` (fixed tick interval).
2. **No random values in movement.** Deterministic physics only.
3. **Separate horizontal/vertical velocity.** `_hVelocity` (Vector3) and `_vVelocity` (float) are never mixed — this prevents floating-point order-of-operations divergence.
4. **CharacterController.Move() is deterministic** given the same input vector and collider state.
5. **Weight tier affects speed multiplier** — `WeightTier` is included in reconcile data so client and server always agree on the current multiplier.

---

## What Is NOT Predicted

| System | Why not predicted |
|--------|------------------|
| Item pickup | Discrete event — server confirms, client sees result via SyncVar |
| Weight changes | Server-only mutation (ADR-0004) — client reads SyncVar |
| Laser fire | Eye is the host/server — no prediction needed (zero latency) |
| Extraction channeling | Server coroutine owns the timer — client shows progress via SyncVar |
| Match phase transitions | Server decides — all clients receive via SyncVar |
| Other players' positions | Interpolated via NetworkTransform, not predicted |

---

## Tick Rate

**30Hz (33.3ms per tick)**

- Matches the relay server's expected throughput.
- Matches LoSManager's raycast frequency (30Hz server tick in SYS-05).
- Standard for competitive FPS-adjacent games (Valorant uses 128Hz, but for a 2-5 player relay game with parkour movement, 30Hz is sufficient).
- Configurable in `NetworkManager` settings.

---

## Consequences

**Positive:**
- Runner movement feels instant — zero perceived input latency for the owner.
- Server remains authoritative — cheating requires modifying the host (Eye), not the Runner client.
- FishNet handles replay and reconciliation internally — we only implement `Move()` and `Reconcile()`.
- `WeightTier` in reconcile data ensures movement speed changes are immediately consistent.

**Negative:**
- `Move()` must be perfectly deterministic — any non-determinism causes constant reconcile snaps. Requires discipline (no `Time.deltaTime`, no randomness, no frame-dependent logic in the tick loop).
- Reconcile data struct grows with movement complexity — every stateful variable that affects movement must be included.
- 30Hz tick means position updates for remote observers lag up to 33ms behind reality. Masked by interpolation.

**Risks:**
- ⚡ MEDIUM: FishNet v4 `[Replicate]`/`[Reconcile]` attribute signatures and `IReplicateData`/`IReconcileData` interfaces may differ from what's documented here. **Verify against the actual FishNet v4 package imported in the project before implementing.**
- CharacterController determinism relies on Unity's physics being frame-rate independent when driven by fixed tick. This is well-established but should be validated in Unity 6 early.

---

## GDD Requirements Addressed

- TR-MOV-001: CharacterController + custom velocity, determinism required for prediction
- TR-MOV-002: FishNet prediction via `IReplicateData` + `IReconcileData`
- TR-MOV-003: Separate `_hVelocity` / `_vVelocity`
- TR-MOV-005: 0.1m reconciliation divergence tolerance
- TR-NET-009: 30Hz server tick rate

---

## References

- `design/gdd/sys-01-runner-movement.md` — Full movement state machine and tuning values
- `design/gdd/sys-33-fishnet-replication.md` — Section 2.3: Client-Side Prediction
- `design/gdd/sys-04-weight-burden.md` — Weight tier affects movement speed
