Torque/Networking/Ghosting

From TDN


Networked Torque Applications use the concept of object ghosts to handle much of the strategy involved in optimizing networked multiplayer games. In a nutshell, the server maintains the authoritative game state for every object in the simulation, and selectively networks objects to individual clients based on the concept of "only send the data the client requires". The server maintains a list of all objects that are important to each client (within the NetConnection class) which is maintained by utilizing that client's ControlObject as the focus for determining the objects that should be scoped to the client. This scope determination functionality is handled within the controlling object's onCameraScopeQuery() method.

During each network update cycle, the server iterates over the clients connected to the game, performs a rapid scope check based on the current controlling object, and then collects the appropriate information to be updated and builds an update packet. This update packet is then handed to the parent NetConnection for transmission to the client.

Contents

Determining Scope

The first point in the inheritence tree where we can start determining scope is in ShapeBase::onCameraScopeQuery. In stock TGE, scoping is based upon field of view, as determined by member variables set in the object as well as the visible distance for the mission. Once the visibility parameters have been determined, we call the SceneGraph::scopeScene method on the server to traverse the SceneGraph and mark each object that our control object can view for scoping. Keep in mind that while this is the top level of scoping, child objects can certainly re-implement ::onCameraScopeQuery(), and in stock 1.3 we do: In player.cc, the player class has it's own Player::onCameraScopeQuery(), and it does a few things prior to calling the parent in ShapeBase.

This technique works very well for FPS style games, because it's intuitively obvious that the only objects the player needs to be aware of on the client side are ones that are in it's immediate viewable area...objects on the other side of the map, or occluded by intervening occlusion objects cannot be immediately observed, and therefore do not need to have updates delivered. However, other gamestyles and genres may need completely different scoping rules. For example, here's a rough overview of how the RTS-SK handles scoping:

  • Determine the visible/not visible state of all units in the mission based on friendly unit locations and visibility ranges
  • construct a list of all visible units per client
  • scope the entire list of visible units to that client

As you can see, this is a completely different set of scoping rules: we cannot simply scope off of a single control object (in fact, the RTS-SK doesn't actually have a control object as we know them from TGE), so we've built additional functionality to handle our game specific needs.

Managing Ghost Objects

One of the biggest benefits other than simply minimizing the amount of update traffic we have to send across the connection that ghosting gives us is the security of having only the objects important to the client for gameplay experience actually in the client's simulation. What this does for us is it keeps clients from having "out of game" knowledge of the simulation's total game state because if they can't view an object based on the server's scoping rules, then the object is never transmitted. There are several "game hacker" techniques such as packet sniffing as well as modifying the client executable itself to over-ride any client side information restrictions, but if the client's computer is never given the information in the first place, then no matter what is hacked on the client side, they cannot gain undue advantage about the simulation.

Another "game hacker" tactic is a form of collusion where two players share the information about the simulation they have, and in some cases have a third hacked client that will form a total picture made up of what each of the clients knows. Torque handles this situation strongly as well, with the concept of GhostID's.

When a new object comes into scope for a specific client, the server assigns a GhostID to this object, and only tells the client what it's unique GhostID is. In other words, the client is never told the actual server ObjectID of the newly scoped object, but instead is only told what the GhostID is for just that client. This means that two clients may be observing the exact same object, but the server has given them two completely different GhostID's for the exact same object--so there is no easy way for two clients to collude and gain an advantage.

Since the server is the only side that has knowledge of both the GhostID and the actual ObjectID it is mapped for, the developer is responsible for managing any communcations between client and server that involve Object/GhostID's. The networked updates takes care of this automatically, but if you are using any RPC command functionality (commandToServer, commandToClient, etc.), then you will need to make sure that the ID numbers are in synch.

Within the source code, we have two functions for this purpose:

In script, we have three ConsoleMethods that do similar work for us, but deal only in ObjectIDs instead of pointers:

  • resolveGhostID--on the client, this console method returns an ObjectID that is associated with a GhostID sent from the server.
  • resolveObjectFromGhostIndex--on the server, this console method returns an ObjectID that is associated with a GhostID sent from a client.
  • getGhostID--on the server, this console method returns the GhostID associated with a server side simulation object for this connection


Ghosting Examples

How the update is marked for construction

Referring back to our general strategies for optimizing networked objects, two of our most critical issues are sending the minimum amount of data necessary, and having the ability to send partial object updates based on state changes. The first is handled by the BitStream class implementation, which is used extensively by the simulation level update tracking and management system within Torque.

The bitstream implementation allows us to logically group data subsets within an object into up to 32 separate condition states. We perform this grouping by having knowledge about how our object is going to interact with the environment, and more specifically what types of predictions we can make about the data within our object changing in response to simulation actions.

Think for a moment about how a player object within Torque operates: when it moves, it is very probable that at least 2, and probably 3 of the position coordinates have changed, and it is also at least somewhat probable that its velocity has changed as well. This becomes a logical grouping for one of our 32 update condition states, because we know that we can pack this information into a very small amount of bits and gain the most "bang for the buck" on our condition state change. Once we've performed strong design analysis on how our object is going to behave, we can make educated decisions about how to group our data so that we can both minimize our total bitstream size, and also stay within our 32 condition state limit.

Since we want to minimize not only our bandwidth for the networking aspect, but also our processing time as well, we use the concept of a BitMask that keeps track of our 32 condition states as they change during normal simulation processing. For each NetObject in our simulation we keep track of a bitmask called mDirtyMaskBits. At the beginning of each network processing tick, this bitmask is initialized to empty (a value of zero), in preparation for tracking any condition changes since the last time a network update was sent for this object.

Now, as the simulation processes, any time one of our 32 condition states is affected by any action, the developer is responsble for marking that condition state as being "dirty", or in need of a network update. With this system we can minimize processing overhead for our networked objects to the main simulation ticking loop, instead of having to have a separate loop that would have to check every NetObject in the simulation during each networking tick--that would be an extreme amount of additional processing time wasted.

In the case of positional movement, we have assigned a Mask (or flag) called MoveMask that is associated with the condition state we have grouped together that includes any changes in position, velocity, or rotation of the object. During our per tick processing of our object see the tick processing system, any time our code changes any of these values, it makes a call to NetObject::setMaskBits, which does two things:

  • sets the MoveMask bit on our object's mDirtyMaskBits
  • adds our object to the client's mDirtyList for later processing

Once this is done, the simulation continues on with normal processing of the rest of this object, and then proceeds with the rest of the objects in the entire simulation. At the end of processing for all of the objects, each one that has had a change that should be networked to the clients has been added to each of those client's dirty object list, and even more important: each object that has a change that needs to be propagated has the exact condition state that changed stored within it's own mDirtyMaskBits.

How the update is constructed

Torque not only processes objects at "tick frequency", but it also has an additional polling loop that operates at a slightly lower frequency for networking. Each time this polling loop is kicked off, it walks the mDirtyList for the simulation, and initiates calls to the member methods that handle the update itself--because only the object knows what has changed, since each NetObject has it's own mDirtyMaskBits.

For every client the loop iterates over each object in the mDirtyList, and the simulation calls the object's packUpdate() method. This is the method that is responsible for compressing into the bitstream each and every set of changed information for this object, and it does this by doing a series of checks on all associated dirtyBits.

For every bit in the mDirtyBits mask for the object, the packUpdate() function will write the condition state information that is associated for that mask, using the various BitStream methods appropriate for the type of data to be delivered. Once the object has stepped through each mask bit, the bitstream is sent back to the processing loop, and stored for placement into the Packet based on the presence of other types of networked data that may also be present during that network cycle.

(more to come)

We had some code running in TSShape::onAdd based on getName() and the properties seemed to be only available on the server object, not the client object. We tracked down the actual creation of the server/client objects in code:

Creation of Server and Client objects

compliedEval.cpp reads the "new ObjectType(ObjectName)" string from the cs/gui/mis in CodeBlock::exec line 399 (in our source 1.0.3 with some 1.7 changes merged.) (to create the server object)

 ConsoleObject *object = ConsoleObject::create(callArgv[1]);

line 461 assigns the name

 currentNewObject->assignName(callArgv[2]); (sets SimObject::objectName)

In netGhost.cpp GhostAlwaysObject::unpack, the client object gets created (the Ghost flag is also set here): line 85

 object = (NetObject *) ConsoleObject::create(ps->getNetClassGroup(), NetClassTypeObject, classId);

line 96 calls

 object->unpackUpdate(ps, bstream);


So if you want properties which are set (that you added to a class) on the server object to be available on the client object, you need to add write/read calls to your class's packUpdate/unpackUpdate.