Master server implementation in plain PHP

Posted on May 12, 2014

For my game SkullRush, users had to be able to browse a list of servers to choose one. This essentially allows users NOT to type in IP addresses manually and also allows them to see detailed information about each server. OK, let’s get to the point: what online game doesn’t have a server browser?

But why PHP you may ask? Before you shoot yourself, let me explain. The game I’m making will probably never be marketed and will never reach a large audience. Therefore the amount of players using the server browser is going to be very low. In that case a free hosting plan satisfies all my needs. However, I can’t expect a free hosting plan to have Django/Ruby on Rails support. That’s why I chose PHP. It runs on any hosting plan, I believe. In the end it also allows to re-host it in case the official master server goes dead (ex: avoids GameSpy and all the crap PS2 and retro PC players have to deal with). I also chose the Laravel PHP framework because it has some pretty good documentation and it was one of the first search results that Google showed me.

I’ve never done any web development before (except for some lame Google App Engine stuff), so don’t expect a perfect implementation. In short, I needed the server to satisfy a few constraints:

  1. Only allow one server to be hosted per IP address (yeah, screw the special cases where one guy behind a NAT wants to host on like ten of his home computers. I mean, seriously?)
  2. Autonomously detect servers that have been shutdown (so don’t rely on servers to send you a nice “I’m shutting down” notice, there’s too much trolling potential right there)
  3. Provide a layer of security so trolls can’t ruin the server list (for example, nobody can “hack” and set a server’s amount of players to 0, or change that server’s current map name, etc.)
  4. I want the server list to be available in-browser, and also obtainable in a JSON format so that the game can parse the list.

I think that I have pretty much succeeded with accomplishing that.

Essentially the master server routes to three pages:

Although I’m talking about pages, think of them as general guides that outline the functionality to implement. They just describe what logic to apply depending on the request that was received by the master server.

  1. main page: displays servers in a nice HTML table
  2. json page: displays server list in JSON format, in case you need to parse the list
  3. server page: this is where all the complexity lies (not that it’s really complicated)

The server page is actually a couple of pages. One for registering a server, one for setting map and gamemode name, and one for setting the amount of current and max players. They all require POST requests.

register server: The master server adds a new server to the database if there isn’t already an existing server for the request’s IP address.

The other pages are fairly self-explanatory.

The trick is that when a server sends its register request, it also sends as a parameter a random alpha-numerical 100 char string, to be set as a password. So next time it sends a request, it must provide the same password as a parameter for that request to be taken into account, whether it be setting the amount of players or the map name.

The last page is the heartbeat page. This request should be fired at least every minute on the game server’s side, and it contains the current server’s password as well as a new generated password, which will replace the old one. This essentially tells the master server “hey, I’m still online” and also prevents password cracking since they alternate every minute or so.

Finally, every time the main or the json page is requested, the master server cycles through the server list and detects whether each server has sent a heartbeat request in the last minute. If a server hasn’t, the master server assumes that it’s dead and removes that server from the list.

There are a few things to take in consideration for security. First off, the server pages should be served through HTTPS, and the passwords that are sent should not be sent as url variables, but as request parameters. Otherwise the passwords can show up on the client’s and the master server’s logs, which is a vulnerability. Also you must set up a database for the master server to use, and you should probably design your main page nicely instead of using only crude HTML tables.

P.S.: This last paragraph is stuff I have yet to finish implementing for my master server, but I’ll get that done soon.

All these explanations are longer than the actual code, so I’m just gonna post the relevant bits of code to clear up any confusion.

/*
|--------------------------------------------------------------------------
| Application Routes
|--------------------------------------------------------------------------
|
| Here is where you can register all of the routes for an application.
| It's a breeze. Simply tell Laravel the URIs it should respond to
| and give it the Closure to execute when that URI is requested.
|
*/

Route::get('/', function()
{
    $servers = Server::all();
    $arr = $servers->toArray();

    $date = new DateTime();
    foreach($servers as $s)
    {
        $diffInSeconds = $date->getTimestamp() - $s->TimeChecked;

        if ($diffInSeconds > 60)
        {
            $servers = array_diff($servers , array($s));
            $s->delete();
        }
    }

    return View::make('ms_table')->with('s_arr', $servers);
});

Route::get('/jsoned', function()
{
    $servers = Server::all();
    $servers_json = Server::all()->toJSON();

    $date = new DateTime();
    foreach($servers as $s)
    {
        $diffInSeconds = $date->getTimestamp() - $s->TimeChecked;

        if ($diffInSeconds > 60)
        {
            $servers = array_diff($servers , array($s));
            $s->delete();
        }
    }

    return $servers_json;
});

Route::any('s/new/{name}/{seckey}', function($name, $seckey)
{
    $check = Server::find($_SERVER['REMOTE_ADDR']);

    if ( is_null($check))
    {
        $s = new Server;
        $s->Name = (string)$name;
        $s->Map = 'test_map';
        $s->GameMode = 'FFA';
        $s->Players = 0;
        $s->MaxPlayers = 12;
        $s->IP = $_SERVER['REMOTE_ADDR'];
        $s->SecKey = (string)$seckey;
        $date = new DateTime();
        $s->TimeChecked = $date->getTimestamp();

        $s->save();
    }
});

Route::any('s/setmapgm/{mapname}/{gm}/{key}', function($mapname, $gm, $key)
{
    $target = Server::findOrFail($_SERVER['REMOTE_ADDR']);

    if (! is_null($target))
    {
        $thekey = $target->SecKey;
        if ($key == $thekey)
        {
            $target->Map = (string)$mapname;
            $target->GameMode = (string)$gm;

            $target->save();
        }
    }
});

Route::any('s/setplayers/{current}/{max}/{key}', function($current, $max, $key)
{
    $target = Server::findOrFail($_SERVER['REMOTE_ADDR']);

    if (! is_null($target))
    {
        $thekey = $target->SecKey;
        if ($key == $thekey)
        {
            $target->Players = (int)$current;
            $target->MaxPlayers = (int)$max;

            $target->save();
        }
    }
});

Route::any('s/pulse/{oldkey}/{newkey}', function($oldkey, $newkey)
{
    $target = Server::findOrFail($_SERVER['REMOTE_ADDR']);

    if (! is_null($target))
    {
        $thekey = $target->SecKey;
        if ($oldkey == $thekey)
        {
            $target->SecKey = (string)$newkey;

            $date = new DateTime();
            $target->TimeChecked = $date->getTimestamp();

            $target->save();
        }
    }
});

class Server extends Eloquent
{

    /**
     * The database table used by the model.
     *
     * @var string
     */
    protected $table = 'servers';

    public $primaryKey = 'IP';

    public $timestamps = false;

    protected $guarded = array('*');

    protected $hidden = array('SecKey', 'TimeChecked');
}

And finally the servers’ table’s columns to set up in the database:

Column Name Column Type
IPvarchar(15)
Namevarchar(20)
GameModevarchar(20)
Playersint(11)
MaxPlayersint(11)
TimeCheckedvarchar(15)
SecKeyvarchar(100)
Mapvarchar(20)