Introduction to PHP Programming for Online Game Development


Revision 1.1

June 18, 2010



by

Aloysius Indrayanto



(C) 2010 AnemoneSoft.com



This document is multi-licensed under the Creative Commons Attribution Share-Alike (CC-BY-SA) license version 3.0 and the GNU Free Documentation License (GNU FDL) version 1.3 or later.



1. Introduction

An online game is a game that is played by multiple players across a computer network. For a public online game, the network is usually the internet. In this configuration, there will be a public server (or a cluster of servers) to where players can connect to and play the game.

An online game may require the user (player) to install a client application to be able to play the game. However, there are online games that can be played without installing any additional client application. This second type of games are usually played using web browser. Some games may still require the player to install a browser plugin such as Flash player, while some other games may require none.

This tutorial presents some basic concepts and techniques that would be needed to develop an online game application using PHP. The topics that will be discussed in this tutorials include: structure of the application/game, database stack, session management and security, and captcha image generator. The presented basic concepts and techniques would be mostly suitable to create browser-based online games that requires only JavaScript (no additional plugin). However, it may be possible to adapts the concepts and techniques for other types of online games. Please note that this tutorial will not create a fully functional online game.

2. Requirements

A basic knowledge in programming using PHP and MySQL will be needed to understand the topic discussed in this tutorial. A system with an installation of Apache Web Server (HTTPD) version 2.2.x, PHP: Hypertext Preprocessor version 5.x, MySQL Community Server version 5.x, and a recent enough browser will be needed to run the code snippets.

All the test database and tables used in the code snippets are unreal. The code snippets assume that those database and tables just exist in the system. However, if necessary, it would be easy to actually create those database and tables after understanding how they are accessed/used.

3. File and Directory Structure of an Online Game

There are two groups of PHP script files (web pages): the client-accessible pages and non-client-accessible pages. It is necessary to store those files/pages in a proper directory tree for clarity. It would be also necessary to inform the web browser, proxy, etc. that these pages are dynamic and should not be cached by setting the appropriate HTTP header using:

        header('Cache-Control: no-store, no-cache, must-revalidate');
        header('Pragma: no-cache');

3.1. Client-Accessible (Visible) Files/Pages

This group contains files/pages that are visible and accessible by clients' browser. Some files/pages that are part of this group are:

3.2. Non-Client-Accessible (Invisible) Files/Pages

This group contains files/pages that are invisible and not directly accessible by clients' browser. These files/pages are meant to be included from other files/pages. It is important to ensure that these files/pages cannot be viewed by clients. This can be achieved by using Apache Web Server access control or, easier, by using a one line PHP statement at the beginning of the script file, such as:

defined('ACCESS_OK') or die;

Hence, all the client-accessible pages should define the 'ACCESS_OK' constant before attempting to include any of the non-client-accessible pages. Some files/pages that are part of this group are:

define('TEXT_GC_LEVEL',        'Level'       );
define('TEXT_GC_SKILL_POINT',  'Skill Point' );
define('TEXT_GC_CREDIT_POINT', 'Credit Point');

or:

$GLOBALS['text_gcLevel'      ] = 'Level';
$GLOBALS['text_gcSkillPoint' ] = 'Skill Point';
$GLOBALS['text_gcCreditPoint'] = 'Credit Point';

3.3. Directory Structure

An example of a possible directory structure is shown in the picture below. The root directory is the root directory of your web server (in Linux, it is usually /var/www/html).

Explanation:

4. Database Stack

In this tutorial, we will use MySQL for the database server. However, in a real application, any supported database server can be used. It is even possible to switch the database server to other vendor at some point in the application lifetime. Therefore, it is important to isolate the database access commands in their own files so that only those files that need to be changed in case the developer decided to switch database vendor.

Isolation, in this regard, is obtained by using database stack. Basically, it is just a collection of classes that encapsulate low-level data base connection and SQL commands. The picture below shows a possible database stack.

The Connection class is the lowest-level class of the stack. Basically it encapsulates all the commands to connect to a database server, execute SQL, locking/unlocking tables, performing transaction, etc. The classes on top of this class (Model, Admin, and Init classes) will use the public API exported by this Connection class to read and write the database.

The Model classes encapsulate models. A model in a game can refer to a playable character, a non playable character, party's inventory, etc. Basically the Model classes encapsulate all the SQL to get the status, update the status, modify the content, etc. of those models. The idea is that these classes will export a consistent public API and hide any possible SQL incompatibilities at the back.

The Admin classes also has similar purposes, to export a consistent public API and hide any possible SQL incompatibilities at the back. However, unlike the Model classes, the Admin classes do not necessarily models something. Basically, a game administrator may need to access multiple models directly to accomplish his/her task. In this case, the developer may just use the individual Model classes directly, or create a combined Admin class to optimize the particular process. Also, sometime, an administrator needs to modify global game status (for example the list of winners for the last month competition), in this case the Admin class created for the purpose does not even encapsulate a model.

The Init classes is similar to the Admin classes. The idea is also the same, to export a consistent public API and hide any possible SQL incompatibilities at the back. The difference is that these classes will only need to be used once for the game initialization. Hence, do not forget to delete them from a production server so that they will not cause security problem (imagine an attacker use these classes to reset all the game data!).

4.1. An Example Connection Class

The code snippet below shows an example of Connection class (named DBConn) that is developed using the PHP's MySQL extension.

<?php
/*
    Copyright (C) 2010 AnemoneSoft.com
                       Aloysius Indrayanto

    This program is free software and comes with ABSOLUTELY NO WARRANTY.

    This program is licensed under the GNU Lesser General Public License (GNU LGPL)
    either version 3 of the license, or (at your option) any later version as
    published by the Free Software Foundation.
*/

// Access information (in a real application, these should be put in 
// a separated configuration file)
define('DB_HOST',     'localhost' );
define('DB_USER',     'mytestuser');
define('DB_PASSWORD', 'mypassword');
define('DB_DATABASE', 'MyTestDB'  );

// Database-connection utility class
class DBConn {
    //
    // Private section
    //
    private $_dbh;            // MySQL connection handle
    private $_result;         // Result resource handle
    private $_anyTableLocked; // Flag to indicate if any table is locked
    private $_inTranscation;  // Flag to indicate if currently a transaction is in progress

    private function _closeConnection()
    {
        // If there is no active connection, just return
        if(!$this->_dbh) return;

        // If there is an active result, free it
        if($this->_result) {
            @mysql_free_result($this->_result);
            $this->_result = null;
        }

        // Unlock tables and rollback transaction
        $this->unlockTables();
        $this->rollbackTransaction();

        // Close connection
        @mysql_close($this->_dbh);
        $this->_dbh = null;
    }

    private function _exitError($message)
    {
        // Close connection
        $this->_closeConnection();

        // Exit with HTTP error
        header('HTTP/1.1 500 Internal Server Error');
        echo '<b>HTTP/1.1 500 Internal Server Error</b><br/>';
        echo '<br/>[DBConn]<br/>' . $message . '<br/>';
        exit;
    }


    //
    // Public section
    //
    public function __construct()
    {
        // Set variables
        $this->_dbh            = null;
        $this->_result         = null;
        $this->_anyTableLocked = false;
        $this->_inTranscation  = false;

        // Connect to the database
        $attempt = 0;
        while(true) {
            // Try to connect to the database
            $this->_dbh = @mysql_connect(DB_HOST, DB_USER, DB_PASSWORD);
            // Succeeded
            if($this->_dbh)
                break;
            // Failed
            else {
                // Increment counter
                ++$attempt;
                // Retry a few times for: error 1040 - "Too many connections"
                //                        error 1203 - "User %s already has more than
                //                                      'max_user_connections' active
                //                                      connections"
                if($attempt < 10 && (mysql_errno() == 1040 || mysql_errno() == 1203))
                    usleep(500000);
                // Report error
                else
                    self::_exitError('Could not connect to the database server: '
                                     . mysql_error());
            }
        }

        // Select database
        if(!mysql_select_db(DB_DATABASE, $this->_dbh)) {
            $msg = mysql_error($this->_dbh);
            mysql_close($this->_dbh);
            self::_exitError('Could not select the game database: ' . $msg);
        }

        // Set timezone to UTC
        $this->sqlExec('SET time_zone = \'+0:00\'');
    }

    public function __destruct()
    { $this->_closeConnection(); }

    public function sqlEscapeString($str)
    { return mysql_real_escape_string($str, $this->_dbh); }

    public function sqlExec($sql)
    {
        // Free previous result (if any)
        if($this->_result) {
            @mysql_free_result($this->_result);
            $this->_result = null;
        }

        // Execute query and check for error
        $this->_result = mysql_query($sql, $this->_dbh);
        if(!$this->_result) {
            $msg  = '<em>SQL execution failed:</em><br/>';
            $msg .= mysql_error($this->_dbh);
            $msg .= '<br/><br/><em>Offending statement:</em><br/>';
            $msg .= $sql;
            mysql_close($this->_dbh);
            self::_exitError($msg);
        }

        // If the query does not produce result, set the result to NULL
        if(gettype($this->_result) != 'resource') $this->_result = null;
    }

    public function sqlNumberOfColumns()
    { return $this->_result ?  mysql_num_fields($this->_result) : 0; }

    public function sqlNumberOfRows()
    { return $this->_result ? mysql_num_rows($this->_result) : 0; }

    public function sqlNumberOfAffectedRows()
    { return mysql_affected_rows($this->_dbh); }

    public function sqlGetLastInsertedID()
    { return mysql_insert_id($this->_dbh); }

    public function sqlFetchColumnNames()
    {
        $colNames = array();

        $idx = 0;
        while($idx < mysql_num_fields($this->_result)) {
            $meta = mysql_fetch_field($this->_result, $idx);
            $colNames[$idx] = $meta->name;
            ++$idx;
        }

        return $colNames;
    }

    public function sqlFetchRow()
    { return mysql_fetch_row($this->_result); }

    public function lockReadTable($table)
    {
        if($this->_inTranscation)
            self::_exitError('A transaction is currently in progress');

        $this->sqlExec('LOCK TABLES ' . $table . ' READ');
        $this->_anyTableLocked = true;
    }

    public function lockWriteTable($table)
    {
        if($this->_inTranscation)
            self::_exitError('A transaction is currently in progress');

        $this->sqlExec('LOCK TABLES ' . $table . ' WRITE');
        $this->_anyTableLocked = true;
    }

    public function unlockTables()
    {
        if(!$this->_anyTableLocked) return;

        $this->sqlExec('UNLOCK TABLES');
        $this->_anyTableLocked = false;
    }

    public function beginTransaction()
    {
        if($this->_anyTableLocked)
            self::_exitError(]
                'Cannot start a transaction: one or more tables are currently locked');
        if($this->_inTranscation)
            self::_exitError('A transaction is already in progress');

        $this->sqlExec('START TRANSACTION');
        $this->_inTranscation = true;
    }

    public function commitTransaction()
    {
        if(!$this->_inTranscation) return;

        $this->sqlExec('COMMIT');
        $this->_inTranscation = false;
    }

    public function rollbackTransaction()
    {
        if(!$this->_inTranscation) return;

        $this->sqlExec('ROLLBACK');
        $this->_inTranscation = false;
    }
}
?>

dbconn.php: An Example Connection Class using the PHP's MySQL Extension.

Explanation:

4.2. An Example Model Class

The code snippet below shows an example of Model class that uses the previously presented DBConn class. To understand the class, assume that this class models a player in a game world. Each player has statuses such as health point, magic point, and spirit point.

<?php
/*
    Copyright (C) 2010 AnemoneSoft.com
                       Aloysius Indrayanto

    This program is free software and comes with ABSOLUTELY NO WARRANTY.

    This program is licensed under the GNU Lesser General Public License (GNU LGPL)
    either version 3 of the license, or (at your option) any later version as
    published by the Free Software Foundation.
*/

// Include the database connection class
include_once 'dbconn.php';

// Basic-player-status class
class PlayerBasicStatus {
    public $healthPoint;
    public $magicPoint;
    public $spiritPoint;

    public function __construct($hp, $mp, $sp)
    {
        $this->healthPoint = $hp;
        $this->magicPoint  = $mp;
        $this->spiritPoint = $sp;
    }
}

// Player-table class
class Table_Player {
    //
    // Private section
    //
    private $_dbc;

    //
    // Public section
    //
    public function __construct($dbc)
    { $this->_dbc = $dbc; }

    public function lockRead()
    { $this->_dbc->lockReadTable('Player'); }

    public function lockWrite()
    { $this->_dbc->lockWriteTable('Player'); }
    public function updateBasicStatuses($id, $hp, $mp, $sp)
    {
$sql = <<<EOT
    UPDATE Player
    SET    HealthPoint = $hp,
           MagicPoint  = $mp,
           SpiritPoint = $sp
    WHERE  PlayerID    = $id
EOT;
        $this->_dbc->sqlExec($sql);
    }

    public function getBasicStatuses($id)
    {
$sql = <<<EOT
    SELECT HealthPoint, MagicPoint, SpiritPoint
    FROM   Player
    WHERE  PlayerID = $id
EOT;
        $this->_dbc->sqlExec($sql);

        $row = $this->_dbc->sqlFetchRow();
        if(!$row) return null;

        return new PlayerBasicStatus((int) $row[0], (int) $row[1], (int) $row[2]);
    }
}
?>

model.php: An Example Model Class.

Explanation:

5. Session Management and Security

Due to HTTP is a stateless protocol, a method is needed to maintain data between requests. The simplest method is to store the user data in cookies. However, for a bit better security, using PHP's session would be a good idea. Basically, PHP will generate a special ID (a session ID) and store it as a cookie. Internally, PHP will associate the session ID with the script-supplied data (called session data). Using this method, only the server knows what data the PHP script actually stores (the browser only know the session name and ID). Hence, it may improve the security.

Rather than directly calling the PHP's session management functions, it would be a good idea to implement a class to manage session. Basically, the class should be able to:

The code snippet below shows an example of Session class that supports the above features.

<?php
/*
    Copyright (C) 2010 AnemoneSoft.com
                       Aloysius Indrayanto

    This program is free software and comes with ABSOLUTELY NO WARRANTY.

    This program is licensed under the GNU Lesser General Public License (GNU LGPL)
    either version 3 of the license, or (at your option) any later version as
    published by the Free Software Foundation.
*/

// Session class
class Session {
    // Private flag to indicate if the session is open (the session variables are writeable)
    private static $_open;

    // Public data
    public static $currentUserID;             // Current user ID
    public static $currentUserLoginName;      // Current user login name
    public static $currentUserVisibleName;    // Current user visible name
    public static $currentUserIsAdmin;        // Flag to indicate if the current user is an administrator
    public static $currentUserTimezoneOffset; // Timezone offset of the currrent user (in seconds)
    public static $captchaValue;              // Captcha value
    public static $randomNumber;              // General purpose random number
    public static $lastCommandTime;           // Last command time (in seconds)
    public static $commandCounter;            // Command counter

    // Open session and read all available variables
    public static function open()
    {
        if(!self::$_open) {
            session_name('_my_game_session_id');
            session_cache_limiter('private_no_expire, must-revalidate');
            session_start();
            self::$_open = true;
        }

        self::$currentUserID             = isset($_SESSION['UID']) ? $_SESSION['UID'] : 0;
        self::$currentUserLoginName      = isset($_SESSION['ULN']) ? $_SESSION['ULN'] : null;
        self::$currentUserVisibleName    = isset($_SESSION['UVN']) ? $_SESSION['UVN'] : null;
        self::$currentUserIsAdmin        = isset($_SESSION['UIA']) ? $_SESSION['UIA'] : false;
        self::$currentUserTimezoneOffset = isset($_SESSION['UTZ']) ? $_SESSION['UTZ'] : 0;

        self::$captchaValue              = isset($_SESSION['CPV']) ? $_SESSION['CPV'] : '';
        self::$lastCommandTime           = isset($_SESSION['LCT']) ? $_SESSION['LCT'] : 0;
        self::$commandCounter            = isset($_SESSION['CMC']) ? $_SESSION['CMC'] : 0;

        if(isset($_SESSION['RNM']))
            self::$randomNumber = $_SESSION['RNM'];
        else {
            self::$randomNumber = mt_rand();
            $_SESSION['RNM'] = self::$randomNumber;
        }
    }

    // Close session (the session variables become read-only)
    public static function close()
    {
        session_write_close();
        self::$_open = false;
    }

    // Destroy session (the session variables become invalid)
    public static function destroy()
    {
        session_regenerate_id(true);
        session_destroy();
        clearstatcache();

        self::$currentUserID             = 0;
        self::$currentUserLoginName      = null;
        self::$currentUserVisibleName    = null;
        self::$currentUserIsAdmin        = false;
        self::$currentUserTimezoneOffset = 0;
        self::$captchaValue              = '';
        self::$randomNumber              = 0;
        self::$lastCommandTime           = 0;
        self::$commandCounter            = 0;
    }

    // Set current user information
    public static function setUserInfo($uid, $uln, $uvn, $adm, $tzo)
    {
        if(!self::$_open) die('Session handling bug in \'Session::' . __FUNCTION__ . '()\'');

        self::$currentUserID             = $_SESSION['UID'] = $uid;
        self::$currentUserLoginName      = $_SESSION['ULN'] = $uln;
        self::$currentUserVisibleName    = $_SESSION['UVN'] = $uln;
        self::$currentUserIsAdmin        = $_SESSION['UIA'] = $adm;
        self::$currentUserTimezoneOffset = $_SESSION['UTZ'] = $tzo;

        session_regenerate_id(true);
    }

    // Set capthca value
    public static function setCapthcaValue($captcha)
    {
        if(!self::$_open) die('Session handling bug in \'Session::' . __FUNCTION__ . '()\'');

        self::$captchaValue = $_SESSION['CPV'] = $captcha;
    }

    // Update last command time and return the time difference with the previous time
    public static function updateLastCommandTime()
    {
        if(!self::$_open) die('Session handling bug in \'Session::' . __FUNCTION__ . '()\'');

        $curTime = time();
        $difTime = $curTime - self::$lastCommandTime;

        self::$lastCommandTime = $_SESSION['LCT'] = $curTime;

        return $difTime;
    }

    // Reset the command counter
    public static function resetCommandCounter()
    {
        if(!self::$_open) die('Session handling bug in \'Session::' . __FUNCTION__ . '()\'');

        self::$commandCounter = $_SESSION['CMC'] = 0;
    }

    // Increment the command counter and return the new value
    public static function incrementCommandCounter()
    {
        if(!self::$_open) die('Session handling bug in \'Session::' . __FUNCTION__ . '()\'');

        ++self::$commandCounter;
        $_SESSION['CMC'] = self::$commandCounter;

        return self::$commandCounter;
    }
};

// Open the session automatically upon including this file
Session::open();

// Set the default timezone to UTC
if(function_exists('date_default_timezone_set')) date_default_timezone_set('UTC');
?>

session.php: An Example Session Class.

Explanation:

Usage of security-related variables:

6. Captcha Generator Script

A captcha is a random sequence of character (usually alphanumeric) to challenge a client so that he/she can prove that he/she is a real person and not an automated script/bot. The code snippet below demonstrate a simple way to generate captcha image. Note that the PHP GD extension will need to be installed to run the script.

<?php
/*
    Copyright (C) 2010 AnemoneSoft.com
                       Aloysius Indrayanto

    This program is free software and comes with ABSOLUTELY NO WARRANTY.

    This program is licensed under the GNU Lesser General Public License (GNU LGPL)
    either version 3 of the license, or (at your option) any later version as
    published by the Free Software Foundation.
*/

// Start session
include_once 'session.php';

// Force no cache for the page
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');

// Helper function for generating random characters
function generateRandomCharacters()
{
    $sourceChars = array( array(48,  57), // Digits
                          array(97, 122)  // Lowercase characters
                        );

    srand((double) microtime() * 1000000);

    $randomStr = '';
    for($i=0; $i < 6; ++$i) {
        $pos        = rand(0, sizeof($sourceChars) - 1);
        $randomStr .= chr(rand($sourceChars[$pos][0], $sourceChars[$pos][1]));
    }

    return $randomStr;
}

// Load the base image
$cimg = @imagecreatefromjpeg('image/' . rand(0, 4) . '.jpg');
if(!$cimg) {
    $cimg = imagecreatetruecolor(128, 32);
    imagefilledrectangle($cimg, 0, 0, 128, 32, imagecolorallocate($cimg, 0, 0, 0));
}

// Generate random characters and store them in the session variable
$rstr = generateRandomCharacters();
Session::setCapthcaValue($rstr);
Session::close();

// Determine the color
$colorMode = rand(0, 2);

// Prepare colors
if($colorMode == 0) {
    $darkColor   = imagecolorallocate($cimg,  32,  64,  64);
    $brightColor = imagecolorallocate($cimg, 128, 255, 255);
}
else if($colorMode == 1) {
    $darkColor   = imagecolorallocate($cimg,  64,  64,  32);
    $brightColor = imagecolorallocate($cimg, 255, 255, 128);
}
else if($colorMode == 2) {
    $darkColor   = imagecolorallocate($cimg,  64,  32,  64);
    $brightColor = imagecolorallocate($cimg, 255, 128, 255);
}

// Load font
$fid = @imageloadfont('anonymous.gdf');
if(!$fid) $fid = 5;

// Determine the printing mode
$printMode = rand(1, 4);

// Print the characters based on the mode
if($printMode == 1 || $printMode == 2) {
    $posX = 25;
    $posY = ($printMode == 1) ? 5 : 13;
    for($i = 0; $i < strlen($rstr); ++$i) {
        $chr = $rstr[$i];
        imagestring($cimg, $fid, $posX - 2, $posY - 2, $chr, $darkColor);
        imagestring($cimg, $fid, $posX + 2, $posY + 2, $chr, $darkColor);
        imagestring($cimg, $fid, $posX,     $posY,     $chr, $brightColor);
        $posX += 37;
        $posY  = ($posY == 5) ? 13 : 5;
    }
}
else if($printMode == 3 || $printMode == 4) {
    $posX = 25;
    $posY = ($printMode == 3) ? 0 : 15;
    $incY = ($printMode == 3) ? 3 : -3;
    for($i = 0; $i < strlen($rstr); ++$i) {
        $chr = $rstr[$i];
        imagestring($cimg, $fid, $posX - 2, $posY - 2, $chr, $darkColor);
        imagestring($cimg, $fid, $posX + 2, $posY + 2, $chr, $darkColor);
        imagestring($cimg, $fid, $posX,     $posY,     $chr, $brightColor);
        $posX += 37;
        $posY += $incY;
    }
}

// Send the image
header('Content-type: image/jpeg');
imagejpeg($cimg, NULL, 100);

imagedestroy($cimg);
?>

captcha.php: A Simple Captcha Image Generator.

Explanation:



References



http://php.net/index.php, June 09, 2010

http://www.mysql.com, June 09, 2010

http://php.net/manual/en, June 09, 2010

http://en.wikipedia.org/wiki/JSON, June 12, 2010

http://en.wikipedia.org/wiki/Online_game, June 15, 2010

http://en.wikipedia.org/wiki/Flash_player, June 15, 2010

http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/emt.html, June 16, 2010

http://en.wikipedia.org/wiki/Data_Encryption_Standard, June 16, 2010

http://en.wikipedia.org/wiki/Advanced_Encryption_Standard, June 16, 2010

http://en.wikipedia.org/wiki/CAPTCHA, June 16, 2010

http://en.wikipedia.org/wiki/Model-view-controller, June 16, 2010