Rivers

In this tutorial I will be showing how to implement rivers into our tilemap.
Since I will be showing off two types of river generation in this tutorial,
This tutorial will be relatively long, but you can implement any of the two you like.

What will be covered in this tutorial?

  • Create some helper methods
  • Downstream river generation
  • Drunken river generation
Downstream river generation
Drunken river generation

Create some helper methods

To begin this tutorial, I would like to first create a few helper methods
that will be helpful in both styles of river generation.

Let’s start with creating a new static class called TilemapHelper.
Let’s add two static methods to this class, one to get all tiles of a certain type(s).
And another to get find the closest tile of a certain type to a specific position.

public static class TilemapHelper
{
	public static List<Vector2Int> GetTilesByType(TilemapStructure tilemap, IEnumerable<int> enumerable)
	{
		// Best to ToList() the IEnumerable because they will otherwise cause multiple enumerations.
		var tileTypes = enumerable.ToList();
		var validTilePositions = new List<Vector2Int>();
		for (int x = 0; x < tilemap.Width; x++)
		{
			for (int y = 0; y < tilemap.Height; y++)
			{
				var tileType = tilemap.GetTile(x, y);
				// Here we use Any to check if any of the tile types match the current tile
				if (tileTypes.Any(a => a == tileType))
				{
					validTilePositions.Add(new Vector2Int(x, y));
				}
			}
		}
		return validTilePositions;
	}

	public static Vector2Int? FindClosestTileByType(TilemapStructure tilemap, Vector2Int startPos, int tileType)
	{
		float smallestDistance = float.MaxValue;
		Vector2Int? smallestDistancePosition = null;
		for (int x = 0; x < tilemap.Width; x++)
		{
			for (int y = 0; y < tilemap.Height; y++)
			{
				if (tilemap.GetTile(x, y) == tileType)
				{
					// Here we check the distance between the start position and the current tile
					float distance = ((startPos.x - x) * (startPos.x - x) + (startPos.y - y) * (startPos.y - y));
					// If this distance is smaller, than the smallest one we have so far encountered
					// Then let's update it
					if (distance < smallestDistance)
					{
						smallestDistance = distance;
						smallestDistancePosition = new Vector2Int(x, y);
					}
				}
			}
		}
		return smallestDistancePosition;
	}
}

We’ll add one more helper method in the TilemapStructure class to retrieve the most direct 4 neighbors of a tile, we already have one that returns all 8 neighbors.
It’s pretty much a copy but a little bit modified.

public List<KeyValuePair<Vector2Int, int>> Get4Neighbors(int tileX, int tileY)
{
	int startX = tileX - 1;
	int startY = tileY - 1;
	int endX = tileX + 1;
	int endY = tileY + 1;

	var neighbors = new List<KeyValuePair<Vector2Int, int>>();
	for (int x = startX; x < endX + 1; x++)
	{
		if (x == tileX || !InBounds(x, tileY)) continue;
		neighbors.Add(new KeyValuePair<Vector2Int, int>(new Vector2Int(x, tileY), GetTile(x, tileY)));
	}
	for (int y = startY; y < endY + 1; y++)
	{
		if (y == tileY || !InBounds(tileX, y)) continue;
		neighbors.Add(new KeyValuePair<Vector2Int, int>(new Vector2Int(tileX, y), GetTile(tileX, y)));
	}

	return neighbors;
}

Let’s also add a new GroundTileType for our River, with the next TileId that comes in line:

public enum GroundTileType
{
	/* 1 - 1000 */
	Empty = 0, // Empty default
	DeepWater = 1,
	UndeepWater = 2,
	Beach = 3,
	Grass = 4,
	Dirt = 5,
	Mountain = 6,
	Snow = 7,
	River = 8
}

And configure this new tile in your TileGrid in the editor:

Downstream river generation

This section will cover the generation of rivers using our noisemap height that
we created in the Perlin noise tutorial, so if you haven’t seen that one yet go check it out right now!

Let’s begin with creating the Algorithm class for our Downstream river generation.
Let’s call it DownstreamRiverGeneration and derive it from AlgorithmBase.
We’ll also add some configuration options like min rivers, max rivers, min distance between starting points for rivers, possible starting tile types where rivers can start.
And we also need a reference to our perlin noise algorithm scriptable object.
We’ll also initialize a System.Random with the seed of the map.

[CreateAssetMenu(fileName = "DownstreamRiverGeneration", menuName = "Algorithms/DownstreamRiverGeneration")]
public class DownstreamRiverGeneration : AlgorithmBase
{
	public int MinRiverQuota;
	public int MaxRiverQuota;
	public int MinDistanceBetweenRiverStartPoints;
	public GroundTileType[] StartingTileTypes;
	public NoiseGeneration GroundHeightmap;

	private System.Random _random;

	public override void Apply(TilemapStructure tilemap)
	{
		_random = new System.Random(tilemap.Grid.Seed);
	}
}

Now we’ll need to create a container for our river, let’s do that by creating a class within the DownstreamRiverGeneration class called ‘DownstreamRiver’.
Here we define a variable for the start position, a HashSet for river positions.
This so we don’t have duplicate positions.
We’ll also add a const int to keep river generation attempts limited, incase we cannot find a valid river.

class DownstreamRiver
{
	public Vector2Int StartPos;
	public HashSet<Vector2Int> RiverPositions;

	private const int _maxAttempts = 1000;

	public DownstreamRiver(Vector2Int startPos)
	{
		StartPos = startPos;
		RiverPositions = new HashSet<Vector2Int> { StartPos };
	}
}

To this river class, let’s add a method to cover the distance checking.
The formula for distance is ((x1 – x2) * (x1 – x2) + (x1 – x2) * (x1 – x2))
This will also return the squared distance, so if the distance is 4, it will return 16.
So we must square our minDistance aswel when we compare.

public bool CheckDistance(Vector2Int startPos, int minDistance)
{
	float distance = ((startPos.x - StartPos.x) * (startPos.x - StartPos.x) + (startPos.y - StartPos.y) * (startPos.y - StartPos.y));
	return distance > minDistance * minDistance;
}

Now let’s add a method to build our DownstreamRiver, we can add it in the same class.

public bool Build(TilemapStructure tilemap, float[] heightmap)
{
	Vector2Int currentPos = RiverPositions.First();

	// The target tile we want the river to attempt to reach
	int waterTileId = (int)GroundTileType.DeepWater;

	bool done = false;
	int attempt = 0;
	while (!done)
	{
		// Check how many attempts we have done so far
		if (attempt >= _maxAttempts)
		{
			break;
		}
		attempt++;

		// Get the height of the current position
		var height = heightmap[currentPos.y * tilemap.Width + currentPos.x];

		// Find the neighbor with the lowest height that isn't already a part of the river
		// Here we use a dirty trick to create a nullable struct, so FirstOrDefault can properly return null
		// Incase we cannot get to a water tile
		var lowestHeightNeighbor = tilemap.Get4Neighbors(currentPos.x, currentPos.y)
			.Select(a => new KeyValuePair<Vector2Int, float>(a.Key, heightmap[a.Key.y * tilemap.Width + a.Key.x]))
			.OrderBy(a => a.Value)
			.Select(a => new KeyValuePair<Vector2Int, float>?(a))
			.FirstOrDefault(a => !RiverPositions.Contains(a.Value.Key));

		// If the lowest neighbor is null
		if (lowestHeightNeighbor == null)
		{
			// Can't go deeper downwards, we made a lake.
			done = true;
			break;
		}

		// Add the current pos to the river positions
		currentPos = lowestHeightNeighbor.Value.Key;
		RiverPositions.Add(lowestHeightNeighbor.Value.Key);

		// Check if we are done, by checking if the current pos tile is a water tile
		done = tilemap.GetTile(lowestHeightNeighbor.Value.Key.x, lowestHeightNeighbor.Value.Key.y) == waterTileId;
	}

	return done;
}

Now that we have a container for our river and a way to construct our river,
All we need is to re-create our heightmap from our NoiseGeneration algorithm and a way to find a suitable starting position.

Let’s begin with the heightmap, in our apply method let’s re-construct our heightmap.

// Re-create the heightmap from our tilemap
var heightmap = Noise.GenerateNoiseMap(tilemap.Width, tilemap.Height, tilemap.Grid.Seed, GroundHeightmap.NoiseScale, GroundHeightmap.Octaves, GroundHeightmap.Persistance, GroundHeightmap.Lacunarity, GroundHeightmap.Offset);
if (GroundHeightmap.ApplyIslandGradient)
{
	var islandGradient = Noise.GenerateIslandGradientMap(tilemap.Width, tilemap.Height);
	for (int x = 0, y; x < tilemap.Width; x++)
	{
		for (y = 0; y < tilemap.Height; y++)
		{
			// Subtract the islandGradient value from the noiseMap value
			float subtractedValue = heightmap[y * tilemap.Width + x] - islandGradient[y * tilemap.Width + x];

			// Apply it into the map, but make sure we clamp it between 0f and 1f
			heightmap[y * tilemap.Width + x] = Mathf.Clamp01(subtractedValue);
		}
	}
}

Now that we have access to our heightmap, we need to create a method to take a random tile from our suitable tiles that fits our distance criteria. Thankfully we have already made the distance calculation. Let’s add a new method “GetValidStartPosition”.

private Vector2Int? GetValidStartPosition(List<Vector2Int> startPositions, List<DownstreamRiver> rivers)
{
	// If no tiles available return null
	if (!startPositions.Any()) return null;

	// Get a random starting tile
	var startPoint = startPositions[_random.Next(0, startPositions.Count)];
	startPositions.Remove(startPoint);

	// Also here we have an attempt check
	const int maxAttempts = 500;
	int attempt = 0;

	// While there is any river where it's startpoint isn't enough distance away from our new startPoint
	while (rivers.Any(river => !river.CheckDistance(startPoint, MinDistanceBetweenRiverStartPoints)))
	{
		if (attempt >= maxAttempts)
		{
			return null;
		}
		attempt++;

		// Get the next random tile
		if (!startPositions.Any()) return null;
		startPoint = startPositions[_random.Next(0, startPositions.Count)];
		startPositions.Remove(startPoint);
	}

	return startPoint;
}

Let’s now build our rivers by looping over the random amount of rivers between min/max we wanna create:

public override void Apply(TilemapStructure tilemap)
{
	_random = new System.Random(tilemap.Grid.Seed);

	// Re-create the heightmap from our tilemap
	var heightmap = Noise.GenerateNoiseMap(tilemap.Width, tilemap.Height, tilemap.Grid.Seed, GroundHeightmap.NoiseScale, GroundHeightmap.Octaves, GroundHeightmap.Persistance, GroundHeightmap.Lacunarity, GroundHeightmap.Offset);
	if (GroundHeightmap.ApplyIslandGradient)
	{
		var islandGradient = Noise.GenerateIslandGradientMap(tilemap.Width, tilemap.Height);
		for (int x = 0, y; x < tilemap.Width; x++)
		{
			for (y = 0; y < tilemap.Height; y++)
			{
				// Subtract the islandGradient value from the noiseMap value
				float subtractedValue = heightmap[y * tilemap.Width + x] - islandGradient[y * tilemap.Width + x];

				// Apply it into the map, but make sure we clamp it between 0f and 1f
				heightmap[y * tilemap.Width + x] = Mathf.Clamp01(subtractedValue);
			}
		}
	}

	// Get all start positions
	var validStartPositions = TilemapHelper.GetTilesByType(tilemap, StartingTileTypes.Select(a => (int)a));
	var amountOfRivers = _random.Next(MinRiverQuota, MaxRiverQuota + 1);

	var rivers = new List<DownstreamRiver>();
	for (int i = 0; i < amountOfRivers; i++)
	{
		// Get valid startPoint with respect to distance between existing rivers
		var startPoint = GetValidStartPosition(validStartPositions, rivers);
		if (!startPoint.HasValue) break;

		// Build river from start based on heightmap
		var river = new DownstreamRiver(startPoint.Value);
		if (river.Build(tilemap, heightmap))
		{
			rivers.Add(river);
		}
	}

	// Set river tiles into tilemap, here we select all Vector2Ints in all rivers
	int riverTileId = (int)GroundTileType.River;
	foreach (var riverPosition in rivers.SelectMany(a => a.RiverPositions))
	{
		tilemap.SetTile(riverPosition.x, riverPosition.y, riverTileId);
	}
}

We should also create our scriptable object algorithm in the editor and assign it to our GroundMap, right after our perlin noise algorithm.

Here is how that would look:

The result:

Seed 12354 size200x200

Drunken river generation

Here is method 2 of river generation, which will be a little bit different.
We well re-use a lot of methods but we’ll re-create our river container.

Let’s begin with creating the DrunkenRiverGeneration class and derive it from AlgorithmBase.

[CreateAssetMenu(fileName = "DrunkenRiverGeneration", menuName = "Algorithms/DrunkenRiverGeneration")]
public class DrunkenRiverGeneration : AlgorithmBase
{
	public int MinRiverQuota;
	public int MaxRiverQuota;
	public int MinDistanceBetweenRiverStartPoints;
	public GroundTileType[] StartingTileTypes;

	[Range(0, 100)]
	public int RiverDriftChance;

	private System.Random _random;

	public override void Apply(TilemapStructure tilemap)
	{
		_random = new System.Random(tilemap.Grid.Seed);
		var rivers = new List<DrunkenRiver>();

		var validStartPositions = TilemapHelper.GetTilesByType(tilemap, StartingTileTypes.Select(a => (int)a));
		var amountOfRivers = _random.Next(MinRiverQuota, MaxRiverQuota + 1);

		for (int i=0; i < amountOfRivers; i++)
		{
			// Get valid startPoint with respect to distance between existing rivers
			var startPoint = GetValidStartPosition(validStartPositions, rivers);
			if (!startPoint.HasValue) break;

			// Find valid endPos (closest deep water tile to startPos)
			var endPos = TilemapHelper.FindClosestTileByType(tilemap, startPoint.Value, (int)GroundTileType.DeepWater);
			if (!endPos.HasValue)  break;

			// Build river from start to end position
			var river = new DrunkenRiver(_random, RiverDriftChance, startPoint.Value, endPos.Value);
			if (river.Build())
			{
				rivers.Add(river);
			}
		}
		
		// Set river tiles into tilemap
		int riverTileId = (int)GroundTileType.River;
		foreach (var riverPosition in rivers.SelectMany(a => a.RiverPositions))
		{
			tilemap.SetTile(riverPosition.x, riverPosition.y, riverTileId);
		}
	}
}

As you can see, the method is almost similar.
The main difference is that we now have a DriftChance, that we pass along to our river container. Let’s actually make this container now:

class DrunkenRiver
{
	public Vector2Int StartPos;
	public Vector2Int EndPos;
	public HashSet<Vector2Int> RiverPositions;

	private readonly System.Random _random;
	private readonly int _riverDriftChance;
	private const int _maxAttempts = 1000;

	public DrunkenRiver(System.Random random, int riverDriftChance, Vector2Int startPos, Vector2Int endPos)
	{
		_random = random;
		_riverDriftChance = riverDriftChance;
		StartPos = startPos;
		EndPos = endPos;
		RiverPositions = new HashSet<Vector2Int> { StartPos, EndPos };
	}

	public bool CheckDistance(Vector2Int startPos, int minDistance)
	{
		float distance = ((startPos.x - StartPos.x) * (startPos.x - StartPos.x) + (startPos.y - StartPos.y) * (startPos.y - StartPos.y));
		return distance > minDistance * minDistance;
	}
}

It’s also very similar setup like our DownstreamRiver, we pass along the drift chance and a System.Random, we also have the same distance check here.
Let’s add our Build() method.

public bool Build()
{
	Vector2Int currentPos = StartPos;

	int attempts = 0;
	while (currentPos != EndPos)
	{
		// Another attempt check here
		if (attempts >= _maxAttempts) return false;
		attempts++;

		var differenceX = currentPos.x - EndPos.x;
		var differenceY = currentPos.y - EndPos.y;

		// If we are on a straight path towards the endPos, we want to have some chance to drift
		if (differenceX == 0 || differenceY == 0)
		{
			var driftChance = _random.Next(0, 100);
			if (driftChance <= _riverDriftChance)
			{
				var difference = _random.Next(1, 4);
				var direction = _random.Next(0, 2);

				// One of our axis is in a straight line, we don't want to keep going straight so lets drift
				if (differenceX == 0)
				{
					for (int i = 0; i < difference; i++)
					{
						currentPos = new Vector2Int(direction == 0 ? currentPos.x - 1 : currentPos.x + 1, currentPos.y);
						RiverPositions.Add(currentPos);
					}
				}
				else if (differenceY == 0)
				{
					for (int i = 0; i < difference; i++)
					{
						currentPos = new Vector2Int(currentPos.x, direction == 0 ? currentPos.y - 1 : currentPos.y + 1);
						RiverPositions.Add(currentPos);
					}
				}
			}
		}

		// Basic direction guide towards the end position
		if (differenceX > 0)
		{
			currentPos = new Vector2Int(currentPos.x - 1, currentPos.y);
			RiverPositions.Add(currentPos);
		}
		else if (differenceX < 0)
		{
			currentPos = new Vector2Int(currentPos.x + 1, currentPos.y);
			RiverPositions.Add(currentPos);
		}
		if (differenceY > 0)
		{
			currentPos = new Vector2Int(currentPos.x, currentPos.y - 1);
			RiverPositions.Add(currentPos);
		}
		else if (differenceY < 0)
		{
			currentPos = new Vector2Int(currentPos.x, currentPos.y + 1);
			RiverPositions.Add(currentPos);
		}
	}
	return true;
}

This method is just some basic movement towards a coordinate by incrementing or decrementing the X or Y value until the start pos is the same as the end pos.
And when one of the axis is the same, that means we only need to travel in the other axis to reach our target, we add some change to drift from the path. Creating a bit of a swerve.

Let’s also add the same method to get a valid starting tile, it’s the same as the one for the downstream river, only we now pass a list of the drunken river container instead.

private Vector2Int? GetValidStartPosition(List<Vector2Int> startPositions, List<DrunkenRiver> rivers)
{
	// If no tiles available return null
	if (!startPositions.Any()) return null;

	// Get a random starting tile
	var startPoint = startPositions[_random.Next(0, startPositions.Count)];
	startPositions.Remove(startPoint);

	// Also here we have an attempt check
	const int maxAttempts = 500;
	int attempt = 0;

	// While there is any river where it's startpoint isn't enough distance away from our new startPoint
	while (rivers.Any(river => !river.CheckDistance(startPoint, MinDistanceBetweenRiverStartPoints)))
	{
		if (attempt >= maxAttempts)
		{
			return null;
		}
		attempt++;

		// Get the next random tile
		if (!startPositions.Any()) return null;
		startPoint = startPositions[_random.Next(0, startPositions.Count)];
		startPositions.Remove(startPoint);
	}

	return startPoint;
}

Since our apply method is now fully functional, let’s create the scriptable object and fill in the configuration to whatever suits your tilemap.

Here is my configuration:

And here is the result:

Be sure to mess around with your noise configuration for your terrain,
As rivers will look different based on the terrain with more water.
Here is a terrain with different noise settings:

Thanks for reading, hope you have learned a little on how I implement my rivers.

The github repository commit for this tutorial can be found here:
https://github.com/Venom0us/Code2DTutorials/commit/d08cccaacfa28e3530f7ccb1092b9fb4d6e0cc38

The source files can be found here:
https://github.com/Venom0us/Code2DTutorials/releases/tag/v7

4 thoughts on “Rivers

    1. OH also, this tutorial was amazing 😀 iv’e learnt so much in the week or so iv’e taken to read everything and learn how its done

      Liked by 1 person

      1. Hi Mitchell,

        To answer your question that is possible and rather simple.
        I’ve actually had this question before and already have a solution for it.
        I will cover it later today in a seperate tutorial.

        Kind regards,
        Venomaus

        Like

Leave a reply to oo Cancel reply

Design a site like this with WordPress.com
Get started