Creating A Game With PHP Part 3: Snake

So far in this series of posts we have looked at detecting key presses from the user and creating a game of tic tac toe using PHP and the command line. The next step is to create a game that has graphics and real time user input. As we are using the command line we don't have much space to work with so the graphics we create aren't going to be very detailed. The simplest action game I could think of is snake. It has a few simple rules, can have very basic graphics and doesn't involve any physics or other mechanics that would effect the game as a whole. In fact the game snake dates back to the 1976 game Blockade, which was created using just text strings.

Unlike the tic tac toe, there are a few more variables to keep track of here. Instead of using pass by reference variables it makes sense to encapsulate these into an object. Using an object as the snake allows us to pass the entire thing by reference into a function and means we can encapsulate certain functionality within the object itself. This is by no means the pinnacle of good design, but more a proof of concept to see if this will work.

Instead of storing the game board as a large array, we only need to keep track of the snake, the apple and the movement of the snake. Here is the Snake class, with some default parameters that I will cover in detail later in this post.

class Snake {
  public $width = 0;
  public $height = 0;

  public $positionX = 0;
  public $positionY = 0;

  public $appleX = 15;
  public $appleY = 15;

  public $movementX = 0;
  public $movementY = 0;

  public $trail = [];
  public $tail = 5;

  public $speed = 100000;

  public function __construct($width, $height) {
    $this->width = $width;
    $this->height = $height;

    $this->positionX = rand(0, $width - 1);
    $this->positionY = rand(0, $height - 1);

    $appleX = rand(0, $width - 1);
    $appleY = rand(0, $height - 1);

    while (array_search([$appleX, $appleY], $this->trail) !== FALSE) {
      $appleX = rand(0, $width - 1);
      $appleY = rand(0, $height - 1);
    }
    $this->appleX = $appleX;
    $this->appleY = $appleY;
  }
}

To create a new instance of the Snake object we just pass in the width and height of the game board.

$width = 20;
$height = 30;
$snake = new Snake($width, $height);

To render the snake game we pass the snake object to a rendering function. The job of this function is to render the game grid that the snake will move around in along with the snake itself (represented by the letter X) and the apple (represented by the number 0). This function isn't exactly performant as it loops through the snake trail over and over again to see if one of the coordinates lies within the current cell being printed. However, because we are printing things in a linear pattern we can't really draw the background and then draw out the trail, we need to draw everything out at the same time. The background is essential here as it keeps everything aligned correctly. Without this in place everything would justify to the left.

function renderGame($snake) {
  $output = '';

  for ($i = 0; $i < $snake->width; $i++) {
    for ($j = 0; $j < $snake->height; $j++) {
      if ($snake->appleX == $i && $snake->appleY == $j) {
        $cell = '0';
      }
      else {
        $cell = '.';
      }
      foreach ($snake->trail as $trail) {
        if ($trail[0] == $i && $trail[1] == $j) {
          $cell = 'X';
        }
      }
      $output .= $cell;
    }
    $output .= PHP_EOL;
  }

  $output .= PHP_EOL;

  return $output;
}

If we render a newly created snake object we see the following output. The snake only has one segment so it is printed out as a single X in the game board. The idea is that when the player moves we generate the segments until it reaches the minimal level of 5.

..............................
..............................
..............................
..............................
..............................
..............................
..............................
..............................
...X..........................
..............................
..............................
..............................
..............................
..............................
..............................
......................0.......
..............................
..............................
..............................
..............................

This doesn't do a lot, so we need to add some movement mechanics to the snake. Again, we use the translateKeypress() function from part one of this series of posts. All we are going here is to detect the arrow key being pressed and update the movement direction of the snake object.

function direction($stdin, $snake) {
  // Listen to the button being pressed.
  $key = fgets($stdin);
  if ($key) {
    $key = translateKeypress($key);
    switch ($key) {
      case "UP":
        $snake->movementX = -1;
        $snake->movementY = 0;
        break;
      case "DOWN":
        $snake->movementX = 1;
        $snake->movementY = 0;
        break;
      case "RIGHT":
        $snake->movementX = 0;
        $snake->movementY = 1;
        break;
      case "LEFT":
        $snake->movementX = 0;
        $snake->movementY = -1;
        break;
     }
  }
}

The movement direction is used to update the position of the snake to that it appears to move across the grid. For example, pressing the right arrow will set the movementY to 1, which means that the positionY value of the snake can be updated by 1 every 'tick' of the program.

The following move() function is used to move the snake across the game board using the above logic and then provides some other checks. We check to make sure that the snake hasn't gone past the boundaries of the game board and make some corrections if it has. By adding the position of the snake's head to the trail and then removing the last element from the trail array we are creating the illusion of a snake moving through the grid. We also need to check that the 'head' of the snake is over an apple, and if it is then we increase the length of the snake and move the apple to a new location. I also added a small while loop to ensure that the new position of the apple didn't sit inside the body of the snake.

function move($snake) {
  // Move the snake.
  $snake->positionX += $snake->movementX;
  $snake->positionY += $snake->movementY;

  // Wrap the snake around the boundaries of the board.
  if ($snake->positionX < 0) {
    $snake->positionX = $snake->width - 1;
  }
  if ($snake->positionX > $snake->width - 1) {
    $snake->positionX = 0;
  }
  if ($snake->positionY < 0) {
    $snake->positionY = $snake->height - 1;
  }
  if ($snake->positionY > $snake->height - 1) {
    $snake->positionY = 0;
  }

  // Add to the snakes trail at the front.
  array_unshift($snake->trail, [$snake->positionX, $snake->positionY]);

  // Remove a block from the end of the snake (but keep correct length).
  if (count($snake->trail) > $snake->tail) {
    array_pop($snake->trail);
  }

  if ($snake->appleX == $snake->positionX && $snake->appleY == $snake->positionY) {
    // The snake has eaten an apple.
    $snake->tail++;

    if ($snake->speed > 2000) {
      // Increase the speed of the game up to a certain limit.
      $snake->speed = $snake->speed - ($snake->tail * ($snake->width / $snake->height + 10));
    }
    // Figure out a different place for the apple.
    $appleX = rand(0, $snake->width - 1);
    $appleY = rand(0, $snake->height - 1);
    while (array_search([$appleX, $appleY], $snake->trail) !== FALSE) {
      $appleX = rand(0, $snake->width - 1);
      $appleY = rand(0, $snake->height - 1);
    }
    $snake->appleX = $appleX;
    $snake->appleY = $appleY;
  }
}

The final thing to check is if the last movement caused the snake to die. This happens if the head of the snake touches its tail and can be worked out by looking at the entire trail of the snake compared to the head of the snake. We don't check for this condition at the start of the game as this would instantly create a game over situation.

If the head is found to intersect the tail then we end the game and print out a game over message.

function gameOver($snake) {
  if ($snake->tail > 5) {
    // If the trail is greater than 5 then check for end condition.
    for ($i = 1; $i < count($snake->trail); $i++) {
      if ($snake->trail[$i][0] == $snake->positionX && $snake->trail[$i][1] == $snake->positionY) {
        die('dead :(');
      }
    }
  }
}

With all of those functions written we can put it together into our infinite loop that controls the game. This is the same infinite loop that I introduced when creating the tic tac toe game. We start the loop by clearing the command line output, then update the direction of the snake before moving the snake and printing out the game board. Finally we then detect if a game over scenario was reached.

while (1) {
  system('clear');
  echo 'Level: ' . $snake->tail . PHP_EOL;
  direction($stdin, $snake);
  move($snake);
  echo renderGame($snake);
  gameOver($snake);
  usleep($snake->speed);
}

The main difference between this loop and the one I created for the game of tic tac toe is that I'm using usleep(). This forces the game to pause for 100000 microseconds (0.1 seconds) on every iteration through the loop and is intended to slow down the game simulation speed so that it runs about 10 frames a second. Without this in place the snake would run exceedingly fast and be impossible to actually play. You may have seen in the move() function that we reduce this value a little every time an apple is eaten. This is intended to replicate the increasing level of difficulty as the snake gets longer.

We now have a functioning game of snake, which looks like this when we get a game over screen.

Level: 64
..............................
..............................
.............0................
..............................
..............................
..............................
XXXX.....................XXXXX
...X.....................X....
...X.....................X....
...X.....................X....
...X......X..............X....
...X......XXXXXXXXXXXXXXXX....
...X................X.........
...X................X.........
...X................X.........
...X................X.........
...X.......XXXXXXXXXX.........
...XXXXXXXXX..................
..............................
..............................

dead :(

This script works very well and is actually a fun version of snake. The included height and width settings allow the snake game board to be any size you want so you could make a very long snake on a very large board.

Some improvements that could be added might be to prevent going backwards on your trail (ie. pressing left when you are going right) causing a game over state. Secondly, some versions of snake allow a little bit of leeway before producing a game over, my version will instantly game over if you come into contact with the trail. This could be done by adding a few frames before producing the game over situation, but I'll leave this as an exercise for the reader.

If you want to see this code in full then I have created a GitHub gist that you can download and run.

More in this series

Comments

Nice and effective...

Permalink

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
6 + 5 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.