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.