Better algorithm for determining sequence of moves needed to reach a success state in game

Solution 1:

I actually thought this was a fun game, so I wrote a quick solution that:

  • defines the game so you can play it
  • solves the game by building a tree of all possible moves (without duplicates)
  • tells you what the minimum number of needed moves for a game is and shows you the resulting steps (for one of the possible solutions)

Edit: the first version I posted actually didn't remove empty columns and allowed 1 block moves - so I updated the answer here, same main idea though.

import random

# plays and solves http://g.fromgame.com/game/13/


class Game:
    def __init__(self, width, height, colours=2):
        self.width = width
        self.height = height
        self.colours = colours
        self.board = [[0 for __ in range(width)] for __ in range(height)]

    def generate(self, seed=0):
        # very naive way to generate a board,
        # heavily biased towards horizontal runs, not really important
        random.seed(seed)
        self.board = []
        while len(self.board) < self.width * self.height:
            self.board.extend([random.randint(0, self.colours)] * random.randint(1, self.width // 2))
        self.board = [self.board[i*self.width:(i+1)*self.width] for i in range(self.height)]
        self.drop()

    def drop(self):
        # simply gravity, keeps dropping blocks until they are all at the 'bottom'
        dropped = True
        while dropped:
            dropped = False
            for lower, above in zip(reversed(self.board), list(reversed(self.board))[1:]):
                for i in range(len(lower)):
                    if lower[i] == 0 and above[i] != 0:
                        lower[i] = above[i]
                        above[i] = 0
                        dropped = True
        # move columns to the left, leaving no empties
        rc = [i for i, c in enumerate(self.board[-1]) if c == 0]
        self.board = [[c for i, c in enumerate(line) if i not in rc] + [0 for __ in range(len(rc))] for line in self.board]

    def copy(self):
        # returns a completely new copy of this game
        game = self.__class__(self.width, self.height, self.colours)
        game.board = [line.copy() for line in self.board]
        return game

    def print(self):
        for line in self.board:
            print(''.join(map(str,line)))

    def get_move(self, start_x, start_y):
        # returns all the pairs x, y that would be removed if the block at x, y is removed
        if self.board[start_y][start_x] == 0:
            return []

        colour = self.board[start_y][start_x]
        queue = [(start_x, start_y)]
        result = {(start_x, start_y)}

        def add(a, b):
            if (a, b) not in result and self.board[b][a] == colour:
                result.add((a, b))
                queue.append((a, b))

        while queue:
            x, y = queue.pop(0)
            if x+1 < self.width:
                add(x+1, y)
            if y+1 < self.height:
                add(x, y+1)
            if x-1 >= 0:
                add(x-1, y)
            if y-1 >= 0:
                add(x, y-1)

        return result

    def get_all_moves(self):
        # finds a set of all possible moves (as tuples of moves that are all the same move)
        moves = set()
        for y in range(self.height):
            for x in range(self.width):
                if self.board[y][x] > 0 and not any((x, y) in m for m in moves):
                    m = self.get_move(x, y)
                    # only moves of more than one block are valid
                    if len(m) > 1:
                        moves.add(tuple(m))
        return moves

    def make_move(self, move):
        for x, y in move:
            self.board[y][x] = 0
        self.drop()

    def check_won(self):
        # returns whether the game has been completed assumes all positive colours
        return sum(sum(line) for line in self.board) == 0

    def play(self):
        # trivial if there's nothing on the board, win in 0 moves, 1 path, empty moves
        if self.check_won():
            return 0, 1, {}
        # otherwise play all possible moves until conclusion
        moves = {}
        size = self.width * self.height
        min_d = size  # worst case as many moves as squares
        total = 0
        for move in self.get_all_moves():
            next_state = self.copy()
            next_state.make_move(move)
            d, n, rest = next_state.play()
            # only save the move if there's a way to win the game after this move
            if d < size:
                moves[(move[0], d)] = rest
                total += n
                min_d = min(min_d, d+1)
        return min_d, total, moves


def main():
    g = Game(4, 5)
    g.generate(seed=1)
    print('Start of the game:')
    g.print()
    min_moves, paths, moves = g.play()

    if min_moves == g.width * g.height:
        print('Game cannot be won!')
    else:
        g_copy = g.copy()
        print(f'The first winning play in {min_moves} moves, out of {paths} possible different games:')
        options = moves
        n = min_moves
        x, y = 0, 0
        next_options = {}
        while n > 0:
            for ((x, y), c), next_options in options.items():
                if c == n:
                    break
            print(f'Make move {(x, y)}:')
            g_copy.make_move(g_copy.get_move(x, y))
            g_copy.print()
            n -= 1
            options = next_options


if __name__ == '__main__':
    main()

This plays the game with a tiny board of 4x5, and the output:

Start of the game:
0101
0111
1122
1121
2211
The first winning play in 3 moves, out of 11 possible different games:
Make move (2, 3):
0100
0101
1101
1111
2211
Make move (2, 4):
0000
0000
0000
0000
2200
Make move (0, 4):
0000
0000
0000
0000
0000

Note that it numbers the board from top-left to lower-right, so 0,0 is top-left and 4,5 is lower-right in this example. The random seed is set, so you get the same result every time you run, though not necessarily the same game on every computer, this depends on the implementation of random - you could add a .save() and .load() function if you need more repeatability.

Note that the answer to your question is really just the play method together with the get_move and get_all_moves methods - which show how to build a tree of possible moves, depth-first with backtracking (using recursion - so limiting the board size through recursion depth, it would doable but a little less readable without recursion).

Also note that after this line: min_moves, paths, moves = g.play(), moves contains the full tree of all possible moves, given the game g, played to conclusion. It's worth having a look in a debugger to see what's actually generated - it's too big to print here.

To see how many solutions of each length there are:

        from collections import defaultdict
        counts = defaultdict(int)
        def count(ms, n):
            for m, k in ms:
                if k == 0:
                    counts[n] += 1
                else:
                    count(ms[(m, k)], n+1)
        count(moves, 0)
        print(counts)

For this example:

defaultdict(<class 'int'>, {2: 7, 3: 4})