Concurrency control is one of the main aspects of multi-player games where all the checks, conditional writes and game state updates must be made as fast as possible and with minimal client/server calls in order to keep the game fair and square. This is especially critical in turn-based games where careless implementation (such as putting code that alters the game state in the client) can lead to concurrency related “race condition” from creeping in.
For example, consider a tic-tac-toe game between John and Jane. If John played three moves simultaneously by opening different browser windows before Jane played her turn, sequence of updates would result in John winning the game by way of “cheating.” Good for John but not so much for Jane! (Till she figures out the same technique… yea, John!)
In this post we will look at a web-based tic-tac-toe game with multiple players playing against each other at the same time. This application uses the Aerospike Node.js Client to store and retrieve game data and AngularJS web framework to illustrate end-to-end application development in Aerospike DB.
In this application, concurrency control is achieved by putting conditional writes and game state updates on the server using User Defined Functions. UDFs are a powerful feature of Aerospike DB and they can be used to extend the capability of the Aerospike DB engine both in terms of functionality and performance. For more information on UDFs, click here. But wait, there’s more! Since the UDFs reside and execute on the server, the logic within it gets executed closer to the data with minimal client/server calls. Effectively providing much better performance in addition to concurrency control. BooYah!
Technical Know-how Prerequisites
Even though this is a pretty lightweight application, I’ve used different technologies to make it decent enough – visually & functionally – and covering all aspects as well as walking through the entire codebase is beyond the scope of this post. So, good understanding and working knowledge of the following technologies is presumed.
- Node.js
- AngularJS
- Aerospike DB
- Aerospike Node.js Client
- Lua
- Socket.io
- Express
Application At A Glance
At a higher-level, here’s what happens — after creating an account and/or logging into an existing account, a user can:
- Start a new game by inviting someone to play
- Accept (pending) invite to play
NOTE: Users are notified of new game invites in real-time via Socket.io. Therefore, in this version of the application both users must be logged in to see the invites. In the future, I will enhance the app such that users will be able to see all pending invites as well as games they’ve already played. Stay tuned!
As the game progresses with every move, here’s what must happen in order to keep the game fair and square:
The following conditions must be checked
- Is the game already over? If so, is there a winner and who won?
- Is it current user’s turn?
- Is the selected square already taken?
If the above three conditions result in NO, YES, NO respectively, the state of the game needs to be modified as follows:
- Selected square’s value needs to be set to current user’s username
- Value of turn needs to be swapped out to the other user
- Record needs to be updated in the database to reflect this state
Then, taking into account the current state (which now includes the latest move), following conditions needs to be checked and state of the game needs to be updated once again in preparation for the next move:
- Is the game now over? If so, set status to “DUNZZO” and if there is a winner, set winner to current user’s username
Data Modeling
Before we get to the code, let’s examine the data model. This is important because data modeling is key to having a well performing application with data models at the core. Data models not only define how the data is structured and stored but sometimes they also tend to drive the UI and UX of the application.
In Aerospike DB, a Set (similar to tables in traditional RDBMs) is a collection of records, and each record is a collection of Bins (similar to columns in traditional RDBMs.) In this tic-tac-toe example, the application stores all game data in a Set called “games.” When a user initiates a new tic-tac-toe game by inviting another user to play, the application creates a new record in the “games” Set.
The “games” Set contains these Bins:
NAME | DESCRIPTION |
---|---|
gameKey | Stores unique ID generated by the application. It is set once when the record is created. |
initiated | Stores username of the user initiating the game. It is set once when the record is created. |
opponent | Stores username of the user invited to play the game. It is set once when the record is created. |
status | Stores current status of the game. Initially set to “PENDING” and other possible values are “IN_PROGRESS” and “DUNZZO” |
turn | Stores username of the user whose turn it is to make the next move which could either be the initiator or the opponent. Initially set to the opponent and then updated with every turn/move. |
winner | Stores username of the user who won. In case the game is tied, it will retain its default blank (“”) value. |
TopLeft | Stores username of the user who picked that square. Initially set to blank (“”) value. |
TopMiddle | Stores username of the user who picked that square. Initially set to blank (“”) value. |
TopRight | Stores username of the user who picked that square. Initially set to blank (“”) value. |
MiddleLeft | Stores username of the user who picked that square. Initially set to blank (“”) value. |
MiddleMiddle | Stores username of the user who picked that square. Initially set to blank (“”) value. |
MiddleRight | Stores username of the user who picked that square. Initially set to blank (“”) value. |
BottomLeft | Stores username of the user who picked that square. Initially set to blank (“”) value. |
BottomMiddle | Stores username of the user who picked that square. Initially set to blank (“”) value. |
BottomRight | Stores username of the user who picked that square. Initially set to blank (“”) value. |
It’s That Time, Yo!
It’s time to look at some code. In Aerospike DB, UDFs are written in Lua. Lua is a powerful, fast, lightweight, embeddable scripting language. For more information on Lua, click here.
UDF Code
Below are the contents of UDF (/lib/udf/updateGame.lua) that checks, sets and updates state of the game in a single-record transaction manner. It accepts three parameters and returns a map with attributes status and message back to the client.
function update(topRec,username,square) local result = map {status = 0, message = "Ok"} -- STEP: check if the game is over == won if topRec["status"] == 'DUNZZO' then result['status'] = -1 if topRec["winner"] == '' then result['message'] = "THIS GAME IS DUNNZO -- NO WINNER!" else result['message'] = "THIS GAME IS DUNNZO -- WINNER IS " .. topRec["winner"] end return result end -- STEP: check if it is your turn if topRec["turn"] ~= username then result["status"] = -1 result["message"] = topRec["turn"] .. " goes next. NOT YOU!" return result end -- STEP: check if the selected square is already taken if topRec[square] ~= '' then result['status'] = -1 result['message'] = "That square is already taken. IT CANNOT BE YOURS!" return result end -- Update Square topRec[square] = username -- Update Turn if topRec["turn"] == topRec["initiated"] then topRec['turn'] = topRec["opponent"] else topRec['turn'] = topRec["initiated"] end -- Update game record aerospike:update(topRec) local status = '' local winner = '' -- Update status if topRec["TopLeft"] == topRec["TopMiddle"] and topRec["TopLeft"] == topRec["TopRight"] then if topRec["TopLeft"] ~= '' then status = "DUNZZO" winner = topRec["TopLeft"] -- Top Row end elseif topRec["MiddleLeft"] == topRec["MiddleMiddle"] and topRec["MiddleLeft"] == topRec["MiddleRight"] then if topRec["MiddleLeft"] ~= '' then status = "DUNZZO" winner = topRec["MiddleLeft"] -- Middle Row end elseif topRec["BottomLeft"] == topRec["BottomMiddle"] and topRec["BottomLeft"] == topRec["BottomRight"] then if topRec["BottomLeft"] ~= '' then status = "DUNZZO" winner = topRec["BottomLeft"] -- Bottom Row end elseif topRec["TopLeft"] == topRec["MiddleLeft"] and topRec["TopLeft"] == topRec["BottomLeft"] then if topRec["TopLeft"] ~= '' then status = "DUNZZO" winner = topRec["TopLeft"] -- Left Column end elseif topRec["TopMiddle"] == topRec["MiddleMiddle"] and topRec["TopMiddle"] == topRec["BottomMiddle"] then if topRec["TopMiddle"] ~= '' then status = "DUNZZO" winner = topRec["TopMiddle"] -- Middle Column end elseif topRec["TopRight"] == topRec["MiddleRight"] and topRec["TopRight"] == topRec["BottomRight"] then if topRec["TopRight"] ~= '' then status = "DUNZZO" winner = topRec["TopRight"] -- Right Column end elseif topRec["TopLeft"] == topRec["MiddleMiddle"] and topRec["TopLeft"] == topRec["BottomRight"] then if topRec["TopLeft"] ~= '' then status = "DUNZZO" winner = topRec["TopLeft"] -- Diagonal end else if topRec["TopLeft"] ~= '' and topRec["TopMiddle"] ~= '' and topRec["TopRight"] ~= '' and topRec["MiddleLeft"] ~= '' and topRec["MiddleMiddle"] ~= '' and topRec["MiddleRight"] ~= '' and topRec["BottomLeft"] ~= '' and topRec["BottomMiddle"] ~= '' and topRec["BottomRight"] ~= '' then status = "DUNZZO" -- Tied end end if status ~= '' then topRec["status"] = status end if winner ~= '' then topRec["winner"] = winner end -- Update game record aerospike:update(topRec) return result end
For every move (i.e. user selecting a square) all the client has to do is execute the UDF by passing in the game record key, current user’s username and the square he/she just clicked on. The UDF then takes care of the rest — checking, setting, and updating the state of the game in a single-record transaction — where it always interacts with the most recent (game) record unlike other implementation where conditional writes and updates are coded in the client; where the client can potentially operate on outdated game record and wrongly overwrite true state of the game.
UDF Registration And Execution Code
And here’s how the UDF is registered and executed from the client application (code snippet taken from /lib/controllers/api.js):
exports.updateGameViaUDF = function(req, res) { var params = req.body; var file = './lib/udf/updateGame.lua'; client.udfRegister(file, function(err) { if ( err.code === aerospike.status.AEROSPIKE_OK) { var key = aerospike.key(aerospikeDBParams.dbName,aerospikeDBParams.gamesTable,params.key); var udf = { module:'updateGame', funcname: 'update', args: [params.username, params.square]}; client.execute(key, udf, function(err, result) { if ( err.code === aerospike.status.AEROSPIKE_OK) { if (result.status == 0) { res.json({status : 'Ok'}); } else { res.json({status: result.message}); } } else { res.json({status: err}); } }); } else { res.json({status: err}); } }); };
Note: aerospikeDBParams is defined in /lib/controllers/aerospike_config.js
Eye Candy
User ‘dash’ starts a new game and invites ‘eva’ to play
User ‘eva’ accepts a game request
After a tough battle ‘eva’ wins — shown here are two different browser windows one for ‘dash’ and the other for ‘eva’
So, Where’s The Entire Solution And How Can I Get My Own Game Going?
Well, the entire working solution as well as instructions on how to quickly get the app up and running are available on GitHub