Basic Network Tutorial - Part 2
From C4 Engine Wiki
This is part 2 of the Basic Network Tutorial. In part one, we created a basic chat application using the C4 engine to handle background processing. In part 2 of this tutorial, we'll expand on that program by adding functionality for changing a user's name.
In Part 1, you could chat back and forth without actually knowing who's who, because everyone was called 'Player'. We'll want to change this first, so that we can see who's talking to who. In order to do this, we'll have to send a Message to the server informing it about the name change. In C4's demo game, there are a number of Messages that can be sent back and forth between the server and the clients which contain not only text to send to people, but also information about the movement and actions of the various players. A Message is an abstract concept containing information important to all the other players in a multiplayer game. In this tutorial, we'll employ C4's Messaging system to send two messages between the server and the clients. But let's start at the beginning.
For the full source code of this tutorial (part 1 + 2), see here.
Contents |
Changing a player's name
Changing a local player's name is pretty easy. When looking at the API for the Player's data, there's a 'SetPlayerName()' method where you can set a new name for a player. In the Message Manager, the object that handles the sending of members and management of Players, we see a method called GetLocalPlayer(), which will return the locally playing Player. On that Player, we can call the SetPlayerName() function with the new name as parameter, and we're done.
However, it's not that easy. When you change your name on your local computer, it'll appear to work just fine. However, it won't work just fine on all the other clients currently connected. While we can simply change the locally playing player's name, changing it will not change that player's name on either the server or all other connected clients. Not only do we need to add a player name changing command, but we'll have to add functionality so that the server is notified of the player's name change. We'll also have to add functionality that, once a player's name is changed on the server, all connected clients are also notified of the name change, so they can update their local player information.
To do this, we first have to add a command to the program that allows the players to indicate they want to change their name. The name change request will be sent to the server, which can do some checks on the name so that, for example, duplicate names won't appear in one chat session. Finally, we'll add functionality so that, once the server agrees with the name change, all the connected clients are notified of the name change - including the client that actually requested the name change. Basically, we won't be changing the player's local name, but his name on the server, and it will in turn notify the clients of the name change.
To do this, we'll have to create our own Message, a piece of data that can be sent between clients and servers. We'll also have to add functionality that receives this Message and translates it into an actual name change. We'll create two Messages in this part: The first message is a NameChangeRequest message, which requests a name change, and the second message is a NameChange message, which tells the clients that a player's name has changed and they need to update that player's name in their local memory.
First, we'll create our name change command. In the Chat.h header file, add the following to the private section of the class, right below the joinCommand field we made in Part 1:
// The name change command will attempt to change a player's name. Command nameCommand;
Second, we'll add the handling function, the one that will be executed when the player types the command. Put this in the public section of the class definition, right below the JoinCommand() method declaration.
// This method will be executed whenever the user uses the name command. static void NameCommand(const char *params);
Third, we'll initialize the Command in the Chat class' constructor. In the Chat.cpp file, we'll add the initialization of the joinCommand variable and register it with the Engine. Here is the full constructor, we've added the nameCommand("name", &NameCommand) statement to the initializer, and the TheEngine->AddCommand(&nameCommand) call to the implementation of the constructor. The rest we added in the previous tutorial.
Chat::Chat() : Singleton<Chat>(TheChat),
// Create the server command to bind to the text 'server' in the console.
serverCommand("server", &ServerCommand),
// Create the join command to bind to the text 'join' in the console.
joinCommand("join", &JoinCommand),
// Create the name command to bind to the text 'name' in the console.
nameCommand("name", &NameCommand)
{
// Register the console commands with the engine.
TheEngine->AddCommand(&serverCommand);
TheEngine->AddCommand(&joinCommand);
TheEngine->AddCommand(&nameCommand);
// Set some settings in the network manager.
TheNetworkMgr->SetProtocol(kGameProtocol);
TheNetworkMgr->SetPortNumber(kGamePort);
}
Fourth and finally, we'll add the implementation for our NameCommand() method.
void Chat::NameCommand(const char *param)
{
}
Creating a Message
The observing reader will notice that the above method is empty. This is because we'll have to do something else first: We'll have to create our Message first. We'll call our first message type NameChangeRequestMessage, which will be a whole new class. Right-click your project in the Solution Explorer on the left side of Visual Stuido, press Add, and select to create a new Header file called NameChangeRequestMessage. Next, create a new C++ file, also called NameChangeRequestMessage.
In our Header file, we'll have to add a few things. First, we have to declare a variable containing the requested new name. Second, we'll have to add a getter method, so we can read the value of the variable. (Note: You can omit that if you want and delcare the variable public, but it's generally considered a good practice to only allow access to class-level variables through accessor methods. See also Wikipedia's article on methods, scroll down to 'accessor methods') Finally, we have to add two functions that have to be added to every Message type: a Compress and a Decompress method. The Compress method will put the information contained in the Message into a 'package', so to speak, so that the information can be sent through the network, while the Decompress does the opposite, and transfers the data from an incoming package into the class-level variables. Here's how our NameChangeRequestMessage.h file looks like:
// file NameChangeRequestMessage.h
#ifndef NameChangeRequestMessage_h
#define NameChangeRequestMessage_h
#include "C4Messages.h"
namespace C4
{
// This bit is important. In here, we give a unique identifier for our NameChangeRequestMessage,
// so our program can determine whether the Message it receives is a NameChangeRequestMessage
// or something else.
// Each item in the list will automatically be assigned a numerical value, starting at 0.
// Because there already are messages defined by the engine which already occupy the first
// few numbers, we'll want to start counting where the engine stopped - hence the first line.
// We'll have to move this definition to a central place when we try to add more messages.
enum
{
kMessageServerInfo = kMessageBaseCount,
// This is the identifier for our name change request message.
kMessageNameChangeRequestMessage
};
/*
The NameChangeRequestMessage is a message sent from a client to a server
(one-way only) where the client requests a change in its player's name.
The server can perform additional validation on the requested name,
and when it agrees, it can send a NameChangeMessage back to the requesting
client, as well as all the other connected clients, informing them that
the player changed his name.
*/
class NameChangeRequestMessage : public Message
{
private:
// We'll limit the length of a player's name with the global value set in
// C4Messages.h. The String object will automatically cut off too long names in size.
String<kMaxPlayerNameLength> newName;
public:
NameChangeRequestMessage();
// We'll only want to set a new player name through the constructor of the message.
NameChangeRequestMessage(const char * name);
~NameChangeRequestMessage();
// We'll get the new name from the object through this Getter method.
const char * GetNewName(void) const
{
return newName;
}
// These two methods will insert and extract the data
// contained in a data package and put them into the class variables.
void Compress(Compressor& data) const;
bool Decompress(Decompressor& data);
};
}
#endif
Next, we'll write the implementation of this class in the NameChangeRequestMessage.cpp file.
// file NameChangeRequestMessage.cpp
#include "NameChangeRequestMessage.h"
using namespace C4;
// The primary constructor tells the parent class (Message) what identifier this Message uses.
// Used in the CreateMessage method.
NameChangeRequestMessage::NameChangeRequestMessage() : Message(kMessageNameChangeRequestMessage)
{
}
// This constructor creates a new NameChangeRequestMessage with the default NameChangeRequestMessage MessageType,
// also setting the new name field to the name indicated in the parameter.
NameChangeRequestMessage::NameChangeRequestMessage(const char *name) : Message(kMessageNameChangeRequestMessage)
{
newName = name;
}
// This destructor does nothing at the moment. It's there for asthetical purposes.
NameChangeRequestMessage::~NameChangeRequestMessage()
{
}
// Our Compress method has to add our newName value to the Compressor data variable
// passed by the engine to our name change request message.
void NameChangeRequestMessage::Compress(Compressor &data) const
{
data << newName;
}
/* Our Decompress method extracts the information from the Data variable and places it
in our newName variable. Officially, we also add validation logic into this method
to determine the validity of the contents of this message. This isn't really needed
in this case though, so we just return true and pretend we did validation.
*/
bool NameChangeRequestMessage::Decompress(Decompressor &data)
{
data >> newName;
return (true);
}
Excellent. Only one more thing to do before we'll do a compile and see if our code works. Add the following line to the Chat.h file, right below the other two #includes:
#include "NameChangeRequestMessage.h"
This is to make sure our Chat can actually use the functionality defined in our NameChangeRequestMessage. Build your project and make sure it works, fix errors (if any), etc. No serious errors are expected if you have your project set up properly, so it should be pretty straightforward.
Great, back to where we were, our NameCommand() method we didn't implement in our Game class. The actual implementation isn't that hard really, and is composed of two steps: Create our NameChangeRequestMessage, and send it to the server.
// Sends a message to the server requesting a name change.
void Chat::NameCommand(const char *param)
{
NameChangeRequestMessage message(param);
TheMessageMgr->SendMessage(kPlayerServer, message);
}
You might be thinking 'Hey, what if I'm the server?', and add a check to make sure the message is only sent to the server when the local computer isn't the server, but that's not really needed - the engine takes care of that. If it finds the local computer is the server, it'll just send the message to itself.
Great, we are now able to send a NameChangeRequestMessage to the server. However, while the server will receive the message, it won't be able to do anything with it - because there isn't any method that checks received messages for the presence of a NameChangeRequestMessage yet. We'll add this to our chat application now.
Adding Message Handling
There's two methods we'll have to add to our Chat class. First, there's a ConstructMessage() method, which translates incoming messages to their appropriate objects, and second there's a ReceiveMessage() method, which handles incoming messages. Whenever the application receives a message, it's first sent to the ConstructMessage method which determines its type, then to the ReceiveMessage which translates the message to an action.
We'll have to add these two definitions to our Chat.h header file first, in the public section. We'll add these under the already existing HandlePlayerEvent() method.
// This method is called whenever the local system receives a Message from a specific player. // On the server system, this method will respond to both locally sent messages (a system can send // messages to itself) or from one of the connected clients. For the client systems, this // method will almost always respond to messages from the server - a client cannot normally // connect to other clients directly. void ReceiveMessage(Player *from, const NetworkAddress& address, const Message *message); // This method will be called whenever the message manager receives a message. // Its task is to create an instance of a Message based on the MessageType given to it. Message *ConstructMessage(MessageType type, Decompressor& data) const;
First, we'll implement our ConstructMessage() method - it's the simplest of the two. The ConstructMessage() should read the Type parameter and return an instance of a Message subclass matching the Type parameter, or a nullptr if the message type did not match anything.
Message * Chat::ConstructMessage(MessageType type, Decompressor &data) const
{
switch(type)
{
case (kMessageNameChangeRequestMessage) :
return (new NameChangeRequestMessage();
// add cases for your other types of messages here.
default:
return nullptr;
}
}
As indicated, you should add a case for every custom-defined message type below the first case statement, but above the default statement. For debugging purposes, you could also add a call to a logger in the default case, so that whenever the system receives a message that it couldn't determine the type of, it'll display a message to you. As a mini-optimization for your application, order the cases in such a way that the most often received message is also at the top of the list, so that the system will have to do a minimal amount of type checks. This is a marginal optimization though.
Next, we'll add our implementation to our ReceiveMessage() method in the .cpp file. The ReceiveMessage() method responds to all developer-defined messages and should translate the messages to actions inside the game, or at the very least forward them to the sections of the program that handle them. In this case, we'll have it do a few things: First, we'll have it set the local player name (of the sender) to the new name (the system that receives this message is always the server). Next, we'll have it send a message to all connected clients - including the sender -, notifying them of a name change, so that they can update their local name.
void Chat::ReceiveMessage(Player *from, const NetworkAddress &address, const Message *message)
{
MessageType type = message->GetMessageType();
switch(type)
{
// (Server) Receive and process a name change request message.
case (kMessageNameChangeRequestMessage) :
{
// First, we'll cast the generic Message into a more defined NameChangeRequestMessage,
// so we can get the new name from it.
const NameChangeRequestMessage * msg = static_cast<const NameChangeRequestMessage *>(message);
// You can find the player who sent the message through the following code.
Player * player = TheMessageMgr->GetPlayer(from->GetPlayerKey());
// Insert validation here, such as checking for duplicate player names, invalid player names, etc.
// Next, we'll have to send a message to all connected clients informing them that a player's
// name has changed.
break;
}
}
}
You can test this code out now, compile your program, start the server and a client, have the client connect to the server, and type 'name xxx' into the console of the client. The next time the client writes a message with the say command, the client's name will appear as the new name on the server. However, because we haven't made the server notify the other players of the name change, the name is still Player on the client computer. We'll have to send each connected client a name change message, informing the message managers on those clients to update the name of the player who sent the original name change request message. We'll also have to send the original sender of the name change request message a confirmation that his name was changed. We'll do these two things at the same time though, the receiving client will update the player's name whether it's the locally playing player or an external client.
We've chosen for this approach so we can do some validation later on on the server side of things. We'll want to make sure that the requested new name isn't already in use and doesn't contain vulgarities (that is, if you don't want vulgar language on your server). We'll add these validations at a later date though, for now it's not really that important.
Back to the name change confirmation message now. We'll have to repeat the process we used in the NameChangeRequestMessage into creating a NameChangeMessage, which basically contains the same information as the other one (i.e. a new name), as well as the identifier of the player whose name has to change. Each player is assigned a unique key when they join a game. This key can be used to find the same player on other clients. As programmers, we're inherently lazy, so instead of re-writing the enitre NameChangeRequestMessage and call it NameChangeMessage, we'll create a subclass of the NameChangeRequestMessage instead, where we override some of the existing methods of that message and add some information. We'll have to add one field to the class containing the player's unique key, we have to add a constructor that accepts a player key, and we'll have to override the Compress() and <code>Decompress() methods to also add the player's key to the Message.
First, we have to change a few things in our NameChangeRequestMessage. We have to add the message type identifier of the new NameChangeMessage and add a constructor that allows subclasses to create a new instance of the message with the new type. In our NameChangeRequestMessage.h class, change the following bits:
enum
{
kMessageServerInfo = kMessageBaseCount,
// This is the identifier for our name change request message.
kMessageNameChangeRequestMessage,
// This is the identifier for our name change confirmation message
kMessageNameChangeMessage
};
(you'll only have to add a comma behind the kMessageNameChangeRequestMessage and add the kMessageNameChangeMessage identifier. If you want, feel free to name them otherwise - Message twice in the name isn't really needed, but we'll do it for completeness).
Next, we'll have to add a secondary constructor that takes a MessageType as argument to pass to the parent Message constructor, and a third constructor that supplies both a MessageType and a player name. We'll implement these in the .cpp file for our NameChangeRequestMessage class by simply calling the parent Message constructor with the messageType argument as argument and assigning the player to a local field. Here's the .h constructor definition, add it below the other two constructors, and above the destructor:
// Constructors for child classes NameChangeRequestMessage(MessageType type); NameChangeRequestMessage(MessageType type, const char *name);
And in our .cpp file:
// Empty constructor used in child classes. Passes the MessageType parameter upwards.
NameChangeRequestMessage::NameChangeRequestMessage(MessageType type) : Message(type)
{
}
// Simple initializing constructor for use in child classes.
NameChangeRequestMessage::NameChangeRequestMessage(MessageType type, const char *name) : Message(type)
{
newName = name;
}
Excellent. We'll now have to create our NameChangeMessage. Considering it's only a small subclass of the NameChangeRequestMessage, we can put it in the same file. I personally like to put all classes in separate files, but in C4 applications it's more common to put similar classes in the same file. So, at the bottom of NameChangeRequestMessage.h, below the class definition of the NameChangeRequestMessage itself but within the block of namespace C4 (and above the #endif compiler statement), we add our NameChangeMessage class:
class NameChangeMessage : public NameChangeRequestMessage
{
private:
// We'll only store the player's Key, an identifier which is unique
// for each player playing on a server.
PlayerKey playerKey;
public:
// The constructors are the only way to set the new name and the new player key.
// The constructors in the NameChangeMessage message type call the parent constructor
// (that of NameChangeRequestMessage) with the MessageType of the NameChangeMessage (see above).
NameChangeMessage();
NameChangeMessage(const char * name, PlayerKey pk);
~NameChangeMessage();
// The compress and decompress methods are obligatory.
void Compress(Compressor& data) const;
bool Decompress(Decompressor& data);
// Simple accessor for our player key value
const PlayerKey GetPlayerKey() const {
return playerKey;
}
};
And at the bottom of our NameChangeRequestMessage.cpp file, we add the implementations:
// Empty constructor, used by the ConstructMessage() method. Just calls the parent's constructor.
NameChangeMessage::NameChangeMessage() : NameChangeRequestMessage(kMessageNameChangeMessage)
{
}
// This constructor also just calls the parent constructor with a new messageType, passing the name parameter to the superclass as well.
NameChangeMessage::NameChangeMessage(const char *name, PlayerKey pk) : NameChangeRequestMessage(kMessageNameChangeMessage, name)
{
playerKey = pk;
}
// The destrutor does nothing (besides destructing).
NameChangeMessage::~NameChangeMessage()
{
}
// We'll add the NameChangeMessage's data (i.e. the playerKey) to the data package, then
// call the parent's compress method so it can add its data to the package.
void NameChangeMessage::Compress(Compressor &data) const
{
data << playerKey;
NameChangeRequestMessage::Compress(data);
}
// We'll do the same with the decompress method, we first get our data from it,
// then pass it to the parent. Here we also pretend we've validated the data,
// by just returning true.
bool NameChangeMessage::Decompress(Decompressor &data)
{
data >> playerKey;
NameChangeRequestMessage::Decompress(data);
return(true);
}
That's simple enough. A good programmer is lazy and will write his program in such a way he'll have to do minimal amounts of typing and re-uses existing solutions as much as possible.
Now that we've created our new message type, we'll have to add it to our ReceiveMessage() function, below where we just received the message and reserved room for validation on the server side. We'll want to send a message to all connected clients that a player changed his name, containing the player whose name has to be changed and the new name. So, we'll add some lines to our ReceiveMessage method that is executed on the server:
void Chat::ReceiveMessage(Player *from, const NetworkAddress &address, const Message *message)
{
MessageType type = message->GetMessageType();
switch(type)
{
// (Server) Receive and process a name change request message.
case (kMessageNameChangeRequestMessage) :
{
// First, we'll cast the generic Message into a more defined NameChangeRequestMessage,
// so we can get the new name from it.
const NameChangeRequestMessage * msg = static_cast<const NameChangeRequestMessage *>(message);
// You can find the player who sent the message through the following code.
Player * player = TheMessageMgr->GetPlayer(from->GetPlayerKey());
// Insert validation here, such as checking for duplicate player names, invalid player names, etc.
// Next, we'll have to send a message to all connected clients informing them that a player's
// name has changed. The message will be sent to the local client as well,
// since we haven't updated the name yet.
NameChangeMessage ncmsg(NameChangeMessage(msg->GetNewName(), player->GetPlayerKey()));
TheMessageMgr->SendMessageAll(ncmsg);
break;
}
}
}
Great. Only one more thing to add, we'll have to receive the NameChangeMessage in our clients. As with the NameChangeRequestMessage, we'll have to add something in two places. First, we'll have to add our new message type to the ConstructMessage() method:
Message * Chat::ConstructMessage(MessageType type, Decompressor &data) const
{
switch(type)
{
// (Server) Name change request message
case (kMessageNameChangeRequestMessage) :
return (new NameChangeRequestMessage());
// (Client) Name change confirmation message.
case (kMessageNameChangeMessage) :
return (new NameChangeMessage());
// add cases for your other types of messages here.
// the method returns nullptr when none of the message types match.
default:
return nullptr;
}
}
Second, we'll have to add the handling of the message to our ReceiveMessage() method. We'll also display a confirmation message at the client, so that clients know someone changed his name. Below the kMessageNameChangeRequestMessage case in the ReceiveMessage() method, add the following:
case (kMessageNameChangeMessage) :
{
// Cast the message
const NameChangeMessage * msg = static_cast<const NameChangeMessage *> (message);
// Find the player
Player * player = TheMessageMgr->GetPlayer(msg->GetPlayerKey());
// change the name if the player exists.
if (player)
{
// we write our confirmation message now, before we do the actual change.
String<128> str("Player ");
str += player->GetPlayerName();
str += " changed name to ";
str += msg->GetNewName();
// Update the player's name
player->SetPlayerName(msg->GetNewName());
// Confirm to the user
TheEngine->Report(str, kReportError);
} else {
// we display a message that the player was not found.
String<128> str("Player ");
str += msg->GetPlayerKey();
str += " not found while trying to update name. =(";
TheEngine->Report(str, kReportError);
}
break;
}
Excellent. Compile, fix errors (if any), and run. If correct, typing 'name xxx' will change your name to xxx, whether you're on the server or on the client. One thing to note however is that the confirmation message won't work properly if you're on the server - it'll appear as 'Player xxx changed name to xxx', where the old name seems to be the new name. This is because the name already changed on the server, so that the old name is actually the new name already. Resultingly, the name will update twice on the server - this is easy to fix though, just add a check in the handling function to only change the name if the client is not the server.
We've tested the above with up to four players simultaneously, and it worked without any problems.
Exercises
- Add some validation to the entered name on the server side:
- Maximum length
- Name already in use (you could add (1), (2) behind the name if it already exists)
- Unproper / reserved usernames
- Allow / disallow colors in usernames (see Exercises in [[Basic Network Tutorial|Part 1)
Full source code
For the full source code of this tutorial (part 1 + 2), see Chat Application.
