Have fun with Microservices & NodeJS: Build a Game Server using Seneca

Have fun with Microservices & NodeJS: Build a Game Server using Seneca

Introduction

Nowadays there are several framework and toolkit that allow to build under NodeJS an architecture using a microservices approach.

If you are not familiar with this software development pattern and want to understand what are the benefits of using a similar approach, I would like to recommend to have a look to microservices.io maintained from Chris Richardson and to read his book Microservices Patterns.

Although recently I had the opportunity to discover and prototyping a small application using the framework Moleculer (https://moleculer.services/) I still prefer to use Seneca (https://senecajs.org) that in my opinion has a better abstraction of the service entity within a microservices ecosystem and does not require to setup any kind of messaging system like a NATS Server.

The Seneca Toolkit

Let’s start saying what is Seneca!

Seneca is a toolkit for NodeJS, that allows to develop a microservices ecosystem where microservices are discovered using a pattern matching system. This means that you don’t need to know where the other services are located or how many other of them there are, you send a message and Seneca dispatch it to the microservice that match with the pattern you sent within your message.

Let’s have a look from a coding point of view in order to better understand what is a pattern.

var seneca = require('seneca')()

seneca.add({role: 'math', cmd: 'sum'}, function (msg, respond) {
  var sum = msg.left + msg.right
  respond(null, {answer: sum})
});

In the code snippet above, we defined a pattern with two key-value pairs: the key role with its value “math” and the key cmd with its value “sum”. When this pattern will match with a message request, the action defined as callback function will be performed and send back a response message.

So, basically as you can see, a pattern is a list of key-value pairs.

The two parameters msg and respond of the callback action function are respectively the inbound message and a callback function that will be used to send back a response.

The pattern strategy used in Seneca, allows you to extend your microservice functionalities using a safe and clean approach avoiding to add complexity to your microservice logic. So, for instance you can extend the functionality of the “sum” action pattern defined above, adding more key-value pairs within the pattern definition in order to achieve a different behaviour. For example below we defined a new action pattern with a new property int that allow to resolve a sum using only integer values:

var seneca = require('seneca')()

seneca.add({role: 'math', cmd: ‘sum’, int: 'true'}, function (msg, respond) {
 // reuse role:math, cmd:sum
  this.act({
    role: 'math',
    cmd: 'sum',
    left: Math.floor(msg.left),
    right: Math.floor(msg.right)
  }, respond)
})

The method act() used in the example above, allows to submit a message to act on, so to call another action pattern.

Bear in mind that in Seneca patterns are unique and they can trigger only one function. When you send a message the pattern with more matching properties win!

In order to be well organised, to help with logging and debugging, you can organise your action patterns using the plugin system available with Seneca.

You define a Seneca plugin as a function with a single options param and you pass this function to the Seneca use() method.

module.exports = function math(options) {
  this.add(role: 'math', cmd: 'sum', function (msg, respond) {
    respond(null, { answer: msg.left + msg.right })
  })
  this.add(role: 'math', cmd: 'product', function (msg, respond) {
    respond(null, { answer: msg.left * msg.right })
  })
  this.add(init: 'math', function (msg, respond) {
   // init your plugin here
  })
}

In the example above we defined a plugin math that encapsulate two action patterns: sum and product

Within a Seneca plugin lifecycle, is available an initialisations method, defined as a reserved action pattern init (see previous math example), that allows to initialise your plugin with your custom code (e.g you can open a db connection).

It is good practice in Seneca, to organise your services and plugins in separate NodeJS resources (js files!).

So, following the plugin math example above we can organise our code as:

  1. a math.js file that is the definition of your math plugin (the logic).
  2. a math-service.js that defines witch plugins to use within your microservice and starts a microservice process listening on a http port for request:
require(‘seneca’)().use(‘math‘).listen();

In order to talk with a listening microservice, you will use the Seneca microservice client:

require('seneca')()
	.client()
	.act({role: 'math', cmd: 'sum', left: 1, right: 2}, function (err, result) {
  		if (err) return console.error(err)
  		console.log(result)
	})

Bear in mind that with the listen() method you are not going to start a web server. Instead, in order to expose your microservices to the external world you need to build an API Gateway: you can achieve that combining Seneca with ExpressJS (we will see later). Integrating Seneca with Express, is a good strategy in order to avoid to expose your internal action patterns to the external world, that from a security point of view is not a good practice.

It’s time to have fun with Seneca!…Let’s build our Game Server.

Now that we understood how Seneca works under the hood, we can try to develop a minimalistic microservices architecture in order to achieve a Game Server able to simulate a Wheel of Fortune (The Big Six!).

The Game Server proposed within this article, cannot be considered as an architecture and application ready for production, but please consider it only as a tutorial and a good point where to start to learn and practice with Seneca.

The architecture that we are going to develop is described below:

  • API Gateway, will expose only an endpoint in order to play the game and to get back the game result.
  • BigSix Service, is the service that implements the logic of the Bix Six Wheel game (https://wizardofodds.com/games/big-six/) using different math models based on the different rules available with different countries/states.
  • Math Service: is a generic service, responsible to load the game math definition.
  • RNG Service: is a generic service, responsible to generate random numbers. As RNG has been used an open source NodeJS module that implements the Mersenne Twister algorithm.

You can clone or download the complete repository of this project on my GitHub here: https://github.com/alchimya/seneca-game-server

RNG Service

We are going to develop a Seneca plugin that through an action pattern generates random numbers. Random numbers are generated through the open source NodeJS module mersenne-twister and available on npm.

As you can see below we defined two action patterns in order to initialise (init) the plugin creating an instance of mersenne-twister and a an action pattern (next) to generate a random number using the MersenneTwister instance created within the initialisations.

var MersenneTwister = require('mersenne-twister');

module.exports = function rng( options ) var generator = null;

	this.add('role:rng,cmd:next', next);
	this.add('init:rng', init);

	function init(msg, respond) {
		generator = new MersenneTwister();
		respond();
	}

	function next( msg, respond ) {
		respond( null, {value: generator.random_int31()});
	}

}

The service definition (rng-service.js), load the rng.js plugin and starts a process listening on the port 8082:

require( 'seneca' )().use( 'rng' ).listen(8082)

Math Service

The purpose of this service is to load a math definition as JSON file and to send back its content. As convention each math definition file, stored in a server folder called math, has been named as $GAME_ID.json where $GAME_ID is an arbitrary name (e.g. BIGSIX_VEGAS.json, BIGSIX_ATLANTIC.json and so on).

The math plugin, defines an init action pattern where has been defined the folder name where the math definition files are located, and a loadMath action pattern that allows, passing a gameID param, to load the JSON file containing the math definition. Each math definition file contained within this project, describe the rules of a BigSix Fortune Wheel.

var fs = require('fs');
module.exports = function math( options ) var mathFolder = null;

	this.add('role:math,cmd:loadMath', loadMath);
	this.add('init:math', init);

	function loadMath (msg, respond) {

		if (!msg.gameID) {
			return respond(new Error("Expected a gameID to be defined."));
		}

		var mathFile = __dirname  +  "/" + mathFolder + '/' + msg.gameID + '.json';

		fs.stat (mathFile, function(err, stats) {
			if (err || !stats) {
				return respond(new Error("Math definition not found or an error occurred:" + mathFile));
			}
			var mathContent = JSON.parse(fs.readFileSync(mathFile), 'utf8');
			respond (null, {mathContent: mathContent});
		});

	}

	function init(msg, respond) {
		//todo create a new folder service.
		mathFolder = "math";
		respond();
	}

}

The math model definition proposed here, it is quite easy to understand.

{
  "game": {
    "symbols": [
      {"name": "1", "occurrencies": 24, "pays": 1},
      {"name": "2", "occurrencies": 15, "pays": 2},
      {"name": "5", "occurrencies": 7, "pays": 5},
      {"name": "10", "occurrencies": 4, "pays": 10},
      {"name": "20", "occurrencies": 2, "pays": 20},
      {"name": "LOGO1", "occurrencies": 1, "pays": 40},
      {"name": "LOGO2", "occurrencies": 1, "pays": 40}
    ],
    "wheel":{
      "items": [
        "LOGO1", "1", "2", "5", "1", "2", "10","1", "2", 
        "1", "5", "1", "2", "1", "20", "1", "2", "1", 
        "5", "1", "2", "1", "10", "2", "1", "LOGO2", "1", 
        "2", "5", "1", "2", "10", "1", "2", "1", "20", 
        "1", "2", "1", "5", "1", "2", "1", "5", "2", 
        "1", "10", "5", "1", "2", "1", "2", "1", "1"
      ]
    }
  }
}

The symbols array contains the definition and the odds of the wheel symbols. Instead the wheel object and its items array contains the symbols distribution on the wheel.

The service definition (math-service.js), load the math.js plugin and starts a process listening on the port 8081.

require( 'seneca' )().use( 'math' ).listen(8081)

BigSix Service

Within this service we are going to implement the logic of the game. 

Basically our game service, interacting with the math and rng services, will send back a result following the rules of the BigSix game. 

The big six.js plugin has been develop using two action patterns.

function init(msg, respond) {
	nodeCache = new NodeCache( { stdTTL: 100, checkperiod: 120 } );
	respond();
}

function play( msg, respond ) {
	//check input params
	if (!msg.gameID || !msg.bet || !msg.betSymbol ) {
		return respond(new Error("Expected a gameID, bet and betSymbol to be defined."));
	}
	//get game math definition
	getMathDef(msg.gameID, function(mathDef) {
	  	if (mathDef.error) {
	  		return respond(new Error(mathDef.error));
	  	}
  		//get game result
  		getResult (mathDef.value , msg.bet, msg.betSymbol, function (gameResult){
  			respond( null, gameResult);
  		})
		
	});
}

The init action pattern create an instance of the module node-cache, that helps to avoid to send a request to the math service for each game round. 

The cache system has been implemented using the open source module node-cache available on npm. I would recommend to have a look to the article Caching like a boss in NodeJS (https://medium.com/@danielsternlicht/caching-like-a-boss-in-nodejs-9bccbbc71b9b)

The second action pattern available within the big six plugin is the play function that allows, using the math and rig services, to perform the result of the game.

The logic of the game implemented within the function getResult is quite straightforward.

function getResult (mathDef, bet, betSymbol, callback) {

	getRandom (function (random) {
	  	if (mathDef.error) {
	  		return respond(new Error(random.error));
	  	}
	  	//get symbols stop by nomralization of the the rng result
	  	var wheelStop = (random.value % mathDef.game.wheel.items.length);
	  	//get the symbol name
	  	var symbolName = mathDef.game.wheel.items[wheelStop].toString();

	  	var win = 0;
	  	if (symbolName === betSymbol) {
		  	//get the symbol item
		  	var symbol = mathDef.game.symbols.filter (function (item){
		  		return item.name === symbolName;
		  	});

		  	if (symbol.length > 0) {
		  		//calculate the bet as bet * symbols pays
				win = bet * symbol[0].pays;
		  	}
	  	}

	  	//retun the result
	  	if (callback) {
	  		callback ({
				result: {
					bet: bet,
					betSymbol: betSymbol,
					symbol: symbolName,
	  				win: win,
	  				wheelStop: wheelStop
				}
	  		});
	  	}
	});
}

A random number is requested via the rng service and its result will be used to find the stop of the wheel. Moreover, through the wheel stop, using the game configuration (game.symbols array) will be calculated the winning as a product of the bet and the value that the symbol pays.

API Gateway

Now that we have our microservices, we also want to expose our api on the web in order to connect client apps.

To achieve that, we can integrate ExpressJS with Seneca defining an express app (server.js within our project) where via a Seneca client will expose a plugin that defines a set of api patterns (bigsix-api.js within our project). If in the future you want to extend your api capabilities, maybe because you added more services that serve different kind of games, you can define additional plugin api definition (e.g. roulette-api.js, slot-api.js) that will be exposed within the Express app (server.js).

Within our project has been exposed a single endpoint as api/bigsix/spin that allows to run a spin of the wheel. See below in the next paragraph the complete endpoint to test the game using different math models.

Setup and startup the game server

After that you have beed cloned or downloaded the project from my GitHub here https://github.com/alchimya/seneca-game-server, in order to setup the app you need to launch the command npm install that will download all the dependencies included within the file package.json. When npm has been finished to download the project dependencies, you can run the project using the command npm run start that will start the three microservices math-service, rng-service and bigsix-service and will start the web server app in order to expose your api to the world! 

Once the application is up and running you can access via web browser using one of the math model available (gameID param) and sending a bet value (bet param).

You can spin a wheel and put your bets here:

https://localhost:3000/api/bigsix/spin?gameID=BIGSIX_VEGAS&bet=5&betSymbol=1

https://localhost:3000/api/bigsix/spin?gameID=BIGSIX_ATLANTIC&bet=5&betSymbol=1

https://localhost:3000/api/bigsix/spin?gameID=BIGSIX_CHARLES&bet=5&betSymbol=1

https://localhost:3000/api/bigsix/spin?gameID=BIGSIX_MACAU&bet=5&betSymbol=1

What’s next?

Since we developed an application using a microservices approach, you can now deploy your microservices using docker and, depending of your knowledge and preferences, to orchestrate the whole architecture using docker swarm or kubernates. If you want to learn more how to deploy your architecture using kubernates on your local machine, you can have a look to my article about Minikube here https://www.dhirubhai.net/pulse/dealing-kubernetesson-minikube-domenico-vacchiano/

Using the same approach used within the BigSix Service described within this project, you can add more services in order to serve a different kind of game algorithm (e.g. a roulette, a slot machine and so on).

Domenico Vacchiano

要查看或添加评论,请登录

Domenico Vacchiano的更多文章

社区洞察

其他会员也浏览了