Building a Game Server in C++ #6: A generic client
In this article I will build a generic client for my server to make testing easier. However, my real objective is to come up with a Client class that will be used by any client application. It should be able to encapsulate all the nitty gritty for the communication, without making any assumptions about specific games.
We know for sure that we will need a TCP socket and a UDP one. UDP packets would generally be arriving at random times, so there should be a separate thread receiving and forwarding them. The application/game interface would then be able to retrieve and manage them.
On the other hand, TCP communication will be dialectical. In other words, for each prompt we send to the server, we should be waiting for an answer. A special case would be the initial connection, when the server would respond with a session id and we’d have to receive that.
With those assumptions in mind, let’s lay some groundwork:
typedef function<void(DGram)> dgramEvent;
class Client {
private:
sockaddr_in serverAddr{};
int tcp,udp;
Parser readTCP();
Parser sendTCP(string);
dgramEvent onDGram;
void receiveUDP();
public:
void sendUDP(DGram);
};
Starting with TCP, readTCP() will wait for data from the server, while also detect if the server disconnected. As it will become apparent later, newlines have no meaning for the client, so they are replaced with spaces. On the other hand, sendTCP() sends a prompt to the server and uses readTCP() to receive the response. Both return a Parser object, which may sound familiar. Their implementation is as follows:
Parser Client::readTCP() {
char buffer[1024];
ssize_t bytes=read(tcp,buffer,sizeof(buffer)-1);
if(!bytes) throw runtime_error("Disconnected");
buffer[bytes]='\0';
string response(buffer);
replace(response.begin(),response.end(),'\n',' ');
return Parser(response);
}
Parser Client::sendTCP(string s) {
s+="\n";
send(tcp,s.c_str(),s.size(),0);
return readTCP();
}
Now moving on to UDP, we have two methods:
sendUDP()is used by the application to send a datagram to the serverreceiveUDP()is used by the UDP receiver thread mentioned above, in order to forward incoming datagrams to the application/interface
There’s also a onDGram field, which is a callback function used by receiveUDP() to notify the application that a datagram just arrived. Let’s see how they all should look like:
void Client::receiveUDP() {
void *buffer=(void*)malloc(1024);
sockaddr_in src{};
socklen_t len=sizeof(src);
ssize_t bytes=recvfrom(udp,buffer,1024,0,(sockaddr*)&src,&len);
if(bytes) onDGram({buffer,bytes}); else free(buffer);
}
void Client::sendUDP(DGram d) { sendto(udp,d.getData(),d.getSize(),0,(sockaddr*)&serverAddr,sizeof(serverAddr)); }
That should do it for the communication layer. Before we move on to the constructor, let’s take a look at the handshaking procedure:
class Client {
private:
/* ... */
bool handshake();
/* ... */
};
bool Client::handshake() {
Parser p=readTCP();
if(p.getToken()!="id") return false;
int sessionId=atoi(p.getToken().c_str());
if(!sessionId) return false;
sendUDP({to_string(sessionId)});
return true;
}
What we’re trying to do here is pretty straightforward: receive a session id and, if all goes well, send it back via UDP. The method returns true on success and false on failure.
Ok, let’s finally deal with the constructor, which will receive the host and port as parameters, along with a callback for the UDP thread. Obviously, the first thing to do is initialize the sockets and make a TCP connection. After that, since the server is expected to send back the session id, we perform a handshake. Last but not least, we spawn the UDP receiver thread, which is nothing more than an infinite loop that calls receiveUDP():
class Client {
private:
/* ... */
string lastError;
/* ... */
public:
/* ... */
Client(string,int,dgramEvent);
string getLastError();
/* ... */
};
Client::Client(string host,int port,dgramEvent onDGram): onDGram(onDGram) {
addrinfo hints{};
hints.ai_family=AF_INET;
hints.ai_socktype=SOCK_STREAM;
addrinfo *res=nullptr;
int rc=getaddrinfo(host.c_str(),nullptr,&hints,&res);
if(rc!=0) {
lastError="Failed to resolve "+host+": "+gai_strerror(rc)+"\n";
return;
}
sockaddr_in *resolved=(sockaddr_in*)res->ai_addr;
serverAddr.sin_family=AF_INET;
serverAddr.sin_port=htons(port);
serverAddr.sin_addr=resolved->sin_addr;
freeaddrinfo(res);
tcp=socket(AF_INET,SOCK_STREAM,0);
if(connect(tcp,(sockaddr*)&serverAddr,sizeof(serverAddr))<0) {
lastError="Failed to connect to "+host+":"+to_string(port)+"\n";
return;
}
udp=socket(AF_INET,SOCK_DGRAM,0);
sockaddr_in localAddr{};
localAddr.sin_family=AF_INET;
localAddr.sin_addr.s_addr=INADDR_ANY;
localAddr.sin_port=0;
bind(udp,(sockaddr*)&localAddr,sizeof(localAddr));
if(!handshake()) {
lastError="Handshake failed\n";
return;
}
thread([this](){ while(true) receiveUDP(); }).detach();
}
Errors are stored in lastError, and getLastError() is a public method that gives applications one-time access to that field:
string Client::getLastError() {
string s=lastError;
lastError="";
return s;
}
The only thing left to do is implement wrapper methods for the available server commands. In this article I will cover four of them: auth, join, games and worlds. The first two work in an identical way, meaning that the server may respond either with ok or an error:
class Client {
private:
/* ... */
bool simpleCommand(string);
/* ... */
public:
/* ... */
bool login(string,string);
bool join(int);
/* ... */
};
bool Client::simpleCommand(string prompt) {
Parser p=sendTCP(prompt);;
if("ok"==p.getToken()) return true;
lastError=p.getToken();
return false;
}
bool Client::join(int world) { return simpleCommand("join "+to_string(world)); }
bool Client::login(string username,string password) { return simpleCommand("auth "+username+" "+password); }
The other two methods are pretty similar too, but not exactly. Each of them must return a map of different kinds of structures:
struct clientGameEntry {
string name;
string description;
};
struct clientWorldEntry {
string gameId;
};
class Client {
public:
/* ... */
map<string,clientGameEntry> getGames();
map<int,clientWorldEntry> getWorlds();
/* ... */
};
map<string,clientGameEntry> Client::getGames() {
Parser p=sendTCP("games");
map<string,clientGameEntry> r;
if("err"==p.getToken()) return r;
do {
string id=p.getToken();
r[id]={p.getToken(),p.getToken()};
} while(p.getToken().length());
return r;
}
map<int,clientWorldEntry> Client::getWorlds() {
Parser p=sendTCP("worlds");
map<int,clientWorldEntry> r;
if("err"==p.getToken()) return r;
do {
int id=atoi(p.getToken().c_str());
r[id]={p.getToken()};
} while(p.getToken().length());
return r;
}
In both cases, the first token returned determines if there’s an error. If not, we construct the corresponding map by consuming the rest of the tokens, meanwhile ignoring the first token of each line (game or world respectively). Since we didn’t take a line-by-line approach, this is where it becomes apparent that newlines are useless for the client.
I will probably add many more commands, but for the purposes of this article, that should suffice. Now let’s move on to a simple client application that uses this class, starting with main():
int main(int argc, char *argv[]) {
try { return run(); }
catch(const exception& e) {
cerr<<"\033[31mFatal: "+string(e.what())+"\033[0m\n";
return 1;
}
}
This is just run() wrapped in a try/catch block. But what should run() do? Well, first of all, it should create a Client object, and then prompt the user to authenticate. For now, I will hardcode localhost:9999 as the target server:
Client client("localhost",9999,incomingDGram);
string err=client.getLastError();
if(err!="") {
cerr<<err<<endl;
return 1;
}
string username,password;
do {
err=client.getLastError();
if(err!="") cerr<<"\033[31m"<<err<<"\033[0m\n";
cout<<"Username/Password: ";
cin>>username>>password;
} while(!client.login(username,password));
cin.ignore(256,'\n');
The third parameter to the constructor is a simple function that stores incoming datagrams:
vector<string> dgrams;
mutex dgramsMutex;
void incomingDGram(DGram d) {
lock_guard<mutex> lock(dgramsMutex);
dgrams.push_back(d.toString());
}
Since this is a callback we intend to give to another thread, we must protect dgrams with a mutex.
Back to run(), after a successful login, it should enter a loop doing the following:
- prompt the user for input
- process that input
- output any errors
- sleep for 1 second
- check for any newly arrived datagrams and output them
Step #4 above is useful in order to give the server time to respond, since UDP communication is asynchronous. Below is the implementation of all these steps, except #2:
string input;
bool running=true;
while(running) {
cout<<"> ";
getline(cin,input);
//TODO: process input
err=client.getLastError();
if(err!="") cerr<<"\033[31m"<<err<<"\033[0m\n";
sleep(1);
cout<<"\033[33m";
{
lock_guard<mutex> lock(dgramsMutex);
for(auto &d:dgrams) cout<<d;
dgrams.clear();
}
cout<<"\033[0m\n";
}
cout<<"Quitting. Bye!\n";
return 0;
As far as input goes, it will be sent via UDP unless it starts with a colon (:), in which case it will be interpreted as a command. If the command is numeric, we will assume it’s a join for the world with the respective id. Otherwise, it is expected to be a single letter, either l or q. In the case of q the program will terminate, otherwise it will output a list of active worlds to join:
if(':'!=input[0]) client.sendUDP({input});
else if(int n=atoi(input.substr(1).c_str())) client.join(n);
else if(input.length()>2) cerr<<"\033[31mAll commands are single-character\033[0m\n";
else switch(input[1]) {
case 'l': {
map<string,clientGameEntry> games=client.getGames();
map<int,clientWorldEntry> worlds=client.getWorlds();
for(auto [id,world]:worlds) {
clientGameEntry game=games[world.gameId];
cout<<id<<"\t"<<game.name<<"\t"<<game.description<<endl;
}
break;
}
case 'q':
running=false;
break;
default: cerr<<"\033[31mUnknown command '"<<input[1]<<"'\033[0m\n";
}
As you may noticed, errors are printed in red and UDP output in yellow/brown-ish. To wrap things up, here’s a screenshot from a test run:


One Comment