At a recent conference in Bulgaria, there was a hackathon for which Andrew Carter created a PHP console version of the popular “snake” game.
I thought it was a really interesting concept, and since Andrew has a history of using PHP for weird things, I figured I’d demystify and explain how it was done.
The original repository is here, but we’ll build a version of it from scratch in this series so no need to clone it.
Key Takeaways
- PHP CLI Keypress Detection: PHP, typically not used for real-time game development, can handle keypress input in CLI applications using `stty` and `readline` functions to modify terminal settings and detect inputs immediately.
- Key Mapping Mechanism: The game utilizes a mapping system to associate key codes with specific directions for controlling snake movements, allowing easy customization for additional controls or players.
- Game Structure and Loop: The structure of the game involves a continuous loop that listens for key inputs and updates the state of the game accordingly, including the direction of the snake based on the pressed key.
- Dual Input Methods: Two methods demonstrated for capturing keypresses in PHP include using `stty cbreak` for immediate character reading and `readline_callback_handler_install` with `stream_select` for a more complex but robust handling.
- Gameplay Implementation: The game is designed to run in a command-line interface, not supporting native Windows without a VM, and features both single-player and multiplayer modes with different game dynamics, such as obstacles and snake length resets.
Prerequisites and Rules
As usual, I’ll be using my Homestead Improved box to get up and running instantly. You can use your own environment, just make sure it has a decent version of PHP runnable from the command line.
The snake game we’re replicating has the following features:
- a snake starts as a single on-screen character, and gets longer by one character every time it eats a piece of food.
- food is spawned randomly anywhere on the map.
- in single player mode, the snake is controlled by the arrow keys.
- in two player mode, one snake is controlled with the WSAD keys, while the other is controlled with the arrow keys.
- in single player mode, the walls are obstacles and cause a collision. Running into a wall or into yourself ends the game.
- in multi player mode, only your own snake or the enemy’s snake is an obstacle – the walls wrap around the world. Colliding will reset your snake’s length to 0. The player with the longest snake after 100 seconds have elapsed is the winner.
- it’s CLI, so does not run in the browser – it runs in the terminal window
Note that the game doesn’t work in native Windows – to run it on a Windows platform, use a good VM like Homestead Improved.
Bootstrapping
To launch a CLI (console) game, we need something similar to an index.php
file in traditional websites – a “front controller” which reads our command line input, parses it, and then launches the required classes, just like in a traditional web app. We’ll call this file play.php
.
<?php
$param = ($argc > 1) ? $argv[1] : '';
echo "Hello, you said: " . $param;
Okay, so if we launch this file with php play.php something
, we’ll get:
Hello, you said: something
Let us now make a bogus SnakeGame.php
in the subfolder classes
class which gets invoked by this front controller.
// classes/Snake.php
<?php
namespace PHPSnake;
class SnakeGame
{
public function __construct()
{
echo "Hello, I am snake!";
}
}
Let’s also update the front controller to load this class and invoke it:
<?php
use PHPSnake\SnakeGame;
require_once 'classes/SnakeGame.php';
$param = ($argc > 1) ? $argv[1] : '';
$snake = new SnakeGame();
Okay, we should be seeing a snakey greeting now if we re-run php play.php
.
Frames
The way that traditional games work is by re-checking the system’s state every frame, and a higher frame rate means more frequent checking. For example, when the screen is rendered (a single frame), the system can tell us that the letter A is pressed, and that one of the players is in the process of colliding with food so a snake growth needs to happen. All this happens in a single frame, and “FPS” or frames per second means exactly that – how many of these system observations happen per second.
Programming languages intended for this have built-in loops for checking state. PHP… not so much. We can hack our way around this, but let’s go through it all step by step. Let’s modify Snake’s constructor like so:
public function __construct()
{
echo "Hello, I am snake!";
$stdin = fopen('php://stdin', 'r');
while (1) {
$key = fgetc($stdin);
echo $key;
}
}
First, we open an “stdin” stream, meaning we’re creating a way for PHP to get “standard input” from the command line, treating it as if it was a file (hence, fopen
). The fgetc
function is used to get a single character from a file pointer (as opposed to fgets
which gets a whole line) and then the key is printed on screen. The while
loop is there so PHP keeps waiting for more input, and doesn’t end the script after a single key is pressed.
If we try running our app, though, we’ll notice that the key is only echoed out after we press enter – so after a new line. What’s more – everything we wrote gets echoed. What we want instead, is for PHP to echo out each key we press, as we press it.
Here are two ways to accomplish this.
stty
The first way is via a tool called stty
which comes native with terminal applications on *nix systems (so no Windows, unless you’re using a VM like Homestead Improved). It’s used to modify and configure terminal input and output through the use of flags – when prefixed with -
, these often mean “deactivation” and vice versa.
What we want is stty’s cbreak
flag. As per the docs:
Normally, the tty driver buffers typed characters until a newline or carriage return is typed. The cbreak routine disables line buffering and erase/kill character-processing (interrupt and flow control characters are unaffected), making characters typed by the user immediately available to the program.
In layman’s terms, we’re no longer waiting for the enter key to be fired to send the inputs.
public function __construct()
{
echo "Hello, I am snake!";
system('stty cbreak');
$stdin = fopen('php://stdin', 'r');
while (1) {
$c = ord(fgetc($stdin));
echo "Char read: $c\n";
}
We invoke the system
function which basically acts as a shell proxy and forwards the command provided as an argument to the terminal we’re running the PHP app from. After this, running the app with php play.php
should let you write characters and echo them immediately after every keypress.
Note that we’re getting the key code because we wrapped the character into ord
– this function returns the ASCII code for a given character.
readline callback
The second way is by using the surprisingly mystical and underdocumented readline_callback_handler_install
function in combination with stream_select
(also *nix only because stream_select
calls the system select
command, which is not available in Windows).
readline_callback_handler_install
takes a prompt message as its first argument (so, what to “ask” the user), and a callback as the second. In our case, we leave it as an empty function because we don’t really need it – we’re reading characters by parsing STDIN, a constant which is actually just a shortcut for fopen('php://stdin', 'r');
. Our code for this part looks like this:
public function __construct()
{
echo "Hello, I am snake!";
readline_callback_handler_install('', function() { });
while (true) {
$r = array(STDIN);
$w = NULL;
$e = NULL;
$n = stream_select($r, $w, $e, null);
if ($n) {
$c = ord(stream_get_contents(STDIN, 1));
echo "Char read: $c\n";
}
}
}
The stream select accepts several streams, and acts as an event listener for when something changes on any of them. Given that we’re only looking for “read” (i.e. input), we define that as an array format of STDIN
. The others are set to NULL, we don’t need them. Since stream_select
accepts only values by reference, we cannot pass NULL in directly, they must be defined as variables beforehand.
The if
block checks if $n
is positive ($n
is the number of updated streams), and if so, it extracts the first character from STDIN, which is our input key press:
Which is better?
I prefer the stty
method because both are *nix only, both invoke system commands, but the latter is arguably more complex, and its effect can vary depending on current terminal settings in a given OS.
Notice a crucial difference between the two gifs above – the stty
method also echoes out the character being pressed before outputting its keycode. To completely remove all auto-output and have PHP process it all, we need another stty
flag: -echo
.
public function __construct()
{
echo "Hello, I am snake!";
system('stty cbreak -echo');
$stdin = fopen('php://stdin', 'r');
while (1) {
$c = ord(fgetc($stdin));
echo "Char read: $c\n";
}
}
As per the docs, -echo
disables the output of input characters.
Mapping Snakes to Directions
It goes without saying we’ll need a way to tell each snake which direction to move in. First, let’s make a new Snake
class in Snake.php
to represent an instance of a player and hold their state.
<?php
namespace PHPSnake;
class Snake
{
/** @var string */
private $name;
/** @var string */
private $direction;
/** @var int */
private $size = 0;
const DIRECTIONS = ['UP', 'DOWN', 'LEFT', 'RIGHT'];
public function __construct(string $name = null)
{
if ($name === null) {
$this->name = $this->generateRandomName();
} else {
$this->name = $name;
}
}
public function getName() : string
{
return $this->name;
}
public function setDirection(string $direction) : Snake
{
$direction = strtoupper($direction);
if (!in_array($direction, Snake::DIRECTIONS)) {
throw new \InvalidArgumentException(
'Invalid direction. Up, down, left, and right supported!'
);
}
$this->direction = $direction;
echo $this->name.' is going '.$direction."\n";
return $this;
}
private function generateRandomName(int $length = 6) : string
{
$length = ($length > 3) ? $length : 6;
$name = '';
$consonants = 'bcdfghklmnpqrstvwxyz';
$vowels = 'aeiou';
for ($i = 0; $i < $length; $i++) {
if ($i % 2 == 0) {
$name .= $consonants[rand(0, strlen($consonants)-1)];
} else {
$name .= $vowels[rand(0, strlen($vowels)-1)];
}
}
return ucfirst($name);
}
}
Upon instantiation, a snake is given a name. If none is provided, a simple function generates one at random. The direction is null at the time of instantiation, and the directions that can be passed in are limited by the DIRECTIONS
constant of the class.
Next, we’ll update our SnakeGame
class.
<?php
namespace PHPSnake;
class SnakeGame
{
/** @var array */
private $snakes = [];
public function __construct()
{
}
/**
* Adds a snake to the game
* @param Snake $s
* @return SnakeGame
*/
public function addSnake(Snake $s) : SnakeGame
{
$this->snakes[] = $s;
return $this;
}
/**
* Runs the game
*/
public function run() : void
{
if (count($this->snakes) < 1) {
throw new \Exception('Too few players!');
}
system('stty cbreak -echo');
$stdin = fopen('php://stdin', 'r');
while (1) {
$c = ord(fgetc($stdin));
echo "Char read: $c\n";
}
}
}
This moves the keypress watching logic into a run function which we call after adding the necessary snakes to the game via addSnake
.
Finally, we can update the frontcontroller to use these updated classes.
<?php
use PHPSnake\Snake;
use PHPSnake\SnakeGame;
require_once 'classes/Snake.php';
require_once 'classes/SnakeGame.php';
$param = ($argc > 1) ? $argv[1] : '';
$game = new SnakeGame();
$game->addSnake(new Snake());
$game->run();
Now things are getting a bit more structured! Finally, let’s map the directions to the snakes.
We’ll have custom key mappings for each player, and depending on how many players we include, that’s how many mappings we’ll load. That way is much simpler than a long switch block. Let’s add the $mappings
property to our SnakeGame
class:
/**
* Key mappings
* @var array
*/
private $mappings = [
[
65 => 'up',
66 => 'down',
68 => 'left',
67 => 'right',
56 => 'up',
50 => 'down',
52 => 'left',
54 => 'right',
],
[
119 => 'up',
115 => 'down',
97 => 'left',
100 => 'right',
],
];
Each array corresponds to a single player/snake. The main player can be controlled either via the cursor keys, or the corresponding numpad numbers. Player two can only be controlled via the WSAD keys. You can see how easy this makes adding new mappings for additional players.
Then, let’s update the run
method:
/**
* Runs the game
*/
public function run() : void
{
if (count($this->snakes) < 1) {
throw new \Exception('Too few players!');
}
$mappings = [];
foreach ($this->snakes as $i => $snake) {
foreach ($this->mappings[$i] as $key => $dir) {
$mappings[$key] = [$dir, $i];
}
}
system('stty cbreak -echo');
$stdin = fopen('php://stdin', 'r');
while (1) {
$c = ord(fgetc($stdin));
echo "Char read: $c\n";
if (isset($mappings[$c])) {
$mapping = $mappings[$c];
$this->snakes[$mapping[1]]->setDirection($mapping[0]);
}
}
}
The run method now loads as many mappings as there are snakes, and reorders them so that the keycode is the key of the mapping array, and the direction and snake index are a child array – this makes it very easy to later on just single-line the direction changes. If we run our game now (I updated play.php
to add two snakes), we’ll notice that pressing random keys just produces key codes, while pressing WSAD or the cursor keys outputs the name of the snake and the direction it’ll be moving in after the keypress:
We now have a rather nicely structured game base for monitoring key presses and responding to them.
Conclusion
In this tutorial, we looked at keypress input game loops in PHP CLI applications. This unconventional use of PHP is about to get even weirder in part two, where we’ll deal with rendering players, movement, borders, and collisions.
In the meanwhile, do you have any ideas on how to further improve fetching the keypress? Let us know in the comments!
Frequently Asked Questions (FAQs) about Keypresses in PHPsnake
How does PHPsnake detect keypresses?
PHPsnake detects keypresses using JavaScript’s event listener for keydown events. When a key is pressed, the event listener triggers a function that changes the direction of the snake based on the key pressed. The key codes for the arrow keys are used to determine the direction. For example, the key code for the left arrow key is 37, so when this key is pressed, the snake moves to the left.
Can I use other keys instead of the arrow keys for controlling the snake?
Yes, you can use any keys for controlling the snake. You just need to change the key codes in the JavaScript function that handles the keydown event. For example, if you want to use the WASD keys, you can replace the key codes for the arrow keys with the key codes for the WASD keys.
How can I add more functionality to the game, like pausing and resuming?
You can add more functionality to the game by adding more event listeners for other keydown events. For example, you can add an event listener for the spacebar keydown event to pause and resume the game. When the spacebar is pressed, the game is paused if it’s currently running, or resumed if it’s currently paused.
Why does the snake move in the opposite direction when I press the opposite arrow key?
This is because of the way the game logic is implemented. When an arrow key is pressed, the direction of the snake is changed to the direction of the arrow key. However, if the snake is currently moving in a certain direction, it cannot immediately move in the opposite direction. This is to prevent the snake from colliding with itself.
How can I make the snake move faster or slower?
The speed of the snake is determined by the interval of the game loop. You can make the snake move faster by decreasing the interval, or slower by increasing the interval. However, be careful not to make the interval too small, as this can make the game unplayable.
Can I use this method to detect keypresses in other programming languages?
Yes, you can use a similar method to detect keypresses in other programming languages that support event-driven programming. The specific implementation may vary depending on the language, but the basic concept is the same: listen for keydown events and trigger a function when a key is pressed.
How can I display the score in the game?
You can display the score in the game by adding a score variable that is incremented whenever the snake eats an apple. You can then display this score on the screen using a text element.
Can I add sound effects to the game?
Yes, you can add sound effects to the game by using the Audio object in JavaScript. You can play a sound effect whenever a certain event occurs, like when the snake eats an apple or when the game is over.
How can I make the game more challenging?
You can make the game more challenging by adding more obstacles, like walls or other snakes. You can also increase the speed of the snake as the score increases, or decrease the size of the apples.
Can I use this game as a starting point for creating my own game?
Yes, you can use this game as a starting point for creating your own game. You can modify the game logic, add more features, and change the graphics to create a completely new game.
Bruno is a blockchain developer and technical educator at the Web3 Foundation, the foundation that's building the next generation of the free people's internet. He runs two newsletters you should subscribe to if you're interested in Web3.0: Dot Leap covers ecosystem and tech development of Web3, and NFT Review covers the evolution of the non-fungible token (digital collectibles) ecosystem inside this emerging new web. His current passion project is RMRK.app, the most advanced NFT system in the world, which allows NFTs to own other NFTs, NFTs to react to emotion, NFTs to be governed democratically, and NFTs to be multiple things at once.