Asynchronous multiplayer games allow two or more people to play together without the need to be participating in the session at the same time. A key component of supporting asynchronous gameplay is saving the game state to an online database so that it can be retrieved by you and your opponents when it’s time to take the next turn. This article will explain how I achieved game state storing and reloading for my cross-platform game, Hero Mages, and provide insights and code examples helpful for doing the same with your games.
This article will explain:
- How to represent game state as data using simple 2D grid representation and command log based representation
- How to write game data to an online database using server side extensions with Smart Fox Server Pro.
Requirements |
|
Representing the Game State as Data
The game state consists of everything that defines the current status of the game board: the layout of the game board, the positions of game pieces, the current attributes and effects of any characters in play, cards in each player’s hand, and (if it’s important to your game) the series of moves that led up to present state of the game. How to best represent a game as data varies based on the complexity of these factors. I will explain two methods I used for Hero Mages: simple 2D grid representation and command log based representation.
Simple 2D Grid Representation
A two-dimensional array can be used to represent the position of game pieces on a game board grid. For example, a simple game of tic-tac-toe might be represented as follows:
1 2 3 4 5 | //CODE EXAMPLE 1: TIC TAC TOE REPRESENTED AS 2D ARRAY var ticTacToeGameState:Array = []; ticTacToeGameState[0] = [X, O, X]; ticTacToeGameState[1] = [X, X, O]; ticTacToeGameState[2] = [O, X, O]; |
This array based representation words adequately enough in the context of program code, but for an async game, it’s necessary to store this representation to an online database using a flat data structure. It’s best to use as little information as possible when representing the game state in order to conserve bandwidth and server space.
Assuming we know our tic-tac-toe grid is always 3×3, this same game could be represented using a flat character string:
1 2 | //CODE EXAMPLE 2: TIC TAC TOE REPRESENTED AS FLAT STRING var ticTacToeGameState:String = "XOXXXOOXO"; |
Upon retrieval of the game state data, we can recreate the two dimensional array shown above:
1 2 3 4 5 6 7 8 9 | //CODE EXAMPLE 3: CONVERT 2D GAME STRING TO 2D ARRAY var ticTacToeGameGrid:Array = []; for(var i:int = 0; i < 3; i++){ var gridRow:Array = []; for(var j:int = 0; j < 3; j++){ gridRow.push(ticTacToeGameState.charAt(i+j)); } ticTacToeGameGrid.push(gridRow); } |
Hero Mages utilizes this simple 2D grid representation to store map layouts as a series of X’s and O’s. X’s represent walls, O’s represent open spaces, and start location are a series of numerical combinations representing player team and specific unit type placement areas.
Command Log Based Representation
Many grid-based games can be fully represented by a two dimensional array flattened out to a single line text string as explained above. Games that depend on when particular game moves take place will benefit from a command log based representation.
Benefits of Command Log Based Representation
The goal of a command log representation is to leverage the game engine to recreate a saved game state by providing the list of game commands that led up to the current state. Commands are stored as abbreviated notation to conserve file space and bandwidth. Upon receiving the command log, the game engine literally “plays” the game as instructed up to the last command. During this time, animations are disabled so that the game can be recreated instantly.
Using a command log to recreate a game ensures that all essential game details: the cards that were played, the pieces on board, and the status of those pieces are accurately represented in exactly the same fashion that led up to the game play state in the first place. Additionally, the command log shows a complete history of what has happened since the start of the game. This is extremely helpful for asynchronous games because the player can review the list of moves to recall what events led up to their current position and plan their strategy accordingly. It’s also possible to reanimate select moves that a player returning from to an async game did not have the opportunity to see (I’ll discuss this last part in a future article).
Abbreviated Game Notation
The challenge of writing an effective command log based representation of a game is devising a form of notation that is as descriptive as necessary and as concise as possible. It’s important to define a series of expected standards for the command format. Here’s a look at how I built the syntax for Hero Mages:
- Each individual command is enclosed in bracketed c tags “<c>command</c>“
- Command properties are separated by pipes “|” and property assignments are delineated by equal sign “=”
- All commands contain a property that defines the command type called “cT”. The different command types are symbols derived from abbreviating the corresponding function name. For example, a command that instructed a unit to use an action would look like “cT=uA”
- Complex data structures such as units and actions are represented with unique numerical ids.
- Units are assigned ids based on the order they are added to the game board.
- Spells are assigned ids based on their index in the deck array.
- Abilities are assigned ids based on their index in the units’ abilities array.
- Targets of a command are separated by commas and grid coordinates (x and y) are separated by semi-colons.
By following a strict notation and referencing complex, instanced objects with numerical ids, game commands can be represented as simple strings using the following function:
1 2 3 4 5 6 7 8 9 | //CODE EXAMPLE 4: OBJECT TO STRING FUNCTION function objectToString(object:Object, separator:String, valAssignment:String):String{ var string:String = ""; for (var prop:* in object){ string += prop + valAssignment + object[prop] + separator; } string = string.substr(0, string.length - separator.length); return string; } |
If your object contains nested arrays, you will need to first encode those arrays as strings with unique separator and assignment characters:
1 2 3 | //CODE EXAMPLE 5: CONVERT ARRAY TO STRING var myArray:Array = [4, 5, 6, 7]; var myStringArray:String = myArray.toString(); |
And here’s what the assembly of a complete game command looks like in Hero Mages:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | //CODE EXAMPLE 6: GENERATE GAME COMMAND function generateUseActionCommandString():String{ //Create a new object to store command properties var HM_UseAction:Object = new Object(); //Store the command type: "uA" represents "useAction" HM_UseAction.cT = "uA"; //The abilityUser is a complex, custom datatype //So, we store the id of the unit using ability HM_UseAction.uId = abilityUser.unitId; //The unit's ability is also a complex, custom datatype //So, we store the id that represents its index in the abilities array HM_UseAction.i = abilityIndex; //pT represents the primary targets //In actual game, a function discerns between target types (units, spaces) //Here, we simply convert the array of choices to a comma deliniated string HM_UseAction.pT = primaryTargetsToActOn.toString(); //Encapsulate the command within c-tags var strCmd:String = "<c>"+objectToString(HM_UseAction, "|", "=")+"</c>"; //A preview of the assembled command trace(strCmd) //<c>cT=uA|uId=1|i=1|pT=4,5,6"</c> //Return the command return strCmd; } |
For reference, the following function can be used to convert string commands back into objects:
1 2 3 4 5 6 7 8 9 10 | //CODE EXAMPLE 7: STRING TO OBJECT FUNCTION function stringToObject(string:String, separator:String, valAssigment:String):Object{ var object:Object = new Object(); var props:Array = string.split(separator); for(var i:int = 0; i < props.length; i++){ var vals:Array = props[i].split(valAssigment); object[vals[0]] = vals[1]; } return object; } |
Writing the Game to Database
Once the game application is capable of representing its save state as a simple text file, the next step is writing the game’s save data to an online database so it can be retrieved asynchronously by other players. In order to do this, you’ll need a web server with MySQL, SQL, or another form of database as well as a web service or server to communicate with the database, run necessary queries, and send/receive data to/from the application.
Hero Mages uses Smart Fox Server for real-time multiuser connectivity (online chat and synchronous gameplay) so I leverage my existing server-side code to handle communication with the database. Hero Mages data is stored in a MySQL database which I’ve previously setup using my hosting provider, GoDaddy.com. The reason I enjoy working with SmartFoxServer is that I can use MySQL directly with ActionScript 1.0 and not have to worry about knowing how to program in PHP or other server-side languages.
Defining Tables to Store Game Data
It’s important to start by defining tables that will be used to store the game information in the database. Hero Mages uses two sets of tables for this purpose:
The table “hm_games” is used to store all of the relevant game data. The “cmdLog” field will store the actual list of notated commands the game engine will use to rebuild the game state.
hm_games | ||
Field | Type | Notes |
ID_GAME | int(10) | Stores the unique game record id |
ranked | tinyint(4) | Indicates whether or not game is counted for rank |
timeRecorded | int(10) | The epoch time in seconds game was last updated |
version | varchar(16) | Indicates client version game was created with |
cmdLog | TEXT | The game state represented via command log |
status | tinyint(4) | Indicates whether the game is in progress or complete |
timeCreated | int(10) | The epoch time in seconds game was created |
whoseTurn | mediumint(8) | The user id of the player whose turn is active |
timeLastTurn | int(10) | The epoch time in seconds last turn was completed |
isAsync | tinyint(4) | Indicates whether or not game is async or synced |
The table “hm_gameresults” is used to store player-specific information related to the game. All of the players for a particular game are connected to the hm_games table via ID_GAME. This table stores the result (whether or not the player won or lost), rating change (if game is ranked), and will also be developed further later to help determine which animations the player needs to see when they rejoin the game.
hm_gameresults | ||
Field | Type | Notes |
ID_MEMBER | mediumint(8) | The unique id of player participating in this game |
ID_GAME | int(10) | The unique id of the game record for this result |
result | tinyint(4) | The outcome of the game for this player (win/loss) |
ratingChange | tinyint(4) | The change in players rating for ranked games |
Creating a New Game Record
Hero Mages was designed for synchronous multiplayer game play, and I haven’t yet developed a user interface design for an asynchronous match-making system. However, the existing game creation screen will work perfectly for the purposes of illustrating how to save a new game record to the database.
The basic communication flow between the game client and online server works like this:
- Once game host has configured all game options to their satisfaction, they will press “Start Game”
- The game client formats the game settings as a notated game command, sends this command to the server, and awaits a response
- The server side script receives the command to create a new game record, executes a MySQL statement to create a new entry in the hm_gameresults table as well as new records in the hm_gameresults table for each player.
- If the server’s database operations are completed successfully, the server returns a response to the client indicating the newly created game record’s ID_GAME. If the server operations fail, a response is returned to the client indicating that the new game could not be created.
- If client receives ID_GAME, the game host repackages the command notation with this property and sends the start game command out to all users. If a “failed operation” is returned, game client shows user an error message.
Note: If you need assistance learning how to write a server-side extension for Smart Fox Server Pro, follow the excellent tutorials available at: http://www.smartfoxserver.com/docs/1x/
Once the notated game command is assembled, the following function is used to send the command to the server side extension:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | //CODE EXAMPLE 8: SEND CREATE GAME COMMAND private function sendStartGameCommand(HM_GameVars:Object):void{ //Store a reference to the created game settings object for use later gameVarObj = HM_GameVars; //Check to see that smartFox is connected and that there are at least 2 players if(smartFox.isConnected == true && playerSettingsList.length > 1){ //Send the command to create new game record to Smart Fox Server extension //Commands can be sent as string or xml; normally, I use string for speed //In this case, I use xml to save the work of encoding to string smartFox.sendXtMessage("HMServer", "CreateGameRecord", HM_GameVars, "xml"); } else{ //If this is a practice game with only 1 player, no need to store to database //Fire game up immediately fireUpGameWithRecordID(-1); } } |
On the server side, I add a new condition for the “Create Game” command which handles the insertion of a new game record in the database:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | //CODE EXAMPLE 9: CREATE GAME RECORD IN DATABASE function handleRequest(cmd, params, user, fromRoom, protocol){ if(protocol == "xml"){ //....CODE FOR OTHER EXTENSION COMMANDS OMITTED.... else if(cmd == "CreateGameRecord"){ //EXTRACT THE PLAYER INFORMATION AND TURN ORDER FROM THE RECEIVED COMMAND var players = String(params.pS).split(","); var randomTurnOrders = params.rTO.split(","); //GENERATE THE MYSQL STATEMENT TO ADD NEW GAME RECORD BASED ON GAME SETTINGS var gameRecordSQL = "INSERT into hm_games (ranked, timeCreated, version, status, timeRecorded, whoseTurn, cmdLog) VALUES (" gameRecordSQL += "'" + params.r + "', " //ranked gameRecordSQL += "'" + Math.floor(getTimer() / 1000) + "', " //timeCreated gameRecordSQL += "'" + params.v + "', " //version gameRecordSQL += "'" + 1 + "', " //status gameRecordSQL += "'" + Math.floor(getTimer() / 1000) + "', " //timeRecorded gameRecordSQL += "'" + stringToObject(players[randomTurnOrders[0]], ";", ":").hmId + "', " //whoseTurn gameRecordSQL += "'" + "<c>" + objectToString(params, "|", "=") + "</c>" + "'" //cmdLog gameRecordSQL += ")"; //EXECUTE MYSQL COMMAND AND CHECK IF IT WAS SUCCESSFUL success = dbase.executeCommand(gameRecordSQL); if(success == false){ //IF THIS FAILS, WE NEED TO REPORT BACK AN ERROR TO CLIENT trace("UNABLE TO CREATE GAME RECORD"); response.error = "Unable to create new game record in database"; } else{ //ONCE GAME RECORD IS ADDED, GRAB ITS ID (WE KNOW ITS THE LAST INSERTED RECORD) sql = "SELECT LAST_INSERT_ID()" var queryRes = dbase.executeQuery(sql); var dataRow = queryRes.get(0); //STORE THE GAME RECORD ID IN OUR RESPONSE OBJECT response.id = dataRow.getItem("LAST_INSERT_ID()"); //CREATE A STATEMENT TO INSERT A NEW RECORD IN GAME RESULTS TABLE FOR EACH PLAYER var gameResultsSQL = "INSERT into hm_gameresults (ID_GAME, ID_MEMBER, result, ratingChange) VALUES "; for(var i = 0; i < players.length; i++){ //CONVERT THE PLAYER OPTIONS FROM STRING TO OBJECT //THIS IS SO WE CAN EXTRACT PROPERTIES LIKE PLAYER ID var playerOptions = stringToObject(players[i], ";", ":") gameResultsSQL += "(LAST_INSERT_ID(), '" + playerOptions.hmId + "', '" + "-2" + "', '" + "0" + "')" if(i < players.length - 1){ gameResultsSQL += ", "; } } success = dbase.executeCommand(gameResultsSQL); if(success == false){ //IF THIS FAILS, WE NEED TO REPORT BACK AN ERROR TO CLIENT trace("UNABLE TO CREATE GAME RESULTS RECORD IN DATABASE"); response.error = "Unable to create game results records in database"; } } } //....CODE FOR OTHER EXTENSION COMMANDS OMITTED.... } } |
Back on the client side, I add a new condition for responding to the “Create Game” command sent from server:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | //CODE EXAMPLE 10: RECEIVE SERVER SIDE RESPONSE private function onExtensionResponse(evt:SFSEvent):void{ //EXTRACT RESPONSE TYPE AND RESPONSE DATA FROM SFSEvent var type:String = evt.params.type; var dataObj:Object = evt.params.dataObj; //....CODE OMITTED.... //EXTRA COMMAND FROM RETURNED DATA OBJECT cmd= dataObj.cmd; var error:String = dataObj.error; //....CODE OMITTED.... if(error != ""){ //IF RESPONSE RETURNS AN ERROR, SHOW USER A MESSAGE PROMPT showPrompt("HM_MessagePrompt", cmd + " Error", error); } else{ //....CODE OMITTED.... //ADD CONDITION FOR SERVER RESPONSE CREATE GAME RECORD else if(cmd == "CreateGameRecord"){ //INSTRUCT GAME OPTION SCREEN TO FIRE UP GAME RECORD gameOptionsScreen.fireUpGameWithRecordID(dataObj.id); } //....CODE OMITTED.... } } |
This calls the final function of the game lobby to send out the start game command
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | //CODE EXAMPLE 11: FIRE UP GAME public function fireUpGameWithRecordID(gameRecordId:int):void{ //Recall in previous step we stored gameVarObj for future use //Here, we add the database id for the new game record gameVarObj.gId = gameRecordId; //Assemble the game command into abbreviated string notation var HM_Command:String = Utils.objectToString(gameVarObj, "|", "="); //Add the command to client side que addToCommandQue(HM_Command); //Send the command to start game to any live players if(smartFox.isConnected == true){ smartFox.sendCmd(HM_Command); } } |
Updating the Game State
With the game record created in the online database and an ID referencing the record available to the game client, changes to the game state can be easily recorded by appending new game commands to the command log field.
In the first article of this series, I consider when updates to the game state should take place based on the style of game being created. In the case of Robot Entertainment’s Hero Academy, which grants players a 5-move turn with the ability to undo actions before submitting a turn, updates to the game state naturally take place at the conclusion of a turn. Hero Mages, by contrast, allows players to interact with each of their available units, cast spells, and make attacks that cause damage based on the results of dice rolls. Due to the random nature of outcomes, an undo feature isn’t plausible for Hero Mages. Therefore, I decided to update my game’s command log each time the player sends a command.
Because Hero Mages can also be played in real time, I decided it was best to upgrade my existing server extension (used for communicating game commands to live players) to also handle updates to the game state stored on the database. This way, I’d make the most efficient use of bandwidth by only having to have the client send the command to the server one time.
Here’s the block of code that handles the game state update:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | //CODE EXAMPLE 12: UPDATE GAME RECORD function handleRequest(cmd, params, user, fromRoom, protocol){ if(protocol == "str"){ //GENERATE LIST OF RECIPIENTS THE SERVER WILL SEND THIS COMMAND TO //....CODE OMITTED.... //params[2] stores game record id //If this game record id is included, we need to write this command to stored game log if(params[2] != undefined){ if(params[1].indexOf("cT=eT") != -1){//If this is an end turn command //Convert notated command into object var cmdObj = stringToObject(params[1]+"", "|", "="); //Get the id of player whose turn is next var nextTurnId = cmdObj.nId; //Write update to game record in database sql = "UPDATE hm_games set cmdLog = CONCAT(cmdLog, '<c>" + params[1] + "</c>'), timeRecorded = " + Math.floor(getTimer() / 1000) + ", timeLastTurn = " + Math.floor(getTimer() / 1000) +", whoseTurn = "+nextTurnId+" WHERE ID_GAME = " + params[2]; } else{ //Write update to game record in database sql = "UPDATE hm_games set cmdLog = CONCAT(cmdLog, '<c>" + params[1] + "</c>'), timeRecorded = " + Math.floor(getTimer() / 1000) +" WHERE ID_GAME = " + params[2]; } success = dbase.executeCommand(sql); if(success == false){ //THE DATABASE DID NOT RECORD THE MOVE CORRECTLY //CREATE A NEW RESPONSE TO NOTIFY GAME CLIENT OF THE ERROR } } _server.sendResponse([params[1]], -1, null, recipients, "str"); return; } } |
Coming Next
This article has covered the first and most fundamental step of creating an asynchronous multiplayer game: representing your game state as data and storing that data to an online database. Next in the series I’ll share how to restore the game state from the database as well as how to seamlessly transition between async multiplayer mode and real-time online play using Flash, ActionScript, and Smart Fox Server extensions.