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:
| State | Description |
|---|---|
Idle | Rod equipped, no action in progress |
PreparingToCast | Strength gauge active, player charging throw |
LineDeployed | Bobber in water, waiting for bite |
CatchChallenge | Active 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 IdleEquip: 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 paddingcastStrengthBar— 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 columnchallengeCursor— Player-controlled target zonechallengeSuccessBar— Green capture progress barchallengeFailureBar— Red escape progress barchallengePrizeIcon—SleekItemIconshowing 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:
| Field | Default | Range |
|---|---|---|
minChangeTargetTicks | 1.5 sec | Interval before fish relocates |
maxChangeTargetTicks | 2.0 sec | Max interval |
maxUpwardAcceleration | 1.5 | Max upward acceleration |
maxDownwardAcceleration | 1.2 | Max downward acceleration |
maxUpwardSpeed | 0.6 | Max upward velocity |
maxDownwardSpeed | 0.45 | Max downward velocity |
upperRestitution | 0.6 | Bounce at top boundary |
lowerRestitution | 0.4 | Bounce at bottom boundary |
minTargetDelta | 0.3 | Min relocation distance |
maxTargetDelta | 0.4 | Max relocation distance |
minTargetPosition | 0.1 | Min position (normalized) |
maxTargetPosition | 0.9 | Max position (normalized) |
captureTicks | 2.0 sec | Time needed to capture |
escapeTicks | 2.0 sec | Time before escape |
springStiffness | 16.0 | Spring constant for fish physics |
springDamping | 4.0 | Damping 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:
- Fish position update — Spring-mass physics with boundary bounce
- Fish target relocation — Random interval with boundary-aware movement
- Player cursor physics — Acceleration, gravity, and restitution
- Capture/escape progress — Additive or subtractive tick-based counter
- Success/failure detection — Threshold comparison
- 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
- Deterministic cross-client sync — Fixed-point integers with explicit
FIXED_POINT_SCALEand sharedRandomseeding ensure the catch challenge produces identical fish behavior on client and server - Layered reward resolution — Reward mode cascades from per-volume spawn tables → per-level default → rod-defined reward, with error context for each fallback
- Multiplier compositing — Bite interval is computed from four independent multipliers (config range, strength, rod, weather), allowing each system to contribute independently
- Animation trigger points — Cast (45%) and Reel (75%) trigger points decouple the visual animation timeline from gameplay event timing
- Water volume abstraction —
WaterVolume.GetFishingVolume()returns dedicated fishing volumes, separating fishing water from decorative water - Audio layer — Fishing uses custom audio references loaded from
core.masterbundlewith randomized pitch/volume; the casting/reel/tug sounds come from individual rod assets - 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
- Integrated failure recovery — On challenge failure the player returns to
LineDeployedinstead ofIdle, avoiding the need to re-cast and preserving their position in the water
