|

Building a Game Server in C++ #8: Tic Tac Toes

Series Navigation<< Building a Game Server in C++ #7: Connecting with MySQLBuilding a Game Server in C++ #9: A Client Framework >>

I want to build a second little game of my server. In fact, I will build two versions of it (hence the plural): one against the machine (PvE) and another between two players (PvP). My motivation this time is to explore two aspects that games should have in a client/server setup, namely minimal network usage and abstract game mechanics. I will assume that everybody knows how Tic Tac Toe is played, so let me explain what I mean by those aspects.

Minimal Network Usage

In RPS, I made the game compose and send text messages in their supposed final form. That served its purpose well, because back then I needed to test the server with telnet/nc. However, a more intelligent client would be able to not only translate symbolic messages to their intended meaning, but also present them to the user in a custom way, i.e. in another language or using non-text media.

On top of that, the network is more often than not the bottleneck in a client/server architecture. Consequently, the less and smaller packages exchanged, the better.

For Tic Tac Toe, the user will only be able to send the place on the board they want to play, in the form of a single character from ‘0‘ to ‘8‘. The server’s packets will also be single-character:

  • 0‘-‘8‘ depending on what the opponent played
  • S‘ (shutdown) when the game instance is terminated
  • F‘ (full) when a user tries to join a game with no free player slots
  • P‘ (play) when a user joins a game and they must play first
  • W‘ (wait) when a user has to wait for an opponent to join

The latter case can only occur in PvP, either when a user joins first or when their opponent quits, and they have to wait for a new one to join.

Abstract Game Mechanics

As you may noticed in the above, we don’t send any error messages. Invalid user input will be silently ignored, just as we did with RPS. In contrast with it though, we don’t even send the score, and that’s because we don’t keep track of it. This way, the burden of sending correct user input is pushed to the client, as well as the decision to keep track of some score or not.

To drive things even further, there’s even no communication regarding the sign (X or O) that is assigned to each player. From the client’s perspective, they could be playing either one. That’s because this information is not essential for the gameplay. All the server needs to know is the unoccupied positions on the board. When a player selects one, we will modify a counter for each row, column and diagonal the position belongs to. For the first player we decrement the counters and for the second we increment them. If any counter reaches -3, that means that the first player wins, while when any of them reaches 3, the second one is the winner. But even then, all the server has to do is reset the game for a new round.

However, a new round also begins when we have a draw, which means that nobody won after the whole board is occupied. This implies another counter for the number of occupied positions. When that counter reaches 9, the game should be reset.

Finally, the players will be identified by the way they modify the counters. In other words, the first player will be player -1 and the second player 1. For the PvE version, the machine is considered -1, as is the first to join in PvP. Moreover for PvP, if player -1 quits, player 1 is promoted to -1 as if they were the one to join first. Player -1 always waits for 1 to join and play first. After that, both play in turns, and a player’s turn is maintained even for the next round.

Basic Functionality

Let’s summarize what we have so far. In order to maintain the game state we will need:

  • an array of nine booleans that designate if a position is occupied
  • eight counters for the rows, columns and diagonals
  • a counter for the total occupied positions on the board
  • a variable that signifies whose turn it is to play (-1 or 1)

Since it’s a turn-based game, it advances only when a player gives some input. When that happens, we have to check the validity of the move, reset the game if the player won or caused a draw, and switch the players’ turn. The following class implements all of the above:

class TicTacToe: public Game {
  
	protected:

      bool board[9];
      int row[3],col[3],diag[2],plays,turn=1,pos;

      void resetGame();

      virtual bool canPlay(int)=0;
      virtual void switchTurns()=0;
      bool wins();

    public:
  
      TicTacToe(GameInterface*);
      void playerSent(int,DGram) override;

};

TicTacToe::TicTacToe(GameInterface *gi): Game(gi) { 
	resetGame();
}

void TicTacToe::resetGame() {
	for(int i=0;i<9;i++) board[i]=false;
	for(int i=0;i<3;i++) row[i]=col[i]=0;
	for(int i=0;i<2;i++) diag[i]=0;
	plays=0;
}

void TicTacToe::playerSent(int player,DGram d) { 

	if(!canPlay(player)) return;
	
	auto c=((char*)d.getData())[0];
	if((c<'0')||(c>'8')) return;
	
	pos=c-'0';
	if(board[pos]) return;
	
	if(wins()) resetGame();
	
	switchTurns();
	
}

While canPlay() and switchTurns() will be different in PvE and PvP, wins() is common in both cases:

bool TicTacToe::wins() {

	board[pos]=true;
	plays++;
	
	auto win=3*turn;
	
	auto r=pos/3;
	if((row[r]+=turn)==win) return true;
	
	auto c=pos%3;
	if((col[c]+=turn)==win) return true;
	
	if(!(pos%2)) {
		switch(pos) {
			case 4:
				diag[0]+=turn;
				diag[1]+=turn;
				break;
			case 0: case 8:
				diag[0]+=turn;
				break;
			case 2: case 6:
				diag[1]+=turn;
				break;
		}
		if((diag[0]==win)||(diag[1]==win)) return true;
	}
	
	if(9==plays) resetGame();
	
	return false;
	
}

Since turn holds either -1 or 1, it is used both to modify the counters and to decide the winning sum (-3 or 3). If a counter reaches that sum the player wins, and if all positions are occupied (plays==9) it’s a draw.

Playing Against the Machine

For our TicTacToePvE class we will have to provide a destructor as well as override the pure virtual methods:

class TicTacToePvE: public TicTacToe {

	private:
		int opponent=0;
		int firstEmpty(int);
		int nthEmpty(int);

	protected:
		bool canPlay(int) override;
		void switchTurns() override;
	
	public:
		TicTacToePvE(GameInterface*);
		~TicTacToePvE();
		void addPlayer(int) override;
		void deletePlayer(int) override;

};

TicTacToePvE::TicTacToePvE(GameInterface *gi): TicTacToe(gi) { }

The opponent field holds the user id of the player, and nthEmpty() uses firstEmpty() to find the n-th unused position on the board, like so:

int TicTacToePvE::firstEmpty(int from=0) {
	while(board[from]) from++;
	return from;
}

int TicTacToePvE::nthEmpty(int n) {

	int i,next;
	i=next=0;
	
	do {
		i=firstEmpty(next);
		next=i+1;
	} while(n--);
	
	return i;
	
}

We need those to implement switchTurns() where the machine plays and switches back to the user. I decided to make the machine play a random available position, because a more intelligent algorithm would make the game impossible to win:

void TicTacToePvE::switchTurns() { 

	turn=-turn;
	pos=nthEmpty(rand()%(9-plays));
	server->sendPlayer(opponent,{to_string(pos)});
	if(wins()) resetGame();
	
	turn=-turn;
	
}

A user can play if their id is the same as the one oppnent holds:

bool TicTacToePvE::canPlay(int player) { return opponent==player; }

The destructor will be called when the game instance is terminated, so we need to notify the opponent:

TicTacToePvE::~TicTacToePvE() { 
	if(!opponent) return;
	server->sendPlayer(opponent,{"S"});
	server->unjoinPlayer(opponent); 
}

When a user joins the game, provided we have no opponent yet, we notify them that we are waiting for their move. On the other hand, when a player leaves the game we simply reset it for the next opponent:

void TicTacToePvE::addPlayer(int player) { 
	if(opponent) {
		server->sendPlayer(player,{"F"});
		server->unjoinPlayer(player); 
		return;
	}
	opponent=player;
	server->sendPlayer(player,{"P"});
}

void TicTacToePvE::deletePlayer(int player) { 
	if(player!=opponent) return;
	opponent=0;
	resetGame();
}

One thing to notice is that PvE never sends ‘W‘. That is because the player always plays first when they join, and the machine always waits for the player to play.

Playing Against Other Players

For the TicTacToePvP class, we have to override the same methods, so it will look pretty similar to its PvE counterpart:

class TicTacToePvP: public TicTacToe {

	private:
		map<int,int> players={{-1,0},{1,0}};

	protected:
		bool canPlay(int) override;
		void switchTurns() override;
	
	public:
		TicTacToePvP(GameInterface*);
		~TicTacToePvP();
		void addPlayer(int) override;
		void deletePlayer(int) override;

};

TicTacToePvP::TicTacToePvP(GameInterface *gi): TicTacToe(gi) { }

The already apparent difference is that we now have to map each player (-1 or 1) to their user id. For convenience, I will also define three macros:

#define PL1 players[-1]
#define PL2 players[1]
#define PLCUR players[turn]

When a user joins, we now have to check if they are the first to join or the second, provided of course that there are free player slots:

void TicTacToePvP::addPlayer(int player) { 
	if(PL1&&PL2) {
		server->sendPlayer(player,{"F"});
		server->unjoinPlayer(player); 
		return;
	}
	if(PL1) {
		PL2=player;
		server->sendPlayer(player,{"P"});
		return;
	}
	PL1=player;
	server->sendPlayer(player,{"W"});
}

When PL1 quits, we have to promote PL2 to PL1. Either way, PL2 must be reset, along with the game itself and the players’ turn. Whoever remains as PL1 must now wait for another user to join:

void TicTacToePvP::deletePlayer(int player) { 
	if(PL1==player) PL1=PL2;
	PL2=0;
	if(PL1) server->sendPlayer(PL1,{"W"});
	resetGame();
	turn=1;
}

As for the game instance termination, our destructor must notify both the server and the players for the event:

TicTacToePvP::~TicTacToePvP() { 
	for(auto &[i,p]:players) {
		if(!p) continue;
		server->sendPlayer(p,{"S"});
		server->unjoinPlayer(p); 
	}
}

Other than that, in canPlay() we simply check if it’s the player’s turn to play, and in switchTurns() we switch turn to its opposite and inform the current player about their opponent’s last move:

bool TicTacToePvP::canPlay(int player) { return PLCUR==player; }

void TicTacToePvP::switchTurns() { 
	turn=-turn; 
	server->sendPlayer(PLCUR,{to_string(pos)});
}

Adding the Games to the Server

Our two versions of Tic Tac Toe are now ready, but the server needs to be aware of them. To do that, we must add two more entries in its games list:

Server::Server(serverParams p) {

	/* ... */

	games["ttt-pvp"]=new gameEntry{
		"Tic tac toe (pvp)",
		"Tic tac toe against another human",
		[](GameInterface *gi)->Game* { return new TicTacToePvP(gi); }
	};
	
	games["ttt-pve"]=new gameEntry{
		"Tic tac toe (pve)",
		"Tic tac toe against the machine",
		[](GameInterface *gi)->Game* { return new TicTacToePvE(gi); }
	};
	
	/* ... */

}

Leave a Reply

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