local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")

local LocalPlayer = Players.LocalPlayer
local Camera = Workspace.CurrentCamera

-- === CONFIGURATION === --
local MIN_SWIM_SPEED, MAX_SWIM_SPEED = 0.5, 1.5
local MIN_TURN_SPEED, MAX_TURN_SPEED = 2, 4
local IDLE_DURATION_MIN, IDLE_DURATION_MAX = 2, 5
local FISH_BOUNDING_RADIUS = 1
local LOD_DISTANCE_NEAR_SQ, LOD_DISTANCE_MEDIUM_SQ = 25 ^ 2, 50 ^ 2
local SCAN_INTERVAL = 2 -- seconds

local clientManagedFish = {} -- { [fishModel] = { coroutine, zone, currentLOD, lastTargetPos } }
local lastScanTime = 0

-- === UTILITIES === --
local function clampPositionToZone(pos, zone, radius)
	if not zone then
		return pos
	end
	local cf, sz = zone.CFrame, zone.Size
	local lp = cf:PointToObjectSpace(pos)
	local cx = math.clamp(lp.X, -sz.X / 2 + radius, sz.X / 2 - radius)
	local cy = math.clamp(lp.Y, -sz.Y / 2 + radius, sz.Y / 2 - radius)
	local cz = math.clamp(lp.Z, -sz.Z / 2 + radius, sz.Z / 2 - radius)
	return cf:PointToWorldSpace(Vector3.new(cx, cy, cz))
end

local function randomZonePoint(zone)
	local sz = zone.Size
	local function rand(min, max)
		return math.random(min * 100, max * 100) / 100
	end
	return Vector3.new(
		rand(-sz.X / 2 + FISH_BOUNDING_RADIUS, sz.X / 2 - FISH_BOUNDING_RADIUS),
		rand(-sz.Y / 2 + FISH_BOUNDING_RADIUS, sz.Y / 2 - FISH_BOUNDING_RADIUS),
		rand(-sz.Z / 2 + FISH_BOUNDING_RADIUS, sz.Z / 2 - FISH_BOUNDING_RADIUS)
	)
end

local function getSwimTarget(fish, zone, lastTarget)
	if not zone then
		return fish.PrimaryPart.Position
	end
	local cf = zone.CFrame
	for _ = 1, 4 do
		local localTarget = randomZonePoint(zone)
		local worldTarget = clampPositionToZone(cf:PointToWorldSpace(localTarget), zone, FISH_BOUNDING_RADIUS)
		if not lastTarget or (worldTarget - lastTarget).Magnitude > 2 then
			return worldTarget
		end
	end
	return fish.PrimaryPart.Position
end

local function getYaw(from, to, cf)
	local delta = to - from
	if delta.X == 0 and delta.Z == 0 then
		local _, yaw, _ = cf:ToOrientation()
		return yaw
	end
	return math.atan2(delta.X, delta.Z)
end

local function countTable(t)
	local c = 0
	for _ in pairs(t) do
		c += 1
	end
	return c
end

-- === FISH COROUTINE MOVEMENT === --
local function swimLoop(fish, zone)
	local primary = fish.PrimaryPart
	local data = clientManagedFish[fish]
	if not primary or not data or not zone then
		return
	end

	local cframe = primary.CFrame
	local state = "moving"
	local idleTimer = 0
	local swimSpeed = math.random(MIN_SWIM_SPEED * 100, MAX_SWIM_SPEED * 100) / 100
	local turnSpeed = math.random(MIN_TURN_SPEED * 100, MAX_TURN_SPEED * 100) / 100
	local targetPos = data.lastTargetPos or getSwimTarget(fish, zone, primary.Position)
	data.lastTargetPos = targetPos
	local targetYaw = getYaw(primary.Position, targetPos, cframe)

	while
		clientManagedFish[fish]
		and clientManagedFish[fish].coroutine == coroutine.running()
		and fish.Parent
		and primary.Parent
		and data.zone == zone
	do
		local dt = RunService.Heartbeat:Wait()
		if state == "moving" then
			local _, currentYaw = cframe:ToOrientation()
			local yawDiff = targetYaw - currentYaw
			yawDiff = (yawDiff + math.pi) % (2 * math.pi) - math.pi
			local maxTurn = turnSpeed * dt
			if math.abs(yawDiff) > maxTurn then
				currentYaw += math.sign(yawDiff) * maxTurn
			else
				currentYaw = targetYaw
			end

			local dir = targetPos - primary.Position
			local dist = dir.Magnitude
			local moveStep = math.min(dist, swimSpeed * dt)
			if dist > 0.1 then
				local newPos = primary.Position + dir.Unit * moveStep
				primary.CFrame = CFrame.new(clampPositionToZone(newPos, zone, FISH_BOUNDING_RADIUS))
					* CFrame.Angles(0, currentYaw, 0)
				cframe = primary.CFrame
			else
				state = "idle"
				idleTimer = math.random(IDLE_DURATION_MIN * 100, IDLE_DURATION_MAX * 100) / 100
				targetPos = getSwimTarget(fish, zone, primary.Position)
				data.lastTargetPos = targetPos
				targetYaw = getYaw(primary.Position, targetPos, cframe)
				swimSpeed = math.random(MIN_SWIM_SPEED * 100, MAX_SWIM_SPEED * 100) / 100
				turnSpeed = math.random(MIN_TURN_SPEED * 100, MAX_TURN_SPEED * 100) / 100
			end
		elseif state == "idle" then
			idleTimer -= dt
			if idleTimer <= 0 then
				state = "moving"
			end
		end
	end
end

-- === LOD AND FISH MANAGEMENT === --
local function getDistanceToCameraSq(pos)
	if not Camera then
		return math.huge
	end
	return (Camera.CFrame.Position - pos).Magnitude ^ 2
end

local function updateFishLOD(fish, zone)
	if not fish or not fish.PrimaryPart or not clientManagedFish[fish] then
		return
	end
	local data = clientManagedFish[fish]
	local d2 = getDistanceToCameraSq(fish.PrimaryPart.Position)
	local newLOD = (d2 <= LOD_DISTANCE_NEAR_SQ) and "NEAR" or (d2 <= LOD_DISTANCE_MEDIUM_SQ) and "MEDIUM" or "FAR"

	if data.currentLOD ~= newLOD then
		data.currentLOD = newLOD
		if newLOD == "NEAR" or newLOD == "MEDIUM" then
			if not data.coroutine or coroutine.status(data.coroutine) == "dead" then
				data.coroutine = coroutine.create(swimLoop)
				coroutine.resume(data.coroutine, fish, zone)
			end
		else -- FAR
			if data.coroutine and coroutine.status(data.coroutine) ~= "dead" then
				-- Kill coroutine (let it exit itself)
				data.coroutine = nil
			end
		end
	end
end

local function scanAndManageFish()
	local plots = Workspace:FindFirstChild("Gameplay") and Workspace.Gameplay:FindFirstChild("Plots")
	if not plots then
		return
	end

	local seenFish = {}
	for _, plot in ipairs(plots:GetChildren()) do
		if plot:IsA("Part") then
			for _, tank in ipairs(plot:GetChildren()) do
				if tank:IsA("Model") and tank.Name == "FishTank" then
					local zone = tank:FindFirstChild("InsertZone")
					if zone then
						for _, fish in ipairs(tank:GetChildren()) do
							if fish:IsA("Model") and fish.PrimaryPart then
								table.insert(seenFish, fish)
								if not clientManagedFish[fish] then
									clientManagedFish[fish] =
										{ coroutine = nil, zone = zone, currentLOD = "FAR", lastTargetPos = nil }
								else
									clientManagedFish[fish].zone = zone
								end
							end
						end
					end
				end
			end
		end
	end

	-- Remove dead/removed fish
	local toRemove = {}
	for fish in pairs(clientManagedFish) do
		local stillExists = false
		for _, seen in ipairs(seenFish) do
			if fish == seen then
				stillExists = true
				break
			end
		end
		if not stillExists then
			table.insert(toRemove, fish)
		end
	end
	for _, fish in ipairs(toRemove) do
		local data = clientManagedFish[fish]
		if data and data.coroutine and coroutine.status(data.coroutine) ~= "dead" then
			data.coroutine = nil
		end
		clientManagedFish[fish] = nil
	end
end

-- === MAIN LOOP === --
RunService.RenderStepped:Connect(function()
	if not LocalPlayer.Character or not LocalPlayer.Character:IsDescendantOf(Workspace) or not Camera then
		-- Cleanup if player leaves
		for fish, data in pairs(clientManagedFish) do
			if data.coroutine and coroutine.status(data.coroutine) ~= "dead" then
				data.coroutine = nil
			end
		end
		table.clear(clientManagedFish)
		return
	end

	if tick() - lastScanTime > SCAN_INTERVAL then
		scanAndManageFish()
		lastScanTime = tick()
	end

	for fish, data in pairs(clientManagedFish) do
		if not fish or not fish.Parent or not fish.PrimaryPart or not fish.PrimaryPart.Parent then
			if data.coroutine and coroutine.status(data.coroutine) ~= "dead" then
				data.coroutine = nil
			end
			clientManagedFish[fish] = nil
		else
			updateFishLOD(fish, data.zone)
		end
	end
end)

-- Initial scan (wait a moment for loading)
task.wait(2.5)
scanAndManageFish()
lastScanTime = tick()


How do i make my fish movement even better so its more natural and super entrancing for people to watch for long hours

Give me full copy paste