Skip to content

UseableFisher — Fishing Mechanics

UseableFisher is one of the most mechanically complex Useable subclasses at 1,138 lines. It implements a complete fishing minigame with a four-state machine (Idle → PreparingToCast → LineDeployed → CatchChallenge), deterministic fixed-point physics for cross-client synchronization of the catch challenge, bobber projectile simulation, water volume detection, spawn-table-driven reward resolution, and per-volume fish tables.

Source code location: Unturned/Useable/UseableFisher.cs, Unturned/Bundles/ItemFisherAsset.cs

State Machine Overview

The EFishingState enum defines four discrete states:

StateDescription
IdleRod equipped, no action in progress
PreparingToCastStrength gauge active, player charging throw
LineDeployedBobber in water, waiting for bite
CatchChallengeActive minigame to reel in the catch

State Transitions

Idle → (startPrimary) → PreparingToCast → (stopPrimary) → LineDeployed
LineDeployed → (startPrimary, within catch window) → CatchChallenge
LineDeployed → (startPrimary, miss window) → Idle (reel in)
CatchChallenge → (success/failure) → LineDeployed or Idle

Equip: UI Initialization

The equip() method creates the entire fishing UI hierarchy:

csharp
public override void equip()
{
    player.animator.play("Equip", true);
    castAnimationLength = player.animator.GetAnimationLength("Cast");
    reelAnimationLength = player.animator.GetAnimationLength("Reel");

    if (channel.IsLocalPlayer)
    {
        firstHook = player.equipment.firstModel.Find("Hook");
        thirdHook = player.equipment.thirdModel.Find("Hook");
        firstLine = (LineRenderer)player.equipment.firstModel.Find("Line").GetComponent<Renderer>();
        thirdLine = (LineRenderer)player.equipment.thirdModel.Find("Line").GetComponent<Renderer>();
        // ...
    }
}

Two LineRenderer components link the rod tip (Hook) to the bobber. The first-person line tracks via viewport-to-world coordinate conversion to align with the third-person bobber position.

UI Elements

The strength gauge is drawn using Glazier primitives positioned at center-bottom of the screen:

  • castStrengthBox — Outer container (40x220 pixels, centered at 50% width, 50% height -110 offset)
  • castStrengthArea — Inner frame with 10px padding
  • castStrengthBar — Fill image that scales vertically from bottom to top

The challenge UI is parented to PlayerLifeUI.container:

  • challengeBox — Outer container (120x320 pixels)
  • challengeWater — Blue-tinted fill representing the water column
  • challengeCursor — Player-controlled target zone
  • challengeSuccessBar — Green capture progress bar
  • challengeFailureBar — Red escape progress bar
  • challengePrizeIconSleekItemIcon showing the potential catch

Strength Gauge Mechanics

The strength gauge operates in tock() (fixed-time-step callback, ~20 Hz):

csharp
if (fishingState == EFishingState.PreparingToCast)
{
    strengthTime++;
    uint period = 100 + ((uint)player.skills.skills[(int)EPlayerSpeciality.SUPPORT][(int)EPlayerSupport.FISHING].level * 20);
    strengthMultiplier = 1.0f - Mathf.Abs(Mathf.Sin((strengthTime + (period / 2)) % period / (float)period * Mathf.PI));
    strengthMultiplier *= strengthMultiplier;
    // Visual update
    castStrengthBar.PositionScale_Y = 1.0f - strengthMultiplier;
    castStrengthBar.SizeScale_Y = strengthMultiplier;
    castStrengthBar.TintColor = ItemTool.getQualityColor(strengthMultiplier);
}

The strength oscillates sinusoidally with a period of 100 + (fishingLevel * 20) ticks. The squared sine value produces a sharper peak curve. Higher fishing levels slow the oscillation, making it easier to release at peak strength. The skill's mastery value (0.0–1.0) linearly scales the cast strength:

csharp
float normalizedSkillLevel = player.skills.mastery((int)EPlayerSpeciality.SUPPORT, (int)EPlayerSupport.FISHING);

Casting and Bobber Physics

When the player releases primary input (stopPrimary), the state transitions to LineDeployed:

csharp
public override void stopPrimary()
{
    if (fishingState == EFishingState.PreparingToCast)
    {
        fishingState = EFishingState.LineDeployed;
        UpdateCastStrengthGaugeVisible(false);
        // ...
        PlayCastAnimation();
        // Server broadcast
        if (Provider.isServer)
            SendPlayCast.Invoke(GetNetId(), ENetReliability.Unreliable, ...);
    }
}

Bobber Spawning

At the animation trigger point (45% through the Cast animation), the bobber GameObject is instantiated:

csharp
if (isPlayingCastAnimation)
{
    Vector3 origin = player.look.aim.position;
    Vector3 direction = player.look.aim.forward;
    RaycastHit hit;
    if (Physics.Raycast(new Ray(origin, direction), out hit, 1.5f, RayMasks.DAMAGE_SERVER))
        origin += direction * (hit.distance - 0.5f);
    else
        origin += direction;

    bobberTransform = Instantiate(bobPrefab, origin, Quaternion.identity).transform;
    bobberRigidbody = bobberTransform.GetComponent<Rigidbody>();
    bobberRigidbody.AddForce(direction * Mathf.Lerp(500.0f, 1000.0f, strengthMultiplier));
    bobberRigidbody.collisionDetectionMode = CollisionDetectionMode.Continuous;
}

The bobber is launched with force scaled from 500 to 1000 units based on strengthMultiplier. The raycast check prevents the bobber from spawning inside nearby walls.

Water Detection

The UpdateBobber() method checks if the bobber has entered a WaterVolume:

csharp
WaterVolume overlappingVolume = WaterVolumeManager.Get().GetFishingVolume(bobberTransform.position);
bool isUnderwater = overlappingVolume != null;
float surfaceElevation = overlappingVolume != null
    ? WaterUtility.getWaterSurfaceElevation(overlappingVolume, bobberTransform.position)
    : -1024f;

The bobber must descend at least minimumDepth (4 units by default, overridable per volume) below the surface:

csharp
if (isUnderwater && bobberTransform.position.y < surfaceElevation - minimumDepth)
{
    bobberRigidbody.collisionDetectionMode = CollisionDetectionMode.Discrete;
    bobberRigidbody.useGravity = false;
    bobberRigidbody.isKinematic = true;
    waterSurfacePosition = bobberTransform.position;
    waterSurfacePosition.y = surfaceElevation;
    isWaitingForBobberToFindWater = false;
    // Notify server
    SendBobberInWaterConfirmation.Invoke(GetNetId(), ENetReliability.Reliable, waterNetId);
}

Once confirmed, the bobber becomes kinematic and sits at the water surface. The server receives the WaterVolume NetId and uses it for reward table resolution.

Bobber Float Animation

After water entry, the bobber oscillates:

csharp
bobberRigidbody.MovePosition(
    Vector3.Lerp(bobberTransform.position,
        waterSurfacePosition + (Vector3.up * Mathf.Sin(Time.time) * 0.25f),
        4 * Time.deltaTime));

During the catch window, the bobber submerges with lateral movement:

csharp
bobberRigidbody.MovePosition(
    Vector3.Lerp(bobberTransform.position,
        waterSurfacePosition + (Vector3.down * 4f) + (Vector3.left * Random.Range(-4f, 4f)) + (Vector3.forward * Random.Range(-4f, 4f)),
        4 * Time.deltaTime));

Fishing Reward Resolution

The server resolves what the player will catch in simulate():

csharp
if (fisherAsset.FishingRewardMode == EFishingRewardMode.WaterVolumes
    && (Level.getAsset()?.SupportsFishingVolumes ?? false))
{
    SpawnAsset spawnAsset = serverWaterVolume.GetFishSpawnTable();
    if (spawnAsset != null)
        rewardAsset = SpawnTableTool.Resolve<ItemAsset>(spawnAsset, EAssetType.ITEM, errorContext);
    else
        rewardAsset = SpawnTableTool.Resolve<ItemAsset>(
            Level.getAsset().GetDefaultFishingSpawnTable(), EAssetType.ITEM, errorContext);
}
else
{
    rewardAsset = SpawnTableTool.Resolve<ItemAsset>(fisherAsset.rewardID, EAssetType.ITEM, errorContext);
}

The reward mode can be Rod (fishing rod defines rewards — backwards compatibility) or WaterVolumes (per-volume spawn tables with per-level fallback).

Bite Timing

ResetTimeUntilFishAppears() calculates the interval before a bite:

csharp
serverTimeUntilFishAppears = Random.Range(minInterval, maxInterval);
serverTimeUntilFishAppears *= Mathf.Lerp(1.0f, maxStrengthMultiplier, strengthMultiplier);
serverTimeUntilFishAppears *= GetEquippedAsset<ItemFisherAsset>().FishBiteIntervalMultiplier;
serverTimeUntilFishAppears *= LevelLighting.GetFishingBiteIntervalMultiplier(player.movement.WeatherMask);

The final multiplier chain: config range → cast strength → rod multiplier → weather/lighting multiplier. Stronger casts result in longer wait times but better catch quality.

Fish Notification

When the timer expires, the server selects an item and sends a notification to the client:

csharp
serverHasSentFishNotification = true;
nextRewardItem = rewardAsset;
nextRewardSeed = Random.Range(int.MinValue, int.MaxValue);
SendFishNotification.Invoke(GetNetId(), ENetReliability.Reliable,
    channel.GetOwnerTransportConnection(), rewardGuid, nextRewardSeed);

The client receives this and enters the catch window (1.4 seconds by default). A splash VFX plays at the water surface position.

The Catch Challenge

If the fishing rod has EnableCatchChallenge = true and config allows it, the player enters the CatchChallenge state instead of receiving the item directly.

Challenge Initialization

On startPrimary() within the catch window:

csharp
fishingState = EFishingState.CatchChallenge;
player.animator.play("Catch_Loop", false);

ItemAsset rewardAsset = nextRewardItem.Get<ItemAsset>();
if (rewardAsset != null && rewardAsset.FishingCatchable != null)
    catchableProperties = rewardAsset.FishingCatchable;
else
    catchableProperties = FishingCatchableProperties.Default;

Deterministic Fixed-Point Physics

The catch challenge uses FishingCatchableProperties.FIXED_POINT_SCALE = 10,000 to represent floating-point values as integers. This ensures the same random seed produces identical fish behavior on client and server:

csharp
Random.State stateToRestore = Random.state;
Random.InitState(nextRewardSeed);
fishTargetPosition = Random.Range(catchableProperties.minTargetPosition,
    catchableProperties.maxTargetPosition + 1);
Random.state = stateToRestore;

The fishing skill level scales challenge difficulty:

csharp
challengeCaptureProgressPerTick = Mathf.RoundToInt(
    FishingCatchableProperties.TIME_SCALE * (1f + normalizedSkillLevel * 0.2f)
    * fisherAsset.CatchChallengeCaptureSpeedMultiplier);
challengeEscapeProgressPerTick = Mathf.RoundToInt(
    FishingCatchableProperties.TIME_SCALE * (1f - normalizedSkillLevel * 0.2f)
    * fisherAsset.CatchChallengeEscapeSpeedMultiplier);

Higher skill levels increase capture speed and decrease escape speed by 20% per mastery level.

Fish AI: Spring-Mass System

The fish position is governed by a damped spring system:

csharp
int acceleration = ((catchableProperties.springStiffness *
    (fishTargetPosition - fishPosition)) / FIXED_POINT_SCALE)
    - ((catchableProperties.springDamping * fishVelocity) / FIXED_POINT_SCALE);

acceleration = Mathf.Clamp(acceleration, -maxDownwardAcceleration, maxUpwardAcceleration);
fishVelocity += acceleration / DELTA_TIME;
fishVelocity = Mathf.Clamp(fishVelocity, -maxDownwardSpeed, maxUpwardSpeed);
fishPosition += fishVelocity / DELTA_TIME;

The fish relocates its target position at random intervals:

csharp
if (ticksUntilFishRelocates <= 0)
{
    ticksUntilFishRelocates = Random.Range(catchableProperties.minChangeTargetTicks,
        catchableProperties.maxChangeTargetTicks);
    int randomDelta = Random.Range(catchableProperties.minTargetDelta,
        catchableProperties.maxTargetDelta);
    // Bias away from edges
    if (fishTargetPosition + randomDelta > catchableProperties.maxTargetPosition)
        fishTargetPosition = Mathf.Max(minPos, fishTargetPosition - randomDelta);
    else if (fishTargetPosition - randomDelta < catchableProperties.minTargetPosition)
        fishTargetPosition = Mathf.Min(maxPos, fishTargetPosition + randomDelta);
    else
    {
        if (Random.value < 0.5f) randomDelta = -randomDelta;
        fishTargetPosition = fishTargetPosition + randomDelta;
    }
}

Boundary collisions with restitution:

csharp
if (fishPosition > FIXED_POINT_SCALE)
{
    fishPosition = FIXED_POINT_SCALE - (fishPosition - FIXED_POINT_SCALE);
    fishVelocity = -fishVelocity * catchableProperties.upperRestitution / FIXED_POINT_SCALE;
}

Player Cursor Physics

The cursor has its own physics with acceleration, gravity, and restitution:

csharp
if (challengeInputWantsToPullUp)
    challengeInputVelocity += equippedAsset.CatchChallengeAcceleration / DELTA_TIME;
else
    challengeInputVelocity -= equippedAsset.CatchChallengeGravity / DELTA_TIME;

challengeInputPosition += challengeInputVelocity / DELTA_TIME;

// Boundary collision with cursor size awareness
if (challengeInputPosition + cursorSize > FIXED_POINT_SCALE)
    challengeInputPosition = FIXED_POINT_SCALE - cursorSize
        - (challengeInputPosition + cursorSize - FIXED_POINT_SCALE);

Capture/Escape Progress

csharp
bool isFishWithinCursor = fishPosition >= challengeInputPosition
    && fishPosition <= challengeInputPosition + equippedAsset.CatchChallengeCursorSize;

if (isFishWithinCursor)
    challengeCaptureProgress += challengeCaptureProgressPerTick;
else
    challengeCaptureProgress -= challengeEscapeProgressPerTick;

challengeCaptureProgress = Mathf.Clamp(challengeCaptureProgress,
    -catchableProperties.escapeTicks, catchableProperties.captureTicks);

When captureProgress == captureTicks, the player succeeds. When it reaches -escapeTicks, the fish escapes.

FishingCatchableProperties

Defined in ItemFisherAsset.cs, the FishingCatchableProperties class has 14 fixed-point fields:

FieldDefaultRange
minChangeTargetTicks1.5 secInterval before fish relocates
maxChangeTargetTicks2.0 secMax interval
maxUpwardAcceleration1.5Max upward acceleration
maxDownwardAcceleration1.2Max downward acceleration
maxUpwardSpeed0.6Max upward velocity
maxDownwardSpeed0.45Max downward velocity
upperRestitution0.6Bounce at top boundary
lowerRestitution0.4Bounce at bottom boundary
minTargetDelta0.3Min relocation distance
maxTargetDelta0.4Max relocation distance
minTargetPosition0.1Min position (normalized)
maxTargetPosition0.9Max position (normalized)
captureTicks2.0 secTime needed to capture
escapeTicks2.0 secTime before escape
springStiffness16.0Spring constant for fish physics
springDamping4.0Damping coefficient

All values are parsed from IDatDictionary data using Mathf.RoundToInt to convert floats to fixed-point integers. The Default static instance provides sensible defaults via constants.

Reward Application

On challenge success (or direct catch), GrantRewards() is called server-side:

csharp
private void GrantRewards()
{
    ItemAsset rewardAsset = nextRewardItem.Get<ItemAsset>();
    if (rewardAsset != null)
        player.inventory.forceAddItem(new Item(rewardAsset, EItemOrigin.NATURE), false);

    player.sendStat(EPlayerStat.FOUND_FISHES);

    ItemFisherAsset fisherAsset = GetEquippedAsset<ItemFisherAsset>();
    int xp = Random.Range(fisherAsset.rewardExperienceMin, fisherAsset.rewardExperienceMax + 1);
    if (xp > 0)
        player.skills.askPay((uint)xp);

    fisherAsset.rewardsList.Grant(player);
}

The reward pipeline: inventory item → stat tracking → XP → NPC reward list.

Reeling In

ReelIn() transitions back to Idle:

csharp
private void ReelIn()
{
    fishingState = EFishingState.Idle;
    player.equipment.isBusy = true;
    startedReel = Time.realtimeSinceStartup;
    isPlayingReelAnimation = true;
    isWaitingForAnimationTrigger = true;
    PlayReelAnimation();
    if (Provider.isServer)
    {
        SendPlayReel.Invoke(GetNetId(), ENetReliability.Unreliable, ...);
        AlertTool.alert(transform.position, 8);
    }
}

At the reel animation trigger point (75% through), the bobber GameObject is destroyed:

csharp
else if (isPlayingReelAnimation)
{
    if (bobberTransform != null)
        Destroy(bobberTransform.gameObject);
}

Line Rendering

UpdateLineEndpoints() projects the bobber position through the viewport for first-person:

csharp
if (player.look.perspective == EPlayerPerspective.FIRST)
{
    Vector3 screen = MainCamera.instance.WorldToViewportPoint(bobberTransform.position);
    Vector3 world = player.animator.viewmodelCamera.ViewportToWorldPoint(screen);
    firstLine.SetPosition(0, firstHook.position);
    firstLine.SetPosition(1, world);
}
else
{
    thirdLine.SetPosition(0, thirdHook.position);
    thirdLine.SetPosition(1, bobberTransform.position);
}

This mapping ensures the fishing line visually connects the rod tip to the bobber in both perspectives.

Detailed State Machine Code Flow

Idle State

In Idle, the rod is held but no action is in progress. The player can see the cast strength gauge trigger area. The isUseableShowingMenu property returns true when the strength box is visible:

csharp
public override bool isUseableShowingMenu => castStrengthBox != null && castStrengthBox.IsVisible;

This allows the base Useable system to determine if the player should be prevented from other actions.

PreparingToCast State

Entered via startPrimary() when fishingState == Idle:

csharp
if (fishingState == EFishingState.Idle)
{
    fishingState = EFishingState.PreparingToCast;
    strengthTime = 0;
    strengthMultiplier = 0.0f;
    if (channel.IsLocalPlayer)
        UpdateCastStrengthGaugeVisible(true);
}

The UpdateCastStrengthGaugeVisible method manages the main HUD overlay state:

csharp
private void UpdateCastStrengthGaugeVisible(bool visible)
{
    castStrengthBox.IsVisible = visible;
    if (hasClosedMainHud != visible)
    {
        hasClosedMainHud = visible;
        if (visible)
            PlayerLifeUI.close(); // Hide health/food/water HUD
        else
            PlayerLifeUI.open(); // Restore HUD
    }
}

While preparing, tock() runs every tick (~50ms or 20 Hz):

csharp
if (fishingState == EFishingState.PreparingToCast)
{
    strengthTime++;
    uint period = 100 + ((uint)player.skills.skills[(int)EPlayerSpeciality.SUPPORT][(int)EPlayerSupport.FISHING].level * 20);
    strengthMultiplier = 1.0f - Mathf.Abs(Mathf.Sin((strengthTime + (period / 2)) % period / (float)period * Mathf.PI));
    strengthMultiplier *= strengthMultiplier;
}

The period calculation: base 100 ticks (5 seconds at 20 Hz) plus 20 ticks per fishing level. A player with level 0 fishing has a 100-tick period; level 5 has 200 ticks. The Mathf.Sin-based oscillation with squared magnitude creates a sharp peak that's easier to time at higher levels.

The UI updates with color-coded feedback:

csharp
castStrengthBar.PositionScale_Y = 1.0f - strengthMultiplier; // Bar fills from bottom
castStrengthBar.SizeScale_Y = strengthMultiplier;
castStrengthBar.TintColor = ItemTool.getQualityColor(strengthMultiplier);

ItemTool.getQualityColor maps the [0, 1] strength range to a green-to-red gradient, providing visual feedback on cast quality.

LineDeployed: Bobber Behavior

After casting, the bobber follows a specific water behavior:

Waiting for water entry (isWaitingForBobberToFindWater = true):

The bobber rigidbody enters continuous collision detection mode and is launched with force proportional to cast strength:

csharp
bobberRigidbody.AddForce(direction * Mathf.Lerp(500.0f, 1000.0f, strengthMultiplier));
bobberRigidbody.collisionDetectionMode = CollisionDetectionMode.Continuous;

The Lerp(500, 1000, strengthMultiplier) maps weak casts to 500 units and strong casts to 1000 units of initial force.

After water entry:

CCD is disabled and the bobber becomes kinematic:

csharp
bobberRigidbody.collisionDetectionMode = CollisionDetectionMode.Discrete;
bobberRigidbody.useGravity = false;
bobberRigidbody.isKinematic = true;

Bobber idle float:

csharp
bobberRigidbody.MovePosition(Vector3.Lerp(bobberTransform.position,
    waterSurfacePosition + (Vector3.up * Mathf.Sin(Time.time) * 0.25f),
    4 * Time.deltaTime));

The Mathf.Sin(Time.time) * 0.25f produces a gentle up-and-down bob with 0.25 meter amplitude.

CatchWindow Bobber Submergence

After the server sends SendFishNotification, the bobber submerges with lateral movement:

csharp
bobberRigidbody.MovePosition(Vector3.Lerp(bobberTransform.position,
    waterSurfacePosition + (Vector3.down * 4f)
    + (Vector3.left * Random.Range(-4f, 4f))
    + (Vector3.forward * Random.Range(-4f, 4f)),
    4 * Time.deltaTime));

The bobber moves downward 4 meters and shifts randomly up to 4 meters horizontally, simulating the fish pulling the line.

Tug Animation

At the same time, the tug sound and animation play:

csharp
if (timeSinceFishNotification >= WARNING_DURATION
    && timeSinceFishNotification <= WARNING_DURATION + CATCH_WINDOW)
{
    if (!hasPlayedTugAnimation)
    {
        hasPlayedTugAnimation = true;
        if (!isPlayingReelAnimation)
        {
            player.playSound(((ItemFisherAsset)player.equipment.asset).tug);
            player.animator.play("Tug", false);
        }
    }
}

The tug animation plays only once per catch window. If the player starts reeling before the tug, it's suppressed.

Challenge State: Complete Tick Logic

The challenge state runs in tock() and encompasses:

  1. Fish position update — Spring-mass physics with boundary bounce
  2. Fish target relocation — Random interval with boundary-aware movement
  3. Player cursor physics — Acceleration, gravity, and restitution
  4. Capture/escape progress — Additive or subtractive tick-based counter
  5. Success/failure detection — Threshold comparison
  6. UI updating — Position bars, colors, and visibility

Fish Position Physics (Full Detail)

The spring-mass system is:

acceleration = (stiffness × (target - position) / FIXED_POINT_SCALE) - (damping × velocity / FIXED_POINT_SCALE)

In integer arithmetic:

csharp
int acceleration = ((catchableProperties.springStiffness * (fishTargetPosition - fishPosition))
    / FishingCatchableProperties.FIXED_POINT_SCALE)
    - ((catchableProperties.springDamping * fishVelocity)
    / FishingCatchableProperties.FIXED_POINT_SCALE);

With default values (stiffness = 16.0, damping = 4.0) scaled to fixed point (160000, 40000), this produces a critically damped spring that smoothly moves toward the target without overshoot.

Player Cursor Physics

The cursor has distinct upward and downward physics:

csharp
if (challengeInputWantsToPullUp)
    challengeInputVelocity += equippedAsset.CatchChallengeAcceleration / DELTA_TIME;
else
    challengeInputVelocity -= equippedAsset.CatchChallengeGravity / DELTA_TIME;

With DELTA_TIME = 50, and default acceleration = 1.0 (10000 fixed) and gravity = 1.0 (10000 fixed), each tick adds ±200 fixed-point units to velocity.

The cursor boundary handling includes cursor size awareness:

csharp
if (challengeInputPosition + equippedAsset.CatchChallengeCursorSize > FIXED_POINT_SCALE)
{
    challengeInputPosition = FIXED_POINT_SCALE - equippedAsset.CatchChallengeCursorSize
        - (challengeInputPosition + equippedAsset.CatchChallengeCursorSize - FIXED_POINT_SCALE);
    challengeInputVelocity = -challengeInputVelocity * equippedAsset.CatchChallengeUpperRestitution;
}

This prevents the cursor from being pushed partially off-screen, which would create a visual discrepancy.

Capture Rate Scaling

The capture and escape rates are skill-scaled:

csharp
float normalizedSkillLevel = player.skills.mastery((int)EPlayerSpeciality.SUPPORT, (int)EPlayerSupport.FISHING);
challengeCaptureProgressPerTick = Mathf.RoundToInt(TIME_SCALE * (1f + normalizedSkillLevel * 0.2f)
    * fisherAsset.CatchChallengeCaptureSpeedMultiplier);
challengeEscapeProgressPerTick = Mathf.RoundToInt(TIME_SCALE * (1f - normalizedSkillLevel * 0.2f)
    * fisherAsset.CatchChallengeEscapeSpeedMultiplier);

At TIME_SCALE = 10000, base capture rate is 10000 per tick. A player with 100% mastery (normalizedSkillLevel = 1.0) gets 12000 capture and 8000 escape per tick — a 50% net improvement over baseline.

Challenge Completion

On success:

csharp
if (challengeCaptureProgress == catchableProperties.captureTicks)
{
    if (channel.IsLocalPlayer)
    {
        challengeBox.IsVisible = false;
        SetPlayingFishingLoop(false);
        PlayFishingSuccess();
        rewardAsset.PlayInventoryAudio2D();
    }
    if (Provider.isServer)
        GrantRewards();
    ReelIn();
}

On failure:

csharp
else if (challengeCaptureProgress == -catchableProperties.escapeTicks)
{
    player.animator.play("Catch_Failure", false);
    if (channel.IsLocalPlayer)
    {
        challengeBox.IsVisible = false;
        SetPlayingFishingLoop(false);
        PlayFishingFailure();
    }
    fishingState = EFishingState.LineDeployed; // Return to waiting
    if (Provider.isServer)
        ResetTimeUntilFishAppears();
}

On failure, the player returns to LineDeployed (not Idle) so they can try again without re-casting. The timeout resets for a new fish to appear.

Fish Bite Interval: Complete Multiplier Chain

csharp
private void ResetTimeUntilFishAppears()
{
    serverHasSentFishNotification = false;

    float minInterval = Provider.modeConfigData?.Gameplay?.Min_Fishing_Bite_Interval ?? 1.0f;
    float maxInterval = Provider.modeConfigData?.Gameplay?.Max_Fishing_Bite_Interval ?? 1.0f;
    float maxStrengthMultiplier = Provider.modeConfigData?.Gameplay?.Fishing_MaxStrength_Bite_Interval_Multiplier ?? 1.0f;

    serverTimeUntilFishAppears = Random.Range(minInterval, maxInterval);
    serverTimeUntilFishAppears *= Mathf.Lerp(1.0f, maxStrengthMultiplier, strengthMultiplier);
    serverTimeUntilFishAppears *= GetEquippedAsset<ItemFisherAsset>().FishBiteIntervalMultiplier;
    serverTimeUntilFishAppears *= LevelLighting.GetFishingBiteIntervalMultiplier(player.movement.WeatherMask);
}

Multiplier chain example:

  • Config range: [5, 15] seconds → Random.Range gives 10.3s
  • Cast strength 0.8, max multiplier 3.0 → 10.3 × Lerp(1, 3, 0.8) = 10.3 × 2.6 = 26.78s
  • Rod multiplier 0.5 (fast rod) → 26.78 × 0.5 = 13.39s
  • Weather multiplier 0.7 (rainy night) → 13.39 × 0.7 = 9.37s

Strong casts delay the bite significantly. Weak rods and bad weather speed it up.

Reward Table Resolution: WaterVolumes Mode

When FishingRewardMode == EFishingRewardMode.WaterVolumes:

csharp
if (serverWaterVolume != null)
{
    SpawnAsset spawnAsset = serverWaterVolume.GetFishSpawnTable();
    if (spawnAsset != null)
        rewardAsset = SpawnTableTool.Resolve<ItemAsset>(spawnAsset, EAssetType.ITEM,
            serverWaterVolume.OnGetFishErrorContext);
    else
    {
        spawnAsset = Level.getAsset()?.GetDefaultFishingSpawnTable();
        if (spawnAsset != null)
            rewardAsset = SpawnTableTool.Resolve<ItemAsset>(spawnAsset, EAssetType.ITEM,
                Level.getAsset().OnGetFishErrorContext);
        else
            rewardAsset = null; // No volume OR level spawn table
    }
}
else
{
    rewardAsset = null; // Water volume was destroyed
}

The fallback chain is: per-volume spawn table → per-level default spawn table → null (no fish in this water). This allows map authors to define unique fishing content per water volume without affecting the rest of the map.

Dequip Cleanup

csharp
public override void dequip()
{
    if (channel.IsLocalPlayer)
    {
        if (bobberTransform != null)
            Destroy(bobberTransform.gameObject);
        if (castStrengthBox != null)
            PlayerUI.container.RemoveChild(castStrengthBox);
        if (challengeBox != null)
            PlayerLifeUI.container.RemoveChild(challengeBox);
        SetPlayingFishingLoop(false);
        if (hasClosedMainHud)
        {
            hasClosedMainHud = false;
            PlayerLifeUI.open();
        }
    }
}

All UI elements and the bobber are cleaned up. The fishing loop audio is stopped. The main HUD is restored if it was hidden for the cast strength gauge.

Key Design Insights

  1. Deterministic cross-client sync — Fixed-point integers with explicit FIXED_POINT_SCALE and shared Random seeding ensure the catch challenge produces identical fish behavior on client and server
  2. Layered reward resolution — Reward mode cascades from per-volume spawn tables → per-level default → rod-defined reward, with error context for each fallback
  3. Multiplier compositing — Bite interval is computed from four independent multipliers (config range, strength, rod, weather), allowing each system to contribute independently
  4. Animation trigger points — Cast (45%) and Reel (75%) trigger points decouple the visual animation timeline from gameplay event timing
  5. Water volume abstractionWaterVolume.GetFishingVolume() returns dedicated fishing volumes, separating fishing water from decorative water
  6. Audio layer — Fishing uses custom audio references loaded from core.masterbundle with randomized pitch/volume; the casting/reel/tug sounds come from individual rod assets
  7. Skill-scaled difficulty — Both the cast strength oscillation period and the challenge capture/escape rates scale with the fishing skill level, making higher-level fishing strictly easier
  8. Integrated failure recovery — On challenge failure the player returns to LineDeployed instead of Idle, avoiding the need to re-cast and preserving their position in the water