Procedurally Generated Game Terrain with React, PHP, and WebSockets
Last time, I began telling you the story of how I wanted to make a game. I described how I set up the async PHP server, the Laravel Mix build chain, the React front end, and WebSockets connecting all this together. Now, let me tell you about what happened when I starting building the game mechanics with this mix of React, PHP, and WebSockets…
The code for this part can be found at github.com/assertchris-tutorials/sitepoint-making-games/tree/part-2. I've tested it with PHP 7.1
, in a recent version of Google Chrome.
Making a Farm
"Let's start simple. We have a 10 by 10 grid of tiles, filled with randomly generated stuff."
I decided to represent the farm as a Farm
, and each tile as a Patch
. From app/Model/FarmModel.pre
:
namespace App\Model;
class Farm
{
private $width
{
get { return $this->width; }
}
private $height
{
get { return $this->height; }
}
public function __construct(int $width = 10,
int $height = 10)
{
$this->width = $width;
$this->height = $height;
}
}
I thought it would be a fun time to try out the class accessors macro by declaring private properties with public getters. For this I had to install pre/class-accessors
(via composer require
).
I then changed the socket code to allow for new farms to be created on request. From app/Socket/GameSocket.pre
:
namespace App\Socket;
use Aerys\Request;
use Aerys\Response;
use Aerys\Websocket;
use Aerys\Websocket\Endpoint;
use Aerys\Websocket\Message;
use App\Model\FarmModel;
class GameSocket implements Websocket
{
private $farms = [];
public function onData(int $clientId,
Message $message)
{
$body = yield $message;
if ($body === "new-farm") {
$farm = new FarmModel();
$payload = json_encode([
"farm" => [
"width" => $farm->width,
"height" => $farm->height,
],
]);
yield $this->endpoint->send(
$payload, $clientId
);
$this->farms[$clientId] = $farm;
}
}
public function onClose(int $clientId,
int $code, string $reason)
{
unset($this->connections[$clientId]);
unset($this->farms[$clientId]);
}
// …
}
I noticed how similar this GameSocket
was to the previous one I had --- except, instead of broadcasting an echo, I was checking for new-farm
and sending a message back only to the client that had asked.
"Perhaps it's a good time to get less generic with the React code. I'm going to rename component.jsx
to farm.jsx
."
From assets/js/farm.jsx
:
import React from "react"
class Farm extends React.Component
{
componentWillMount()
{
this.socket = new WebSocket(
"ws://127.0.0.1:8080/ws"
)
this.socket.addEventListener(
"message", this.onMessage
)
// DEBUG
this.socket.addEventListener("open", () => {
this.socket.send("new-farm")
})
}
}
export default Farm
In fact, the only other thing I changed was sending new-farm
instead of hello world
. Everything else was the same. I did have to change the app.jsx
code though. From assets/js/app.jsx
:
import React from "react"
import ReactDOM from "react-dom"
import Farm from "./farm"
ReactDOM.render(
<Farm />,
document.querySelector(".app")
)
It was far from where I needed to be, but using these changes I could see the class accessors in action, as well as prototype a kind of request/response pattern for future WebSocket interactions. I opened the console, and saw {"farm":{"width":10,"height":10}}
.
"Great!"
Then I created a Patch
class to represent each tile. I figured this was where a lot of the game's logic would happen. From app/Model/PatchModel.pre
:
namespace App\Model;
class PatchModel
{
private $x
{
get { return $this->x; }
}
private $y
{
get { return $this->y; }
}
public function __construct(int $x, int $y)
{
$this->x = $x;
$this->y = $y;
}
}
I'd need to create as many patches as there are spaces in a new Farm
. I could do this as part of FarmModel
construction. From app/Model/FarmModel.pre
:
namespace App\Model;
class FarmModel
{
private $width
{
get { return $this->width; }
}
private $height
{
get { return $this->height; }
}
private $patches
{
get { return $this->patches; }
}
public function __construct($width = 10, $height = 10)
{
$this->width = $width;
$this->height = $height;
$this->createPatches();
}
private function createPatches()
{
for ($i = 0; $i < $this->width; $i++) {
$this->patches[$i] = [];
for ($j = 0; $j < $this->height; $j++) {
$this->patches[$i][$j] =
new PatchModel($i, $j);
}
}
}
}
For each cell, I created a new PatchModel
object. These were pretty simple to begin with, but they needed an element of randomness --- a way to grow trees, weeds, flowers … at least to begin with. From app/Model/PatchModel.pre
:
public function start(int $width, int $height,
array $patches)
{
if (!$this->started && random_int(0, 10) > 7) {
$this->started = true;
return true;
}
return false;
}
I thought I'd begin just by randomly growing a patch. This didn't change the external state of the patch, but it did give me a way to test how they were started by the farm. From app/Model/FarmModel.pre
:
namespace App\Model;
use Amp;
use Amp\Coroutine;
use Closure;
class FarmModel
{
private $onGrowth
{
get { return $this->onGrowth; }
}
private $patches
{
get { return $this->patches; }
}
public function __construct(int $width = 10,
int $height = 10, Closure $onGrowth)
{
$this->width = $width;
$this->height = $height;
$this->onGrowth = $onGrowth;
}
public async function createPatches()
{
$patches = [];
for ($i = 0; $i < $this->width; $i++) {
$this->patches[$i] = [];
for ($j = 0; $j < $this->height; $j++) {
$this->patches[$i][$j] = $patches[] =
new PatchModel($i, $j);
}
}
foreach ($patches as $patch) {
$growth = $patch->start(
$this->width,
$this->height,
$this->patches
);
if ($growth) {
$closure = $this->onGrowth;
$result = $closure($patch);
if ($result instanceof Coroutine) {
yield $result;
}
}
}
}
// …
}
There was a lot going on here. For starters, I introduced an async
function keyword using a macro. You see, Amp handles the yield
keyword by resolving Promises. More to the point: when Amp sees the yield
keyword, it assumes what is being yielded is a Coroutine (in most cases).
I could have made the createPatches
function a normal function, and just returned a Coroutine from it, but that was such a common piece of code that I might as well have created a special macro for it. At the same time, I could replace code I had made in the previous part. From helpers.pre
:
async function mix($path) {
$manifest = yield Amp\File\get(
.."/public/mix-manifest.json"
);
$manifest = json_decode($manifest, true);
if (isset($manifest[$path])) {
return $manifest[$path];
}
throw new Exception("{$path} not found");
}
Previously, I had to make a generator, and then wrap it in a new Coroutine
:
use Amp\Coroutine;
function mix($path) {
$generator = () => {
$manifest = yield Amp\File\get(
.."/public/mix-manifest.json"
);
$manifest = json_decode($manifest, true);
if (isset($manifest[$path])) {
return $manifest[$path];
}
throw new Exception("{$path} not found");
};
return new Coroutine($generator());
}
I began the createPatches
method as before, creating new PatchModel
objects for each x
and y
in the grid. Then I started another loop, to call the start
method on each patch. I would have done these in the same step, but I wanted my start
method to be able to inspect the surrounding patches. That meant I would have to create all of them first, before working out which patches were around each other.
I also changed FarmModel
to accept an onGrowth
closure. The idea was that I could call that closure if a patch grew (even during the bootstrapping phase).
Each time a patch grew, I reset the $changes
variable. This ensured the patches would keep growing until an entire pass of the farm yielded no changes. I also invoked the onGrowth
closure. I wanted to allow onGrowth
to be a normal closure, or even to return a Coroutine
. That's why I needed to make createPatches
an async
function.
Note: Admittedly, allowing onGrowth
coroutines complicated things a bit, but I saw it as essential for allowing other async actions when a patch grew. Perhaps later I'd want to send a socket message, and I could only do that if yield
worked inside onGrowth
. I could only yield onGrowth
if createPatches
was an async
function. And because createPatches
was an async
function, I would need to yield it inside GameSocket
.
"It's easy to get turned off by all the things that need learning when making one's first async PHP application. Don't give up too soon!"
The last bit of code I needed to write to check that this was all working was in GameSocket
. From app/Socket/GameSocket.pre
:
if ($body === "new-farm") {
$patches = [];
$farm = new FarmModel(10, 10,
function (PatchModel $patch) use (&$patches) {
array_push($patches, [
"x" => $patch->x,
"y" => $patch->y,
]);
}
);
yield $farm->createPatches();
$payload = json_encode([
"farm" => [
"width" => $farm->width,
"height" => $farm->height,
],
"patches" => $patches,
]);
yield $this->endpoint->send(
$payload, $clientId
);
$this->farms[$clientId] = $farm;
}
This was only slightly more complex than the previous code I had. I needed to provide a third parameter to the FarmModel
constructor, and yield $farm->createPatches()
so that each could have a chance to randomize. After that, I just needed to pass a snapshot of the patches to the socket payload.
Random patches for each farm
"What if I start each patch as dry dirt? Then I could make some patches have weeds, and others have trees …"
I set about customizing the patches. From app/Model/PatchModel.pre
:
private $started = false;
private $wet {
get { return $this->wet ?: false; }
};
private $type {
get { return $this->type ?: "dirt"; }
};
public function start(int $width, int $height,
array $patches)
{
if ($this->started) {
return false;
}
if (random_int(0, 100) < 90) {
return false;
}
$this->started = true;
$this->type = "weed";
return true;
}
I changed the order of logic around a bit, exiting early if the patch had already been started. I also reduced the chance of growth. If neither of these early exits happened, the patch type would be changed to weed.
I could then use this type as part of the socket message payload. From app/Socket/GameSocket.pre
:
$farm = new FarmModel(10, 10,
function (PatchModel $patch) use (&$patches) {
array_push($patches, [
"x" => $patch->x,
"y" => $patch->y,
"wet" => $patch->wet,
"type" => $patch->type,
]);
}
);
Continue reading %Procedurally Generated Game Terrain with React, PHP, and WebSockets%