| |
|
|
(October 2012, Reddit-ed)
Solving the "Unblock Me" puzzle

Even since I can remember, I've always been fascinated with puzzles. This fascination has manifested itself in temporary obsessions of various natures - mathematical, probabilistic, chess related, AI ... you name it, I've probably wasted time playing with it :-)
A natural consequence is the kind of computer games I am drawn to... that is, puzzle games. I still consider Sokoban the best puzzle game I've ever played (and finished) - but also love most of Simon Tatham's collection. And recently, I started passing my half-hour train commutes to and from work, playing Unblock-Me on my iPhone.
Would you like to play a game?
This game is deceptively simple: you have to move horizontal blocks left or right, and vertical blocks up or down, in order to free ("unblock") the red block - i.e. allow it to escape from the exit to its right. The red block is always at the 3rd line, as is the exit.
If you haven't played before, here's a link to a random Youtube video I found with a quick search. Apparently I am not the only one who likes this puzzle; there are lots of similar videos, with players showcasing how they solved a level they had trouble with. Obligatory warning: this game is very addictive, if you try it, there's no turning back.
My brain has more or less adapted, and quickly solves most of the levels. But now that I am close to completing the Expert set, there are some levels that torture me for minutes!
How dare they! :-)
Time to use my brain - in a different way.
Stage 1: Detecting board sets from screenshots
On my iPhone, hitting both the main and the power button at the same time, results in an auto-saved 480x320 snapshot of the current screen (btw, that's how the picture you see above was obtained). Since the dimensions of this screenshot are constant, the placement of the tile grid will also remain constant: at identical pixel positions in every such snapshot.
(Translation: if you want to replicate my results in phones with different screenshot sizes... you'll have to edit my code :-)
This means we can do a small number of tests, and identify the tile coordinates in these snapshots:
Hunting down the tile coordinates
In the screenshot above, you see how I manipulated the .ppm data of the image (generated via ImageMagick: "convert IMG_0354.PNG old.ppm") and emitted horizontal lines. Fine-tuning the location of these white lines allowed me to find where the tile centers are located, in both horizontal, and (via a similar pass) vertical coordinates.
At that point ...
- I sampled the color of the center pixel of each tile - and mapped the RGB values into the 3 tile categories (empty, block and prisoner) via threshold values that I experimented with.
- I sampled the top center and the bottom center pixel of each tile - and stored whether it's RGB values thresholded as white, black, or non-border. The game uses these colors to "shade" the blocks, and thus allowed me to learn the vertical "borders" between blocks.
I could then write a simple algorithm that re-created the block information from these two arrays:
#define SIZE 6
enum TileKind {
empty = 0,
block = 1,
prisoner = 2
};
static TileKind g_tiles[SIZE][SIZE];
enum BorderKind {
notBorder = 0,
white = 1,
black = 2
};
static BorderKind g_borders[2*SIZE][SIZE];
struct Block {
static int BlockId;
int _id;
int _y, _x;
bool _isHorizontal;
TileKind _kind;
int _length;
Block(int y, int x, bool isHorizontal, TileKind kind, int length):
_id(BlockId++), _y(y), _x(x), _isHorizontal(isHorizontal),
_kind(kind), _length(length)
{}
};
int Block::BlockId = 0;
list<Block> ScanBodiesAndBordersAndEmitStartingBlockPositions()
{
list<Block> blocks;
bool isTileKnown[SIZE][SIZE];
memset(isTileKnown, false, sizeof(isTileKnown));
while (true) {
for(int y=0; y<SIZE; y++) {
for(int x=0; x<SIZE; x++) {
if (isTileKnown[y][x])
continue;
if (empty == g_tiles[y][x]) {
isTileKnown[y][x] = true;
continue;
}
bool isMarker = (g_tiles[y][x]==prisoner);
const char *marker = isMarker?" (marker)":"";
if (g_borders[2*y][x] == white &&
g_borders[2*y+1][x] == black) {
isTileKnown[y][x] = true;
int xend = x+1;
while(xend<SIZE && g_borders[2*y+1][xend] == black &&
g_borders[2*y][xend] == white) {
isTileKnown[y][xend] = true;
xend++;
}
if (xend-x==4) {
cout << "Horizontal blocks at " << y << "," << x;
cout << " of length 2 " << marker << "\n";
blocks.push_back(
Block(y,x, true, g_tiles[y][x], 2));
blocks.push_back(
Block(y,x+2, true, g_tiles[y][x+2], 2));
} else {
cout << "Horizontal block at " << y << "," << x;
cout << " of length " << xend-x << marker << "\n";
blocks.push_back(
Block(y,x, true, g_tiles[y][x], xend-x));
}
} else if (g_borders[2*y][x] == white) {
isTileKnown[y][x] = true;
int yend = y+1;
while(yend<SIZE && g_borders[2*yend+1][x] != black) {
isTileKnown[yend][x] = true;
yend++;
}
cout << "Vertical block at " << y << "," << x;
cout << " of length " << yend-y+1 << marker << "\n";
blocks.push_back(
Block(y,x, false, g_tiles[y][x], yend-y+1));
} else
isTileKnown[y][x] = true;
}
}
bool allDone = true;
for(int y=0; y<SIZE; y++)
for(int x=0; x<SIZE; x++)
allDone = allDone && isTileKnown[y][x];
if (allDone)
break;
}
return blocks;
}
|
With the exception of neighbouring horizontal blocks - which I had to hack around - it worked on the first try. Feeding it with screenshots, I saw it emit the board information:
From screenshot to block data
Stage 2: The Solver
So, I had a list of blocks that represented a board - what then?
Well, from each such list, I could generate ... a list of such lists - that is, the list of possible boards that can be reached from this one, by moving a block one position to the left, right, top or bottom.
Following the same approach I used in my Knight's Tour, I wrote a brute-force recursive traversal:
void SolveBoard( board ) {
can prisoner escape?
he can! Bliss
he cant?
for each possible board that can arise out of the current one
SolveBoard(newBoard)
}
|
I also added a set<Board> that was checked upon SolveBoard entry, so that the algorithm never went back to a position that it had already passed through before (in the call stack).
And run it...
And waited...
Did some web browsing. Went for some coffee...
Came back - still running.
Apparently, the problem space of this puzzle is not as small as I thought - this recursive attempt at finding the solution, goes "deep" first, exhausts the "move" it tried, then tries another.
In computer-science parlance, what I did was a "depth-first" search of the solution - meaning that the algorithm descended the tree of possible moves all the way to it's leaves (i.e. to the board states where there is no move left), and then backtracked back to try something else. And as is obvious on hindsight, this will only work for "small" problem spaces - like my Knight's Tour search. In UnblockMe however, the combinations of moves one can do at every level, multiply with the ones he can do on the next one - and basically, "explode" exponentially.
Instead of diving "deep" in the tree, I had to visit it in "layers" of increasing depth - i.e. in Breadth First Search:
Enqueue the current board
while Q not empty:
Dequeue a board and examine it
can prisoner escape?
he can! Bliss
he cant?
for each possible board that can arise out of this one
add board to END of Q
|
The pseudocode above visits the first level of available moves, then all the second-level moves, then the 3rd, etc. This means that it finds the optimal solution - the one the prisoner can use to escape, in the least amount of moves.
The actual implementation, below, has to account for not visiting already visited boards, and for tracing back from the solved board into the one we started from - I used a map<Board,MoveIusedToGetThere> to do that.
struct Move {
int _blockId;
enum Direction {left, right, up, down} _move;
Move(int blockID, Direction d):
_blockId(blockID),
_move(d) {}
};
...
void SolveBoard(list<Block>& blocks)
{
cout << "\nSearching for a solution...\n";
map< Board, Move> previousMoves;
previousMoves.insert(
pair<Board,Move>(renderBlocks(blocks), Move(-1, Move::left)));
set<Board> visited;
list< list<Block> > queue;
queue.push_back(blocks);
while(!queue.empty()) {
list<Block> blocks = *queue.begin();
queue.pop_front();
Board board = renderBlocks(blocks);
if (visited.find(board) != visited.end())
continue;
visited.insert(board);
list<Block>::iterator it=blocks.begin();
for(; it!=blocks.end(); it++) {
Block& block = *it;
if (block._kind == prisoner)
break;
}
assert(it != blocks.end());
bool allClear = true;
for (int x=it->_x+it->_length; x<SIZE ; x++) {
allClear = allClear && !board(it->_y, x);
if (!allClear)
break;
}
if (allClear) {
cout << "Solved!\n";
list<list<Block> > solution;
solution.push_front(copyBlocks(blocks));
map<Board,Move>::iterator itMove = previousMoves.find(board);
while (itMove != previousMoves.end()) {
if (itMove->second._blockId == -1)
break;
for(it=blocks.begin(); it!=blocks.end(); it++) {
if (it->_id == itMove->second._blockId) {
switch(itMove->second._move) {
case Move::left: it->_x++; break;
case Move::right: it->_x--; break;
case Move::up: it->_y++; break;
case Move::down: it->_y--; break;
}
break;
}
}
assert(it != blocks.end());
solution.push_front(copyBlocks(blocks));
board = renderBlocks(blocks);
itMove = previousMoves.find(board);
}
for(list<list<Block> >::iterator itState=solution.begin();
itState != solution.end(); itState++)
{
printBoard(*itState);
cout << "Press ENTER for next move\n";
cin.get();
}
cout << "Run free, prisoner, run! :-)\n";
exit(0);
}
for(it=blocks.begin(); it!=blocks.end(); it++) {
Block& block = *it;
#define COMMON_BODY(direction) \
list<Block> copied = copyBlocks(blocks); \
\
queue.push_back(copied); \
\
previousMoves.insert( \
pair<Board,Move>( \
renderBlocks(copied), \
Move(block._id, Move::direction)));
if (block._isHorizontal) {
if (block._x>0 &&
empty==board(block._y, block._x-1)) {
block._x--;
COMMON_BODY(left)
block._x++;
}
if (block._x+block._length<SIZE &&
empty==board(block._y, block._x+block._length)) {
block._x++;
COMMON_BODY(right)
block._x--;
}
} else {
if (block._y>0 &&
empty==board(block._y-1, block._x)) {
block._y--;
COMMON_BODY(up)
block._y++;
}
if (block._y+block._length<SIZE &&
empty==board(block._y + block._length, block._x)) {
block._y++;
COMMON_BODY(down)
block._y--;
}
}
}
}
}
|
And to my immense delight, I saw it work:
Never again shall I be blocked for long on a level :-)
No, this is not cheating! It's a product of my own brain, addressing the same problem... in a different way. It just so happens... that I know how to extend my brain :-)
Some thoughts
The code lives in GitHub, and it's relatively simple to follow through (I commented it a lot). However, if you compare this implementation with the one I did for Score4, you will naturally notice the verbosity of C++.
Why didn't I use OCaml for this? Or even C++11, which would drastically cut down on e.g. iterator loops?
Well - the problem parameters... are that I want to run this on my phone. And as it turns out, I have succeeded in installing a Linux-based cross compiler for my jailbroken iPhone - but it only works for old-style C++ (i.e. non-C++11).
OCaml on the iPhone would be cool, though - if I ever get my hands on an iPhone OCaml compiler, I'll probably port my code.
Update, Oct 22, 2012: I did a quick "port" to C++11.
Update, Oct 27, 2012: I ported the code to OCaml.
| Nomen est omen... main(O){10<putchar(6^--O?84-(31&0x20558400>>5*O):10)&&main(2+O);} |
|
|
|
|