Gamer Services - Multiplayer Game Programming: Architecting Networked Games (2016)

Multiplayer Game Programming: Architecting Networked Games (2016)

Chapter 12. Gamer Services

Most players today have profiles on services such as Steam, Xbox Live, or PlayStation Network. These services provide many features, to both the players and the games, including matchmaking, stats, achievements, leaderboards, cloud-based saves, and more. Because the use of these aptly named gamer services has become so prevalent, players expect that every game, even single-player ones, be integrated with one of these services in some meaningful way. This chapter takes a look at how such services can be integrated into your game.

Choosing a Gamer Service

With so many options, it is worthwhile to consider which gamer service you want to integrate into your game. In some cases, the choice is made for you based on the platform the game is released on. For example, all Xbox One games must be integrated with the Xbox Live gamer service—it’s simply not possible to integrate an Xbox One game with PlayStation Network. For PC, Mac, and Linux, however, there are several potential options. Without a doubt, the most popular service on these platforms today is Valve Software’s service, Steam. In existence for over 10 years, the Steam platform has a large install base with thousands of available games. Given that RoboCat RTS is a PC/Mac game, it made sense to integrate Steam into it.

There are a few prerequisites in order to integrate Steam into your game. First, you must agree to the terms of the Steamworks SDK Access Agreement. This agreement is available online at https://partner.steamgames.com/documentation/sdk_access_agreement. Next, you must register as a Steamworks partner, which involves signing further nondisclosure agreements as well as providing relevant information. Finally, you must get an app ID for your game. An app ID is only provided once you sign up to become a Steamworks partner and your game is greenlit to be offered on Steam.

However, when you complete the first step, agreeing to the Steamworks SDK Access Agreement, you are given access to the SDK files, documentation, and a sample game project (called SpaceWar!) that has its own app ID. For demonstration purposes, the code samples provided in this chapter utilize the app ID for SpaceWar. This is more than sufficient to understand how to integrate Steamworks into your game once you do complete all of the other steps and receive your own unique app ID.

Basic Setup

Before writing any code specific to a gamer service, consider how you want to integrate the code into your game. A quick option would be to directly add calls to the gamer service code wherever it is needed. So in our case, we would directly call the Steamworks SDK functions in all the files that need to use the gamer service. However, this is discouraged for a couple of reasons. First, this means that every developer on your team may need to have some level of familiarity with Steamworks, because the code using it will be spread throughout your codebase. Second, and more importantly, this makes it far more difficult to integrate a different gamer service into your game. This is particularly a concern for cross-platform games, because, as discussed, different platforms have different restrictions on which gamer service can be used. So even if we know thatRoboCat RTS is only on PC and Mac for now, if we ever wanted to port it to PlayStation 4, we’d want to make the transition from Steamworks to PlayStation Network as seamless as possible. Having Steamworks code everywhere is counter to this goal.

This leads to a major design decision for the implementation of gamer services in this chapter. The code in the GamerServices.h header makes no references to any Steamworks functions or objects, and thus does not need to include the steam_api.h header. One of the mechanisms used to accomplish this is the pointer to implementation construct, a C++ idiom used to hide the implementation details of a class. When using pointer to implementation, you have a class that contains both a forward declaration of an implementation class and a pointer to this implementation class. In this manner, the implementation details of the class are separated from its declaration. The basic components of pointer to implementation that are used in the GamerServices class is shown in Listing 12.1. Notice that the class uses a unique_ptr rather than a raw pointer, as this is the recommended approach in modern C++.

Listing 12.1 Pointer to Implementation in GamerServices.h


class GamerServices
{
public:
//lots of other stuff omitted
//...

//forward declaration
struct Impl;
private:
//pointer to implementation
std::unique_ptr<Impl> mImpl;
};


It’s important to note that the implementation class itself is never fully declared in the header. Instead, the details of the implementation class are declared in the object file—in this case GamerServicesSteam.cpp, and this is also where the mImpl pointer is initialized. This means that the only place any Steamworks API calls are made is in this single C++ file. In this way, if at any point we wanted to integrate Xbox Live, it would be possible to create another implementation of the GamerServices class in GamerServicesXbox.cpp. We would then add this new file to our project instead of the Steam implementation, and in theory no other code should have to change.

Although pointer to implementation is a powerful way to abstract away platform-specific details, there is a performance concern that bears mentioning, particularly for games. When using a pointer to implementation, it means that the vast majority of member function calls for the object will require an additional pointer dereference. Pointer dereferences have a cost associated with them. For a class that will have a very high number of member function calls, such as the render device, the performance decrease would be noticeable. However, in the case of the GamerServicesobject, we should not be making a particularly high number of calls per frame. So in this case, trading performance for flexibility is acceptable.

It should also be noted that the available functionality in the GamerServices object is a small subset of the overall Steamworks functionality. This is because it only includes wrappers for the functionality that was desired for RoboCat RTS—it would certainly be possible to add more to it. However, if you are adding significantly more features, it probably would be a good idea to separate the gamer services code into multiple files. For example, rather than having the handful of peer-to-peer networking functions directly in GamerServices, it might make sense to create aGamerServiceSocket class that has functionality similar to TCPSocket or UDPSocket.

Initialization, Running, and Shutdown

Steamworks is initialized by calling SteamAPI_Init. This function takes no parameters and returns a Boolean based on the success of the initialization. The code for this is in GamerServices::StaticInit. It’s noteworthy that the gamer services are initialized inEngine::StaticInit before the renderer is initialized. This is because one of the features Steam provides is an overlay. The overlay allows for the player to perform actions such as chat with friends or use a web browser without leaving their current game. The way this overlay works is by hooking into OpenGL functionality. This means that in order for the overlay rendering to work correctly, SteamAPI_Init must be called before any rendering initialization. If SteamAPI_Init succeeds, it will populate a series of global interface pointers. These pointers can then be accessed via global functions such as SteamUser, SteamUtils, and SteamFriends.

Normally, a game on Steam is launched through the Steam client. This is how Steamworks knows the app ID of the game being run. However, during development you won’t be launching your game through the Steam client—typically you will be launching through the debugger or as a standalone executable. In order to let Steamworks know the app ID during development, a steam_appid.txt file that contains the app ID is placed in the same directory as the executable. However, even though this removes the requirement of launching the game via the Steam client, an instance of the Steam client with a logged-in user must still be running. If you do not have the Steam client, you can get it from the Steam website at http://store.steampowered.com/about/.

Furthermore, in order to test multiple users playing against each other on Steam, you must create multiple test accounts. Testing locally is a bit more complicated than the Chapter 6 version of the game because it is not possible to run multiple instances of Steam on the same computer. So in order to test the multiplayer functionality for this chapter’s code, you will need to either use multiple computers or set up a virtual machine.

Since Steamworks often must communicate with a remote server, many of its function calls are asynchronous. In order to notify the application when the asynchronous call has finished, Steamworks utilizes callbacks. In order to ensure that the callbacks are triggered, the game must callSteamAPI_RunCallbacks on a regular basis. It is recommended this function is called once per frame, and so this is what is done in GamerServices::Update, which is called once per frame in Engine::DoFrame.

Similar to initialization, shutdown of Steamworks is very straightforward via the SteamAPI_Shutdown function. This is called in the destructor of GamerServices.

For client-server games, it is further necessary to initialize/shutdown game server code via SteamGameServer_Init and SteamGameServer_Shutdown. This requires also including steam_gameserver.h. Dedicated servers can be run in an anonymous mode that does not require a user to be logged in. However, since RoboCat RTS only uses peer-to-peer communication, the code for this chapter does not use any of the game server functionality.

User IDs and Names

In the earlier version of RoboCat RTS discussed in Chapter 6, player IDs were stored as unsigned 32-bit integers. You may recall that in this older version of the game, the player IDs were assigned by the master peer. When using a gamer service, each player would already have a unique player ID assigned by the service, so it makes little sense to try to assign unique IDs on your own. In the case of Steamworks, unique IDs are encapsulated by the CSteamID class. However, it would defeat the purpose of the modularization of the GamerServices class if CSteamIDs were used everywhere. Luckily, CSteamIDs can be converted to and from unsigned 64-bit integers.

So it follows that changing the player IDs to correspond to the Steam ID first required changing all player ID variables to be of type uint64_t. Furthermore, rather than having the player IDs be assigned by the master peer, the NetworkManager now initializes each player’s ID by querying the GamerServices object, specifically by calling the GetLocalPlayerId function in Listing 12.2.

Listing 12.2 Basic User ID and Name Functionality


uint64_t GamerServices::GetLocalPlayerId()
{
CSteamID myID = SteamUser()->GetSteamID();
return myID.ConvertToUint64();
}

string GamerServices::GetLocalPlayerName()
{
return string(SteamFriends()->GetPersonaName());
}
string GamerServices::GetRemotePlayerName(uint64_t inPlayerId)
{
return string(SteamFriends()->GetFriendPersonaName(inPlayerId));
}


Similar thin wrappers for getting the name of both the local player and another player are also in Listing 12.2. Instead of having the players specify their name, as in the old version of RoboCat, it makes more sense to use the name associated with the player on Steam.

It’s worth mentioning that although using 64-bit integers for the player ID works for Steamworks, there’s no guarantee that it would work for all gamer services. For example, another gamer service might use a 128-bit UUID to identify all the players. In this case, it would be necessary to add a further layer of abstraction. For example, you could create a GamerServiceID class that is a wrapper for the underlying representation used for identification by the gamer service.

Lobbies and Matchmaking

The earlier version of RoboCat RTS had a nontrivial amount of code associated with all the players meeting up in a pregame lobby. Each new peer had to first say hello to the master peer, then wait to be welcomed, before finally introducing themselves to all the other peers in the game. For this chapter, all the code related to this welcoming process was removed. The reason is that Steam, along with most major gamer services, provides its own lobby feature. Thus, it makes sense to leverage the Steam functionality, especially given that it has far more features than the functionality previously implemented in RoboCat.

The basic flow of preparing to play a multiplayer game via Steamworks is roughly as follows:

1. The game searches for a lobby based on application-customizable parameters. These parameters can include game modes or even skill level (if performing skill-based matchmaking).

2. If one or more suitable lobbies are found, the game either selects one automatically or the player is allowed to pick from a list. If no lobby is found, the game can choose to create one for the player. In any event, once a lobby is either found or created, the player joins the lobby.

3. While in the lobby, it’s possible to further configure the parameters of the upcoming game such as characters, map, and so on. During this period, other players will hopefully join the same lobby. It’s also possible to send chat messages to each other while in the same lobby.

4. Once the game is ready to start, the players join their game and leave the lobby. Normally, this involves connecting to a game server (either a dedicated server or a player-hosted one). In the case of RoboCat RTS, there is no server, so the players instead start communicating peer-to-peer with each other before leaving the lobby.

Since RoboCat has no menus or mode selection, the game begins a lobby search almost immediately after Steamworks is initialized. The lobby search is encapsulated by the LobbySearchAsync function shown in Listing 12.3. The only filter used is for the game name, which ensures that only lobbies for RoboCat are found. But any additional filters could be applied by calling the appropriate filter functions prior to the call to RequestLobbyList. Note that the code only asks for one result, because the game will simply auto-join the first lobby it finds.

Listing 12.3 Searching for a Lobby


const char* kGameName = "robocatrts";

void GamerServices::LobbySearchAsync()
{
//make sure it's Robo Cat RTS!
SteamMatchmaking()->AddRequestLobbyListStringFilter("game",
kGameName, k_ELobbyComparisonEqual);

//only need one result
SteamMatchmaking()->AddRequestLobbyListResultCountFilter(1);

SteamAPICall_t call = SteamMatchmaking()->RequestLobbyList();
mImpl->mLobbyMatchListResult.Set(call, mImpl.get(),
&Impl::OnLobbyMatchListCallback);
}


The use of the SteamAPICall_t struct in LobbySearchAsync requires a bit more explanation. In the Steamworks SDK, all asynchronous calls return a SteamAPICall_t struct, which essentially is a handle to the asynchronous call. Once given this handle, you must let Steamworks know what callback function to invoke when the asynchronous call completes. This association between an asynchronous handle and a callback is encapsulated by an instance of CCallResult. In this case, the instance is the mLobbyMatchListResult member of the implementation class. This member and the OnLobbyMatchListCallback functions are defined as follows inside GamerServices::Impl:

//Call result when we get a list of lobbies
CCallResult<Impl, LobbyMatchList_t> mLobbyMatchListResult;
void OnLobbyMatchListCallback(LobbyMatchList_t* inCallback, bool inIOFailure);

In this particular instance, the implementation of OnLobbyMatchListCallback has a couple of cases to consider, as shown in Listing 12.4. Note that we check for the IOfailure bool. All callbacks have this bool, and it should be assumed that if the value is true, there is an error and the callback should not proceed. However, if a lobby is successfully found, the code requests to enter that lobby. Otherwise, it will create a new lobby. Both of these cases involve an additional asynchronous function call as well, so there are two more callbacks to look at:OnLobbyEnteredCallback and OnLobbyCreateCallback. To see the implementation of these callbacks, consult the sample code. One important thing to note in these functions is that once the player enters a lobby, the NetworkManager is notified via an EnterLobbyfunction.

Listing 12.4 Callback When Lobby Search Completes


void GamerServices::Impl::OnLobbyMatchListCallback(LobbyMatchList_t* inCallback,
bool inIOFailure)
{
if(inIOFailure) {return;}

//if we find a lobby, enter, otherwise create one
if(inCallback->m_nLobbiesMatching > 0)
{
mLobbyId = SteamMatchmaking()->GetLobbyByIndex(0);
SteamAPICall_t call = SteamMatchmaking()->JoinLobby(mLobbyId);
mLobbyEnteredResult.Set(call, this, &Impl::OnLobbyEnteredCallback);
}
else
{
SteamAPICall_t call = SteamMatchmaking()->CreateLobby( k_ELobbyTypePublic,
4);
mLobbyCreateResult.Set(call, this, &Impl::OnLobbyCreateCallback);
}
}


The NetworkManager::EnterLobby function ends up not being particularly noteworthy, except that it does call another function in NetworkManager called UpdateLobbyPlayers. This UpdateLobbyPlayers function is called both when the player first enters the lobby, and whenever another player enters or leaves the lobby. This way, the NetworkManager can always be sure that it has an up-to-date list of all the players who are currently in the lobby. This is important, because with the removal of the introduction packets, it is the only way that peers can know when the players in the lobby change.

The way to ensure that UpdateLobbyPlayers is always called when the players in the lobby change is to use a general callback function. The difference between callbacks and call results is that call results are associated with a specific asynchronous call, whereas general callbacks are not. Thus, general callbacks can be seen as a way to register for notifications regarding a specific event. Conveniently, a callback is posted every time a user leaves or enters a lobby. For these general callbacks, you use a STEAM_CALLBACK macro inside the class that will respond to the callback. In this case, it’s the implementation class, and the macro looks like this:

//Callback when a user leaves/enters lobby
STEAM_CALLBACK(Impl, OnLobbyChatUpdate, LobbyChatUpdate_t,
mChatDataUpdateCallback);

This macro simplifies declaring the name of the callback function and the member variable that encapsulates the callback. This member variable needs to be instantiated in the initializer list of GameServices::Impl like so:

mChatDataUpdateCallback(this, &Impl::OnLobbyChatUpdate),

The implementation for OnLobbyChatUpdate then simply calls UpdateLobbyPlayers on the NetworkManager. Thus, every time a player enters or leaves the lobby, you can guarantee that UpdateLobbyPlayers gets called. Since UpdateLobbyPlayers also needs some way to grab a map containing the ID and name of every player in the game, the GamerServices class provides a GetLobbyPlayerMap function, shown in Listing 12.5.

Listing 12.5 Generating a Map of All the Players in a Lobby


void GamerServices::GetLobbyPlayerMap(uint64_t inLobbyId,
map< uint64_t, string >& outPlayerMap)
{
CSteamID myId = GetLocalPlayerId();
outPlayerMap.clear();
int count = GetLobbyNumPlayers(inLobbyId);
for(int i = 0; i < count; ++i)
{
CSteamID playerId = SteamMatchmaking()->
GetLobbyMemberByIndex(inLobbyId, i);
if(playerId == myId)
{
outPlayerMap.emplace(playerId.ConvertToUint64(),
GetLocalPlayerName());
}
else
{
outPlayerMap.emplace(playerId.ConvertToUint64(),
GetRemotePlayerName(playerId.ConvertToUint64()));
}
}
}


If you want to support player chat messages in the lobby, Steamworks provides a SetLobbyChatMsg function to transmit messages. Then there is a LobbyChatMsg_t callback that can be registered in order to be notified when new messages appear. Since RoboCat does not have any interface for chatting, the GamerServices class does not provide this functionality. However, it would not be too time consuming to add wrapper functions for chatting if you desire to support it.

Once the game is ready to start, for a client-server game you would use Steamworks function SetLobbyGameServer to associate a specific server with the lobby. This server can be associated either via IP address (for dedicated servers) or it can be associated with a Steam ID (for player-hosted servers). This then triggers a LobbyGameCreated_t callback to all the players that can be used to let them know it is time to connect to a server.

However, since RoboCat RTS is a peer-to-peer game, it does not utilize this server functionality. Instead, once the game is ready to start, there are three steps taken. First, the lobby is set to be no longer joinable, so no further players can join. Second, the peers begin communication with each other to synchronize the game start. Finally, once the game enters the playing state, everyone leaves. Once all players leave a Steam lobby, the lobby is automatically destroyed. The functions for setting the lobby to be unjoinable and leaving the lobby are declared in GamerServices asSetLobbyReady and LeaveLobby. These functions are very thin wrappers that each calls a single Steamworks function.

Networking

Many gamer services also provide a wrapper for networked communication between two users on the service. In the case of Steamworks, it provides a handful of functions to send packets to other players. The GamerServices class wraps some of these functions, as shown in Listing 12.6.

Listing 12.6 Peer-to-Peer Networking via Steamworks


bool GamerServices::SendP2PReliable(const OutputMemoryBitStream&
inOutputStream, uint64_t inToPlayer)
{
return SteamNetworking()->SendP2PPacket(inToPlayer,
inOutputStream.GetBufferPtr(),
inOutputStream.GetByteLength(),
k_EP2PSendReliable);
}

bool GamerServices::IsP2PPacketAvailable(uint32_t& outPacketSize)
{
return SteamNetworking()->IsP2PPacketAvailable(&outPacketSize);
}

uint32_t GamerServices::ReadP2PPacket(void* inToReceive, uint32_t inMaxLength,
uint64_t& outFromPlayer)
{
uint32_t packetSize;
CSteamID fromId;
SteamNetworking()->ReadP2PPacket(inToReceive, inMaxLength,
&packetSize, &fromId);
outFromPlayer = fromId.ConvertToUint64();
return packetSize;
}


You may notice that none of these networking functions refers to an IP or socket address. This is intentional, because Steamworks only allows you to send packets to a particular user via their Steam ID, not via IP address. The reason for this is twofold. First, it provides some amount of protection to each user because their IP address is never revealed to any other user on the service. Second, and perhaps more importantly, this allows Steam to completely handle the network address translation. Recall that in Chapter 6, one of the concerns of directly referencing a socket address was that the address may not be on the same network. However, by using the Steamworks networking calls, this issue is entirely handle by Steam. We request to send a packet to a particular user and Steam will attempt to send the data to this user via NAT punch-through, if possible. In the event that the NAT cannot be traversed, Steam will use a relay server as a fallback. This guarantees that if the destination user is connected to Steam, there will be some route for the packet to reach them.

As an added bonus, Steamworks also provides a couple of different modes of transmission. In the case of RoboCat RTS, all the communication for the turn information is critical, so all packets are sent reliably as noted by the k_EP2PSendReliable parameter. This mode allows and sends of up to 1 MB at a time, with automatic packet fragmentation and reassembly at the destination. However, it is also possible to request UDP-like communication via k_EP2PSendUnreliable. There are also modes to transmit unreliably assuming a connection is already established, and reliably that buffers via the Nagle algorithm.

The first time a packet is sent to a particular user via SendP2PPacket, it may take several seconds to be received. This is because the Steam service will take some time to negotiate the route between the source and the destination. Furthermore, when the destination receives a packet from a new user, the destination must accept the session request from the source. This is to disallow unwanted packets from a particular user. In order to accept a session request, a callback is fired every time a session request is received. Similarly, there’s another callback that’s fired when a session connection fails. The code RoboCat uses to handle both of these callbacks is shown in Listing 12.7.

Listing 12.7 Peer-to-Peer Session Callbacks


void GamerServices::Impl::OnP2PSessionRequest(P2PSessionRequest_t* inCallback)
{
CSteamID playerId = inCallback->m_steamIDRemote;
if(NetworkManager::sInstance->IsPlayerInGame(playerId.ConvertToUint64()))
{
SteamNetworking()->AcceptP2PSessionWithUser(playerId);
}
}

void GamerServices::Impl::OnP2PSessionFail(P2PSessionConnectFail_t* inCallback)
{
//we've lost this player, so let the network manager know
NetworkManager::sInstance->HandleConnectionReset(
inCallback->m_steamIDRemote.ConvertToUint64());
}


To account for the fact that the first packet sent to a peer takes some amount of time, the startup procedure for RoboCat was adjusted slightly. When the lobby owner/master peer is ready to start the game, they press the return key as before. However, rather than immediately starting the game countdown, the NetworkManager enters a new “ready” state. This ready state transmits a packet to all the other peers in the game. In turn, when a peer receives a ready packet, it transmits its own ready packet to all the other peers. This allows all the peers to establish sessions with each other before the game starts.

Once the master peer receives a ready packet from every peer in the game, it then enters the “starting” state and issues a start packet to all the peers, as before. The key observation is that without a ready state, there would not be any sessions established between the peers before the game starts. This would mean that the turn 0 packets would take several seconds to arrive, meaning that every player would end up in a delay state at the start of the game.

As for where this new networking code is used, the packet handling code in the NetworkManager was rewritten for this version of RoboCat. Rather than using the UDPSocket class as before, all packet handling is now done via the functions provided by the GamerServices class.

Player Statistics

A popular feature of gamer services is the ability to track various statistics. This way, it is possible to browse your or your friend’s profile to see what they have accomplished in various games. To support statistics like this, there typically is some way to query the server for the player’s statistics as well as a way to update and write new values to the server. Although it is conceivably possible to always read and write directly from the server, generally it is a good idea to cache the values locally in memory. This is the approach taken by the stats functionality implemented in the GamerServices class.

For a Steamworks game, the name and type of stats are defined for a particular app ID on the Steamworks partner site. Since the code for this chapter is using the SpaceWar app ID, this means that it is limited to using the stats that were defined for SpaceWar. However, the functionality provided would still work for any game’s set of stats, you would just have to change the stat definitions to match.

Steam supports three different types of stats. Integer and float stats are, unsurprisingly, integer and floating point values. The third type of stat is called an “average rate” stat. The way this stat works is it provides a sliding window average, with a configurable window size. When you retrieve an average rate stat from the server, you still only receive a single floating point value. However, when you update an average rate stat, you provide a value as well as a duration during which the value was achieved. Steam will then automatically compute for you a new sliding average. This way, it is possible for a stat such a “gold per hour” to still change noticeably as a player’s performance improves in the game, even when the player has logged many hours.

When defining the stats for a game on the Steamworks site, one of the properties assigned is the “API Name,” which is a string value. Then, all the SDK functions associated with getting and setting a particular stat require you to pass in the string corresponding to the stat. A simple approach would be to have the GamerServices functions related to stats simply taking in a string as a parameter. However, the problem with this is that it requires you to remember the exact API names for each stat, and there is always the potential for a typo. Furthermore, since there is a local cache of the stats, each query into the local cache would likely require some sort of hashing. Both these issues can be solved by instead using an enum to define all the possible stats.

One approach would be to define this enum and then separately define an array that contains the API names for each corresponding value in the enum. But the problem with this approach is that if the stats change, it means you now need to remember to update both the enum and the array of strings. There might even be a third place to edit if your game also uses a scripting language, because somewhere in the scripts there would be a redefinition of the same enums. Remembering to keep all three of these in sync is both error-prone and annoying.

Luckily, there is an interesting technique that can be employed, thanks to the C++ preprocessor. This technique, called an X macro, allows the stats to be defined in a single location. These definitions are then automatically reused wherever needed, which guarantees synchronization. This completely eliminates any potential for error when changing the stats supported by the game.

The first step to implementing an X macro is to create a definition file that defines each element as well as any additional properties of the element that are important. In this case, the definitions are placed in a separate Stats.def file. There are two pieces of data we care about for each stat: its name and the type associated with the stat. Thus, the definitions of the stats look something like this:

STAT(NumGames,INT)
STAT(FeetTraveled,FLOAT)
STAT(AverageSpeed,AVGRATE)

Next, in GamerServices.h, there are two definitions of enums related to stats. One of the enums, StatType, is nothing special. It just defines the three INT, FLOAT, and AVGRATE types of stats that are supported. The other enum, Stat, is much more complex because it uses the X macro technique. Thus, it is shown in Listing 12.8.

Listing 12.8 Declaring the Stat Enum via X Macro


enum Stat
{
#define STAT(a,b) Stat_##a,
#include "Stats.def"
#undef STAT
MAX_STAT
};


The code first defines a macro called STAT that takes two parameters. Notice that this corresponds to the number of parameters each entry in Stats.def contains. In this case, the macro completely ignores the second parameter. This is because the type of the stat does not matter for this particular enum. It then uses the ## operator to concatenate the characters of the first parameter with the prefix of Stat_. Next, we include Stats.def which will, in essence, copy and paste the contents of Stats.def into the enum’s declaration. Since STAT is now defined as a macro, it will be replaced by the evaluation of the macro. So for example, the first element of the enum will be defined as Stat_NumGames, because that is what the macro STAT(NumGames,INT) evaluates to.

Finally, the STAT macro is undefined, and the last element of the enum is defined as MAX_STAT. So the X macro trick not only defines every member of the enum to correspond to a stat definition in Stats.def, it also yields the total number of stats that have been defined.

What makes the X macro so powerful is that the same idiom can be reused anywhere the list of stats is needed. This way, whenever Stats.def is modified, a simple recompile of the code will perform macro magic and update all the code that depends on it. Furthermore, becauseStats.def is a fairly simple file, it could also easily be parsed by a scripting language, should your game use one.

An X macro is used once more when it is time to declare the array of the stats in the implementation file. First, there is a StatData structure that represents the locally cached values associated with each stat. To simplify things, each StatData has elements for an integer, float, or average rate statistic. This is shown in Listing 12.9.

Listing 12.9 StatData Structure


struct StatData
{
const char* Name;
GamerServices::StatType Type;

int IntStat = 0;
float FloatStat = 0.0f;
struct
{
float SessionValue = 0.0f;
float SessionLength = 0.0f;
} AvgRateStat;

StatData(const char* inName, GamerServices::StatType inType):
Name(inName),
Type(inType)
{ }
};


Next, the GamerServices::Impl class has a member array declared as follows:

std::array<StatData, MAX_STAT> mStatArray;

Notice how the definition of the array takes in MAX_STAT, an automatically updated value, as the number of elements it should contain.

Finally, the X macro comes into play during the initializer list of GamerServices::Impl. It is used to construct each StatData element of mStatArray, as shown in Listing 12.10.

Listing 12.10 Initializing mStatArray via X Macro


mStatArray({
#define STAT(a,b) StatData(#a, StatType::##b),
#include "Stats.def"
#undef STAT
} ),


For this second X macro, both elements of the STAT macro are used. The first element is converted into a string literal via the # operator, and the second element corresponds to an element of the StatType enum. So for example, the STAT(NumGames,INT) definition would conveniently evaluate to the following StatData instantiation:

StatData("NumGames", StatType::INT),

The X macro technique is also used for the definitions of the achievements and the leaderboards, since both of those are also instances where multiple values need to stay synchronized in multiple places. That being said, even though this is a powerful technique, it should not be overused as it does not result in particularly readable code. However, it is certainly a useful tool to have in your tool belt for situations like this where it is helpful.

With the X macro implemented, the rest of the stats code falls into place relatively easily. GamerServices has a protected function called RetrieveStatsAsync that is called when the GamerServices object initializes. When the stats are received, Steamworks issues a callback. Both of these are in Listing 12.11. Notice how the code for OnStatsReceived does not hardcode the stats in anyway—it uses the information stored in the mStatsArray, which was auto-generated by the X macro. Also, for debugging purposes, the code logs out the values of the stats when they are first loaded.

Listing 12.11 Retrieving Stats from the Steam Server


void GamerServices::RetrieveStatsAsync()
{
SteamUserStats()->RequestCurrentStats();
}

void GamerServices::Impl::OnStatsReceived(UserStatsReceived_t* inCallback)
{
LOG("Stats loaded from server...");
mAreStatsReady = true;
if(inCallback->m_nGameID == mGameId && inCallback->m_eResult == k_EResultOK)
{
//load stats
for(int i = 0; i < MAX_STAT; ++i)
{
StatData& stat = mStatArray[i];
if(stat.Type == StatType::INT)
{
SteamUserStats()->GetStat(stat.Name, &stat.IntStat);
LOG("Stat %s = %d", stat.Name, stat.IntStat);
}
else
{
//when we get average rate, we still only get one float
SteamUserStats()->GetStat(stat.Name, &stat.FloatStat);
LOG("Stat %s = %f", stat.Name, stat.FloatStat );
}
}

//load achievements
//...
}
}


The GamerServices class also provides functions to get and update stat values. When a get function is called, the value is immediately returned from the locally cached copy. When a function to update the stat value is called, it will update the locally cached copy and also issue an update request to the server. This ensures that the server and the local cache stay synchronized. The code for GetStatInt and AddToStat for integers is shown in Listing 12.12. The code for float and average rate stats is rather similar, though as previously mentioned, the average rate stat updates with two values.

Listing 12.12 GetStatInt and AddToStat Functions


int GamerServices::GetStatInt(Stat inStat)
{
if(!mImpl->mAreStatsReady)
{
LOG("Stats ERROR: Stats not ready yet");
return -1;
}

StatData& stat = mImpl->mStatArray[inStat];
if(stat.Type != StatType::INT)
{
LOG("Stats ERROR: %s is not an integer stat", stat.Name);
return -1;
}
return stat.IntStat;
}

void GamerServices::AddToStat(Stat inStat, int inInc)
{
//Check if stats are ready
//...
StatData& stat = mImpl->mStatArray[inStat ];
//Check if is integer stat
//...
stat.IntStat += inInc;
SteamUserStats()->SetStat(stat.Name, stat.IntStat );
}


RoboCat RTS currently uses the stats to track the number of enemy cats destroyed, as well as the number of friendly cats lost. The code that updates the stats is in RoboCat.cpp. This sort of approach where the stat updating code is called wherever necessary is fairly common in games that track stats.

Player Achievements

Another popular feature of gamer services is achievements. These are awarded to players after accomplishing certain feats during the course of a game. Some examples of achievements include one-time events such as defeating a particular boss or winning the game on a certain difficulty. Other achievements are given as a stat accrues over time—for example, an achievement for winning 100 matches. Some dedicated players enjoy achievements so much that they try to unlock everyone.

In Steam, achievements are treated in a similar manner as stats. The set of achievements for a particular game is defined on the Steamworks site, and so as with the stats, RoboCat is limited to the set of achievements associated with SpaceWar. As with stats, the code for achievements uses X macros. The achievements are defined in Achieve.def, and a corresponding Achievement enum is derived from this. There also is an AchieveData struct and an array of said structs called mAchieveArray.

The RequestCurrentStats function also grabs the current achievement information from Steam. This means that when the OnStatsReceived callback is triggered, the achievement data can also be locally cached. These achievements are copied with a small loop that callsGetAchievement to get the Boolean value signifying whether or not the achievement is unlocked:

for(int i = 0; i < MAX_ACHIEVEMENT; ++i)
{
AchieveData& ach = mAchieveArray[i];
SteamUserStats()->GetAchievement(ach.Name, &ach.Unlocked);
LOG("Achievement %s = %d", ach.Name, ach.Unlocked);
}

Next, there are some fairly simple wrappers for determining whether an achievement is unlocked and actually unlocking an achievement. As was the case with the stats, checking for an unlocked achievement uses the local cache, whereas the function that unlocks the achievement both updates the cache and immediately writes it to the server. This code is shown in Listing 12.13.

Listing 12.13 Checking for and Unlocking Achievements


bool GamerServices::IsAchievementUnlocked(Achievement inAch)
{
//Check if stats are ready
//...
return mImpl->mAchieveArray[inAch].Unlocked;
}

void GamerServices::UnlockAchievement(Achievement inAch)
{
//Check if stats are ready
//...
AchieveData& ach = mImpl->mAchieveArray[inAch];
//ignore if already unlocked
if(ach.Unlocked) {return;}

SteamUserStats()->SetAchievement(ach.Name);
ach.Unlocked = true;
LOG("Unlocking achievement %s", ach.Name);
}


As for when achievements should be unlocked, generally it’s a good idea to unlock the achievement soon after it is earned. Otherwise, a player may get confused when they meet the conditions to unlock an achievement, but it doesn’t unlock. That being said, for a multiplayer game it may be a good idea to queue up the achievements to be unlocked at the end of the match. This way, the player doesn’t potentially get distracted by a UI notification for the achievement.

Since the tracked achievements in RoboCat RTS are based on achieving a certain number of kills in game, code to track achievement progress was added in the TryAdvanceTurn function in NetworkManager. This way, at the end of each turn the game will check whether or not the player has unlocked an achievement.

Leaderboards

Leaderboards are a way to provide rankings for certain aspects of a game, for example, a score or time to complete a particular level. Generally, leaderboard rankings can be browsed both in terms of a global rank as well as ranks relative to your friends on the gamer service. For leaderboards on Steam, they can either be created via the Steamworks website, or they can be created programmatically via an SDK call.

As with stats and achievements, the GamerServices implementation uses an X macro to define the enum of leaderboards. In this case, the leaderboards are defined in Leaderboards.def. Each entry in this file contains the name of the leaderboard, how the leaderboard should be sorted, and how the leaderboard values should be displayed when viewed on Steam.

The code for retrieving the leaderboards is a bit different than the code for stats or achievements. First, it is only possible to find one leaderboard at a time. When the leaderboard is found, it triggers a call result. So if you want your game to find all the leaderboards in sequence, the call result’s code should request a find for the next leaderboard, and repeat this process until all leaderboards are found. This is shown in Listing 12.14.

Listing 12.14 Finding All the Leaderboards


void GamerServices::RetrieveLeaderboardsAsync()
{
FindLeaderboardAsync(static_cast<Leaderboard>(0));
}

void GamerServices::FindLeaderboardAsync(Leaderboard inLead)
{
mImpl->mCurrentLeaderFind = inLead;
LeaderboardData& lead = mImpl->mLeaderArray[inLead];
SteamAPICall_t call = SteamUserStats()->FindOrCreateLeaderboard(lead.Name,
lead.SortMethod, lead.DisplayType);
mImpl->mLeaderFindResult.Set(call, mImpl.get(),
&Impl::OnLeaderFindCallback);
}

void GamerServices::Impl::OnLeaderFindCallback(
LeaderboardFindResult_t* inCallback, bool inIOFailure)
{
if(!inIOFailure && inCallback->m_bLeaderboardFound)
{
mLeaderArray[mCurrentLeaderFind].Handle =
inCallback->m_hSteamLeaderboard;
//load the next one
mCurrentLeaderFind++;
if(mCurrentLeaderFind != MAX_LEADERBOARD)
{
GamerServices::sInstance->FindLeaderboardAsync(
static_cast<Leaderboard>(mCurrentLeaderFind));
}
else
{
mAreLeadersReady = true;
}
}
}


The other thing that’s different is that finding the leaderboard doesn’t download any of the entries from the leaderboard. Instead, it simply gives you a handle to the leaderboard. If you want to download the entries from a leaderboard for display, you provide the handle and the parameters of your download (global, friends only, etc.) to the DownloadLeaderboardEntries function in the Steamworks SDK. This will then trigger a call result when the leaderboard entries have downloaded, at which point you can display the leaderboards. A similar process is used for uploading leaderboard scores, via the UploadLeaderboardScore function. Code using these two functions can be found in GamerServicesSteam.cpp.

Since RoboCat doesn’t contain a user interface to display the leaderboard, to verify the leaderboard functionality, there are a couple of debug commands. Pressing F10 will upload your current kill count to the leaderboard, and pressing F11 will download the global kill count leaderboard, centered on your current global rank. On a related note, pressing F9 will also reset all the achievements and stats associated with the app ID (in this case, SpaceWar).

One cool aspect of leaderboards on Steam is that it is possible to upload user-generated content associated with a leaderboard entry. For example, a quick run through a level could have an associated screenshot or video showing the run. Alternatively, a racing game could have a ghost that players could download to race against. This allows for ways to make the leaderboards more interactive than simply listing top scores.

Other Services

Although this chapter has covered many different aspects of the Steamworks SDK, there still is much more available. There’s cloud storage that allows users to synchronize their saves across multiple computers. There’s support for a text entry UI for playing in “Big Picture Mode,” that’s designed for users with only a controller. There’s also support for microtransactions and downloadable content (DLC).

There also are many other gamer service options in use today. PlayStation Network works on the PlayStation family of devices such as PlayStation consoles, PlayStation Vita, and PlayStation mobile phones. Xbox Live has historically been designed for the Xbox consoles, but with Windows 10, it is also available on PC. Other services include Apple’s Game Center for Mac/iOS games and the Googles Play Games Services, which work on both Android and iOS devices.

Gamer services sometimes have features specific to them. For example, Xbox Live supports the idea of parties persisting between different games, and the idea that an entire party can start a new game together. Also, on the consoles it’s very common to have standardized user interfaces provided via the gamer service. So for example, choosing a save location on the Xbox must always use a specific UI that’s provided via a gamer service call.

The concept of what a gamer service should provide is constantly evolving over time. Players will expect these features to be integrated with the latest and greatest games, so whichever gamer service you choose, it is important to spend some time thinking about how best you can leverage the service to improve the experience for your players.

Summary

Gamer services provide a wide range of features for players. Some gamer services are tied to a specific platform, but on a platform such as PC, there are many possible choices. Arguably the most popular gamer service for PC, Mac, and Linux is Steam, and this was the service that was integrated throughout this chapter.

One important decision when adding gamer service code is to devise a method to modularize the code specific to a particular gamer service. This is important because a future port on a different platform may not support the first gamer service you add to your codebase. One way to accomplish this is via the pointer to implementation idiom.

Matchmaking is an important feature provided by most gamer services. This allows users to meet up with each other in order to play a game. In the case of Steamworks, the players first search for and join a lobby. Once the game is ready to start, the players connect to a server (if client-server), or begin communicating with each other (if peer-to-peer) prior to leaving the lobby.

Gamer services also commonly provide a mechanism to send packets of data to other users. This is both to protect users from having their IP addresses revealed, and to allow for the gamer service to perform any necessary NAT punch-through or relaying. In the case of RoboCat RTS, the networking code was changed to solely use the Steamworks SDK for sending data. As a bonus, the SDK provides a reliable method of communication. Because the first packet sent to a user has some amount of delay for the session to be established, the startup procedure for RoboCat was modified so that peers begin communicating with each other in a “ready” state prior to beginning the game countdown.

Other common features in a gamer service include statistics tracking, achievements, and leaderboards. The GamerServices class’ implementation of statistics involved declaring all the possible statistics in an external Stats.def file. This information then was used in multiple spots via an X macro, in order to ensure that an enum and an array containing the stats information remained synchronized. A similar approach was used for the implementation of both achievements and leaderboards.

Review Questions

1. Describe the pointer to implementation idiom. What advantages does it provide? What are its disadvantages?

2. What purpose does a callback serve in Steamworks?

3. Roughly describe the lobby and matchmaking procedure used by Steamworks.

4. What are the advantages of networking provided by the gamer service?

5. Describe how the X macro technique works. What benefits and drawbacks does it have?

6. Implement a GamerServiceID class, and use this as a wrapper for a Steam ID. Change every reference to a uint64_t player ID value to use this new class.

7. Implement a GamerServicesSocket class, in the vein of the UDPSocket class, which internally uses the Steamworks SDK to send data. Be sure to provide the ability to specify the reliability of communication. Change the NetworkManager to use this new class.

8. Implement a menu that displays the stats for the current user. Now implement a leaderboard browser.

Additional Readings

Apple, Inc. “Game Center for Developers.” Apple Developer. https://developer.apple.com/game-center/. Accessed September 14, 2015.

Google. “Play Games Services.” Google Developers. https://developers.google.com/games/services/. Accessed September 14, 2015.

Microsoft Corporation. “Developing Games – Xbox One and Windows 10.” Microsoft Xbox. http://www.xbox.com/en-us/Developers/. Accessed September 14, 2015.

Sony Computer Entertainment America. “Develop.” PlayStation Developer. https://www.playstation.com/en-us/develop/. Accessed September 14, 2015.

Valve Software. “Steamworks.” Steamworks. https://partner.steamgames.com/. Accessed September 14, 2015.