We will develop a clone of Threes in Symfony. If you ever played 2048, they are very similar. Threes is its predecessor.

Gameplay:

The player slides numbered tiles on a four-by-four grid to combine addends and multiples of three.For example, ones and twos merge to become a single “three” tile, two threes merge into “six”, and two sixes merge into “12”. Swiping the screen up, down, left, or right moves all of the tiles on the grid in that direction and adds a new tile to the grid in the same direction

Before you start, I suggest you give the game a try, you can find a web version on the official website. Just a quick try to get a feeling about the gameplay then come back here.

You’re back, great! Let’s start by creating a standard Symfony application.

composer create-project symfony/framework-standard-edition threes

Hit enter to set default parameters when prompted.

We will develop this game using TDD style, if you are not familiar with TDD, this should be a good exercise to get you started. I recommend that you follow this tutorial as a workshop that you develop in a dev environment. It is not possible to feel the TDD click just by reading.

By default, Symfony standard doesn’t come with phpunit, so let’s require it.

composer require phpunit/phpunit --dev

The game is played on a 4×4 grid board, which gives us a good starting point. So a new board should have 16 empty cells, right? Let’s rewrite this assumption in PHP.

tests/Threes/BoardTest.php

<?php

namespace Tests\Threes;

use PHPUnit\Framework\TestCase;
use Threes\Board;

class BoardTest extends TestCase
{

    public function test_new()
    {
        $board = new Board();

        $this->assertEquals(
            [
                [0, 0, 0, 0],
                [0, 0, 0, 0],
                [0, 0, 0, 0],
                [0, 0, 0, 0],
            ],
            $board->getGrid()
        );
    }
}

The first version of the Board class in src/Threes/Board.php must be straightforward

<?php

namespace Threes;

class Board
{
    private $grid = [
        [0, 0, 0, 0],
        [0, 0, 0, 0],
        [0, 0, 0, 0],
        [0, 0, 0, 0],
    ];

    public function getGrid()
    {
        return $this->grid;
    }
}

Running the test suite, all good

./vendor/bin/phpunit
PHPUnit 4.8.35 by Sebastian Bergmann and contributors.

.

Time: 72 ms, Memory: 6.00MB

OK (1 test, 1 assertion)

Next, when we slide up, all cells should move up, this will be the second test case.

public function test_up()
{
    $board = new Board();

    $board->setGrid([
        [0, 0, 0, 0],
        [1, 0, 0, 0],
        [0, 0, 0, 0],
        [0, 0, 0, 0],
    ]);

    $board->up();

    $this->assertEquals(
        [
            [1, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
        ],
        $board->getGrid()
    );
}

First, we need to add a grid setter to the Board

public function setGrid(array $grid)
{
    $this->grid = $grid;
}

Then the up method

public function up()
{
    for ($l = 1; $l < 4; $l++) {
        for ($c = 0; $c < 4; $c++) {
            $currentValue = $this->grid[$l][$c];
            $this->grid[$l-1][$c] = $currentValue;
            $this->grid[$l][$c] = 0;
        }
    }
}

Running the tests confirms this algorithm works. Let’s update the test_up to cover the case where a value is stuck to the top border.

public function test_up()
{
    $board = new Board();

    $board->setGrid([
        [0, 1, 0, 0],
        [1, 0, 0, 0],
        [0, 0, 0, 0],
        [0, 0, 0, 0],
    ]);

    $board->up();

    $this->assertEquals(
        [
            [1, 1, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
        ],
        $board->getGrid()
    );
}
./vendor/bin/phpunit
PHPUnit 4.8.35 by Sebastian Bergmann and contributors.

.F

Time: 80 ms, Memory: 6.00MB

There was 1 failure:

1) Tests\Threes\BoardTest::test_up
Failed asserting that two arrays are equal.
--- Expected
+++ Actual
@@ @@
 Array (
     0 => Array (
         0 => 1
-        1 => 1
+        1 => 0
         2 => 0
         3 => 0
     )
     1 => Array (...)
     2 => Array (...)
     3 => Array (...)
 )

/home/ilyes/projects/threes/tests/Threes/BoardTest.php:46

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

So our initial algorithm did overwrite the [0,1] cell. So if the current value is 0, we can simply exit the loop.

public function up()
{
    for ($l = 1; $l < 4; $l++) {
        for ($c = 0; $c < 4; $c++) {
            $currentValue = $this->grid[$l][$c];
            if ($currentValue === 0) {
                continue;
            }
            $this->grid[$l - 1][$c] = $currentValue;
            $this->grid[$l][$c] = 0;
        }
    }
}
$ ./vendor/bin/phpunit
PHPUnit 4.8.35 by Sebastian Bergmann and contributors.

..

Time: 74 ms, Memory: 6.00MB

OK (2 tests, 2 assertions)

Next case is when all the vertical column is filled, nothing should move. We can add this situation to the third column of our test.

public function test_up()
{
    $board = new Board();

    $board->setGrid([
        [0, 1, 1, 0],
        [1, 0, 1, 0],
        [0, 0, 1, 0],
        [0, 0, 1, 0],
    ]);

    $board->up();

    $this->assertEquals(
        [
            [1, 1, 1, 0],
            [0, 0, 1, 0],
            [0, 0, 1, 0],
            [0, 0, 1, 0],
        ],
        $board->getGrid()
    );
}

The test fails with

Failed asserting that two arrays are equal.
--- Expected
+++ Actual
@@ @@
         1 => 0
-        2 => 1
+        2 => 0
         3 => 0
     )
 )

Apparently our algorithm set the cell [2,3] to 0. So we need to check if a cell is empty before overwriting it’s value. The new up method looks like

public function up()
{
    for ($l = 1; $l < 4; $l++) {
        for ($c = 0; $c < 4; $c++) {
            $currentValue = $this->grid[$l][$c];
            if ($currentValue === 0) {
                continue;
            }
            if ($this->grid[$l - 1][$c] !== 0) {
                continue;
            }
            $this->grid[$l - 1][$c] = $currentValue;
            $this->grid[$l][$c] = 0;
        }
    }
}

Next case is when values are separated by an empty cell. We can use the fourfth column for this.

public function test_up()
{
    $board = new Board();

    $board->setGrid([
        [0, 1, 1, 0],
        [1, 0, 1, 1],
        [0, 0, 1, 0],
        [0, 0, 1, 1],
    ]);

    $board->up();

    $this->assertEquals(
        [
            [1, 1, 1, 1],
            [0, 0, 1, 0],
            [0, 0, 1, 1],
            [0, 0, 1, 0],
        ],
        $board->getGrid()
    );
}

Tests pass, this was covered out of the box.

Next game rule is that two cells with values 1 and 2 should merge into 3.

public function test_up_add()
{
    $board = new Board();

    $board->setGrid([
        [1, 0, 0, 0],
        [2, 0, 0, 0],
        [0, 0, 0, 0],
        [0, 0, 0, 0],
    ]);

    $board->up();

    $this->assertEquals(
        [
            [3, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
        ],
        $board->getGrid()
    );
}

Currently, our algorithm will skip processing if the cell below the current one is not empty if ($this->grid[$l - 1][$c] !== 0) {continue;}. So before continue we need to check if the current value and the next one are 1 and 3 and add them. The if block becomes

if ($this->grid[$l - 1][$c] !== 0) {
    if ($currentValue === 2 && $this->grid[$l - 1][$c] === 1) {
        $this->grid[$l - 1][$c] = $currentValue + $this->grid[$l - 1][$c];
        $this->grid[$l][$c] = 0;
    }
    continue;
}

Test are green, let’s use the second column of the test to include the reverse case, means 3 and 1.

public function test_up_add()
{
    $board = new Board();

    $board->setGrid([
        [1, 2, 0, 0],
        [2, 1, 0, 0],
        [0, 0, 0, 0],
        [0, 0, 0, 0],
    ]);

    $board->up();

    $this->assertEquals(
        [
            [3, 3, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
        ],
        $board->getGrid()
    );
}

The tests fail again, we need to extend the condition to cover this case as well, the extended version looks like

if (
    $currentValue === 2 && $this->grid[$l - 1][$c] === 1
    ||
    $currentValue === 1 && $this->grid[$l - 1][$c] === 2
)

Tests are green, but the up method started to look unreadable.

public function up()
{
    for ($l = 1; $l < 4; $l++) {
        for ($c = 0; $c < 4; $c++) {
            $currentValue = $this->grid[$l][$c];
            if ($currentValue === 0) {
                continue;
            }
            if ($this->grid[$l - 1][$c] !== 0) {
                if (
                    $currentValue === 2 && $this->grid[$l - 1][$c] === 1
                    ||
                    $currentValue === 1 && $this->grid[$l - 1][$c] === 2
                ) {
                    $this->grid[$l - 1][$c] = $currentValue + $this->grid[$l - 1][$c];
                    $this->grid[$l][$c] = 0;
                }
                continue;
            }

            $this->grid[$l - 1][$c] = $currentValue;
            $this->grid[$l][$c] = 0;
        }
    }
}

Let’s perform some small refactorings to understand it better. We can start by extracting $this->grid[$l - 1][$c] into a local variable $nextValue.

Result:

public function up()
{
    for ($l = 1; $l < 4; $l++) {
        for ($c = 0; $c < 4; $c++) {
            $currentValue = $this->grid[$l][$c];
            if ($currentValue === 0) {
                continue;
            }
            $nextValue = $this->grid[$l - 1][$c];

            if ($nextValue !== 0) {
                if (
                    $currentValue === 2 && $nextValue === 1
                    ||
                    $currentValue === 1 && $nextValue === 2
                ) {
                    $this->grid[$l - 1][$c] = $currentValue + $nextValue;
                    $this->grid[$l][$c] = 0;
                }
            }

            $this->grid[$l - 1][$c] = $currentValue;
            $this->grid[$l][$c] = 0;
        }
    }
}

Next, let’s replace the second continue with an explicit second if.
Result:

public function up()
{
    for ($l = 1; $l < 4; $l++) {
        for ($c = 0; $c < 4; $c++) {
            $currentValue = $this->grid[$l][$c];
            if ($currentValue === 0) {
                continue;
            }
            $nextValue = $this->grid[$l - 1][$c];

            if ($nextValue !== 0) {
                if (
                    $currentValue === 2 && $nextValue === 1
                    ||
                    $currentValue === 1 && $nextValue === 2
                ) {
                    $this->grid[$l - 1][$c] = $currentValue + $nextValue;
                    $this->grid[$l][$c] = 0;
                }
            }
            if ($nextValue === 0) {
                $this->grid[$l - 1][$c] = $currentValue;
                $this->grid[$l][$c] = 0;
            }

        }
    }
}

Let’s merge the two embedded ifs into single one, so instead of

if ($nextValue !== 0) {
    if (
        $currentValue === 2 && $nextValue === 1
        ||
        $currentValue === 1 && $nextValue === 2
    ) {
        $this->grid[$l - 1][$c] = $currentValue + $nextValue;
        $this->grid[$l][$c] = 0;
    }
}

we have

if (
    $nextValue !== 0 && (
        $currentValue === 2 && $nextValue === 1
        ||
        $currentValue === 1 && $nextValue === 2
    )
) {
    $this->grid[$l - 1][$c] = $currentValue + $nextValue;
    $this->grid[$l][$c] = 0;
}

Now we can merge the two ifs into a single one with a large condition

Result:

public function up()
{
    for ($l = 1; $l < 4; $l++) {
        for ($c = 0; $c < 4; $c++) {
            $currentValue = $this->grid[$l][$c];
            if ($currentValue === 0) {
                continue;
            }
            $nextValue = $this->grid[$l - 1][$c];

            if (
                $nextValue === 0 ||
                $nextValue !== 0 && (
                    $currentValue === 2 && $nextValue === 1
                    ||
                    $currentValue === 1 && $nextValue === 2
                )
            ) {
                $this->grid[$l - 1][$c] = $currentValue + $nextValue;
                $this->grid[$l][$c] = 0;
            }
        }
    }
}

Is clear now that the block

if ($currentValue === 0) {
    continue;
}

is not relevant anymore, so we can get rid of it.

Let’s extract the if condition into a private method areMergable

Result:

public function up()
{
    for ($l = 1; $l < 4; $l++) {
        for ($c = 0; $c < 4; $c++) {
            $currentValue = $this->grid[$l][$c];
            $nextValue = $this->grid[$l - 1][$c];
            if ($this->areMergeable($nextValue, $currentValue)) {
                $this->grid[$l - 1][$c] = $currentValue + $nextValue;
                $this->grid[$l][$c] = 0;
            }
        }
    }
}

private function areMergeable($nextValue, $currentValue)
{
    return $nextValue === 0 ||
    $nextValue !== 0 && (
        $currentValue === 2 && $nextValue === 1
        ||
        $currentValue === 1 && $nextValue === 2
    );
}

I am tempted to extract a merge method as well

Result:

public function up()
{
    for ($l = 1; $l < 4; $l++) {
        for ($c = 0; $c < 4; $c++) {
            $this->mergeUp($l, $c);
        }
    }
}

private function mergeUp($line, $column)
{
    $currentValue = $this->grid[$line][$column];
    $nextValue = $this->grid[$line - 1][$column];
    if ($this->areMergeable($nextValue, $currentValue)) {
        $this->grid[$line - 1][$column] = $currentValue + $nextValue;
        $this->grid[$line][$column] = 0;
    }
}

Now with simple boolean substitution, we can make the areMergeable method more readable.

private function areMergeable($nextValue, $currentValue)
{
    return
        $nextValue === 0
        ||
        in_array([$currentValue, $nextValue], [[1, 2], [2, 1]]);
}

Don’t forget to confirm the correctness of each refactoring step by running the test suite.

Back to our test_up_add now, the next rule says that starting from 3 equal values add together. We use the third column for this case

public function test_up_add()
{
    $board = new Board();

    $board->setGrid([
        [1, 2, 3, 0],
        [2, 1, 3, 0],
        [0, 0, 0, 0],
        [0, 0, 0, 0],
    ]);

    $board->up();

    $this->assertEquals(
        [
            [3, 3, 6, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
        ],
        $board->getGrid()
    );
}

Now it becomes trivial to cover this case, we need an extra simple check in areMergeable method to see if the current and next values are equal.

private function areMergeable($nextValue, $currentValue)
{
    return
        $nextValue === 0
        ||
        in_array([$currentValue, $nextValue], [[1, 2], [2, 1]])
        || $currentValue === $nextValue && $nextValue > 2;
}

Next rule is when there are 3,3,1,2 on a column, only the first 3+3 should merge, we can add this case using the fourfth column in test_up_add

public function test_up_add()
{
    $board = new Board();

    $board->setGrid([
        [1, 2, 3, 3],
        [2, 1, 3, 3],
        [0, 0, 0, 1],
        [0, 0, 0, 2],
    ]);

    $board->up();

    $this->assertEquals(
        [
            [3, 3, 6, 6],
            [0, 0, 0, 1],
            [0, 0, 0, 2],
            [0, 0, 0, 0],
        ],
        $board->getGrid()
    );
}

Again, this was already covered. Now we can try the down feature. The test case will be very similar with test_up with the cells moving down.

public function test_down()
{
    $board = new Board();

    $board->setGrid([
        [0, 0, 1, 1],
        [1, 0, 1, 0],
        [0, 0, 1, 1],
        [0, 1, 1, 0],
    ]);

    $board->down();

    $this->assertEquals(
        [
            [0, 0, 1, 0],
            [0, 0, 1, 1],
            [1, 0, 1, 0],
            [0, 1, 1, 1],
        ],
        $board->getGrid()
    );
}

Let’s have a first try by duplicating up and mergeUp method and change the direction from up to down.

public function down()
{
    for ($l = 2; $l >= 0; $l--) {
        for ($c = 0; $c < 4; $c++) {
            $this->mergeDown($l, $c);
        }
    }
}

private function mergeDown($line, $column)
{
    $currentValue = $this->grid[$line][$column];
    $nextValue = $this->grid[$line + 1][$column];

    if ($this->areMergeable($nextValue, $currentValue)) {
        $this->grid[$line + 1][$column] = $currentValue + $nextValue;
        $this->grid[$line][$column] = 0;
    }
}

Running the tests.. Works! Looking at mergeUp and mergeDown method the only difference is $line - 1 in the first and $line + 1 in the second. It seems that we can pass it as $nextLine argument and merge the two methods into a single merge method. The new method becomes

private function merge($line, $column, $nextLine)
{
    $currentValue = $this->grid[$line][$column];
    $nextValue = $this->grid[$nextLine][$column];

    if ($this->areMergeable($nextValue, $currentValue)) {
        $this->grid[$nextLine][$column] = $currentValue + $nextValue;
        $this->grid[$line][$column] = 0;
    }
}

We get rid of mergeUp and mergeDown and we change the call to merge from up and down method.

public function up()
{
    for ($l = 1; $l < 4; $l++) {
        for ($c = 0; $c < 4; $c++) {
            $this->merge($l, $c, $l - 1);
        }
    }
}

public function down()
{
    for ($l = 2; $l >= 0; $l--) {
        for ($c = 0; $c < 4; $c++) {
            $this->merge($l, $c, $l + 1);
        }
    }
}

What we did so far is that we separated the 3 main concepts into separate methods
– Traversal boundaries and direction
– Merging algorithm
– Merging conditions

It is clear now that the test_down_add will work without any code change, let’s add the test

public function test_down_add()
{
    $board = new Board();

    $board->setGrid([
        [0, 0, 0, 1],
        [0, 0, 0, 2],
        [2, 2, 3, 3],
        [1, 1, 3, 3],
    ]);

    $board->down();

    $this->assertEquals(
        [
            [0, 0, 0, 0],
            [0, 0, 0, 1],
            [0, 0, 0, 2],
            [3, 3, 6, 6],
        ],
        $board->getGrid()
    );
}

Green tests confirms our assumption.

I bet you are now able to add right and left methods and get them right in the first shot. Let’s start from the right

public function test_right()
{
    $board = new Board();

    $board->setGrid([
        [1, 0, 0, 0],
        [0, 0, 0, 1],
        [1, 1, 1, 1],
        [1, 0, 1, 0],
    ]);

    $board->right();

    $this->assertEquals(
        [
            [0, 1, 0, 0],
            [0, 0, 0, 1],
            [1, 1, 1, 1],
            [0, 1, 0, 1],
        ],
        $board->getGrid()
    );
}
public function right()
{
    for ($l = 0; $l < 4; $l++) {
        for ($c = 2; $c >= 0; $c--) {
            $this->merge($l, $c, $l, $c + 1);
        }
    }

}

Like we added $nextLine argument to merge, it is obvious now that we need a $nextColumn one.

private function merge($line, $column, $nextLine, $nextColumn)
{
    $currentValue = $this->grid[$line][$column];
    $nextValue = $this->grid[$nextLine][$nextColumn];

    if ($this->areMergeable($nextValue, $currentValue)) {
        $this->grid[$nextLine][$nextColumn] = $currentValue + $nextValue;
        $this->grid[$line][$column] = 0;
    }
}

up and down methods will simply pass $c as third parameter.

I expect test_right_add to just work now, let’s see

public function test_right_add()
{
    $board = new Board();

    $board->setGrid([
        [0, 0, 2, 1],
        [0, 0, 1, 2],
        [0, 0, 3, 3],
        [1, 2, 3, 3],
    ]);

    $board->right();

    $this->assertEquals(
        [
            [0, 0, 0, 3],
            [0, 0, 0, 3],
            [0, 0, 0, 6],
            [0, 1, 2, 6],
        ],
        $board->getGrid()
    );
}

The left tests and method should be extremely trivial now

public function test_left()
{
    $board = new Board();

    $board->setGrid([
        [0, 1, 0, 0],
        [1, 0, 0, 0],
        [1, 1, 1, 1],
        [0, 1, 0, 1],
    ]);

    $board->left();

    $this->assertEquals(
        [
            [1, 0, 0, 0],
            [1, 0, 0, 0],
            [1, 1, 1, 1],
            [1, 0, 1, 0],
        ],
        $board->getGrid()
    );
}

public function test_left_add()
{
    $board = new Board();

    $board->setGrid([
        [1, 2, 0, 0],
        [2, 1, 0, 0],
        [3, 3, 0, 0],
        [3, 3, 1, 2],
    ]);

    $board->left();

    $this->assertEquals(
        [
            [3, 0, 0, 0],
            [3, 0, 0, 0],
            [6, 0, 0, 0],
            [6, 1, 2, 0],
        ],
        $board->getGrid()
    );
}
public function left()
{
    for ($l = 0; $l < 4; $l++) {
        for ($c = 1; $c < 4; $c++) {
            $this->merge($l, $c, $l, $c - 1);
        }
    }
}

In the next part, we will integrate the Board class into Symfony to be able to play it in a web browser.