Designing and implementing games in my chatbot

Posted on Feb 23, 2022
tl;dr: Implementing a Finite-State Machine

Let’s say you wanted to build a game for a chatbot or other forms of text based input. One of my main goals when I started building the bot was to play a Cards Against Humanity game in our group.

I knew I needed to build two separate things, a game manager that will take care of managing the games, and an implementation of the game itself. This post will focus on the implementation of the game itself and some the design behind it.

Message Flow

Messages are processed in four different areas in the chatbot:

  1. Command Handler
  2. App Manager
  3. Game Manager
  4. Automatic Processing

Each area will process the incoming message and perform the necessary actions. Commands start with !, and automatic processing will apply a regex expression on the message contents.

For example, the chatbot would respond with a Pong message if you send it a !ping command. On the other hand, one of the automatic processing rules will apply this regex expression /@all/g to detect messages with @all and mentions all the group users.

So if you send !ping @all it would process the ping command and also mention all the group users.

For applications and games, the inputs are dynamic and would require a state to be managed as it would affect how messages are processed. At their core, applications and games, depend on a custom Handler object that manages assigning and removing handler functions for the participants.

Handler Object

var util = require("util");
var eventEmitter = require("events").EventEmitter;
var exports = (module.exports = {});

function handler() {
	if (false === this instanceof handler) {
		return new handler();
	}
	var self = this;
	self.handlers = {}; 
}

util.inherits(handler, eventEmitter);

handler.prototype.addHandler = function(jid, handlerFunc) {
	var self = this;
	self.handlers[jid] = handlerFunc;
};
handler.prototype.getHandler = function(jid) {
	var self = this;
	if (self.handlers.hasOwnProperty(jid)) {
		return self.handlers[jid];
	}
	return null;
};
handler.prototype.deleteHandler = function(jid) {
	var self = this;
	delete self.handlers[jid];
	if (isEmptyObject(self.handlers)) {
		self.emit("handlersEmpty");
	}
};

function isEmptyObject(obj) {
	for (var key in obj) {
		if (Object.prototype.hasOwnProperty.call(obj, key)) {
			return false; //Not empty
		}
	}
	return true; //Empty
}

exports.Handler = handler;

Going through the code, a handler class is created that inherits EventEmitter. A dictionary property called handlers is defined that will map a custom handler function to a jid which is an identifier. In the messaging service, all the users and groups have unique JIDs that can be referred to when sending / receiving messages.

Three methods are defined to add, get and delete handlers. Whenever a handler function is deleted, an event will be emitted if the handlers property is empty. This will be useful later on when games are implemented.

Defining a handler

The code snippet below shows how a handler function is defined and added for a specific user_jid. The handler function will process the input and delete the handler if the correct input is provided.

var handler_object = require("../handlerObject.js").Handler;
var handler = new handler_object();
var handler_function = (jid, message) => {
	console.log(message.object.body);
	// Process message here
	if (message.object.body === "correct") {
		console.log("Correct input");
		handler.deletHandler(jid);
	} else {
		console.log("Input is incorrect, try again");
	}
}
handler.addHandler(user_jid, handler_function);

What is a game?

So, how does all this fit together?

Let’s take a few steps back and understand how a text based game works.

Think of the game as a set of states and associated inputs. For a given state, the game expects inputs from a set of players and only once all the correct inputs are provided does it process and transition to a different state. A very simple example of this would be a Lobby state. When a game is started it would be in a Lobby state, and it will ask all the group members whether they want to join the game. The code below is taken directly from my Cards Against Humanity implementation, it asks users to join the game and times out after 1 minute to force a transition.

Inputs

members.forEach((member) => {
	var timeout = setTimeout(
		() => {
			eventEmitter.emit("playerLeft", member);
			handler.deleteHandler(member);
		},
		60000,
		member
	);

	var handlerFunction = (jid, message) => {
		if (message.object.body === "1") {
			contact.getContactByJid(db, jid).then((row) => {
				Players[jid] = {
					Cards: [],
					Name: row[0].nick,
					Points: 0,
					isCzar: false
				};
				clearTimeout(timeout);
				handler.deleteHandler(jid);
			});
			return;
		} else if (message.object.body === "2") {
			handler.deleteHandler(jid);
			eventEmitter.emit("playerLeft", jid);
			clearTimeout(timeout);
			return;
		}
	};
	handler.addHandler(member, handlerFunction);
});
var text =
	"Cards Against Humanity starting\n *Score limit:* " +
	score_limit +
	"\n -Send 1 to join\n -Send 2 to decline";

api.postMessage({
	type: "send_text",
	to: group_jid,
	body: text
});

If you remember previously when defining the handler class, whenever it is empty it would emit an event. In the case of the Lobby, all the members have either replied with 1 or 2, or a minute has passed, and the handler was deleted automatically. Once this event is emitted, the game knows that for the state it is currently in (Lobby) all the required inputs have been collected, and it can take the next steps to transition. How this will look like exactly is something that will be covered next.

States

The other side of the equation is defining the states and the transitions. Continuing with the Cards Against Humanity example, the code below defines the game states and the processing that needs to be done when all the inputs are received.

So for the lobby example above, once all the inputs have been collected the game will check if enough players joined the game and proceed.

var handler = new handler_object();
var gameStates = {
	Lobby: 1, // Waiting for players
	ReplaceCards: 2, // Give option to replace cards
	PlayingAnswer: 3, // Waiting on player picks
	CzarPick: 4 // Waiting on czar to pick winner
};
handler.on("handlersEmpty", () => {
	if (currentState == gameStates.Lobby) {
		if (Object.keys(Players).length > 2) {
			// Setup game
			setup();
		} else {
			api.postMessage({
				type: "send_text",
				to: group_jid,
				body: "*CAH:* Not enough players"
			});
			allLeave();
			eventEmitter.emit("gameOver");
		}
	} else if (currentState == gameStates.ReplaceCards) {
		start();
	} else if (currentState == gameStates.PlayingAnswer) {
		setupCzarPick();
	} else if (currentState == gameStates.CzarPick) {
		if (scoreLimit()) {
			var text = "*Results:*";
			for (var jid in Players) {
				if (Players[jid].Winner) {
					leaderboard.add_score(db, group_jid, "cah", jid);
					text =
						text +
						"\n*" +
						Players[jid].Name +
						"* - " +
						Players[jid].Points +
						" points (Winner)";
				} else {
					text =
						text +
						"\n*" +
						Players[jid].Name +
						"* - " +
						Players[jid].Points +
						" points";
				}
			}

			api.postMessage({
				type: "send_text",
				to: group_jid,
				body: text
			});
			allLeave();
			eventEmitter.emit("gameOver");
		} else {
			cycle();
		}
	}
});

Putting it all together

Let’s try to put it back together, all game implementations in the chatbot consist of the following:

  1. Handler Object - to manage the handler functions and input processing for players.
  2. Handler Functions - handler functions that are assigned to a set of players depending on the game state.
  3. Game States - a set of predefined game states and the processing that is done when all inputs are received.

Cards Against Humanity - Example

In Cards Against Humanity, once all the players provide their answers the Czar gets to pick the best one. Looking back at the code snippet earlier, when the game is in the PlayingAnswer state it is expecting answers from all the players except for the Czar.

  1. A handler function will be assigned to those players. It will process their messages and if the input is valid the chosen answer card will be added to the round and the handler will be deleted.
  2. Once all the players provide valid input, all the handlers would have been deleted and the handler object would emit an event notifying the game that it has received all the required inputs.
  3. The game will see that it is in the PlayingAnswer state, so it would call the setupCzarPick() function to continue the round.
  4. The setupCzarPick() function will update the state to CzarPick, print the question with all the previously selected answers and assign a handler function to the Czar himself to choose which one of the answers would win that round.
  5. Once the Czar picks the answer, the handler will be deleted and the game will check if a player has reached the score limit and end the game or continue to the next round by calling the cycle() function.
  6. The cycle function will in turn set the state to PlayingAnswers and the above will repeat.

Setting up the Czar Pick

As a reference, the code for the setupCzarPick() function is provided below:

function setupCzarPick() {
	// Setting up the message which will include the original question + the played cards
	var text = "*Question Card:* " + questionText + "\n *Played Cards:* ";
	shuffle(playedCards); 
	shuffle(playedCards);
	playedCards.forEach((inputs, index) => {
		text = text + "\n*" + (index + 1) + "* - ";
		inputs.cards.forEach((card, index, array) => {
			if (index == array.length - 1) {
				text = text + card.text;
			} else {
				text = text + card.text + " *&* ";
			}
		});
	});

	// Defining the handler function to be assigned to the Czar
	var handlerFunction = function(jid, message) {
		var elements = message.object.body.split(" ");
		if (elements.length == 1) {
			var re = /^([1-9]|10)$/;
			if (re.test(elements[0])) {
				var messageInt = parseInt(message.object.body);
				Players[jid].isCzar = false; // not czar anymore
				var round_winner_jid = playedCards[messageInt - 1].jid;
				Players[round_winner_jid].Points++;
				printWhoAndPoints(round_winner_jid);
				playedCards = [];
				handler.deleteHandler(jid);
			}
		}
	};
	// The handler function is assigned
	handler.addHandler(czarJid, handlerFunction);
	// The state is updated to CzarPick
	currentState = gameStates.CzarPick;
	api.postMessage({
		type: "send_text",
		to: group_jid,
		body: text
	});
}

Wrapping up

Writing this post gave me the chance to revisit code that I haven’t touched for several years. I noticed little things, like when the Czar input is handled, I am not checking if the input is a valid index. So if there are 3 options to choose from the Czar can pick the 4th, 5th, 6th, etc. This was never an issue because the function would raise an undefined error and never continue processing if the input is incorrect.

Right now, there is a lot of boilerplate that is redundant between games. Each game would implement a similar skeleton that consists of defining the states, creating the handler object, handling the events, etc. I feel there is an opportunity to abstract all that away into a small game engine. That way adding and iterating to games for the chatbot will be much quicker and simpler.

I’ve included the entire implementation of Cards Against Humanity below, but keep in mind that it contains some code to handle cross group play that was removed from the above snippets for simplification.