Puzzle 15

Date: 2020-09-16

Keywords: puzzle 15, ADR’s numbers game

Introduction

I was inspired by ADR’s numbers gameAdam’s blog and code is way cooler than this, so your time would be better spent reading those articles than mine for right now.

and thought to try to code it myself. I wrote a version in Forth first, then a version that is more how I write D (but ported from the Forth version), and a version that is a little more OOP leaning in D. I should have taken more notes while I was doing this exercise, but I didn’t.There really isn’t much point to this article other than to have some content to test out the Github user page stuff and Pandoc.

Puzzle 15 is a game on a 4 x 4 grid with 15 tiles and space. The object is to line the tiles up in order. I thought it would be a nice programming exercise to play with Forth, which I have not done for a very long time.

The approach in this code differs from ADR’s numbers game in a couple of ways. First, there is no selection tile so the user can only move 1 tile at time. Movement keys are mapped to Vi or arrow keys. Second, the board starts in an initialized state of a final solution and then randomly makes moves. This ensures the board stays in a solvable state. There is some math voodoo that can be done to ensure this, but I thought this was easier.

Forth

The great thing about Forth is the REPL and how functions compose. There are a number of things that are less great, but for this kind of problem, Forth is a perfectly suited language for it.

The source lines of code for this version are right around 110 lines. About 10 of those lines come from including a pseudo random number generator.I was thinking that this was a good candidate for another project I dabble with, so wanted to reduce dependencies. I ended up with utime for the seed, so still ended up with a dependency.

This implementation works with GForth.

A few observations while I was writing the Forth version.

  1. It is fun and relatively painless to refactor.

    This is likely due to a number of reasons. One is that Forth words are basically a High level Intermediate language. These definitions can be thought of as a tree. Therefore, common refactoring things are extracting sequence into a new word, moving sequence up or down the tree, or expanding a word in place, possibly in preparation for another refactoring.I need to record myself writing this program to really capture the ease and flow of writing the code. I don’t know how to explain it.

    Variables and constants are global. Don’t have to worry about arguments or scopes causing friction in refactoring. It’s all lexical and logical organization.This is obviously one of the downsides of long term maintenance an

    Expressions are postfix and pass parameters on the stack. This becomes quite natural really quickly.There are some “code smells” specific to Forth. For example, excessive swaps or rotates. As long as these aren’t in the word definitions, refactoring seems pretty seamless.

  1. The code is “dense” in that it implements the Puzzle-15 game in about 100 lines of code. The D versions are close to 2x this amount and I’m not sure why (yet).

  2. I noticed that I wrote 3 styles of code for this program. There is the most common one liner style the typifies Forth definitions. There is a vertical block style that seems natural for CASE statements and matching patterns. There is sort of a free form style that doesn’t match either of the other two. This could be a code smell. I haven’t written enough Forth code to know.

  3. The Forth runtime was nice. No worrying about ncurses or anything, the terminal worked as expected with ." and key.

  4. There is no need for string interpolation ." There are " noApples @ . ." apples" just switches between string and expression contexts very naturally.

  5. While @ and ! are natural for fetching and storing values to variables, they are not nearly as nice as an array syntax.

  6. I like strong types and missed them in this exercise.

  7. It is very nice to name expressions. For example 0 moves ! vs resetMoves both seem very similar but sort of different in init shuffleBoard resetMoves gameLoop. I note that in this context, resetMoves might be better named something else, maybe prepareMoves.

D

D and C++ are the main languages I’m coding in right now. As a comparison, I copied the Forth code and then did as simple of a direct translation as I could. Thus the D code looks very Forth-like in this implementation, though a lot of my code looks Forth-like because I like refactoringMy rule of thumb is to only have 1 control structure per function, which I find to be a great place to aim for.

functions until they do one thing.

A few observations while I was writing the D version.

  1. I really like nested functions for limiting the scope and putting the function declarations next to where they are used. There are some things that are different from functions outside. I’m not sure it is the way that I use them or what, but it seems to be:

    1. For example, they get a frame pointer to their parent which is cool and there is static if it only uses the function arguments.
    2. UFCS doesn’t seem to work with nested functions. I’m not sure if it supposed to or not.
    3. Functions have to be declared before they are used.
  2. The direction translation in my style was about 250 lines long. This includes 50 lines of opening { since I prefer them on a new line rather than trailing on the far right. Still this is surprising that the code is 2x the size. I didn’t even expand all functions into multiple lines, i.e. the one-liners still stayed one line.

  3. I’m not sure how to return a single character from keyboard, because stdin seems to be line buffered, so I looped in ncurses. Completely overkill for this exercise, but probably desirable in a real program that was going to be used in a serious manner.Pressing ENTER after every key immediately makes the game unplayable. It kind of surprises me that such a thing can make such a difference in experience, but I’ve seen this over and over in UIs. An extra click, excessive pop-up. They just disrupt or add to cognitive load enough to make the experience not enjoyable. Similarly, adding in Vi movement keys felt very natural and increased the experience, though I’m sure it would not have the same effect for users of other IDEs.

  4. I didn’t know how to effect state outside a loop, without using a global variable in Forth version, so I did that with the ?win variable and functions. The D version can just put the isWinner condition check in the if condition, which was simpler.

  5. I’m much more comfortable programming in D or C++ than I am in Forth.

  6. The 250 lines of D expanded to 35,000 lines of assembly language!I used ldc -O3 –output-ll and –output-s for looking at assembly output.

    It looked like there were a lot of lines related to exceptions, bounds checking, and thread local storage. In the first version, a fixed sized array and a few variables were used, so there wasn’t any direct user code allocation. I think many of the things were due to the write family of functions.

  7. This program on an x86_64 is about 60k bytes of a 1.4M bytes stripped executable. 60k still seems pretty excessive to me for this simple program.Note that a hello world stripped executable is also 1.4M bytes. So that is all the runtime and startup

Appendix Forth

Note: Apparently Pandoc doesn’t support Forth for syntax highlighting.

Note: I’m not sure all of the stack effect comments are correct.

\ A board is an array of 16 cells to make a 4 x 4 layout
\ The Tiles have the values 0 - 16 where 16 is the 'Space'
\ Thus, if the index is the same as the value for all 16 places
\    the board is in a winning configuration.
\ 
\ Use vi keys to shift one tile to the left, right, up, down
\ Use x to leave the game

4   CONSTANT SIZE           \ an n x n board
100 CONSTANT RANDOMLEVEL    \ Bigger is harder (more shuffling)

SIZE dup *   CONSTANT BOARDSIZE
BOARDSIZE 1- CONSTANT BLANKHOME

VARIABLE board BOARDSIZE CELLS allot
VARIABLE blankPos BOARDSIZE             \ The index/position of the 'space' tile
VARIABLE ?gameOver                      \ Games end because user e'x'its or wins
( -- ) :  gameOver 1 ?gameOver ! ;
( -- ) : -gameOver 0 ?gameOver ! ;

( -- ) : initBoard BOARDSIZE 0 DO i board i CELLS + ! LOOP ;
( -- ) : initBlankPos BLANKHOME blankPos ! ;

VARIABLE ?win               \ Variable if the board state is a winner
( -- ) :  winner 1 ?win ! ;
( -- ) : -winner 0 ?win ! ;
( -- ) : updateWinner winner BOARDSIZE 0 DO i board i CELLS + @ <> IF -winner THEN LOOP ;

( idx -- ) : ?newLine SIZE MOD 0= IF cr THEN ;
( tile -- ) : showTile dup BLANKHOME = IF space space space ELSE dup 10 < IF space . ELSE . THEN THEN ;
( -- ) : showBoard BOARDSIZE 0 DO i ?newLine board i CELLS + @ showTile LOOP ;

( -- key ) : askForMove ." hjklxq?> " key cr ;

( -- row ) : blankRow blankPos @ SIZE / ;
( -- col ) : blankCol blankPos @ SIZE MOD ;

\ These use board indexes
( idx -- )       : tile@ cells board + @ ;
( tile idx -- )  : tile! cells board + ! ;
( idx1 idx2 -- ) : swapTiles dup tile@ >r over tile@ swap tile! r> swap tile!  ;

\ These are the valid move checks
( -- b ) : ?right blankCol ; \ 0 is invalid, 1 2 3 are all true
( -- b ) : ?left  blankCol SIZE 1- <> ;
( -- b ) : ?up    blankRow SIZE 1- <> ;
( -- b ) : ?down  blankRow ;

VARIABLE moves
( -- ) : moves+1 moves @ 1+ moves ! ;
( -- ) : resetMoves 0 moves ! ;
( n -- ) : moveTile  blankPos @ + dup blankPos @  swapTiles blankPos ! moves+1 ;
( -- ) : left  1 moveTile ;
( -- ) : down  SIZE negate moveTile ;
( -- ) : up    SIZE moveTile ;
( -- ) : right 1 negate moveTile ;

( -- ) : doLeft  ?left  IF left  THEN ;
( -- ) : doDown  ?down  IF down  THEN ;
( -- ) : doUp    ?up    IF up    THEN ;
( -- ) : doRight ?right IF right THEN ;
( -- ) : leaveGame ." Leaving game.  Thank you for playing." cr gameOver ;
( -- ) : doHelp cr
    ."   h or left  arrow moves tile left  into blank space." cr
    ."   l or right arrow moves tile right into blank space." cr
    ."   j or down  arrow moves tile down  into blank space." cr
    ."   k or up    arrow moves tile up    into blank space." cr
    ."   x,q Abandons current game." cr
    ."   ? This help screen." cr ;

( key -- ) : handleKey CASE 
    [char] q OF leaveGame ENDOF \ 'q'
    [char] x OF leaveGame ENDOF \ 'x'
    [char] h OF doLeft  ENDOF   \ 'h'
    [char] j OF doDown  ENDOF   \ 'j'
    [char] k OF doUp    ENDOF   \ 'k'
    [char] l OF doRight ENDOF   \ 'l'
     68 OF doLeft  ENDOF   \ arrows
     66 OF doDown  ENDOF
     65 OF doUp    ENDOF
     67 OF doRight ENDOF
     63 OF doHelp  ENDOF
    ." You pressed " . cr
    ENDCASE ;


     16807 CONSTANT a
2147483647 CONSTANT m

VARIABLE MYSEED
( -- ) : checkSeed MYSEED @ 0= IF utime drop MYSEED ! recurse THEN ;
( -- ) : seed checkSeed a MYSEED @ * m mod MYSEED ! ;
( m -- ) : random seed MYSEED @ swap mod ;

VARIABLE lastShuffleMove
( n -- b ) : ?avoidUndoMove lastShuffleMove @ <> ;
( -- ) : shuffleOneMove 4 random CASE
    0 OF ." L" 3 ?avoidUndoMove IF doLeft  0 lastShuffleMove ! THEN ENDOF
    1 OF ." D" 2 ?avoidUndoMove IF doDown  1 lastShuffleMove ! THEN ENDOF
    2 OF ." U" 1 ?avoidUndoMove IF doUp    2 lastShuffleMove ! THEN ENDOF
    3 OF ." R" 0 ?avoidUndoMove IF doRight 3 lastShuffleMove ! THEN ENDOF
    ENDCASE
    ;


( -- ) : shuffleBoard resetMoves BEGIN shuffleOneMove moves @ RANDOMLEVEL = UNTIL ;
( -- ) : init initBoard initBlankPos -gameOver ;
( -- ) : movesMessage ." It took " moves @ . ." moves to complete." cr ;
( -- ) : congratulate cr ." !!! Congratulations on winning the game !!!" cr movesMessage ;
( -- ) : turn showBoard cr askForMove handleKey ;
( -- ) : gameBody turn updateWinner ?win @ IF showBoard congratulate gameOver THEN ;
( -- ) : gameLoop BEGIN gameBody ?gameOver @ UNTIL ;
( -- ) : game init shuffleBoard resetMoves gameLoop ;
( -- ) : anotherGame BEGIN game cr ." Would you like to play again, y/n?" key 121 <> UNTIL ;
anotherGame
cr bye

Appendix D

/+
A board is an array of 16 cells to make a 4 x 4 layout
The Tiles have the values 0 - 16 where 16 is the 'Space'
Thus, if the index is the same as the value for all 16 places
   the board is in a winning configuration.

Use vi keys to shift one tile to the left, right, up, down
Use x to leave the game
+/

import std.algorithm.mutation : swap;
import std.range : iota;
import std.stdio : write, writef, writefln, writeln, stdout;
import std.array : array;
import std.random : choice;

private:
@safe:

extern(C) int getch();

const SIZE = 4;           // an n x n board
const RANDOMLEVEL = 600;  // Bigger is harder (more shuffling)

const BOARDSIZE = SIZE * SIZE;
const BLANKHOME = BOARDSIZE - 1;

const auto boardInit = iota(0,BOARDSIZE).array;
int[BOARDSIZE] board;
long blankPos = BOARDSIZE;
auto blankRow() { return blankPos / SIZE; }
auto blankCol() { return blankPos % SIZE; }

bool qGameOver;
void   setGameOver() { qGameOver = true; }
void resetGameOver() { qGameOver = false; }

long moves;

@trusted
void anotherGame() {
    static void playGame() {
        static void initGame() {
            board[]  = boardInit;
            blankPos = BLANKHOME;
            resetGameOver();
        }
        static void moveTile( long stride ) { 
            // These use board indexes
            static void swapTiles( long i1, long i2 ) {
                swap( board[i1], board[i2] );
            }

            swapTiles( blankPos, blankPos + stride );
            blankPos += stride;
            moves++ ;
        }
        static void left()  { moveTile( 1 ); }
        static void down()  { moveTile( -SIZE ); }
        static void up()    { moveTile( SIZE ); }
        static void right() { moveTile( -1 ); }

        // These are the valid move checks
        static bool isValidLeft()  { return blankCol() != (SIZE - 1); }
        static bool isValidDown()  { return blankRow() != 0; }
        static bool isValidUp()    { return blankRow() != (SIZE - 1); }
        static bool isValidRight() { return blankCol() != 0; } // 0 is invalid, 1 2 3 are all true

        static void doLeft()  { if ( isValidLeft() )  left(); }
        static void doDown()  { if ( isValidDown() )  down(); }
        static void doUp()    { if ( isValidUp() )    up(); }
        static void doRight() { if ( isValidRight() ) right(); }

        static void shuffleBoard() {
            static void shuffleOneMove() {
                final switch( [ 0, 1, 2, 3 ].choice() )
                {
                    case 0: write(" L"); doLeft();  break;
                    case 1: write(" D"); doDown();  break;
                    case 2: write(" U"); doUp();    break;
                    case 3: write(" R"); doRight(); break;
                }
            }

            for ( long i = 0; i < RANDOMLEVEL; i++ )
                shuffleOneMove();
        }

        static void gameLoop()
        {

            static bool qWinner() { return board[] == boardInit; }
            static void congratulate() {
                writeln("\r");
                writefln( " !!! Congratulations on winning the game !!!\r" );
                writefln( " It took %s moves to complete.\r", moves );
            }
            static void leaveGame() { 
                writeln( " Leaving game.  Thank you for playing.\r");
                setGameOver();
            }

            static void doHelp() {   writeln();
                writefln( "   h or left  arrow moves tile left  into blank space.\r" );
                writefln( "   l or right arrow moves tile right into blank space.\r" );
                writefln( "   j or down  arrow moves tile down  into blank space.\r" );
                writefln( "   k or up    arrow moves tile up    into blank space.\r" );
                writefln( "   x,q Abandons current game.\r" );
                writefln( "   ? This help screen.\r" );
            }

            static void showBoard( ) {
                static void qNewLine( long index ) {
                    if ( index % SIZE == 0 )
                        writefln!"\r";
                }
                static void showTile( long tile ) {
                    if ( tile == BLANKHOME )
                        write( "   " );
                    else
                        writef( " %2s", tile );
                }

                foreach( i, t; board ) {
                    qNewLine( i );
                    showTile( t );
                }
            }

            static void turn() {
                @trusted
                static void askForMove() {
                    write( " hjklxq?> " );
                    stdout.flush( );
                }
                static void handleKey( int asciiKey ) {
                    switch ( asciiKey ) {
                        case 'q': leaveGame(); break;  // 'q'
                        case 'x': leaveGame(); break;  // 'x'
                        case 'l': board.doLeft();    break;  // 'l'
                        case 'j': board.doDown();    break;  // 'j'
                        case 'k': board.doUp();      break;  // 'k'
                        case 'l': board.doRight();   break;  // 'l'
                        case  68: doLeft();    break;  // arrows
                        case  66: doDown();    break;
                        case  65: doUp();      break;
                        case  67: doRight();   break;
                        case  63: doHelp();    break;  
                        default:
                            writefln( " You pressed %s\r", asciiKey );
                    }
                }

                showBoard();
                writeln("\r");
                askForMove();
                auto ch = getch();
                writeln("\r");
                handleKey( ch );
            }

            turn();
            if (qWinner() ) {
                showBoard();
                congratulate();
                setGameOver();
            }
        }
        static void resetMoves() { moves = 0; }

        initGame();
        shuffleBoard();
        resetMoves();
        while( !qGameOver ) {
            gameLoop();
        }
    }

    do {
        playGame();
        writeln("\r");
        writeln("\r");
        write( " Would you like to play again, y/n?" );
        stdout.flush( );
    }
    while ( getch() == 121 );  // 'y'
}

int main() {
    import nwin;
    initializeNCurses();
    scope(exit) { teardownNCurses(); }
    anotherGame();
    return 0;
}

Appendix D OOP

/+
A board is an array of 16 cells to make a 4 x 4 layout
The Tiles have the values 0 - 16 where 16 is the 'Space'
Thus, if the index is the same as the value for all 16 places
   the board is in a winning configuration.

Use vi keys to shift one tile to the left, right, up, down
Use x to leave the game
+/

import std.algorithm.mutation : swap;
import std.range : iota;
import std.stdio : write, writef, writefln, writeln, stdout;
import std.array : array;
import std.random : choice;
import std.format : format;

private:
@safe:

extern(C) int getch();

const SIZE = 4;           // an n x n board
const RANDOMLEVEL = 20;  // Bigger is harder (more shuffling)

const BOARDSIZE = SIZE * SIZE;
const BLANKHOME = BOARDSIZE - 1;

public class Board
{
private:
    const auto boardInit = iota(0,BOARDSIZE).array;
    int[BOARDSIZE] board;
    long moves;

    long blankPos;
    auto blankRow() const { return blankPos / SIZE; }
    auto blankCol() const { return blankPos % SIZE; }

    void init()
    {
        board[]  = boardInit;
        blankPos = BLANKHOME;
        moves    = 0;
    }
    void moveTile( long stride ) { 
        // These use board indexes
        void swapTiles( long i1, long i2 ) {
            swap( board[i1], board[i2] );
        }

        swapTiles( blankPos, blankPos + stride );
        blankPos += stride;
        moves++ ;
    }
    void left()  { moveTile( 1 ); }
    void down()  { moveTile( -SIZE ); }
    void up()    { moveTile( SIZE ); }
    void right() { moveTile( -1 ); }

    // These are the valid move checks
    bool isValidLeft()  { return blankCol() != (SIZE - 1); }
    bool isValidDown()  { return blankRow() != 0; }
    bool isValidUp()    { return blankRow() != (SIZE - 1); }
    bool isValidRight() { return blankCol() != 0; } // 0 is invalid, 1 2 3 are all true

    void doLeft()  { if ( isValidLeft() )  left(); }
    void doDown()  { if ( isValidDown() )  down(); }
    void doUp()    { if ( isValidUp() )    up(); }
    void doRight() { if ( isValidRight() ) right(); }
    bool qWinner() { return board[] == boardInit; }

    void show( ) {
        static void qNewLine( long index ) {
            if ( index % SIZE == 0 )
                writefln!"\r";
        }
        static void showTile( long tile ) {
            if ( tile == BLANKHOME )
                write( "   " );
            else
                writef( " %2s", tile );
        }

        foreach( i, t; board ) {
            qNewLine( i );
            showTile( t );
        }
    }


}

public class Game
{
private:
    bool qGameOver;
    void   setGameOver() { qGameOver = true; }
    void resetGameOver() { qGameOver = false; }

    Board board;
    this( Board board_ )
    {
        board = board_;
        initGame();
    }

    void initGame() {
        board.init();
        qGameOver = false;
    }

    void playGame() {
        void shuffleBoard() {
            void shuffleOneMove() {
                final switch( [ 0, 1, 2, 3 ].choice() )
                {
                    case 0: write(" L"); board.doLeft();  break;
                    case 1: write(" D"); board.doDown();  break;
                    case 2: write(" U"); board.doUp();    break;
                    case 3: write(" R"); board.doRight(); break;
                }
            }

            for ( long i = 0; i < RANDOMLEVEL; i++ )
                shuffleOneMove();
        }

        void gameLoop()
        {
            void congratulate() const {
                writeln("\r");
                writefln( " !!! Congratulations on winning the game !!!\r" );
                writefln( " It took %s moves to complete.\r", board.moves );
            }
            void leaveGame() { 
                writeln( " Leaving game.  Thank you for playing.\r");
                setGameOver();
            }

            static void doHelp() {   writeln();
                writefln( "   h or left  arrow moves tile left  into blank space.\r" );
                writefln( "   l or right arrow moves tile right into blank space.\r" );
                writefln( "   j or down  arrow moves tile down  into blank space.\r" );
                writefln( "   k or up    arrow moves tile up    into blank space.\r" );
                writefln( "   x,q Abandons current game.\r" );
                writefln( "   ? This help screen.\r" );
            }

            void turn() {
                @trusted
                static void askForMove() {
                    write( " hjklxq?> " );
                    stdout.flush( );
                }
                void handleKey( int asciiKey ) {
                    switch ( asciiKey ) {
                        case 'q': leaveGame(); break;  // 'q'
                        case 'x': leaveGame(); break;  // 'x'
                        case 'l': board.doLeft();    break;  // 'l'
                        case 'j': board.doDown();    break;  // 'j'
                        case 'k': board.doUp();      break;  // 'k'
                        case 'l': board.doRight();   break;  // 'l'
                        case  68: board.doLeft();    break;  // arrows
                        case  66: board.doDown();    break;
                        case  65: board.doUp();      break;
                        case  67: board.doRight();   break;
                        case  63: doHelp();    break;  
                        default:
                            writefln( " You pressed %s\r", asciiKey );
                    }
                }

                board.show();
                writeln("\r");
                askForMove();
                auto ch = getch();
                writeln("\r");
                handleKey( ch );
            }

            turn();
            if (board.qWinner() ) {
                board.show();
                congratulate();
                setGameOver();
            }
        }

        initGame();
        shuffleBoard();
        while( !qGameOver ) {
            gameLoop();
        }
    }

    @trusted
    public void anotherGame( ) {

        do {
            playGame();
            writeln("\r");
            writeln("\r");
            write( " Would you like to play again, y/n?" );
            stdout.flush( );
        }
        while ( getch() == 121 );  // 'y'
    }
}

int main() {
    import nwin;
    initializeNCurses();
    scope(exit) { teardownNCurses(); }
    auto board = new Board();
    auto game = new Game( board );
    game.anotherGame();
    return 0;
}

Edit history

Date What
20200916 Use character literals in handleKey(). Thank you for the suggestion, Adam.