2025, Oct 30 07:00
How to Remove Directional Bias in Grid-Based Water Flow Simulations with Symmetric Selection and Buffered Updates
Learn how to eliminate directional bias in grid-based water flow by refactoring the decision tree into symmetric rules with tie-breakers and buffered updates.
Eliminating directional bias in grid-based water flow
When simulating water flow across a tile grid, subtle ordering in conditional logic can skew results. A frequent symptom is water drifting in one horizontal direction even though the rules are meant to be symmetric. Below is a real-world pattern that triggers such bias and a way to refactor it so choices remain fair and maintainable.
The problematic decision tree
The core idea is simple: each frame, a tile tries to push a small amount of water to a single adjacent tile based on eligibility rules. Adjacency is represented as a two-item structure per direction where index 0 holds the neighbor tile object and index 1 flags whether that neighbor is “tall” (not eligible if True). The code below shows a deeply nested decision tree that attempts to prefer South, then evaluate West and East with additional tie-breakers, and finally check North.
import random
def disperse_liquid_step(self):
# self.<dir>_ref[0] = neighbor tile object
# self.<dir>_ref[1] = taller-than-allowed check
target_cell = self
if self.water > FLOW:
# South exists
if self.south_ref != None:
# South not tall
if self.south_ref[1] != True:
# South not full
if self.south_ref[0].water < (100-FLOW):
target_cell = self.south_ref[0]
# South full
else:
# South full, West exists
if self.west_ref != None:
# West not tall
if self.west_ref[1] != True:
# West not full
if self.west_ref[0].water < (100 - FLOW):
# Check East
# East exists
if self.east_ref != None:
# East tall
if self.east_ref[1] == True:
target_cell = self.west_ref[0]
# East not tall
else:
# East not full
if self.east_ref[0].water <= (100 - FLOW):
# East less water than self
if self.east_ref[0].water < self.water:
# West less water than self
if self.west_ref[0].water < self.water:
if self.east_ref[0].water > self.west_ref[0].water:
target_cell = self.west_ref[0]
elif self.east_ref[0].water < self.west_ref[0].water:
target_cell = self.east_ref[0]
else:
target_cell = random.choice([self.east_ref[0], self.west_ref[0]])
# West equal or more than self
else:
target_cell = self.east_ref[0]
# East equal or more than self
else:
target_cell = self.west_ref[0]
# East doesn't exist
else:
target_cell = self.west_ref[0]
# West full
else:
# East exists
if self.east_ref != None:
# East not tall
if self.east_ref[1] != True:
# East not full
if self.east_ref[0].water < (100-FLOW):
target_cell = self.east_ref
# East full
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East tall
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East doesn't exist
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# West tall
else:
# East exists
if self.east_ref != None:
# East not tall
if self.east_ref[1] != True:
# East not full
if self.east_ref[0].water < (100-FLOW):
target_cell = self.east_ref
# East full
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East tall
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East doesn't exist
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# South full, West doesn't exist
else:
# East exists
if self.east_ref != None:
# East not tall
if self.east_ref[1] != True:
# East not full
if self.east_ref[0].water < (100-FLOW):
target_cell = self.east_ref
# East full
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East tall
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East doesn't exist
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# South tall
else:
# West exists
if self.west_ref != None:
# West not tall
if self.west_ref[1] != True:
# West not full
if self.west_ref[0].water < (100 - FLOW):
# Check East
# East exists
if self.east_ref != None:
# East tall
if self.east_ref[1] == True:
target_cell = self.west_ref[0]
# East not tall
else:
# East not full
if self.east_ref[0].water < (100 - FLOW):
# East less water than self
if self.east_ref[0].water < self.water:
# West less water than self
if self.west_ref[0].water < self.water:
if self.east_ref[0].water > self.west_ref[0].water:
target_cell = self.west_ref[0]
elif self.east_ref[0].water < self.west_ref[0].water:
target_cell = self.east_ref[0]
else:
target_cell = random.choice([self.east_ref[0], self.west_ref[0]])
# West equal or more than self
else:
target_cell = self.east_ref[0]
# East equal or more than self
else:
target_cell = self.west_ref[0]
# East doesn't exist
else:
target_cell = self.west_ref[0]
# West full
else:
# East exists
if self.east_ref != None:
# East not tall
if self.east_ref[1] != True:
# East not full
if self.east_ref[0].water < (100-FLOW):
target_cell = self.east_ref
# East full
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East tall
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East doesn't exist
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# West tall
else:
# East exists
if self.east_ref != None:
# East not tall
if self.east_ref[1] != True:
# East not full
if self.east_ref[0].water < (100-FLOW):
target_cell = self.east_ref
# East full
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East tall
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East doesn't exist
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# West doesn't exist
else:
# East exists
if self.east_ref != None:
# East not tall
if self.east_ref[1] != True:
# East not full
if self.east_ref[0].water < (100-FLOW):
target_cell = self.east_ref
# East full
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East tall
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East doesn't exist
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# South doesn't exist
else:
# West exists
if self.west_ref != None:
# West not tall
if self.west_ref[1] != True:
# West not full
if self.west_ref[0].water < (100 - FLOW):
# Check East
# East exists
if self.east_ref != None:
# East tall
if self.east_ref[1] == True:
target_cell = self.west_ref[0]
# East not tall
else:
# East not full
if self.east_ref[0].water < (100 - FLOW):
# East less water than self
if self.east_ref[0].water < self.water:
# West less water than self
if self.west_ref[0].water < self.water:
if self.east_ref[0].water > self.west_ref[0].water:
target_cell = self.west_ref[0]
elif self.east_ref[0].water < self.west_ref[0].water:
target_cell = self.east_ref[0]
else:
target_cell = random.choice([self.east_ref[0], self.west_ref[0]])
# West equal or more than self
else:
target_cell = self.east_ref[0]
# East equal or more than self
else:
target_cell = self.west_ref[0]
# East doesn't exist
else:
target_cell = self.west_ref[0]
# West full
else:
# East exists
if self.east_ref != None:
# East not tall
if self.east_ref[1] != True:
# East not full
if self.east_ref[0].water < (100-FLOW):
target_cell = self.east_ref
# East full
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East tall
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East doesn't exist
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# West tall
else:
# East exists
if self.east_ref != None:
# East not tall
if self.east_ref[1] != True:
# East not full
if self.east_ref[0].water < (100-FLOW):
target_cell = self.east_ref
# East full
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East tall
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East doesn't exist
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# West doesn't exist
else:
# East exists
if self.east_ref != None:
# East not tall
if self.east_ref[1] != True:
# East not full
if self.east_ref[0].water < (100-FLOW):
target_cell = self.east_ref
# East full
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East tall
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
# East doesn't exist
else:
# North exists
if self.north_ref != None:
# North not tall
if self.north_ref[1] != True:
# North not full
if self.north_ref[0].water < (100 - FLOW):
target_cell = self.north_ref
What’s really going on
The shape of the decision tree matters. If one horizontal side is evaluated before the other, that direction wins whenever both are eligible and earlier checks short-circuit the rest. In other words, the comparison and branching order can create a preference even if you intended a “fair” choice. That’s why a nested tree like the one above can end up choosing East more often: East is checked and confirmed before West in several branches, while West is only selected after East fails multiple conditions. This ordering effect alone can be enough to skew your simulation.
There is another practical consideration. When you iterate tiles and update water immediately, earlier tiles can change the state in a way that biases decisions for later tiles. Buffering the outcome per frame—compute where water should go for every tile first, then apply the transfers—prevents iteration order from affecting the result.
A simpler, testable flow rule
A robust way to avoid accidental bias is to isolate the per-direction checks, collect all eligible options, and select among them in a single place. This keeps the rules symmetric, reduces branching, and makes it trivial to introduce tie-breakers without privileging one side by default. Below is a compact approach that implements exactly that. The adjacency convention stays the same: for each direction, index 0 holds the neighbor tile object and index 1 is the “tall” check.
import random
FLOW = 10 # minimum transferable amount
class Cell:
def __init__(self, level=0, raised=False):
self.level = level
self.raised = raised
self.north_ref = None
self.south_ref = None
self.east_ref = None
self.west_ref = None
def can_drain_into(self, ref):
return ref and not ref[1] and ref[0].level < (100 - FLOW)
def decide_flow(self):
target = self
if self.level <= FLOW:
return target
# Immediate preference for South if eligible
if self.can_drain_into(self.south_ref):
return self.south_ref[0]
# Collect lateral options symmetrically
lateral = []
if self.can_drain_into(self.west_ref):
lateral.append(self.west_ref[0])
if self.can_drain_into(self.east_ref):
lateral.append(self.east_ref[0])
# Tie-breaking between West and East when both are valid
if self.west_ref and self.east_ref:
west_ok = self.west_ref[0] in lateral
east_ok = self.east_ref[0] in lateral
if west_ok and east_ok:
w_amt = self.west_ref[0].level
e_amt = self.east_ref[0].level
if w_amt < self.level or e_amt < self.level:
if w_amt < e_amt:
return self.west_ref[0]
elif e_amt < w_amt:
return self.east_ref[0]
else:
return random.choice([self.east_ref[0], self.west_ref[0]])
else:
return self.west_ref[0] # default branch as in the original logic
# If only one lateral option exists, use it
if lateral:
return random.choice(lateral)
# Finally, consider North
if self.can_drain_into(self.north_ref):
return self.north_ref[0]
return target
Why this works
Splitting direction checks into focused predicates and deciding from a gathered set of candidates removes implicit priority caused by branching order. When West and East are both valid, the code compares their water levels consistently and falls back to a fair random choice when they’re equal. Because South and North have distinct roles in the rule set, they can still be handled explicitly without reintroducing hidden bias. This approach is easier to reason about and test because each condition is expressed once and the final selection is centralized.
Practical takeaways
Keep eligibility checks independent from selection, so you can reason about them in isolation and avoid unintentional preferences baked into the order of if/else blocks. If you update water in-place while iterating over the grid, buffer decisions for all tiles first and only then apply the changes; this prevents iteration order from nudging the flow in one direction. Refactoring toward small helper functions not only reduces branching depth but also makes the flow rules extensible and debuggable.
Conclusion
Directional bias in grid simulations often stems from the structure of the decision tree rather than the rules themselves. By turning per-direction checks into small helpers, collecting all valid targets, and choosing among them with clear tie-breakers or randomness, you keep the simulation fair, symmetric, and easier to maintain. And if your loop updates tiles immediately, introduce a per-frame buffer to decouple computation from application—your flow will look much more natural.