Object Replication - Multiplayer Game Programming: Architecting Networked Games (2016)

Multiplayer Game Programming: Architecting Networked Games (2016)

Chapter 5. Object Replication

Serializing object data is only the first step in transmitting state between hosts. This chapter investigates a generalized replication framework which supports synchronization of world and object state between remote processes.

The State of the World

To be successful, a multiplayer game must make concurrent players feel like they are playing in the same world. When one player opens a door or kills a zombie, all players in range need to see that door open, or that zombie explode. Multiplayer games provide this shared experience by constructing a world state at each host and exchanging any information necessary to maintain consistency between each host’s state.

Depending on the game’s network topology, discussed more in Chapter 6, “Network Topologies and Sample Games,” there are various ways to create and enforce consistency between remote hosts’ world states. One common method is to have a server transmit the state of the world to all connected clients. The clients receive this transmitted state and update their own world state accordingly. In this way, all players on client hosts eventually experience the same world state.

Assuming some kind of object-oriented game object model, the state of the world can be defined as the state of all game objects in that world. Thus, the task of transmitting the world state can be decomposed into the task of transmitting the state of each of those objects.

This chapter addresses the task of transmitting object state between hosts in an effort to maintain a consistent world state for multiple, remote players.

Replicating an Object

The act of transmitting an object’s state from one host to another is known as replication. Replication requires more than just the serialization discussed in Chapter 4, “Object Serialization.” To successfully replicate an object, a host must take three preparatory steps before serializing the object’s internal state:

1. Mark the packet as a packet containing object state.

2. Uniquely identify the replicated object.

3. Indicate the class of the object being replicated.

First the sending host marks the packet as one containing object state. Hosts may need to communicate in ways other than object replication, so it is not safe to assume that each incoming datagram contains object replication data. As such, it is useful to create an enum PacketType to identify the type of each packet. Listing 5.1 gives an example.

Listing 5.1 PacketType Enum


enum PacketType
{
PT_Hello,
PT_ReplicationData,
PT_Disconnect,
PT_MAX
};


For every packet it sends, the host first serializes the corresponding PacketType into the packet’s MemoryStream. This way, the receiving host can read the packet type immediately off each incoming datagram and then determine how to process it. Traditionally, the first packet exchanged between hosts is flagged as some kind of “hello” packet, used to initiate communication, allocate state, and potentially begin an authentication process. The presence of PT_Hello as the first byte in an incoming datagram signifies this type of packet. Similarly, PT_Disconnectas the first byte indicates a request to begin the disconnect process. PT_MAX is used later by code that needs to know the maximum number of elements in the packet type enum. To replicate an object, a sending host serializes PT_ReplicationData as the first byte of a packet.

Next, the sending host needs to identify the serialized object to the receiving host. This is so the receiving host can determine if it already has a copy of the incoming object. If so, it can update the object with the serialized state instead of instantiating a new object. Remember that theLinkingContext described in Chapter 4 already relies on objects having unique identifier tags. These tags can also identify objects for the purpose of state replication. In fact, the LinkingContext can be expanded, as shown in Listing 5.2, to assign unique network identifiers to objects that don’t currently have them.

Listing 5.2 Enhanced LinkingContext


class LinkingContext
{
public:
LinkingContext():
mNextNetworkId(1)
{}

uint32_t GetNetworkId(const GameObject* inGameObject,
bool inShouldCreateIfNotFound)
{
auto it = mGameObjectToNetworkIdMap.find(inGameObject);
if(it != mGameObjectToNetworkIdMap.end())
{
return it->second;
}
else if(inShouldCreateIfNotFound)
{
uint32_t newNetworkId = mNextNetworkId++;
AddGameObject(inGameObject, newNetworkId);
return newNetworkId;
}
else
{
return 0;
}
}

void AddGameObject(GameObject* inGameObject, uint32_t inNetworkId)
{
mNetworkIdToGameObjectMap[inNetworkId] = inGameObject;
mGameObjectToNetworkIdMap[inGameObject] = inNetworkId;
}

void RemoveGameObject(GameObject *inGameObject)
{
uint32_t networkId = mGameObjectToNetworkIdMap[inGameObject];
mGameObjectToNetworkIdMap.erase(inGameObject);
mNetworkIdToGameObjectMap.erase(networkId);
}

//unchanged ...
GameObject* GetGameObject(uint32_t inNetworkId);

private:
std::unordered_map<uint32_t, GameObject*> mNetworkIdToGameObjectMap;
std::unordered_map<const GameObject*, uint32_t>
mGameObjectToNetworkIdMap;

uint32_t mNextNetworkId;
}


The new member variable mNextNetworkId keeps track of the next unused network identifier, and increments each time one is used. Because it is a 4-byte unsigned integer, it is usually safe to assume it will not overflow: In cases where more than 4 billion unique replicated objects might be necessary over the duration of a game, you will need to implement a more complex system. For now, assume that incrementing the counter safely provides unique network identifiers.

When a host is ready to write inGameObject’s identifier into an object state packet, it calls mLinkingContext->GetNetworkId(inGameObject, true), telling the linking context to generate a network identifier if necessary. It then writes this identifier into the packet after thePacketType. When the remote host receives this packet, it reads the identifier and uses its own linking context to look up the referenced object. If the receiving host finds an object, it can deserialize the data into it directly. If it does not find the object, it needs to create it.

For a remote host to create an object, it needs information regarding what class of object to create. The sending host provides this by serializing some kind of class identifier after the object identifier. One brute force way to achieve this is to select a hardcoded class identifier from a set using dynamic casts, as show in Listing 5.3. The receiver would then use a switch statement like the one shown in Listing 5.4 to instantiate the correct class based on the class identifier.

Listing 5.3 Hardcoded, Tightly Coupled Class Identification


void WriteClassType(OutputMemoryBitStream& inStream,
const GameObject* inGameObject)
{
if(dynamic_cast<const RoboCat*>(inGameObject))
{
inStream.Write(static_cast<uint32_t>('RBCT'));
}
else if(dynamic_cast<const RoboMouse*>(inGameObject))
{
inStream.Write(static_cast<uint32_t>('RBMS'));
}
else if(dynamic_cast<const RoboCheese*>(inGameObject))
{
inStream.Write(static_cast<uint32_t>('RBCH'));
}
}


Listing 5.4 Hardcoded, Tightly Coupled Object Instantiation


GameObject* CreateGameObjectFromStream(InputMemoryBitStream& inStream)
{
uint32_t classIdentifier;
inStream.Read(classIdentifier);
switch(classIdentifier)
{
case 'RBCT':
return new RoboCat();
break;
case 'RBMS':
return new RoboMouse();
break;
case 'RBCH':
return new RoboCheese();
break;
}

return nullptr;
}


Although this works, it is inadequate for several reasons. First, it uses a dynamic_cast, which usually requires C++’s built-in RTTI to be enabled. RTTI is often disabled in games because it requires extra memory for every polymorphic class type. More importantly, this approach is inferior because it couples the game object system with the replication system. Every time you add a new gameplay class that may be replicated, you have to edit both the WriteClassType and CreateGameObjectFromStream functions in the networking code. This is easy to forget, and can cause the code to grow out of sync. Also, if you want to reuse your replication system in a new game, it requires completely rewriting these functions, which reference the gameplay code of your old game. Finally, the coupling makes unit testing more difficult, as tests cannot load the network unit without also loading the gameplay unit. In general, it is fine for gameplay code to depend on network code, but network code should almost never depend on gameplay.

One clean way to reduce the coupling between gameplay and network code is to abstract the object identification and creation routines from the replication system using an object creation registry.

Object Creation Registry

An object creation registry maps a class identifier to a function that creates an object of the given class. Using the registry, the network module can look up the creation function by id and then execute it to create the desired object. If your game has a reflection system, you probably already have such a system implemented, but if not, it is not difficult to create.

Each replicable class must be prepared for the object creation registry. First, assign each class a unique identifier and store it in a static constant named kClassId. Each class could use a GUID to ensure no overlap between identifiers, though 128-bit identifiers can be unnecessarily heavy considering the small subset of classes that need to be replicated. A good alternative is to use a four-character literal based on the name of the class and then check for conflicting names when the classes are submitted to the registry. A final alternative is to create class ids at compile time using a build tool which autogenerates the code to ensure uniqueness.


Warning

Four-character literals are implementation dependent. Specifying 32-bit values with a literal using four characters like ‘DXT5’ or ‘GOBJ’ can be a simple way to come up with well-differentiated identifiers. They are also nice because they stick out clearly when present in a memory dump of your packets. For this reason, many third-party engines, from Unreal to C4, use them as markers and identifiers. Unfortunately, they are classified as implementation dependent in the C++ standard, which means not all compilers handle the conversion of a string literal into an integer in the same way. Most compilers, including GCC and Visual Studio, use the same convention, but if you are using multicharacter literals to communicate between processes compiled with different compilers, run some tests first to make sure both compilers translate the literals the same way.


Once each class has a unique identifier, add a GetClassId virtual function to GameObject. Override this function for each child class of GameObject so that it returns the identifier of the class. Finally, add a static function to each child class which creates and returns an instance of the class. Listing 5.5 shows how GameObject and two child classes should be prepared for the registry.

Listing 5.5 Classes Prepared for the Object Creation Registry


class GameObject
{
public:
//...
enum{kClassId = 'GOBJ'};
virtual uint32_t GetClassId() const {return kClassId;}
static GameObject* CreateInstance() {return new GameObject();}
//...
};

class RoboCat: public GameObject
{
public:
//...
enum{kClassId = 'RBCT'};
virtual uint32_t GetClassId() const {return kClassId;}
static GameObject* CreateInstance() {return new RoboCat();}
//...
};

class RoboMouse: public GameObject
{
//...
enum{kClassId = 'RBMS'};
virtual uint32_t GetClassId() const {return kClassId;}
static GameObject* CreateInstance() {return new RoboMouse();}
//...
};


Note that each child class needs the GetClassId virtual function implemented. Even though the code looks identical, the value returned changes because the kClassId constant is different. Because the code is similar for each class, some developers prefer to use a preprocessor macro to generate it. Complex preprocessor macros are generally frowned on because modern debuggers do not handle them well, but they can lessen the chance of errors that come from copying and pasting code over and over. In addition, if the copied code needs to change, just changing the macro will propagate the changes through to all classes. Listing 5.6 demonstrates how to use a macro in this case.

Listing 5.6 Classes Prepared for the Object Creation Registry Using a Macro


#define CLASS_IDENTIFICATION(inCode, inClass)\
enum{kClassId = inCode}; \
virtual uint32_t GetClassId() const {return kClassId;} \
static GameObject* CreateInstance() {return new inClass();}

class GameObject
{
public:
//...
CLASS_IDENTIFICATION('GOBJ', GameObject)
//...
};

class RoboCat: public GameObject
{
//...
CLASS_IDENTIFICATION('RBCT', RoboCat)
//...
};
class RoboMouse: public GameObject
{
//...
CLASS_IDENTIFICATION('RBMS', RoboMouse)
//...
};


The backslashes at the end of each line of the macro definition instruct the compiler that the definition continues to the following line.

With the class identification system in place, create an ObjectCreationRegistry to hold the map from class identifier to creation function. Gameplay code, completely independent from the replication system, can fill this in with replicable classes, as show in Listing 5.7.ObjectCreationRegistry doesn’t technically have to be a singleton as shown, it just needs to be accessible from both gameplay and network code.

Listing 5.7 ObjectCreationRegistry Singleton and Mapping


typedef GameObject* (*GameObjectCreationFunc)();

class ObjectCreationRegistry
{
public:
static ObjectCreationRegistry& Get()
{
static ObjectCreationRegistry sInstance;
return sInstance;
}
template<class T>
void RegisterCreationFunction()
{
//ensure no duplicate class id
assert(mNameToGameObjectCreationFunctionMap.find(T::kClassId) ==
mNameToGameObjectCreationFunctionMap.end());
mNameToGameObjectCreationFunctionMap[T::kClassId] =
T::CreateInstance;
}

GameObject* CreateGameObject(uint32_t inClassId)
{
//add error checking if desired- for now crash if not found
GameObjectCreationFunc creationFunc =
mNameToGameObjectCreationFunctionMap[inClassId];
GameObject* gameObject = creationFunc();
return gameObject;
}

private:
ObjectCreationRegistry() {}
unordered_map<uint32_t, GameObjectCreationFunc>
mNameToGameObjectCreationFunctionMap;
};

void RegisterObjectCreation()
{
ObjectCreationRegistry::Get().RegisterCreationFunction<GameObject>();
ObjectCreationRegistry::Get().RegisterCreationFunction<RoboCat>();
ObjectCreationRegistry::Get().RegisterCreationFunction<RoboMouse>();
}


The GameObjectCreationFunc type is a function pointer which matches the signature of the CreateInstance static member functions in each class. The RegisterCreationFunction is a template used to prevent a mismatch between class identifier and creation function. Somewhere in the gameplay startup code, call RegisterObjectCreation to populate the object creation registry with class identifiers and instantiation functions.

With this system in place, when a sending host needs to write a class identifier for a GameObject, it just calls its GetClassId method. When the receiving host needs to create an instance of a given class, it simply calls Create on the object creation registry and passes the class identifier.

In effect, this system represents a custom-built version of C++’s RTTI system. Because it is hand built for this purpose, you have more control over its memory use, its type identifier size, and its cross-compiler compatibility than you would just using C++’s typeid operator.


Tip

If your game uses a reflection system like the one described in the generalized serialization section of Chapter 4, you can augment that system instead of using the one described here. Just add a GetDataType virtual function to each GameObject which returns the object’sDataType instead of a class identifier. Then add a unique identifier to each DataType, and an instantiation function. Instead of mapping from class identifier to creation function, the object creation registry becomes more of a data type registry, mapping from data type identifier to DataType. To replicate an object, get its DataType through the GetDataType method and serialize the DataType’s identifier. To Instantiate it, look up the DataType by identifier in the registry and then use the DataType’s instantiation function. This has the advantage of making the DataType available for generalized serialization on the receiving end of the replication.


Multiple Objects per Packet

Remember it is efficient to send packets as close in size to the MTU as possible. Not all objects are big, so there is an efficiency gain in sending multiple objects per packet. To do so, once a host has tagged a packet as a PT_ReplicationData packet, it merely repeats the following steps for each object:

1. Writes the object’s network identifier

2. Writes the object’s class identifier

3. Writes the object’s serialized data

When the receiving host finishes deserializing an object, any unused data left in the packet must be for another object. So, the host repeats the receiving process until there is no remaining unused data.

Naïve World State Replication

With multi-object replication code in place, it is straightforward to replicate the entire world state by replicating each object in the world. If you have a small enough game world, like that of the original Quake, then the entire world state can fit entirely within a single packet. Listing 5.8introduces a replication manager that replicates the entire world in this manner.

Listing 5.8 Replicating World State


class ReplicationManager
{
public:
void ReplicateWorldState(OutputMemoryBitStream& inStream,
const vector<GameObject*>& inAllObjects);
private:
void ReplicateIntoStream(OutputMemoryBitStream& inStream,
GameObject* inGameObject);

LinkingContext* mLinkingContext;
};

void ReplicationManager::ReplicateIntoStream(
OutputMemoryBitStream& inStream,
GameObject* inGameObject)
{
//write game object id
inStream.Write(mLinkingContext->GetNetworkId(inGameObject, true));

//write game object class
inStream.Write(inGameObject->GetClassId());

//write game object data
inGameObject->Write(inStream);
}

void ReplicationManager::ReplicateWorldState(
OutputMemoryBitStream& inStream,
const vector<GameObject*>& inAllObjects)
{
//tag as replication data
inStream.WriteBits(PT_ReplicationData, GetRequiredBits<PT_MAX>::Value );

//write each object
for(GameObject* go: inAllObjects)
{
ReplicateIntoStream(inStream, go);
}
}


ReplicateWorldState is a public function which a caller can use to write replication data for a collection of objects into an outgoing stream. It first tags the data as replication data and then uses the private ReplicateIntoStream to write each object individually.ReplicateIntoStream uses the linking context to write the network ID of each object and the virtual GetClassId to write the object’s class identifier. It then depends on a virtual Write function on the game object to serialize the actual data.


Getting the Required Bits to Serialize a Value

Remember that the bit stream allows serialization of a field’s value using an arbitrary number of bits. The number of bits must be large enough to represent the maximum value possible for the field. When serializing an enum, the compiler can actually calculate the best number of bits at compile time, removing the chance for error when elements are added or removed from the enum. The trick is to make sure that the final element of the enum is always suffixed as a _MAX element. For instance, for the PacketType enum, it is named PT_MAX. This way, the value of the _MAX element will always increment or decrement automatically when elements are added or removed, and you have an easy way to track the maximum value for the enum.

The ReplicateWorldState method passes this final enum value as a template argument to GetRequiredBits to calculate the number of bits required to represent the maximum packet type. To do so most efficiently, at compile time, it uses something known as template metaprogramming, a somewhat dark art of C++ engineering. It turns out the language of C++ templates is so complex it is actually Turing universal, and a compiler can calculate any arbitrary function as long as the inputs are known at compile time. In this case, the code for calculating the number of bits required to represent a maximum value is as follows:

template<int tValue, int tBits>
struct GetRequiredBitsHelper
{

enum {Value = GetRequiredBitsHelper<(tValue >> 1),
tBits + 1>::Value};
};

template<int tBits>
struct GetRequiredBitsHelper<0, tBits>
{
enum {Value = tBits};
};
template<int tValue>
struct GetRequiredBits
{
enum {Value = GetRequiredBitsHelper<tValue, 0>::Value};
};

Template metaprogramming has no explicit loop functionality, so it must use recursion in lieu of iteration. Thus, GetRequiredBits relies on the recursive GetRequiredBitsHelper to find the highest bit set in the argument value and thus calculate the number of bits necessary for representation. It does so by incrementing the tBits argument each time it shifts the tValue argument one bit to the right. When tValue is finally 0, the base case specialization is invoked, which simply returns the accumulated value in tBits.

With the advent of C++11, the constexpr keyword allows some of the functionality of template metaprogramming with less complexity, but at the time of writing it is not currently supported by all modern compilers (i.e., Visual Studio 2013) so it is safer to go with templates for compatibility.


When the receiving host detects a replication state packet, it passes it to the replication manager, which loops through each serialized game object in the packet. If a game object does not exist, the client creates it and deserializes the state. If a game object does exist, the client finds it and deserializes state into it. When the client is done processing the packet, it destroys any local game objects that did not have data in the packet, as the lack of data means the game object no longer exists in the world of the sending host. Listing 5.9 shows additions to the replication manager that allow it to process an incoming packet identified as containing replication state.

Listing 5.9 Replicating World State


class ReplicationManager
{
public:
void ReceiveReplicatedObjects(InputMemoryBitStream& inStream);

private:
GameObject* ReceiveReplicatedObject(InputMemoryBitStream& inStream);

unordered_set<GameObject*> mObjectsReplicatedToMe;
};

void ReplicationManager::ReceiveReplicatedObjects(
InputMemoryBitStream& inStream)
{
unordered_set<GameObject*> receivedObjects;

while(inStream.GetRemainingBitCount() > 0)
{
GameObject* receivedGameObject = ReceiveReplicatedObject(inStream);
receivedObjects.insert(receivedGameObject);
}

//now run through mObjectsReplicatedToMe.
//if an object isn't in the recently replicated set,
//destroy it
for(GameObject* go: mObjectsReplicatedToMe)
{
if(receivedObjects.find(go)!= receivedObjects.end())
{
mLinkingContext->Remove(go);
go->Destroy();
}
}

mObjectsReplicatedToMe = receivedObjects;
}

GameObject* ReplicationManager::ReceiveReplicatedObject(
InputMemoryBitStream& inStream)
{
uint32_t networkId;
uint32_t classId;
inStream.Read(networkId);
inStream.Read(classId);

GameObject* go = mLinkingContext->GetGameObject(networkId);
if(!go)
{
go = ObjectCreationRegistry::Get().CreateGameObject(classId);
mLinkingContext->AddGameObject(go, networkId);
}

//now read update
go->Read(inStream);

//return gameobject so we can track it was received in packet
return go;
}


Once the packet receiving code reads the packet type and determines the packet is replication data, it can pass the stream to ReceiveWorld. ReceiveWorld uses ReceiveReplicatedObject to receive each object and tracks each received object in a set. Once all objects are received, it checks for any objects that were received in the previous packet that were not received in this packet and destroys them to keep the world in sync.

Sending and receiving world state in this manner is simple, but is limited by the requirement that the entire world state must fit within each packet. To support larger worlds, you need an alternate method of replicating state.

Changes in World State

Because each host maintains its own copy of the world state, it is not necessary to replicate the entire world state in a single packet. Instead, the sender can create packets that represent changes in world state, and the receiver can then apply these changes to its own world state. This way, a sender can use multiple packets to synchronize a very large world with a remote host.

When replicating world state in this manner, each packet can be said to contain a world state delta. Because the world state is composed of object states, a world state delta contains one object state delta for each object that needs to change. Each object state delta represents one of three replication actions:

1. Create game object

2. Update game object

3. Destroy game object

Replicating an object state delta is similar to replicating an entire object state, except the sender needs to write the object action into the packet. At this point, the prefix to serialized data is getting so complex that it can be useful to create a replication header that incorporates the object’s network identifier, replication action, and class if necessary. Listing 5.10 shows an implementation.

Listing 5.10 Replication Header


enum ReplicationAction
{
RA_Create,
RA_Update,
RA_Destroy,
RA_MAX
};

class ReplicationHeader
{
public:
ReplicationHeader() {}

ReplicationHeader(ReplicationAction inRA, uint32_t inNetworkId,
uint32_t inClassId = 0):
mReplicationAction(inRA),
mNetworkId(inNetworkId),
mClassId(inClassId)
{}

ReplicationAction mReplicationAction;
uint32_t mNetworkId;
uint32_t mClassId;

void Write(OutputMemoryBitStream& inStream);
void Read(InputMemoryBitStream& inStream);
};

void ReplicationHeader::Write(OutputMemoryBitStream& inStream)
{
inStream.WriteBits(mReplicationAction, GetRequiredBits<RA_MAX>::Value );
inStream.Write(mNetworkId);
if( mReplicationAction!= RA_Destroy)
{
inStream.Write(mClassId);
}
}

void ReplicationHeader::Read(InputMemoryBitStream& inStream)
{
inStream.Read(mReplicationAction, GetRequiredBits<RA_MAX>::Value);
inStream.Read(mNetworkId);
if(mReplicationAction!= RA_Destroy)
{
inStream.Read(mClassId);
}
};


The Read and Write methods aid in serializing the header into a packet’s memory stream ahead of the object’s data. Note that it is not necessary to serialize the object’s class identifier in the case of object destruction.

When a sender needs to replicate a collection of object state deltas, it creates a memory stream, marks it as a PT_ReplicationData packet, and then serializes a ReplicationHeader and appropriate object data for each change. The ReplicationManager should have three distinct methods to replicate creation, update, and destruction, as shown in Listing 5.11. These encapsulate the ReplicationHeader creation and serialization so that they aren’t exposed outside the ReplicationManager.

Listing 5.11 Replicating Sample Object State Deltas


ReplicationManager::ReplicateCreate(OutputMemoryBitStream& inStream,
GameObject* inGameObject)
{
ReplicationHeader rh(RA_Create,
mLinkingContext->GetNetworkId(inGameObject,
true),
inGameObject->GetClassId());
rh.Write(inStream);
inGameObject->Write(inStream);
}

void ReplicationManager::ReplicateUpdate(OutputMemoryBitStream& inStream,
GameObject* inGameObject)
{
ReplicationHeader rh(RA_Update,
mLinkingContext->GetNetworkId(inGameObject,
false),
inGameObject->GetClassId());
rh.Write(inStream);
inGameObject->Write(inStream);
}

void ReplicationManager::ReplicateDestroy(OutputMemoryBitStream&inStream,
GameObject* inGameObject)
{
ReplicationHeader rh(RA_Destroy,
mLinkingContext->GetNetworkId(inGameObject,
false));
rh.Write(inStream);
}


When a receiving host processes a packet, it now must appropriately apply each action. Listing 5.12 shows how.

Listing 5.12 Processing Replication Actions


void ReplicationManager::ProcessReplicationAction(
InputMemoryBitStream& inStream)
{
ReplicationHeader rh;
rh.Read(inStream);

switch(rh.mReplicationAction)
{
case RA_Create:
{
GameObject* go =
ObjectCreationRegistry::Get().CreateGameObject(rh.mClassId);
mLinkingContext->AddGameObject(go, rh.mNetworkId);
go->Read(inStream);
break;
}
case RA_Update:
{
GameObject* go =
mLinkingContext->GetGameObject(rh.mNetworkId);
//we might have not received the create yet,
//so serialize into a dummy to advance read head
if(go)
{
go->Read(inStream);
}
else
{
uint32_t classId = rh.mClassId;
go =
ObjectCreationRegistry::Get().CreateGameObject(classId);
go->Read(inStream);
delete go;
}
break;
}
case RA_Destroy:
{
GameObject* go = mLinkingContext->GetGameObject(rh.mNetworkId);
mLinkingContext->RemoveGameObject(go);
go->Destroy();
break;
}
default:
//not handled by us
break;
}
}


After identifying a packet as one containing object state, the receiver loops through each header and chunk of serialized object data. If the header indicates creation, the receiver ensures that the object does not already exist. If it does not, it creates the object with the serialized data.

If the replication header indicates an object update, the receiver finds the object and deserializes the data into it. Due to any number of factors, including unreliability of the network, it is possible that the receiver might not find the target game object. In this case, the receiver still needs to process the rest of the packet, so it must advance the memory stream’s read head by an appropriate amount. It can do this by creating a temporary dummy object, serializing the object state into the dummy, and then deleting the dummy object. If this is too inefficient, or not possible due to the way in which objects are constructed, you can add a field to the object replication header indicating the size of the serialized data. Then, the receiver can look up the size of the serialized data for the unlocatable object and advance the memory stream’s current read head by that amount.


Warning

Partial world and object state replication only work if the sender has an accurate representation of the receiver’s current world state. This accuracy helps the sender determine which changes it needs to replicate. Because the Internet is inherently unreliable, this is not as simple as assuming that the receiver’s world state is based on the latest packets transmitted by the sender. Either hosts need to send packets via TCP, so reliability is guaranteed, or they need to use an application level protocol designed on top of UDP to provide reliability. Chapter 7, “Latency, Jitter, and Reliability,” addresses this topic.


Partial Object State Replication

When sending an object update, the sender might not need to send every property in the object. The sender may want to serialize only the subset of properties that have changed since the last update. To enable this, you can use a bit-field to represent the serialized properties. Each bit can represent a property or group of properties to be serialized. For instance, the MouseStatus class from Chapter 4 might use the enum in listing 5.13 to assign properties to bits.

Listing 5.13 MouseStatus Properties Enum


enum MouseStatusProperties
{
MSP_Name = 1 << 0,
MSP_LegCount = 1 << 1,
MSP_HeadCount = 1 << 2,
MSP_Health = 1 << 3,
MSP_MAX
};


These enum values can be bitwise ORed together to represent multiple properties. For instance, an object state delta containing values for mHealth and mLegCount would use MSP_Health | MSP_LegCount. Note that a bit-field containing a 1 for each bit indicates that all properties should be serialized.

The Write method of a class should be amended to take a property bit-field indicating which properties to serialize into the stream. Listing 5.14 provides an example for the MouseStatus class.

Listing 5.14 Using Property Bit-Fields to Write Properties


void MouseStatus::Write(OutputMemoryBitStream& inStream,
uint32_t inProperties)
{
inStream.Write(inProperties, GetRequiredBits<MSP_MAX >::Value);
if((inProperties & MSP_Name) != 0)
{
inStream.Write(mName);
}
if((inProperties & MSP_LegCount)!= 0)
{
inStream.Write(mLegCount);
}
if((inProperties & MSP_HeadCount) != 0)
{
inStream.Write(mHeadCount);
}
if((inProperties & MSP_Health)!= 0)
{
inStream.Write(mHealth);
}
}


Before writing any properties, the method writes inProperties into the stream so that the deserialization procedure can read only the written properties. It then checks the individual bits of the bit-field to write the desired properties. Listing 5.15 demonstrates the deserialization process.

Listing 5.15 Deserialization of Partial Object Update


void MouseStatus::Read(InputMemoryBitStream& instream)
{
uint32_t writtenProperties;
inStream.Read(writtenProperties, GetRequiredBits<MSP_MAX>::Value);
if((writtenProperties & MSP_Name) != 0)
{
inStream.Read(mName );
}
if((writtenProperties & MSP_LegCount) != 0)
{
inStream.Read(mLegCount);
}
if((writtenProperties & MSP_HeadCount) != 0)
{
inStream.Read(mHeadCount);
}
if((writtenProperties & MSP_Health) != 0)
{
inStream.Read(mHealth);
}
}


The Read method first reads the writtenProperties field so it can use the value to deserialize only the correct properties.

This bit-field approach to partial object state replication also works with the more abstract, bidirectional, data-driven serialization routines given at the end of Chapter 4. Listing 5.16 extends that chapter’s implementation of Serialize to support a bit-field for partial object state replication.

Listing 5.16 Bidirectional, Data-Driven Partial Object Update


void Serialize(MemoryStream* inStream, const DataType* inDataType,
uint8_t* inData, uint32_t inProperties)
{
inStream->Serialize(inProperties);

const auto& mvs = inDataType->GetMemberVariables();
for(int mvIndex = 0, c = mvs.size(); mvIndex < c; ++mvIndex)
{
if(((1 << mvIndex) & inProperties) != 0)
{
const auto& mv = mvs[mvIndex];
void* mvData = inData + mv.GetOffset();
switch(mv.GetPrimitiveType())
{
case EPT_Int:
inStream->Serialize(*reinterpret_cast<int*>(mvData));
break;
case EPT_String:
inStream->Serialize(
*reinterpret_cast<string*>(mvData));
break;
case EPT_Float:
inStream->Serialize(
*reinterpret_cast<float*>(mvData));
break;
}
}
}
}


Instead of manually defining the meaning of each bit using an enum, the data-driven approach uses the index of the bit to represent the index of the member variable being serialized. Note that Serialize is called on the inProperties value right away. For an output stream, this will write the bit-field into the stream. However, for an input stream, this will read the written properties into the variable, overwriting anything that was passed in. This is correct behavior, as an input operation needs to use the serialized bit-field that corresponds to each of the serialized properties. If there are more than 32 potential properties to serialize, use a uint64_t for the properties. If there are more than 64 properties, consider grouping several properties under the same bit or splitting up the class.

RPCs as Serialized Objects

In a complex multiplayer game, a host might need to transmit something other than object state to another host. Consider the case of a host wanting to transmit the sound of an explosion to another host, or to flash another host’s screens. Actions like this are best transmitted using remote procedure calls, or RPCs. A remote procedure call is the act of one host causing a procedure to execute on one or more remote hosts. There are many application-level protocols available for this, ranging from text-based ones like XML-RPC to binary ones like ONC-RPC. However, if a game already supports the object replication system described in this chapter, it is a simple matter to add an RPC layer on top of it.

Each procedure invocation can be thought of as a unique object, with a member variable for each parameter. To invoke an RPC on a remote host, the invoking host replicates an object of the appropriate type, with the member variables filled in correctly, to the target host. For instance, for the function PlaySound,

void PlaySound(const string& inSoundName, const Vector3& inLocation,
float inVolume);

The PlaySoundRPCParams struct would have three member variables:

struct PlaySoundRPCParams
{
string mSoundName;
Vector3 mLocation;
float mVolume;
};

To invoke PlaySound on a remote host, the invoker creates a PlayerSoundRPCParams object, sets the member variables, and then serializes the object into an object state packet. This can result in spaghetti code if many RPCs are used, as well as run through a lot of network object identifiers that aren’t really necessary, as RPC invocation objects don’t need to be uniquely identified.

A cleaner solution is to create a modular wrapper around the RPC system and then integrate it with the replication system. To do this, first add an additional replication action type, RA_RPC. This replication action identifies the serialized data that follows it as an RPC invocation, and allows the receiving host to direct it to a dedicated RPC processing module. It also tells the ReplicationHeader serialization code that a network identifier is not necessary for this action and thus should not be serialized. When the ReplicationManager's ProcessReplicationAction detects an RA_RPC action, it should pass the packet to the RPC module for further processing.

The RPC module should contain a data structure that maps from each RPC identifier to an unwrapping glue function that can deserialize parameters for and then invoke the appropriate function. Listing 5.17 shows a sample RPCManager.

Listing 5.17 An Example RPCManager


typedef void (*RPCUnwrapFunc)(InputMemoryBitStream&)

class RPCManager
{
public:
void RegisterUnwrapFunction(uint32_t inName, RPCUnwrapFunc inFunc)
{
assert(mNameToRPCTable.find(inName) == mNameToRPCTable.end());
mNameToRPCTable[inName] = inFunc;
}

void ProcessRPC(InputMemoryBitStream& inStream)
{
uint32_t name;
inStream.Read(name);
mNameToRPCTable[name](inStream);
}
unordered_map<uint32_t, RPCUnwrapFunc> mNameToRPCTable;
};


In this example, each RPC is identified with a four-character code unsigned integer. If desired, the RPCManager can use full strings instead: While strings allow for more variety, they use more bandwidth. Note the similarity to the object creation registry. Registering functions through a hash map is a common way to decouple seemingly dependent systems.

When the ReplicationManager detects the RA_RPC action, it passes the received memory stream to the RPC module for processing, which then unwraps and calls the correct function locally. To support this, game code must register an unwrap function for each RPC. Listing 5.18 shows how to register the PlaySound function.

Listing 5.18 Registering an RPC


void UnwrapPlaySound(InputMemoryBitStream& inStream)
{
string soundName;
Vector3 location;
float volume;

inStream.Read(soundName);
inStream.Read(location);
inStream.Read(volume);
PlaySound(soundName, location, volume);
}

void RegisterRPCs(RPCManager* inRPCManager)
{
inRPCManager->RegisterUnwrapFunction('PSND', UnwrapPlaySound);
}


UnwrapPlaySound is a glue function which handles the task of deserializing the parameters and invoking PlaySound with them. Gameplay code should invoke the RegisterRPCs function and pass it an appropriate RPCManager. Other RPCs can be added to the RegisterRPCsfunction as desired. Presumably the PlaySound function is implemented elsewhere.

Finally, to invoke an RPC, the caller needs a function to write the appropriate ObjectReplicationHeader and parameters into an outgoing packet. Depending on the implementation, it can either create the packet and send it, or check with the gameplay code or the networking module to see if any packet is already pending to go out to the remote host. Listing 5.19 gives an example of a wrapper function that writes an RPC call into an outgoing packet.

Listing 5.19 Writing PlaySoundRPC into a Pending Packet


void PlaySoundRPC(OutputMemoryBitStream& inStream,
const string&inSoundName,
const Vector3& inLocation, float inVolume)
{
ReplicationHeader rh(RA_RPC);
rh.Write(inStream);
inStream.Write( inSoundName);
inStream.Write(inLocation);
inStream.Write(inVolume);
}


It can be a lot of work to manually generate the wrapping and unwrapping glue functions, register them with the RPCManager, and keep their parameters in sync with the underlying functions. For this reason, most engines that support RPCs use build tools to autogenerate the glue functions and register them with an RPC module.


Note

Sometimes, a host may wish to remotely invoke a method on a specific object instead of just calling a free function. While similar, this is technically known as a Remote Method Invocation, or RMI, as opposed to a remote procedural call. A game that supports these could use the network identifier in the ObjectReplicationHeader to identify the target object of the RMI. An identifier of zero would indicate a free function RPC and a nonzero value would indicate an RMI on the specified game object. Alternatively, to conserve bandwidth at the expense of code size, a new replication action, RA_RMI, could indicate the relevance of the network identifier field, whereas the RA_RPC action would continue to ignore it.


Custom Solutions

No matter how many general-purpose object replication or RPC invocation tools an engine includes, some games still call for custom replication and messaging code. Either some desired functionality is not available, or, for certain rapidly changing values, the framework of generalized object replication is just too bulky and bandwidth inefficient. In these cases, you can always add custom replication actions by extending the ReplicationAction enum and adding cases to the switch statement in the ProcessReplicationFunction. By special casing theReplicationHeader serialization for your object, you can include or omit the corresponding network identifier and class identifier as desired.

If your customization falls outside the purview of the ReplicationManager entirely, you can also extend the PacketType enum to create entirely new packet types and managers to handle them. Following the design pattern of the registration maps used in theObjectCreationRegistry and RPCManager, it is easy to inject higher-level code to handle these custom packets without polluting the lower-level networking system.

Summary

Replicating an object involves more than just sending its serialized data from one host to another. First, an application-level protocol must define all possible packet types, and the network module should tag packets containing object data as such. Each object needs a unique identifier, so that the receiving host can direct incoming state to the appropriate object. Finally, each class of object needs a unique identifier so that the receiving host can create an object of the correct class if one does not exist already. Networking code should not depend on gameplay classes, so use an indirect map of some sort to register replicable classes and creation functions with the network module.

Small-scale games can create a shared world between hosts by replicating each object in the world in each outgoing packet. Larger games cannot fit replication data for all objects in every packet, so they must employ a protocol that supports transmission of world state deltas. Each delta can contain replication actions to create an object, update an object, or destroy an object. For efficiency, update-object actions may send serialization data for only a subset of object properties. The appropriate subset depends on the overall network topology and reliability of the application-level protocol.

Sometimes, games need to replicate more than just object state data between hosts. Often, they need to invoke remote procedure calls on each other. One simple way to support RPC invocation is to introduce the RPC replication action and fold RPC data into replication packets. An RPC module can handle registration of RPC wrapping, unwrapping, and invocation functions, and the replication manager can channel any incoming RPC requests to this module.

Object replication is a key piece of the low-level multiplayer game tool chest, and will be a critical ingredient when implementing support for some of the higher-level network topologies described in Chapter 6.

Review Questions

1. What three key values should be in a packet replicating object state, other than the object’s serialized data?

2. Why is it undesirable for networking code to depend on gameplay code?

3. Explain how to support creation of replicated objects on the receiving host without giving the networking code a dependency on gameplay classes.

4. Implement a simple game with five moving game objects in it. Replicate those objects to a remote host by sending the remote host a world state packet 15 times a second.

5. Regarding the game in Question 4, what problem develops as the number of game objects increase? What is a solution to this problem?

6. Implement a system which supports sending updates of only some of an object’s properties to a remote host.

7. What is an RPC? What is an RMI? How are they different?

8. Using the chapter’s framework, implement an RPC SetPlayerName(const string& inName) which tells other hosts the local player’s name.

9. Implement a custom packet type that replicates which keys a player is currently holding down on the keyboard, using a reasonably efficient amount of bandwidth. Explain how to integrate this into this chapter’s replication framework.

Additional Readings

Carmack, J. (1996, August). Here Is the New Plan. Retrieved from http://fabiensanglard.net/quakeSource/johnc-log.aug.htm. Accessed September 12, 2015.

Srinivasan, R. (1995, August). RPC: Remote Procedure Call Protocol Specification Version 2. Retrieved from http://tools.ietf.org/html/rfc1831. Accessed September 12, 2015.

Van Waveren, J. M. P. (2006, March 6). The DOOM III Network Architecture. Retrieved from http://mrelusive.com/publications/papers/The-DOOM-III-Network-Architecture.pdf. Accessed September 12, 2015.

Winer, Dave (1999, June 15). XML-RPC Specification. Retrieved from http://xmlrpc.scripting.com/spec.html. Accessed September 12, 2015.