Building a Game Server in C++ #3: Rock Paper Scissors
In the last article we made a very basic version of the server that accepts TCP connections and keeps track of its clients’ sessions. But what is a game server without games? In this article we will implement a simple, text-based Rock Paper Scissors game.
Preparing the Server
First of all, since we are about to start working with UDP, let’s define a simple class that holds a datagram’s data along with its size:
class DGram {
private:
void *data;
ssize_t size;
public:
DGram(ssize_t);
DGram(void*,ssize_t);
DGram(char);
DGram(string);
DGram(const DGram&);
~DGram();
void *getData();
ssize_t getSize();
string toString();
};
DGram::DGram(ssize_t size): size(size) { data=(void*)malloc(size); }
DGram::DGram(void *data,ssize_t size): size(size), data(data) { }
DGram::DGram(char c): DGram((ssize_t)sizeof(char)) { memcpy(data,&c,size); }
DGram::DGram(string s): DGram((ssize_t)s.length()) { memcpy(data,s.c_str(),size); }
DGram::DGram(const DGram& d): DGram(d.size) { memcpy(data,d.data,size); }
DGram::~DGram() { free(data); }
void *DGram::getData() { return data; }
ssize_t DGram::getSize() { return size; }
string DGram::toString() { return string((char*)data,size); }
Now, I know I said in the first article that, in order for a client to join a game, they have to be authenticated. We will ignore that for now, since the authentication procedure is far from ready. One thing I didn’t explicitly mention, but I think is implied nevertheless, is that they also need to have completed the TCP/UDP handshake. We cannot ignore this one, since it’s essential for the communication between the game and the player. We will address this later in this article.
For now, the following method assumes that a handshake has been made, and is used by games to send data to a player with a specific session id:
class GameInterface {
public:
virtual void sendPlayer(int,DGram)=0;
};
class Server: public SessionInterface, public GameInterface {
public:
/* ... */
void sendPlayer(int,DGram) override;
/* ... */
};
void Server::sendPlayer(int player,DGram d) {
sockaddr_in a=getSessionUDPAddr(player);
sendto(udp,d.getData(),d.getSize(),0,(sockaddr*)&a,sizeof(a));
}
In any given time, there will be a number of game instances (worlds) running, from all sorts of different game types. However, a user can only join one of them, and this in turn means that the server needs to know which game instance (if any) corresponds to which session. As shown above, the client data list can help facilitate the communication from the game to the player, but not the other way around. When a UDP packet arrives, the server needs to know where to route it, meaning to what game and which player. The only information it can utilize to do that is the client address received with the packet. We do have that information in the client data list, but it would take a tedious search procedure to derive the session id (and other data) from it.
Instead, we will keep another list, this time for players. Each entry will contain the game the user is playing, along with their session id. Every user who has joined a game will be represented in this list, keyed by the client’s UDP address/port. And of course, this list too has to be thread-safe, so we will protect it by its own mutex:
struct playerEntry {
Game *game;
int session=0;
};
class Server: public SessionInterface, public GameInterface {
private:
/* ... */
map<pair<uint32_t,uint16_t>,playerEntry*> players;
recursive_mutex playerMutex;
/* ... */
};
When a user is disconnected, we now have to notify the game they are playing (if any) to delete them. This action should eventually also delete the relevant player entry, but we will discuss that in a future article. For now, we will only modify clientDisconnect() like so:
void Server::clientDisconnect(int id) {
lock_guard<recursive_mutex> sLock(sessionMutex);
sockaddr_in a=sessions[id]->udpAddr;
pair<uint32_t,uint16_t> p={a.sin_addr.s_addr,a.sin_port};
lock_guard<recursive_mutex> pLock(playerMutex);
if(players[p]) players[p]->game->deletePlayer(id);
delete sessions[id];
sessions.erase(id);
log->info("[TCP] Client "+to_string(id)+" disconnected.");
}
Before we talk about the newly introduced Game class, let’s create a method for users to join games:
class Server: public SessionInterface, public GameInterface {
private:
/* ... */
void joinGame(int,Game*);
/* ... */
};
void Server::joinGame(int player,Game *game) {
lock_guard<recursive_mutex> sLock(sessionMutex);
sockaddr_in a=sessions[player]->udpAddr;
pair<uint32_t,uint16_t> p={a.sin_addr.s_addr,a.sin_port};
lock_guard<recursive_mutex> pLock(playerMutex);
players[p]=new playerEntry{game,player};
game->addPlayer(player);
}
We simply create a player entry for the game/player pair and we notify the game that a user needs to join.
The last but certainly not least intervention to the Server object is the modification of the handleUDP() method, in order to facilitate UDP handshaking and routing:
void Server::handleUDP() {
void *buffer=(void*)malloc(1024);
sockaddr_in clientAddr{};
socklen_t len=sizeof(clientAddr);
ssize_t bytes=recvfrom(udp,buffer,sizeof(buffer)-1,0,(sockaddr*)&clientAddr,&len);
if(bytes<=0) return;
lock_guard<recursive_mutex> sLock(sessionMutex);
pair<uint32_t,uint16_t> p={clientAddr.sin_addr.s_addr,clientAddr.sin_port};
lock_guard<recursive_mutex> pLock(playerMutex);
if(players[p]) {
players[p]->game->playerSent(players[p]->session,{(void*)buffer,bytes});
return ;
}
char *c=(char*)buffer;
c[bytes]='\0';
int id=atoi(c);
free(buffer);
if(!sessions[id]) return;
sessions[id]->udpAddr=clientAddr;
joinGame(id,new RPSGame(this)); // temp
}
Just as we did before, we receive the packet from the socket. Provided there’s valid data in it, we check if the packet’s client address corresponds to an entry in the players list. If so, we notify the relevant game that the player has sent some data. But if not, we assume that the client sent back its session id for handshaking, so we try to interpret it as an integer and update the relevant session entry with the client’s address.
Also, since the Rock Paper Scissors game is (or rather will be) the only game supported, we automatically create a new instance of it and let the user join. This line of code will be removed once we define some functionality for the user to select which world to join or create. But that’s a topic for another article.
Introducing Games
Alright, so now let’s finally talk games!
From what we have so far, RPSGame is a specific type of a more abstract Game class. Besides a constructor and a destructor, all games should implement at least three other methods:
addPlayer()to notify the game that a new user has joineddeletePlayer()to tell the game that the user is no longer playingplayerSent()to forward player data to the game
Given all these, here’s an abstract class of all games:
class Game {
protected:
GameInterface *server;
public:
Game(GameInterface*);
virtual ~Game();
virtual void addPlayer(int)=0;
virtual void deletePlayer(int)=0;
virtual void playerSent(int,DGram)=0;
};
Game::Game(GameInterface *gi): server(gi) { }
Game::~Game() { }
You may notice that addPlayer() doesn’t return a value, which means it’s unconditional. We don’t ask for a game to accept the player, we are telling it. If for some reason the game shouldn’t accept the player, it should notify the user via UDP. Granted, it should also notify the server, but that does not apply to our case yet, so let’s ignore it for now.
Now for our specific game, as mentioned above, Rock Paper Scissors (RPSGame) should be a subclass of Game. Its class definition looks like this:
class RPSGame: public Game {
private:
int opponent,myScore=0,opponentScore=0;
public:
RPSGame(GameInterface *);
void addPlayer(int) override;
void deletePlayer(int) override;
void playerSent(int,DGram) override;
};
The player will play against the machine, so only their id (opponent) is needed, however we do need to keep track of both scores. As seen above, the player automatically joins a new instance of the game upon handshake, and there is no way to quit without disconnecting. Both the constructor and addPlayer() are very simple:
RPSGame::RPSGame(GameInterface *gi): Game(gi) { }
void RPSGame::addPlayer(int player) { opponent=player; }
But how about the game mechanics? There are three possible “hands” for each player to play: (r)ock, (p)aper and (s)cissors. Rock beats scissors, scissors beats paper and paper beats rock. If both players’ hands are the same, it’s a tie and the score doesn’t change. Both players are supposed to reveal their hand simultaneously, but since only the opponent is a human in our case, they are the one to initiate each round by sending their chosen hand. Then the machine randomly decides a hand and the result is calculated. The user should be able to send multiple hands at once for the game to process them one by one.
Having said that, this is how we would implement the playerSent() method:
map<string,bool> winCombo={
{"rp",false},{"rs",true},
{"pr",true},{"ps",false},
{"sp",true},{"sr",false}
};
string validHands="rps";
void RPSGame::playerSent(int player,DGram d) {
if(player!=opponent) return;
auto input=string((char*)d.getData(),d.getSize());
string hands="__",response;
for(auto &c:input) {
hands[1]=c;
if(validHands.find(hands[1])==string::npos) continue; // Invalid hand
hands[0]=validHands[rand()%3];
if(hands[0]==hands[1])
response=hands+" Tie!\n";
else {
if(winCombo[hands]) myScore++; else opponentScore++;
response=hands+" score: "+to_string(myScore)+"-"+to_string(opponentScore)+"\n";
}
server->sendPlayer(player,{response});
}
}
Valid hands are defined as a string, while winCombo contains the winning combinations for the machine. When we have new input, we process each of its valid characters one by one. Namely, we decide a random hand for the machine and, if the two hands are not the same, we check them against winCombo and update the scores accordingly. In any case other than an illegal hand, we notify the user of the outcome.
The only thing missing now is deletePlayer(), which will send a farewell message to the user and reset the game:
void RPSGame::deletePlayer(int player) {
if(player!=opponent) return;
server->sendPlayer(opponent,{"Nice playing with you. Bye!\n"});
opponent=0;
myScore=0;
opponentScore=0;
}
And that’s it. One way to test it after compiling and running is via nc:
$ nc localhost 9999 id 1
I did receive a session id via TCP, so now I have to connect from another terminal via UDP, send my id and play the game:
$ nc -u localhost 9999 1 s ps score: 0-1 p pp Tie! p rp score: 0-2 r pr score: 1-2 rsppspssr sr score: 1-3 ps score: 1-4 pp Tie! rp score: 1-5 rs score: 2-5 pp Tie! ss Tie! ps score: 2-6 sr score: 2-7 ^C
Wow, I’m killing it! Anyway, this is the output from the server’s terminal:
[2025-11-05 18:37:36] Game Server v0.1.0 [2025-11-05 18:37:36] Server listening at port 9999 [2025-11-05 18:37:45] [TCP] Client 1 connected
Phew, that was a long one… In the next article we will cover the application protocol for the user session, so that users can authenticate, start worlds from available games and join running worlds.

One Comment