Color Flood Solver

A Python solver for Ponder Club's daily Color Flood puzzle. The program scrapes the board straight off the screen, then searches for solutions.

  • Python
  • Playwright
  • OpenCV
  • NumPy

The problem

Color Flood looks trivial until you try to play it well. Every move floods the connected region from the top left corner into a new color, and a greedy choice that looks good now often costs you two moves later. I wanted a solver that could play the daily puzzle end to end, solving it int he most efficient manner.

Solving Color Flood Optimally: From Greedy Heuristic to IDA*

A case study in scraping a live game, modeling it as a graph problem, and proving optimality.

Overview

Ponder Club publishes a new daily Color Flood puzzle: a 10×10 grid of five colors, where each move recolors the connected region anchored at the top-left cell, absorbing any newly-adjacent same-colored cells. The goal is to turn the whole board into a single color in as few moves as possible.

I built a Python pipeline that scrapes the day’s puzzle directly from the live page, solves it with two different algorithms, and can output the true minimum number of moves required. This write-up covers the full arc: the scraper, the first working solver, why it wasn’t good enough, and the graph-search rebuild that replaced it.

Stack: Python, Playwright (Firefox) for scraping, OpenCV/NumPy for image processing, plain data structures and hand-rolled search for the solving logic.

Initial working program

The first version of this project used a greedy heuristic: repeatedly find every cell touching the current captured region, and shift to whichever adjacent color is most common. It’s simple, fast, and it always finds a solution. What it doesn’t do is tell you anything about how good that solution is.

I can tell you “My solver used 18 moves” but that is just a number with nothing to compare it to. Without a known correct solution, there’s no way to say whether the greedy approach is finding near-perfect solutions or leaving obvious moves on the table. Finding the true solution by building something that could report the actual minimum moves became the real goal of the project.

Step 1: Getting a reliable board out of a live web page

Before any solving logic matters, the board has to be read correctly. This piece turned out to have more failure modes than expected.

Scraping. Playwright drives a real Firefox instance, waits for the how-to-play popup, and dismisses it:

page.get_by_role("button", name="Close").click()

Color matching. Early on, colors were matched by checking for an exact RGB match against a reference table. To harden this against any antialiasing or PNG re-encoding on the site’s end, the fix was nearest-neighbor distance in RGB space instead of exact equality:

def rgb_to_color(pixel):
    d = np.sum((_REFS - np.array(pixel, dtype=np.int32)) ** 2, axis=1)
    return _NAMES[int(np.argmin(d))]

Step 2: Collapsing 100 cells into a graph

The greedy solver thought entirely in individual cells. That’s an inefficient unit of work for anything more sophisticated. A move in Color FLood requires considering the whole connected blob of same-colored cells at once. Every cell in that blob is interchangeable from the solver’s point of view.

So the first real structural change was collapsing the grid into a region graph: each node is a maximal same-colored connected component (found via BFS flood-fill), and each edge connects two regions that share a border.

def find_regions(grid):
    rows, cols = len(grid), len(grid[0])
    visited = set()
    regions = {}
    next_index = 0

    for r in range(rows):
        for c in range(cols):
            if (r, c) in visited:
                continue
            tempset = set()
            visited.add((r, c))
            tempset.add((r, c))
            search_list = deque([(r, c)])

            while search_list:
                current = search_list.popleft()
                for x in adjacency(current[0], current[1]):
                    if x not in visited and grid[current[0]][current[1]] == grid[x[0]][x[1]]:
                        tempset.add(x)
                        visited.add(x)
                        search_list.append(x)

            regions[next_index] = {'color': grid[r][c], 'cells': tempset}
            next_index += 1

    return regions

On a typical board, this turns 100 cells into somewhere around 20–60 nodes, depending on how fragmented the day’s puzzle is. Every algorithm downstream for the IDA*, the heuristic, the search, and the solver now operate on a graph an order of magnitude smaller than the raw grid.

Adjacency between regions is a second BFS-adjacent pass, using a cell→region lookup to detect when two different regions share a border:

def build_region_graph(regions):
    cell_to_region = {}
    for i, region in regions.items():
        for cell in region['cells']:
            cell_to_region[cell] = i

    graph = {i: set() for i in range(len(regions))}
    for i, region in regions.items():
        for cell in region['cells']:
            for neighbor in adjacency(cell[0], cell[1]):
                j = cell_to_region[neighbor]
                if i != j:
                    graph[i].add(j)
                    graph[j].add(i)
    return graph

Step 3: A provable lower bound

To know how good a solution is, you need a floor to compare it against: some number you can prove no solution can beat.

Color Flood has a natural one: each move can only ever absorb regions that are directly adjacent to the currently-captured blob, so the region graph’s eccentricity ( the shortest-path distance in region-hops from the root to the farthest region), is a hard lower bound on the number of moves needed. You cannot finish in fewer moves than the number of hops to the farthest point in the graph, no matter how well you play.

That distance falls straight out of a BFS from the root:

def bfs_distances(graph, start):
    distances = {start: 0}
    queue = deque([start])
    while queue:
        current = queue.popleft()
        for neighbor in graph[current]:
            if neighbor not in distances:
                distances[neighbor] = distances[current] + 1
                queue.append(neighbor)
    return distances

def heuristic(graph):
    return max(bfs_distances(graph, 0).values())

Step 4: IDA* — finding the actual optimal solution

With a real lower bound in hand, the last piece was a search algorithm that could use it to find the true minimum move count, rather than just a plausible one.

I used Iterative Deepening A* (IDA*): rather than keeping a large frontier in memory like regular A*, it runs repeated depth-first searches, each bounded by a cost threshold, raising the threshold each round based on the smallest cost it saw get pruned. The first time it finds a solution, that solution is provably optimal because the threshold only ever rises to the minimum amount necessary to admit it.

Each node in the search simulates a move by merging every root-adjacent region of a chosen color into the root, producing a fresh graph and region set without mutating the original state it was given, since IDA* backtracks constantly and tries multiple branches from the same state:

def make_move(graph, regions, root, color):
    to_absorb = {i for i in graph[root] if regions[i]['color'] == color}
    new_graph = {i: set(neighbors) for i, neighbors in graph.items()}

    for i in to_absorb:
        for neighbor in graph[i]:
            if neighbor != root and neighbor not in to_absorb:
                new_graph[root].add(neighbor)
                new_graph[neighbor].discard(i)
                new_graph[neighbor].add(root)

    for i in to_absorb:
        new_graph[root].discard(i)

    new_regions = {i: {'color': r['color'], 'cells': set(r['cells'])} for i, r in regions.items()}
    for i in to_absorb:
        new_regions[root]['cells'].update(new_regions[i]['cells'])
        del new_regions[i]

    return new_graph, new_regions

The search itself:

FOUND = "FOUND"

def ida_search(graph, regions, root, g, threshold, path):
    f = g + heuristic(graph)
    if f > threshold:
        return f
    if len(regions) == 1:
        return FOUND

    min_exceeded = float('inf')
    candidate_colors = {regions[i]['color'] for i in graph[root]}

    for color in candidate_colors:
        new_graph, new_regions = make_move(graph, regions, root, color)
        result = ida_search(new_graph, new_regions, root, g + 1, threshold, path)
        if result == FOUND:
            path.append(color)
            return FOUND
        min_exceeded = min(min_exceeded, result)

    return min_exceeded

def ida_star(graph, regions, root=0):
    threshold = heuristic(graph)
    path = []
    while True:
        result = ida_search(graph, regions, root, 0, threshold, path)
        if result == FOUND:
            path.reverse()
            return threshold, path
        threshold = result
        path.clear()

Note that only the colors actually present among root’s current neighbors are tried at each node, which means, a root can never neighbor its own color by construction. Since same-colored neighbors were already merged during region-finding, every branch is guaranteed to make progress, with no wasted exploration.

The path list threads through the recursion and is only appended to on the way back up a successful branch, which means it naturally builds in last-move-first order; one .reverse() at the end puts it back in the order the moves should actually be played.

Results

Run against a real scraped puzzle:

Puzzle on: 9/17/2026

Regions: 58
Total edges: 117
Heuristic lower bound (moves): 7
Optimal move count: 12

The heuristic correctly identified 7 as a floor, which no solution could beat, and the full search found the true optimal at 12, along with the exact sequence of colors that achieves it.

Bugs worth remembering

Most of the real work in this project wasn’t the algorithms themselves — it was the state-management bugs that graph search surfaces if you’re not careful:

  • Shallow copies of nested structures. dict(graph) copies the outer dict but leaves inner set objects shared with the original, therefore, mutating a “copy” silently corrupts the source. Every copy in make_move explicitly rebuilds inner sets and dicts.
  • List indices vs. dict keys under deletion. Removing an absorbed region from a list shifts every subsequent index, breaking every other reference to that data. Switching regions from a list to a dict keyed by a stable index made deletion safe without a renumbering pass.
  • Dangling graph edges. An early version of make_move deleted absorbed regions as graph keys, but never removed root’s own direct references to those now-deleted keys.This produced a graph that looked fine until something actually traversed it (BFS), at which point it threw KeyError on a “neighbor” that no longer existed.

None of these are idea errors, they’re the standard traps that occur when implementing mutable shared state and index-based data structures, which is exactly why they were worth having encountered personally rather than just read about.

What’s next

  • Feed the recovered move sequence back into Playwright to actually auto-solve the live puzzle, rather than just reporting the answer.
  • Integrate other algorithms, to test time vs accuracy on multiple searches.

What I’d do differently

  • If I had already planned to find the optimal solution I should have used a graph based approach from the start to avoid having to recode it in later.
  • Restructuring of the code could have been better. Currently this code is two separate programs in one python file: A greedy sorter and a IDA* sorter that barely have any overlap apart from adjacency(). Instead utilizing the scraper and the neighboring cells finder to do both Greedy and IDA*. This would streamline the code for future algorithm implementations.

← All projects