Using PHP & Symfony to prototype a 4X strategy game (Map generation: Part 1)

·

13 min read

I've always preferred PHP to any other language. It's simple, dynamic and clean, not to mention really easy to write in an object-oriented format.

So, when I had an idea for a 4X strategy game (if you're not sure what that is, think Sid Meier's Civilization series) I immediately wanted to prototype my ideas as quickly as possible. I turned to PHP and the Symfony framework to help me out.

Why Symfony?

The Symfony framework provides a really quick and easy way to get an application running without having to write all of the backend boilerplate stuff that can grind starting a new project to a halt. It's split up into components and for this prototype I used the following composer.json to require everything I thought I might need right from the start:

{
    "type": "project",
    "license": "proprietary",
    "require": {
        "php": ">=7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11",
        "doctrine/common": "v2.13.3",
        "doctrine/doctrine-bundle": "^2.1",
        "doctrine/doctrine-migrations-bundle": "^3.0",
        "doctrine/orm": "^2.7",
        "doctrine/persistence": "v1.3.8",
        "easycorp/easyadmin-bundle": "^3.1",
        "sensio/framework-extra-bundle": "^5.6",
        "symfony/console": "5.1.*",
        "symfony/dotenv": "5.1.*",
        "symfony/flex": "^1.3.1",
        "symfony/framework-bundle": "5.1.*",
        "symfony/twig-bundle": "^5.1",
        "symfony/yaml": "5.1.*",
        "twig/extra-bundle": "^2.12|^3.0",
        "twig/twig": "^2.12|^3.0"
    },
    "require-dev": {
        "roave/security-advisories": "dev-master",
        "symfony/maker-bundle": "^1.21",
        "symfony/stopwatch": "^5.1",
        "symfony/web-profiler-bundle": "^5.1"
    },
    "config": {
        "optimize-autoloader": true,
        "preferred-install": {
            "*": "dist"
        },
        "sort-packages": true
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\": "tests/"
        }
    },
    "replace": {
        "paragonie/random_compat": "2.*",
        "symfony/polyfill-ctype": "*",
        "symfony/polyfill-iconv": "*",
        "symfony/polyfill-php72": "*",
        "symfony/polyfill-php71": "*",
        "symfony/polyfill-php70": "*",
        "symfony/polyfill-php56": "*"
    },
    "scripts": {
        "auto-scripts": {
            "cache:clear": "symfony-cmd",
            "assets:install %PUBLIC_DIR%": "symfony-cmd"
        },
        "post-install-cmd": [
            "@auto-scripts"
        ],
        "post-update-cmd": [
            "@auto-scripts"
        ]
    },
    "conflict": {
        "symfony/symfony": "*"
    },
    "extra": {
        "symfony": {
            "allow-contrib": false,
            "require": "5.1.*"
        }
    }
}

I am actually running PHP 7.4.11, but prefer to make sure my code is compatible with a version a couple of olders than that, as my webserver I like to deploy to (where I use shared hosting) isn't always the most up-to-date.

Getting started

The first thing I needed to do was sort out a general plan for how I wanted to approach the prototype. I've never attempted to make a game before, so I thought that I should begin by writing a list of things that I thought a 4X game might need. It quickly became clear to me that everything hinges off of having the functionality to generate a map for the game to take place on.

Building the map:gen command

I decided to start working out map generation by working with square tiles, with the plan to update the code to hex tiles once I had wrapped my head around how map generation should work.

If I wasn't just prototyping this, I might consider writing a service App\Service\MapGenerationEngine to handle our map generation - but I'd really like to just keep on generating maps really quickly without having to leave my IDE, so I'm going to use the Symfony Console component to get the project off the ground.

The Symfony Console component allows us to make neat console applications and commands which can be called from our controllers to do whatever we want. We could make a whole application of commands to handle various bits of logic if we wanted to - but since we aren't probably going to want to migrate this code into a service at some point in the future, we can just add a command to the base application which exists in bin/console.

I start by creating a new file in src/Command:

<?php
namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class MapGenCommand extends Command
{
    protected function configure(): void
    {
        $this
            ->setName('map:gen')
            ->setDescription('Generate map');
    }
}

It helped at this point to take a moment to think about what a map is and what its core properties are. I wrote another list!

  • As the map is tile based, the map must surely have a defined height & width. It would be nice if we could generate maps with dynamic heights and widths by passing some parameters to our map generation engine.
  • Again, because the map is tile based, it can be split into rows. The map is therefore just a collection of rows, and a row is a collection of tiles. Now I am starting to realise that a tile based map is really just a glorified CSV.
  • Finally, after the map is generated, it needs to have a unique identifier token so that we can recall it whenever we need to.

The first point on that list should be really easy to get started with. We can add some parameters to our command to take care of that.

    protected function configure(): void
    {
        $this
            ->setName('map:gen')
            ->setDescription('Generate map')
            ->addArgument(
                'height',
                InputArgument::REQUIRED,
                'Map Height'
            )
            ->addArgument(
                'width',
                InputArgument::REQUIRED,
                'Map Width'
            );
    }

Those arguments will be passed into our generation logic for us to pick up later.

Let's finish off our command now by adding another function:

    /**
     * @param InputInterface  $input
     * @param OutputInterface $output
     * @return int
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $height = $input->getArgument('height');
        $width = $input->getArgument('width');

        dump($height.' '.$width);

        return 1;
    }

We can test out our command by running symfony console map:gen 10 20 in our terminal from our application's directory (note that I am using the Symfony CLI to make things easier - I highly recommend it!). Let's see what the output is when we run it:

$ symfony console map:gen 10 20
"10 20"
exit status 1

Nice! Now we have a working command which spits back our height and width arguments to us. We can build on this later.

Creating some entities

The idea of a map as a collection of rows is really enticing. I can already imagine a data structure that looks like:

['map' => [
    'height' => 10,
    'width' => 20,
   'rows' => [
        // ...
    ],
]];

But if we are going to want to be able to save this data structure to a database of some kind, it doesn't make sense to serialize a potentially huge array like this and just save that somewhere. We are going to need to make it more manageable. Let's create the maps table.

First we run symfony composer require maker --dev to install the symfony\maker-bundle as a dev dependency.

If you're familiar with modern PHP, you've probably used Doctrine ORM before, and there's tonnes of material online to find out more if it's a new concept to you, so I won't rehash how to set that up.

I've already gone through the steps to set up my Doctrine connection to my local MariaDB Docker instance. Now we can use Symfony to create an entity by running symfony console make:entity Map. Here's how the command went for me:

$ symfony console make:entity Map

created: src/Entity/Map.php
created: src/Repository/MapRepository.php

Entity generated! Now let's add some fields!
You can always add more fields later manually or by re-running this command.

New property name (press <return> to stop adding fields):
> height

Field type (enter ? to see all types) [string]:
> integer

Can this field be null in the database (nullable) (yes/no) [no]:
> no

updated: src/Entity/Map.php

Add another property? Enter the property name (or press <return> to stop adding fields):
> width

Field type (enter ? to see all types) [string]:
> integer

Can this field be null in the database (nullable) (yes/no) [no]:
> no

updated: src/Entity/Map.php

Add another property? Enter the property name (or press <return> to stop adding fields):
>


Success! 


Next: When you're ready, create a migration with php bin/console make:migration

You can see your new entity in the newly created src/Entity folder and your new repository in the newly created src/Repository folder.

As we noticed previously, we think a map should be a collection of of rows. Let's create the Row entity too then!

$ symfony console make:entity Row

created: src/Entity/Row.php
created: src/Repository/RowRepository.php

Entity generated! Now let's add some fields!
You can always add more fields later manually or by re-running this command.

New property name (press <return> to stop adding fields):
> map

Field type (enter ? to see all types) [string]:
> ManyToOne

What class should this entity be related to?:
> Map

Is the Row.map property allowed to be null (nullable)? (yes/no) [yes]:
> no

Do you want to add a new property to Map so that you can access/update Row objects from it - e.g. $map->getRows()? (yes/no) [yes]:
> yes

A new property will also be added to the Map class so that you can access the related Row objects from it.

New field name inside Map [rows]:
> rows

Do you want to activate orphanRemoval on your relationship?
A Row is "orphaned" when it is removed from its related Map.
e.g. $map->removeRow($row)

NOTE: If a Row may *change* from one Map to another, answer "no".

Do you want to automatically delete orphaned App\Entity\Row objects (orphanRemoval)? (yes/no) [no]:
> yes

updated: src/Entity/Row.php
updated: src/Entity/Map.php

Add another property? Enter the property name (or press <return> to stop adding fields):
>


Success! 


Next: When you're ready, create a migration with php bin/console make:migration

So now we have two entities: Map and Row, where a Map may be linked to many Rows, but a Row can only be linked to a single Map. Symfony was also kind enough to add a few methods to our Map entity for us: $map->addRow($row), $map->removeRow($row), and $map->getRows()! Thanks Symfony ❤️

If a map is a collection of rows, isn't a row really just a collection of tiles? Let's add a Tile entity too.

$ symfony console make:entity Cell

Your entity already exists! So let's add some new fields!

New property name (press <return> to stop adding fields):
> row

Field type (enter ? to see all types) [string]:
> ManyToOne

What class should this entity be related to?:
> Row

Is the Cell.row property allowed to be null (nullable)? (yes/no) [yes]:
> no

Do you want to add a new property to Row so that you can access/update Cell objects from it - e.g. $row->getCells()? (yes/no) [yes]:
> yes

A new property will also be added to the Row class so that you can access the related Cell objects from it.

New field name inside Row [cells]:
> cells

Do you want to activate orphanRemoval on your relationship?
A Cell is "orphaned" when it is removed from its related Row.
e.g. $row->removeCell($cell)

NOTE: If a Cell may *change* from one Row to another, answer "no".

Do you want to automatically delete orphaned App\Entity\Cell objects (orphanRemoval)? (yes/no) [no]:
> yes

updated: src/Entity/Cell.php
updated: src/Entity/Row.php

Add another property? Enter the property name (or press <return> to stop adding fields):
>


Success! 


Next: When you're ready, create a migration with php bin/console make:migration

And boom, now we have 3 entities and repositories set up, which are linked together. We can run symfony console make:migration and symfony doctrine:migrations:migrate to create those 3 tables on our database instance now.

Wrapping up

Phew, that's a pretty long post already so we'll call it a day for now. In the next post we can start getting into some proper business logic to actually generate a realistic looking map. It's something you'll be able to run with and tweak as you see fit to create something which suits the needs of your game best.

See you in Map generation: Part 2.