Building a Factory game with Pipes - How to handle circular pipes?

Godot Version

4.6.1

Question

I’m trying to build a factory game that mostly uses fluid mechanics. Players place their own pipe chains to move resources from the sources to their power source. Right now, I’m trying to handle the case where the pipes go in a circle - that is pipes split, then loop back around and connect back to the same splitter. This is important to let people split their resources into thirds instead of halves or quarters. Like so

The math for this is proving to be very complex. My current solution relies too heavily on checking each case I can think of. This seems dangerous, as if there’s a situation I didn’t think of it could result in infinite recursion and crash the game.

Do you have any advice for a more robust and less hacky solution to this?

How the math should work:

  • There’s no throughput limits. If an extractor (where the resources come from) is connected to an end point (phylactery and vaporizer in this case) it shows up immediately
  • The system works in terms of flow rate - there are no discrete objects moving around. Numbers should be calculated immediately
  • Small rounding errors are fine

Relevant code - I tried to keep it all commented for easy understanding

# Builds the building. Puts it on the grid and gives the game all the info it needs about the building.
func spawn_building(location : Vector2) -> void:
	#for buildArea in buildingPreviewInstance.get_overlapping_areas():
	# Placeholder because I don't want to have to take time to enable build mode while I'm testing it
	# Makes it so I don't have to fix indentation later
	if 1 == 1:
		#if buildArea == get_tree().get_first_node_in_group("Player").get_node("BuildArea"):
		# Placeholder because I don't want to have to take time to enable build mode while I'm testing it
		# Makes it so I don't have to fix indentation later
		if 1 == 1:
			# Check to see if there's a pipe at that location already
			if pipeInfo.has(location) == false:
				# Keeps track of whether or not building can be built on that tile
				var canBuild = false
				
				# Variables for pipe dictionary
				# Keeps track of all the info on placed pipes
				
				# Name of the building
				var nameBuilding : String
				
				# Where the pipes connect - which location feeds into this pipe?
				var recieves : Vector2
				
				# Mergers have two locations for this, so they need an extra. 
				# For everyone else it's (-100, -100)
				var mergeRecieves := Vector2(-100, -100)
				
				# Where the pipes connect - which location does this feed out to?
				var gives : Vector2
				
				# Splitters feed out into two locations, so they need an extra one
				# For everyone else it's (-100, -100)
				var splitGives := Vector2(-100, -100)
				
				# The list of resources that can run through the pipes
				# Defaults to an amount of 0 for everything but the things that pull the resource from the environment
				var sourceDictionary : Dictionary[String, float] = {"Water" : 0, "Fire" : 0, "Air" : 0, "Earth" : 0}
				
				# Each pipe has unique info, so check the type of pipe and populate the info accordingly
				# Some have unique circumstances where they can't be placed, so check that too
				# Each pipe has a script that says which direction it's facing. That's used to determine which tiles connect to it
				if selectedBuilding == normalPipe:
					canBuild = true
					nameBuilding = "Normal Pipe"
					recieves = get_grid_coordinates(location) + NormalPipe.get_normal_pipe_recieves(buildingRotation, flip)
					recieves = get_grid_position(recieves)
					gives = get_grid_coordinates(location) + NormalPipe.get_normal_pipe_gives(buildingRotation, flip)
					gives = get_grid_position(gives)
				
				elif selectedBuilding == turnPipe:
					canBuild = true
					nameBuilding = "Turn Pipe"
					recieves = get_grid_coordinates(location) + TurnPipe.get_turn_pipe_recieves(buildingRotation, flip)
					recieves = get_grid_position(recieves)
					gives = get_grid_coordinates(location) + TurnPipe.get_turn_pipe_gives(buildingRotation, flip)
					gives = get_grid_position(gives)
				
				elif selectedBuilding == mergePipe:
					canBuild = true
					nameBuilding = "Merge Pipe"
					recieves = get_grid_coordinates(location) + MergePipe.get_merge_pipe_recieves(buildingRotation, flip)
					recieves = get_grid_position(recieves)
					mergeRecieves = get_grid_coordinates(location) + MergePipe.get_merge_pipe_merges(buildingRotation, flip)
					mergeRecieves = get_grid_position(mergeRecieves)
					gives = get_grid_coordinates(location) + MergePipe.get_merge_pipe_gives(buildingRotation, flip)
					gives = get_grid_position(gives)
				
				elif selectedBuilding == splitPipe:
					canBuild = true
					nameBuilding = "Split Pipe"
					recieves = get_grid_coordinates(location) + SplitPipe.get_split_pipe_recieves(buildingRotation, flip)
					recieves = get_grid_position(recieves)
					gives = get_grid_coordinates(location) + SplitPipe.get_split_pipe_gives(buildingRotation, flip)
					gives = get_grid_position(gives)
					splitGives = get_grid_coordinates(location) + SplitPipe.get_split_pipe_splits(buildingRotation, flip)
					splitGives = get_grid_position(splitGives)
				
				# After an underground pipe is placed, the end point must be placed
				# The gives value is set once the end is placed
				if selectedBuilding == undergroundPipeStart:
					canBuild = true
					nameBuilding = "Underground Pipe Start"
					recieves = get_grid_coordinates(location) + NormalPipe.get_normal_pipe_recieves(buildingRotation, flip)
					recieves = get_grid_position(recieves)
					undergroundLocation = cursor_snap()
					isBuildingUnderground = true
				
				# Sets it's recieves variable based on where the first underground was placed
				# Sets the first underground pipe's gives based on its own location
				if selectedBuilding == undergroundPipeEnd:
					canBuild = true
					nameBuilding = "Underground Pipe End"
					recieves = undergroundLocation
					gives = get_grid_coordinates(location) + NormalPipe.get_normal_pipe_gives(buildingRotation, flip)
					gives = get_grid_position(gives)
					pipeInfo[undergroundLocation]["Gives"] = cursor_snap()
				
				if selectedBuilding == vaporizer:
					canBuild = true
					nameBuilding = "Vaporizer"
					recieves = get_grid_coordinates(location) + NormalPipe.get_normal_pipe_recieves(buildingRotation, flip)
					recieves = get_grid_position(recieves)
				
				# Extractors can only be placed on resource tiles
				# The first check makes sure that it is overlapping it (a source)
				# Extractors are the only one to have an element value set on creation as well
				elif selectedBuilding == extractor: 
					# Check where all the sources are
					for source in sourceArray:
						# Check to see if the buildingPreview overlaps with the source
						for body in source.get_overlapping_areas():
							if body == buildingPreviewInstance:
								canBuild = true
								nameBuilding = "Extractor"
								gives = get_grid_coordinates(location) + Extractor.get_extractor_gives(buildingRotation, flip)
								gives = get_grid_position(gives)
								recieves = Vector2.ZERO
								var elementCounter = 0
								for type in source.type:
									sourceDictionary[type] = source.amount[elementCounter]
									elementCounter += 1
								continue
				
				# Only one phylactery can be placed on each map/scene
				elif selectedBuilding == phylactery:
					if pipeInfo.size() != 0:
						for item in pipeInfo:
							var valueArray = pipeInfo[item].values()
							if valueArray.has("Phylactery") == true:
								return
					
					canBuild = true
					nameBuilding = "Phylactery"
					recieves = get_grid_coordinates(location) + Phylactery.get_phylactery_recieves(buildingRotation)
					recieves = get_grid_position(recieves)
					gives = Vector2.ZERO
				
				# If the conditions for building are fulfilled, then build it
				if canBuild == true:
					var building = selectedBuilding.instantiate()
					
					# Populates the pipe dictionary
					# Name, name of the building
					# X, x location in pixels
					# Y, y location in pixels
					# Rotation, building rotation in increments of 90 degrees
					# Flip, whether or not the building had to be flipped horizontally
					# Recieves, which tile the pipe gets its resources from
					# Merge Recieves, for mergers, since they get resources from two locations
					# Gives, which tile the pipe sends its resource to
					# Splot Gives, for splitters since they send resources to two locations
					# Elements, the list of resources in the pipe, only extractors have this populated at the beginning
					pipeInfo[location] = {
						"Name" : nameBuilding, 
						"X" : location.x, 
						"Y" : location.y, 
						"Rotation" : buildingRotation, 
						"Flip" : flip, 
						"Recieves" : recieves, 
						"Merge Recieves" : mergeRecieves,
						"Gives" : gives, 
						"Split Gives" : splitGives,
						"Elements" : sourceDictionary, 
					}
					
					# Places the visuals for the pipe and adds it to the Building Global Group
					get_parent().add_child(building)
					building.position = cursor_snap() + (CELL_SIZE / 2)
					if flip == true:
						building.scale.x = -1
					building.get_node("Sprite2D").rotate(deg_to_rad(buildingRotation))
					building.add_to_group("Buildings")
					
					# Need to make sure the end point of an underground pipe is placed immediately after the start point
					if selectedBuilding == undergroundPipeStart or selectedBuilding == undergroundPipeEnd:
						select_building()
						isBuildingUnderground = false
					
					# Calculates the resources running through the factory
					recalculate_factory()
# Used to calculate how many resources are flowing through the pipes
# There are no throughput limits
# Any source connected to the phylactery is immediately counted. There's nothing based on time
# In other words, everything can be calculated immediately
func recalculate_factory():
	# Reset energy
	waterAmount = 0
	fireAmount = 0
	airAmount = 0
	earthAmount = 0
	
	# Every non-extractor pipe needs to have its resource amounts reset
	for pipe in pipeInfo:
		if pipeInfo[pipe]["Name"] != "Extractor":
			for element in pipeInfo[pipe]["Elements"]:
				pipeInfo[pipe]["Elements"][element] = 0
	
	# Goes through the entire pipe list to find all the extractors
	# Each path starts from the extractor
	# Each path is calculated separately. Mathmatically, this works out
	for item in pipeInfo:
		if pipeInfo[item]["Name"] == "Extractor":
			phylacteryLocation = Vector2(-100, -100)
			
			# Sees if a path exists
			# If it does, get an array of pipes in the path
			# If it doesn't (returns empty array) do nothing
			var pathArray = find_pipe_path(item, [])
			if pathArray != []:
				# If a path is found, calculate how many resources are going through the pipe path
				calculate_flow(pathArray, [], pipeInfo[item]["Elements"], false)
				
				# If the path includes a phylactery, update the global variables for the values going into it
				if phylacteryLocation != Vector2(-100, -100):
					waterAmount = pipeInfo[phylacteryLocation]["Elements"]["Water"]
					fireAmount = pipeInfo[phylacteryLocation]["Elements"]["Fire"]
					airAmount = pipeInfo[phylacteryLocation]["Elements"]["Air"]
					earthAmount = pipeInfo[phylacteryLocation]["Elements"]["Earth"]
	
	# Update the player's stats based on the resources going through the factory
	# The values are stored in a global class, which then updates the player
	FactoryGlobal.get_total_water(waterAmount)
	FactoryGlobal.get_total_fire(fireAmount)
	FactoryGlobal.get_total_air(airAmount)
	FactoryGlobal.get_total_earth(earthAmount)
# Sees if a path to an end point can be found
# Right now, the only end points are the phylactery and vaporizer
# item is the location of the starting pipe
# mergerList is a list of Merge Pipes to check for looping pipes
# Returns an array of pipes connected to each other if an end point is found
# Returns an empty array if no end point is found
func find_pipe_path(item : Vector2, mergerList : Array[Vector2]) -> Array[Vector2]:
	# The array of connected pipes. This is returned if a path is found
	var pathArray : Array[Vector2] = []
	
	# The current pipe being checked
	var current = pipeInfo[item]
	
	# x, y coordinates of the current pipe
	var currentCoords = item
	
	# The previous pipe being looked at - null on the first pass, gets updated every loop
	var previous = null
	
	# The pipe properties that need to be checked
	var check = pipeInfo[item]["Name"]
	var next = pipeInfo[item]["Gives"]
	
	# Need an extra check in case the end is right next to a splitter
	if check == "Phylactery" or check == "Vaporizer":
		pathArray.append(currentCoords)
		if check == "Phylactery":
			phylacteryLocation = currentCoords
		return pathArray
	
	# Check to see if you've gotten to the Phylactery
	while check != "Phylactery":
		# The current pipe gets added to the array every loop
		pathArray.append(currentCoords)
		
		# Escape the loop if Phylactery is not found
		if previous == current:
			return []
		
		# Update previous to the current pipe. If current and previous are the same we've run out of pipes to look through
		previous = current
		
		# Loop through all the pipes until you've found an end point or the next pipe in the chain
		for pipe in pipeInfo:
			if pipe == next:
				if pipeInfo[pipe]["Name"] == "Phylactery" or pipeInfo[pipe]["Name"] == "Vaporizer":
					pathArray.append(pipe)
					if pipeInfo[pipe]["Name"] == "Phylactery":
						phylacteryLocation = pipe
					
					return pathArray
				
				# The next pipe must actually recieve from the current pipe
				# No trying to be silly and feed pipes sideways!
				# Sets the things we need to check for the next loop to find the next pipe in the chain
				if pipeInfo[pipe]["Recieves"] == currentCoords \
				or pipeInfo[pipe]["Merge Recieves"] == currentCoords:
					check = pipeInfo[pipe]["Name"]
					next = pipeInfo[pipe]["Gives"]
					current = pipeInfo[pipe]
					currentCoords = pipe
					
					# If it's a merge pipe, make sure we haven't passed it before
					# Failure to do so results in infinite recursion and thus stack overflow
					# Basically, checking to make sure the pipes don't make a circle
					if check == "Merge Pipe":
						if mergerList.has(currentCoords):
							return []
						
						# If it's new, add it to the list just in case this one loops
						mergerList.append(currentCoords)
					
					# If the pipe is a split pipe, we need to check both sides
					# First, check the gives side. If you find something, great a path exists
					# If no end point exists on one path, try the other one
					elif check == "Split Pipe":
						var splitPath : Array[Vector2] = find_pipe_path(pipe, mergerList)
						if splitPath != []:
							pathArray.append_array(splitPath)
							return pathArray
						else:
							next = pipeInfo[pipe]["Split Gives"]
					
					# Don't need to keep looping through the dictinary once we find the next one
					break
	
	return pathArray
# Now that we know a path exists, calculate the resource amounts flowing through the path
# pathArray is an array of pipe locations that make up the found path
# mergerList is a list of Merge Pipes we've passed. There to make sure there's no infinite recursion if there's a chain of pipes that make a circle
# originalAmount is the amount in the initial extractor. Necessary for looping pipes
# recursionLoop checks if we're going through a looping pipe
# Returns nothing, but all the resource values are updated in each pipe
func calculate_flow(pathArray : Array[Vector2], mergerList : Array[Vector2], originalAmount : Dictionary, recursionLoop : bool) -> void:

	# First pipe in the path
	var first = pipeInfo[pathArray[0]]
	
	# The first splitter encountered - stays null if there's no splitters
	var baseSplit = null
	
	# If there's a circle of pipes, get the first merger in the loop
	var recursivePipe := Vector2(-100, 100)
	
	for index in pathArray.size():
		
		# Check for looping pipes first. They need special logic
		# Their resource is based on what's going through the pipe added to the original amount from the extractor
		if pipeInfo[pathArray[index]]["Name"] == "Merge Pipe":
			if mergerList.has(pathArray[index]):
				mergerList.clear()
				mergerList.append(pathArray[index])
				recursivePipe = pathArray[index]
				var elementCheck
				
				# Once the difference between the amount of resources from the current loop and previous loop is small, stop looping
				for element in originalAmount:
					if originalAmount[element] > 0:
						elementCheck = element
				
				var previousLoop : float = pipeInfo[pathArray[index]]["Elements"][elementCheck]
				var nextLoop : float = originalAmount[elementCheck] + first["Elements"][elementCheck]
				
				if nextLoop - previousLoop >= originalAmount[elementCheck] / 1000:
					recursionLoop = true
					for element in pipeInfo[pathArray[index]]["Elements"]:
						pipeInfo[pathArray[index]]["Elements"][element] = originalAmount[element] + first["Elements"][element]
				else:
					return
				
				first = pipeInfo[pathArray[index]]
				
			else:
				# If it's a new merger, add it to the mergerList
				mergerList.append(pathArray[index])
		
		# If we're in the middle of a loop of pipes, set it to the amounts in the merger instead of adding it to what's already there
		if recursionLoop == true and first != pipeInfo[pathArray[index]] and pathArray[index] != recursivePipe:
			for element in pipeInfo[pathArray[index]]["Elements"]:
				pipeInfo[pathArray[index]]["Elements"][element] = first["Elements"][element]
		
		# If we're just going through normally, add the value inside the pipe to the value coming through
		# Keeps things accurate if multiple extractors are flowing through this route
		elif first != pipeInfo[pathArray[index]] and pipeInfo[pathArray[index]]["Name"] != "Extractor":
			for element in pipeInfo[pathArray[index]]["Elements"]:
				pipeInfo[pathArray[index]]["Elements"][element] += first["Elements"][element]
		
		#if pipeInfo[pathArray[index]]["Name"] == "Phylactery" or pipeInfo[pathArray[index]]["Name"] == "Vaporizer":
		
		# If splitters exist we have to do all kinds of shenanigans
		# This gets the splitter so we can check all its paths
		if pipeInfo[pathArray[index]]["Name"] == "Split Pipe":
			baseSplit = pipeInfo[pathArray[index]]
			break
	
	# If a splitter exists, we must check all its paths
	# Splitter logic is like this
	# If both sides lead somewhere, the resources in it is split equally
	# If one side is a dead end, it becomes a normal pipe - 100% of the resources flowing through it go to that side
	# If neither side goes anywhere, it's a dead end and can be ignored
	# If the pipe is looping, we don't add to the existing value, we set it to the value in the first splitter
	if baseSplit != null:
		var gives = baseSplit["Gives"]
		var splitGives = baseSplit["Split Gives"]
		
		# Both the points that feed out of the splitter have a pipe on the tile
		if pipeInfo.has(gives) == true and pipeInfo.has(splitGives) == true:
			
			# Check to see if both sides actually have a path
			var givesPath : Array[Vector2] = find_pipe_path(gives, [])
			var splitsPath : Array[Vector2] = find_pipe_path(splitGives, [])
			
			# If both sides reach an end point, split the resources in them
			if givesPath != [] and splitsPath != []:
				if recursionLoop == true:
					for element in baseSplit["Elements"]:
						pipeInfo[gives]["Elements"][element] = baseSplit["Elements"][element] / 2
						pipeInfo[splitGives]["Elements"][element] = baseSplit["Elements"][element] / 2
				else:
					for element in baseSplit["Elements"]:
						pipeInfo[gives]["Elements"][element] += baseSplit["Elements"][element] / 2
						pipeInfo[splitGives]["Elements"][element] += baseSplit["Elements"][element] / 2
				
				# Then continue calculating from here
				calculate_flow(givesPath, mergerList, originalAmount, recursionLoop)
				if recursionLoop == false:
					calculate_flow(splitsPath, mergerList, originalAmount, recursionLoop)
			
			# If only one side has an end point, don't divide the resources
			elif givesPath != [] and splitsPath == []:
				if recursionLoop == true:
					for element in baseSplit["Elements"]:
						pipeInfo[gives]["Elements"][element] = baseSplit["Elements"][element]
				else: 
					for element in baseSplit["Elements"]:
						pipeInfo[gives]["Elements"][element] += baseSplit["Elements"][element]
				
				# Then continue calculating from here
				calculate_flow(givesPath, mergerList, originalAmount, recursionLoop)
			
			# If only one side has an end point, don't divide the resources - but for the other side
			elif givesPath == [] and splitsPath != []:
				if recursionLoop == true:
					for element in baseSplit["Elements"]:
						pipeInfo[splitGives]["Elements"][element] = baseSplit["Elements"][element]
				else:
					
					for element in baseSplit["Elements"]:
						pipeInfo[splitGives]["Elements"][element] += baseSplit["Elements"][element]
				
				# Then continue calculating from here
				calculate_flow(splitsPath, mergerList, originalAmount, recursionLoop)
		
		# Only one spot that the splitter feeds out to has a pipe on it
		elif pipeInfo.has(gives) == true and pipeInfo.has(splitGives) == false:
			
			# Make sure a path actually exists and we're not trying to feed into the pipe sideways or something
			var newPath = find_pipe_path(gives, [])
			
			if newPath != []:
				if recursionLoop == true:
					for element in baseSplit["Elements"]:
						pipeInfo[gives]["Elements"][element] = baseSplit["Elements"][element]
				else:
					for element in baseSplit["Elements"]:
						pipeInfo[gives]["Elements"][element] += baseSplit["Elements"][element]
				
				# Then continue calculating from here
				calculate_flow(newPath, mergerList, originalAmount, recursionLoop)
		
		# Only one spot that the splitter feeds out to has a pipe on it - but it's the other side from the above
		elif pipeInfo.has(gives) == false and pipeInfo.has(splitGives) == true:
			
			# Make sure a path actually exists and we're not trying to feed into the pipe sideways or something
			var newPath = find_pipe_path(splitGives, [])
			
			if newPath != []:
				if recursionLoop == true:
					for element in baseSplit["Elements"]:
						pipeInfo[splitGives]["Elements"][element] = baseSplit["Elements"][element]
				else:
					for element in baseSplit["Elements"]:
						pipeInfo[splitGives]["Elements"][element] += baseSplit["Elements"][element]
				
				# Then continue calculating from here
				calculate_flow(newPath, mergerList, originalAmount, recursionLoop)

long code ngl

may i ask - do you use tiles / grid for pipes or maybe node like system?

Refine the prompts.

Everything is placed on a grid, yes. The grid is generated via code and not a TileMapLayer or anything like that.

The pipes tell which tiles it gets resources from (called “receives” in code) and which tiles it sends resources to (“gives” in code). I make the chains of pipes based on that.

1. Don’t nest

Things like this:

for pipe in pipeInfo:
	if pipe == next:
		if pipeInfo[pipe]["Name"] == "Phylactery" or pipeInfo[pipe]["Name"] == "Vaporizer":
			pathArray.append(pipe)
			if pipeInfo[pipe]["Name"] == "Phylactery":
				phylacteryLocation = pipe
			return pathArray

Should be:

for pipe in pipeInfo:
	if pipe != next:
		continue
	if pipeInfo[pipe]["Name"] != "Phylactery" and pipeInfo[pipe]["Name"] != "Vaporizer":
		continue
	pathArray.append(pipe)
	if pipeInfo[pipe]["Name"] == "Phylactery":
		phylacteryLocation = pipe
	return pathArray

2. Replace convoluted execution flow with data architecture based on common data structures - arrays, dictionaries and classes. It’ll make your code much simpler.

So your problem is that you can’t handle pipes that loops?

or your problem is that you need to transfer item to next pipe adjacent to current pipe

In both cases - i’d suggest you this:

  1. On game frame / tick update pipe’s tile
  2. Check front, back, left, right and if your pipe’s direction matches it
  3. Then for each item in the pipe, select random side from 4 of them and then send item to the next pipe

Currently, I do update all the pipes just fine, provided there isn’t any pipes that go in a circle, like the pipe on the right in my diagram

This is how the pipe in the diagram is structured

  • A resource is coming in from the top left
  • It comes across the first splitter, which splits it in half
  • Half goes down the left side, which is then split in half again
  • Half goes down the right side, which is then split in half again
  • Half of that second split goes somewhere
  • The other half goes back up top into the first pipe before it is split in half by the first splitter

Every other case is working just fine! It’s only looping pipes like this that break things. You can split the resource and merge the resource all you want and it is calculated correctly.

I want to allow for any abomination of pipe maze, so I can’t simply divide by 3, even though it would work in this example.

So I do have the pipes updating. I know how to do the updating. I just need help figuring out this specific case. I want to allow for any number of looping pipes, coming and going as they please.

I also do appreciate the suggestions for how to make my code more readable. I know there’s lots I could do to clean it up. I would just like to solve this specific problem for the time being.

If you have any thoughts on how I could clean up the logic to use the simpler data structures, I would appreciate the guidance. I am new to Godot, and the kinds of things I want people to be able do with the pipes gets pretty complicated. I am open to rewriting much of the code if you have suggestions for how to implement the logic.

Reading through, I initially thought Dijkstra’s algorithm might be what you were looking for, because it would 1) yield monotonic paths (sidestepping the feedback loops and thus infinite loops) and 2) would allow a certain form of resource distribution based on path cost. However, having played Satisfactory and Factorio, and taking a look at your diagram again (especially where you note splitting into 3 paths evenly), I no longer think this is the case. It sounds like you’re asking for similar flow balancing mechanics similar to those games.

I think the issue here is that those flow balancing mechanics are a byproduct of how those games work, i.e. the resources are discrete “objects” that are actually passed around. This is part of why they can be so demanding on the CPU. This balancing diagram specifically would work in Satisfactory because of the round robin nature of the splitters.

For your game, I get the impression that it’s more like an electric current, where there aren’t discrete objects and the flow resolves/settles instantly. In other words, there is no “process” time, just throughput, to put it into factory terminology.

Things are a bit tricky here, because I see the value in an emergent balancing mechanic like that in Factorio or Satisfactory for discrete items. However, with fluids, it’s a different animal entirely.

If you really want it to work this way, though, it’s still possible. You just have to treat the “units” of fluid as discrete items and handle them one by one like they would be handled in those other games. If you’re building something on the scale of those games, that could be incredibly challenging. But if your players are only managing like 100K units then you can very easily get away with just using a C++ module. If you make it fast enough, you can might even be able to get away with doing a mini simulation whenever the pipes are changed around to saturate the lines and then gauge an average throughput for each pipe segment/output. Then you could cache it so you don’t need to keep it running the whole time. Could probably even hide/coordinate the simulation time behind an animation and start/stop button.

Represent the network as a directed cyclic graph data structure with junctions as graph vertices and pipes as directed edges. Do many iterations. In each iteration: Traverse the graph from source, calculate the flow into each vertex by adding all input edge flows and calculate the flow through output edges by dividing that. Stop at vertices you already visited, so you don’t loop per iteration. The flow values should eventually converge enough after N iterations. You can damp the flow through edges that close the loops to affect the converged values.

The other option is to simply prune the looped branches from the graph, turning it into acyclic graph and calculate the flows as you currently do.