|

Building a Game Server in C++ #4: Let’s talk protocol

Series Navigation<< Building a Game Server in C++ #3: Rock Paper ScissorsBuilding a Game Server in C++ #5: Joining games >>

As promised in the previous article, we will define the application protocol (over TCP) that will be used to manage the user’s session. As I mentioned before, it should be text-based, and every line of text received will correspond to one prompt (or command). This functionality is already covered in sessionThread(), where prompts are assembled and sent to clientCommand().

Each prompt or response should be comprised of one or more tokens separated by whitespace or a semicolon (;). In other words, semicolons will be interpreted as token separators. Potential spaces in tokens should be replaced by underscores to help disambiguation. Let’s put together a small parser class for this job:

class Parser {

	private:
	
		istringstream source;

	public:
	
		Parser(string);
		string getToken();

};

Parser::Parser(string s) { 
	replace(s.begin(),s.end(),';',' ');
	source=istringstream(s);
}

string Parser::getToken() {
	string s;
	source >> s;
	replace(s.begin(),s.end(),'_',' ');
	return s;
}

The parser’s constructor replaces semicolons with spaces before initializing the source field with the modified string. On the other hand, the getToken() method returns the next token from source, using the >> operator which conveniently handles whitespace for us, and restoring underscores to spaces. Since the same rules apply for responses, we will also need a simple function to encode tokens, namely replace spaces with underscores:

string encodeToken(string s) { 
	replace(s.begin(),s.end(),' ','_');
	return s;
}

For each prompt, the first token will be the command to execute and all others will be parameters. Depending on the command, a certain number of arguments will be used. Missing mandatory arguments will cause an error, while excessive ones will be silently ignored. Also, everything will be case sensitive. In order to get a wider picture of what needs to be done, I want to put together a list of commands that come to mind:

  • quit: Disconnect the user. No arguments needed.
  • auth: Authenticate the user. It expects two arguments, the username and the password.
  • games: Get a list of supported games. No arguments needed.
  • worlds: Get a list of active game worlds. No arguments needed.
  • run: Create a new game world of the type specified. Its only argument should be the game type id.
  • kill: Destroy a game world. It should accept one argument, the id of the running world to kill.

This is obviously not an exhaustive list. A more complete server version will probably use a lot more, but let’s go with those for now. First off, we need to figure out a way to execute some code depending on the command string. A switch() is a good solution, however it cannot be used with strings. Luckily, we can map our strings to values switch() can understand:

enum commandType { cmdQuit=1,cmdAuth,cmdGames,cmdRun,cmdWorlds,cmdKill,cmdJoin, };

map<string,commandType> commandStrType={
	{"quit",cmdQuit},
	{"auth",cmdAuth},
	{"games",cmdGames},
	{"run",cmdRun},
	{"worlds",cmdWorlds},
	{"kill",cmdKill},
	{"join",cmdJoin},
}; 

Notice that our commandType values start at 1. This is essential, because in case of an unknown command, commandStrType[] will be 0. If we didn’t set cmdQuit to 1, it would also be 0. In other words, unknown commands would be interpreted as disconnection attempts. Having said that, the new version of clientCommand() would look something like this:

string Server::clientCommand(int id,string command) {

	Parser p(command);

	switch(commandStrType[p.getToken()]) {
	
		case cmdQuit: 
		
		case cmdAuth: 
		
		case cmdGames: 

		case cmdWorlds: 

		case cmdRun: 

		case cmdKill: 

		default: return {false,"err "+encodeToken("Unknown command\n")};
		
	}

}

Now all we have to do is fill in the blanks for all the cases. Starting with cmdQuit, we simply return “ok” along with a signal for the thread to disconnect the client:

		case cmdQuit: { return {true,"ok\n"}; }

Continuing with cmdAuth, we have to check if a username and a password are provided and, if so, the password will have to match the one that corresponds to the username in the database:

		case cmdAuth: {
		
			if(""!=getSessionUsername(id)) return {false,"err "+encodeToken("Already authenticated\n")};
		
			string username=p.getToken();
			if(!username.length()) return {false,"err "+encodeToken("Missing username and password\n")};
			
			string password=p.getToken();
			if(!password.length()) return {false,"err "+encodeToken("Missing password\n")};
			
			if(!storage->matchPassword(username,password)) return {false,"err "+encodeToken("Authentication failed\n")};
			
			lock_guard<recursive_mutex> lock(sessionMutex);
			sessions[id]->username=username;
			return {false,"ok\n"};
			
		}

Notice that we first check if a user has already authenticated. Apart from that, there’s no matchPassword() in the Storage class. In fact, I’ve postponed the whole thing up till now, and I still don’t want to deal with it. Let’s just instead refactor the class to accommodate some hardcoded accounts:

class Storage {

	public:
		Storage(string,string,string,string);
		bool matchPassword(string,string);
		
};

Storage::Storage(string h,string d,string u,string p) { }

bool Storage::matchPassword(string username,string password) {

	map<string,string> accounts={
		{"mario","Its-aMe"},
		{"luigi","OkeyDokey"},
		{"michelangelo","cowabunga"},
	};
	
	return accounts[username]==password;
	
}

That should do the job for now. Moving on to cmdGames, the concept is to iterate through a structure that holds information about the games supported, and return a list with one game per line. Each line will start with the word “game” followed by a space, followed by a semicolon-separated list of values (id, name and description). The structure mentioned will be static for now, but I want it to be dynamic and thread-safe in the future, so let’s proactively protect it with a mutex:

		case cmdGames: {
		
			lock_guard<recursive_mutex> lock(gamesMutex);

			if(!games.size()) return {false,"err "+encodeToken("No games defined\n")};
		
			string r;
			for(auto &[gid,entry]:games) r+="game "+gid+";"+encodeToken(entry->name)+";"+encodeToken(entry->description)+"\n";
			
			return {false,r};
		
		}

Now what should the games structure look like? Let’s use a map again, indexed by string ids:

struct gameEntry {
	string name,description;
	function<Game*(GameInterface*)> run;
};

class Server: public SessionInterface, public GameInterface {

	private:
  	
  		/* ... */
	
		map<string,gameEntry*> games;
		recursive_mutex gamesMutex;
		
  		/* ... */
	
};

Apart from the game name and description, the gameEntry structure also holds a run field, which is a pointer to a function. That function should accept a pointer to the server object as a parameter, and return a game object pointer. As you might have guessed by now, it will be used by cmdRun to spawn new game worlds.

Let’s populate our new map in the constructor with the only game we have so far. Spawning a new RPSGame is pretty simple, so a lambda function should do the job:

Server::Server(serverParams p) {

	// backend initialization
	/* ... */

	games["rps"]=new gameEntry{
		"Rock Paper Scissors",
		"A game of Rock Paper Scissors against the machine",
		[](GameInterface *gi) -> Game* { return new RPSGame(gi); }
	};
	
	// socket initialization
	/* ... */

}

Ok, now our users can browse the available games. The implementation of cmdWorlds would be pretty much along similar lines, although this time the map key will be an integer:

struct worldEntry {
	string gameId;
	Game *game;
};

class Server: public SessionInterface, public GameInterface {

	private:
  	
  		/* ... */
	
		int nextWorld=1;
		map<int,worldEntry*> worlds;
		recursive_mutex worldsMutex;
		
  		/* ... */
	
};

The worldEntry structure consists of a pointer to the game instance, along with a string id referencing the games map, while nextWorld represents the next world id to be assigned by cmdRun. Now that we have the necessary tools, let’s look at what cmdWorlds should look like:

		case cmdWorlds: {
		
			lock_guard<recursive_mutex> lock(worldsMutex);
			
			if(!worlds.size()) return "err "+encodeToken("No worlds running\n");
		
			string r;
			for(auto &[wid,entry]:worlds) r+="world "+to_string(wid)+";"+entry->gameId+"\n";
			
			return {false,r};
			
		}

The last two commands we will cover, cmdRun and cmdKill, will be used to manipulate the worlds map by inserting and deleting entries respectively. Of course, in a real world scenario, we wouldn’t let any user spawn and destroy worlds — that would lead to chaotic situations. But to simplify things for now, we will settle with the user having authenticated:

class Server: public SessionInterface, public GameInterface {

	private:
  	
  		/* ... */
	
		bool canRunGame(int,string);
		
  		/* ... */
	
};

bool Server::canRunGame(int id,string gid) { return ""!=getSessionEntry(id)->username; }

The rule is that, if you can run a game, you can also kill worlds of that game. Having said that, the cmdRun and cmdKill cases should look like this:

		case cmdRun: {
			
			string gid=p.getToken();
			if(!gid.length()) return {false,"err "+encodeToken("A game id is required\n")};
			
			lock_guard<recursive_mutex> gLock(gameMutex);
			gameEntry *g=games[gid];
			if(!g) return {false,"err "+encodeToken("Unknown game id "+gid+"\n")};

			if(!canRunGame(id,gid)) return {false,"err "+encodeToken("Permission denied\n")};
			
			Game *game=g->run(this);
			lock_guard<recursive_mutex> wLock(worldMutex);
			int wid=nextWorld++;
			worlds[wid]=new worldEntry{gid,game};
			
			return {false,"world "+to_string(wid)+"\n"};
			
		}

		case cmdKill: {
			
			string wid_s=p.getToken();
			if(!wid_s.length()) return {false,"err "+encodeToken("A world id is required\n")};
			
			int wid=atoi(wid_s.c_str());
			if(wid_s!=to_string(wid)) return {false,"err "+encodeToken("Invalid world id "+wid_s+"\n")};
			
			lock_guard<recursive_mutex> lock(worldMutex);
			if(!worlds[wid]) return {false,"err "+encodeToken("Unknown world id "+wid_s+"\n")};

			if(!canRunGame(id,worlds[wid]->gameId)) return {false,"err "+encodeToken("Permission denied\n")};
			
			delete worlds[wid]->game;
			delete worlds[wid];
			worlds.erase(wid);
			
			return {false,"ok\n"};
			
		}

Now that we have covered the general form of the protocol from the client’s perspective, as well as six basic commands, let’s have a look at the response format. In the last case, as well as in cmdQuit and cmdAuth assuming a successful outcome, we simply respond with “ok“, meaning that the command succeeded and there’s no other information to send. Meanwhile, in all cases where something goes wrong, we respond with “err“, a space and an error message. When a new world is created, we send “world“, a space and the newly created world id. Lastly, when the response requires a list, multiple lines are returned. Each line starts with a word describing the object of the line, followed by a space and a list of fields separated by semicolons. There’s a clear pattern here, and it’s the following:

  • Each response may consist of one or more lines (ending with \n)
  • Each line starts with a keyword
  • If no more information needs to be returned, the keyword is “ok“, otherwise it is followed by a space and said information

Before we wrap it up, let’s give it a test, using telnet this time:

$ telnet localhost 9999
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
id 1
fubar
err Unknown_command
games 
game rps;Rock_Paper_Scissors;A_game_of_Rock_Paper_Scissors_against_the_machine
run rps
err Permission_denied
auth
err Missing_username_and_password
auth alice caterpillar
err Authentication_failed
auth mario Its-aMe
ok
run tps
err Unknown_game_id_tps
run rps
world 1
worlds
world 1;rps
kill 1
ok
worlds

There’s still a lot to be done for a decent game management system. First of all, we still spawn an RPSGame instance upon UDP handshake. Moreover, when we terminate a game world, we neither remove the relevant entries from the players list, nor do we give games the opportunity to do so themselves. We will address these and more in the next article, where we’ll implement a command for users to join game worlds.

Leave a Reply

Your email address will not be published. Required fields are marked *