The Case for Message Passing Architectures Dave Weinstein Red Storm Entertainment Ubisoft Introduction Message passing architectures have been used in game development for over a decade, and while many developers assume that networking needs drive this style, it is my contention that the benefits in terms of overall code clarity, and reduction of critical path dependencies make message passing architectures useful even for single player games. Message Passing for Network Traffic Message passing architectures lend themselves particularly well to network games. One of the biggest risks for any multiplayer game is the degree to which the single player component and the multiplayer component differ. The more the code paths diverge, the more likely it is that fixes in one area will break another, and the less benefit either gets from QA testing. Message Passing for Network Traffic: Dividing Logical Tasks In single player games, it has been common for the code that determines whether an action is desired, whether an action is valid, and what the results are to be intertwined. These are three discreet steps that any networked game requires separated, and message passing architectures are no different. However, if the game messages are divided into request, confirm, and report steps, the code path of the single player game and the code path of the multiplayer game can be almost identical. Message Passing for Network Traffic: Fail Soft Solutions If game flow is broken up into multiplayer friendly discreet steps, however, multiplayer gameplay for new features becomes almost automatic. A generic “fail soft” enables any new feature to work by default in a multiplayer environment, even if the network traffic involved is higher than optimal. By keeping the networking engineers from having to play catch-up with the rest of the engineering team, the amount of time that new features during development breaks the multiplayer component is greatly reduced. Message Passing for Network Traffic: Optimizations The generic case for any message (regardless of implementation) will almost always be less efficient in terms of bandwidth than a specialized version. The more that can be assumed about the contents of a message, the tighter the actual network traffic can be. Message Passing for Network Traffic: Example, Tom Clancy’s Ghost Recon void IkeMsg_GunshotMissed::WriteBin( std::ostream& outStream )const { ASSERT( mGameMessage ); mGameMessage->WritePayloadWithoutType( 0, outStream ); // GameObjectID mGameMessage->WritePayloadWithoutType( 1, outStream ); // InventoryItemIndex // compress direction vector RSFloat2* compVec = RSFloat2::Create(); RSPayloadUtils::CompressUnitVector( (RSFloat3*)mGameMessage->GetPayloadObject( 2 ), compVec ); compVec->WriteBin( outStream ); compVec->Release(); } Source, Tom Clancy’s Ghost Recon, copyright Red Storm Entertainment/Ubisoft, used with permission Message Passing as Abstraction Layer With the introduction of Object Oriented programming methodologies, developers became used to deferring the actual implementation of a logical concept. Message passing architectures allow us to use the same basic principles for the flow of the application. Message Passing as Abstraction Layer: Genericity Example: C++ object oriented application pUnit = gObjectManager.GetObject( unitID ); if( pUnit ) { pUnit->ApplyDamage( damageAmount ); } Example: Message passing interface Send( kMsgID_UnitDamaged, unitID, damageAmount ); Message Passing as Abstraction Layer: Genericity Note that these two approaches are not contradictory. It would be entirely reasonable to see the object oriented approach as the implementation of the response to the damage message at the simulation level. Message Passing as Abstraction Layer: Genericity The message passing system works, even in a single player architecture, for the same reasons that we use virtual function interfaces at the class level. It provides a generic interface that provides a significant abstraction layer between modules, and from a project management standpoint, between developers. Message Passing as Abstraction Layer In the following example source code, taken from Force 21, the User Interface is handling the damage message, and using the information in it to adjust the display. Message Passing as Abstraction Layer: Example, Force 21 case kDamageMsg: { for( int i=0; i < Unit::kMaxVehicles; i++ ) { SetVehicleDamage( i ); } // Find unit damaged and blink that button. GameObject* obj = NULL; if ( target.Type() == MessageArg::kMAObject ) { obj = target; } else if ( target.Type() == MessageArg::kMAUnsignedLong ) { obj = theGame.mObjDB->FindObject( ObjId(target) ); } if ( obj && ISA(obj, Actor) ) { ObjId unitId = ((Actor*)obj)->GetUnitId(); Unit *pU = theGame.mObjDB->FindUnit( unitId ); Force* force = theGame.mRulesEngine->GetForce( theGame.mRulesEngine->WhoDoIControl() ); if ( pU && force && unitId == force->mUnits[ (int)pU->GetForceIndex() ] ) { if ( pU == theGame.mArbiter->GetDisplayedUnit() ) { int damagedVehicle = (int)pU->GetVehicleIndex( obj->GetId() ); if ( mVehicles[ damagedVehicle ] ) mVehicles[ damagedVehicle ]->Blink(); } } } Source, Force 21, copyright Red Storm Entertainment/Ubisoft, used with permission Message Passing as Abstraction Layer By using messages as a generic abstract interface between modules, the architecture allows for: • Removal of other modules from the critical path • Clean separation of implementation from semantics • Easier integration of automated testbeds Mechanisms of Message Passing Message passing mechanisms vary, ranging from a limited number of arguments (possibly with type information inferred entirely from the message type), to flexible and extensible systems with an almost unlimited set of possible arguments (including the ability to change type from message call to message call). Mechanisms of Message Passing: Example, Tom Clancy’s Ghost Recon // compose and send a message to the server newMsg = RSGameMessage::Create( kMsgID_GunshotMissed ); newMsg->AddPayload( id ); newMsg->AddPayload( index ); newMsg->AddPayload( direction ); // game object id // inventory item that fired // direction for tracer IRSGameMessageMgr::SendMaster( kSystemChannel_Sim, newMsg ); newMsg->Release(); // note: we have to release it since we 'Create' it here Source, Tom Clancy’s Ghost Recon, copyright Red Storm Entertainment/Ubisoft, used with permission Mechanisms of Message Passing: Example, Force 21 void ClientRouter::SendMessage( F21MessageTarget targetType, const MessageArg& target, F21MessageType message, const MessageArg& arg1, const MessageArg& arg2 ) { // Now, handle anything for which we do default networking if( ShouldSendToNetwork( targetType, target, message, arg1, arg2 ) ) { TransmitMessage( targetType, target, message, arg1, arg2 ); } // And finally, do any processing which we need to do on this // machine. if( ShouldSendToRecipient( targetType, target, message ) ) { ProcessMessage( targetType, target, message, arg1, arg2 ); } } Source, Force 21, copyright Red Storm Entertainment/Ubisoft, used with permission Mechanisms of Message Passing: Tradeoffs Rules based systems like Force 21 provide a simple and effective mechanism, and guarantee that the same message is always handled in the same way. Use based systems like Tom Clancy’s Ghost Recon allow for more flexibility in how a message is used, and allow for the reuse of the same message, at the cost of increased code complexity and the risk of human error. Mechanisms of Message Passing: Tradeoffs Fixed argument systems allow for a simpler calling convention and for smaller base cases, subject to the limitations of the fixed number of arguments. Variable argument systems increase the complexity of the calling convention (although varargs like helpers can help mask this), and require more complex message handlers. Summary: Message Passing Architecture Benefits • Basic networking as the normal case, rather than a special case • Journaling/Replays as the normal case, rather than a special case • Obvious integration point for unit testing or automated test beds • Reduction of critical-path conflicts and inter-module dependencies Message Passing Architectures Risks: Performance • There is no such thing as an “inline message”. There is an overhead to any message traffic. • Increased function calls result in greater stack depth and complexity • Optimal network usage will require special cases for the most used messages, however, once those cases exist, any change to a message needs to be duplicated in the optimized version. Message Passing Architectures Risks: Team Management • Team understanding and “buy-in”. If the team does not understand the message passing heuristics, or is not convinced of the efficacy, the result will likely be code that is only partly message based, resulting in the worst of both worlds. Message Passing Architectures Risks: Team Management • Lack of documented heuristics on what should be sent as a message, how messages should be broken up, and, depending on the architecture, what options for sending messages are valid when. Many of the strengths of the system are lost if it is used inconsistently, and bugs will result if it is used improperly. Message Passing Architectures Risks: Engineering • Sending a message should be as lightweight as a function call to the programmer. The more difficult it is to construct and send a message, the more likely it is that an engineer will construct helper functions to build the message, obscuring the use of messages and making the code more difficult to follow. Message Passing Architectures Risks: Engineering • Over-engineering can be a significant risk. If there are too many possible options, especially if they are incompletely understood by the team, it can cause confusion and result in the wrong options being used (or more likely, an engineer using the options they are familiar with regardless of how appropriate the option is for the task at hand) Message Passing Architectures Risks: Engineering • Because messages act as virtualized functions, it is often necessary for an engineer to search the code base to find all the places that use a message to determine the full semantics of the message. Message Passing Architectures Risks: Engineering • The order in which different components react to messages may need to vary based on the message. This presents risks as to timing issues on a message by message basis, and can remove many of the benefits of message passing systems as far as removing dependencies on other modules, depending on how message priorities are handled. Message Passing Architectures: Next Generation Based on the categorized risks, and in order to maximize the benefits we get from moving to a message passing system, we can set some requirements for what we want out of the next generation of message passing systems. Message Passing Architectures: Next Generation Requirements • The full semantics of a message, including information on message payload, and the order of processing, must be in a single location, such that any use of the message will always use the same logic. • Heuristics for what should be sent as a message, how messages should be broken up, and how the rules for messages should be determined must be set and documented at the project level. Message Passing Architectures: Next Generation Requirements • Searchable documentation should be kept synchronized with the source code, to enable any engineer to determine how a message should be used and why. • Enforcement of message semantics should be performed, as much as possible, by the compiler, and otherwise at run time by the code, rather than being enforced by manual programming. Message Passing Architectures: Next Generation Requirements • Sending a message must be as simple as a normal function call. • The engineer using a message must be able to assume that the message definition and messaging engine will determine how the message should be sent and handled, regardless of single player or multiplayer game mode. Message Passing Architectures: The Next Generation One approach to meeting the requirements of a next generation system is to draw heavily from Bertrand Meyer’s DesignByContract™, and apply that model to our messaging core. Additionally, we know that it is highly unlikely, even if messages were fully documented before creation, that the documentation will be manually kept in sync throughout the development life of a project. To meet our documentation requirements, we will want to draw off the source/view model used in Eiffel. Message Passing Architectures: The Next Generation What follows is a sketch of what a next generation message definition could look like. There is currently no code to take the hypothetical .m file and turn it into the combination of source code, documentation, and database needed, nor is there a messaging engine to use that information. The example, however, is real. The sequence of a claiming a gunshot, displaying a gunshot, and reporting the effects, is drawn from the way in which Red Storm has traditionally built tactical shooters. Message Passing Architectures: RequestFireGunshot.m // A human (either AI or player) has requested to fire their gun Message: RequestFireGunshot Descriptors: Combat, Simulation, Action, Request, Input, AI Modes: Action::Action Order: Simulation > DontCare Payload: UInt32 mShooterID is ActorID UInt8 mWeaponID is WeaponIndex IsValid: Reception: { return false; } IsNotValid: Processing: { return( !SystemRulesManager::IsOwnedByLocalSystem( mShooterID ) ); } Perform: Transmission: { return false; } Message Passing Architectures: ClaimGunshot.m // Claim a gunshot, and associated results. Message: ClaimGunshot Descriptors: Combat, Simulation, Action, Claim Modes: Action::Action Order: Simulation > DontCare Payload: UInt32 mShooterID is ActorID UInt8 mWeaponID is WeaponIndex ImpactType mImpactType is [kImpactNone,kImpactHuman,kImpactObject,kImpactWorld] UInt32 is ActorID if ((mImpactType == kImpactHuman) || (mImpactType == kImpactObject)) mImpactID otherwise is ObjectManager::kInvalidActorID Vector3 mImpactPoint is WorldCoordinate if (mImpactType == kImpactWorld) is LocalCoordinate if ((mImpactType == kImpactHuman) || (mImpactType == kImpactObject)) otherwise is NullCoordinate Vector3 mImpactNormal is NullVector if (mImpactType == kImpactNone) otherwise is NormalizedVector IsValid: Reception: { return( SystemRulesManager::IsOwnedBySystem( SenderID(), mShooterID ); } IsNotValid: Reception: { return( !SystemRulesManager::IsMaster() ); } Perform: Transmission: { return( SystemRulesManager::IsOwnedByLocalSystem( mShooterID ) ); } Processing: { return( SystemRulesManager::IsMaster() ); } { return( SystemRulesManager::IsOwnedByLocalSystem( mShooterID ) ); } DoNotPerform: Transmission: { return( SystemRulesManager::IsMaster() ); } Message Passing Architectures: ReportGunshot.m // Report that a gunshot has been fired by the specified player with the specified weapon. Message: ReportGunshot Descriptors: Combat, Simulation, Action, Report Modes: Action::Action Order: Simulation > DontCare > Sound > UI Payload: UInt32 mShooterID is ActorID UInt32 mWeaponID is WeaponIndex Valid: Reception: { return( SenderID() == SystemRulesManager::kMasterSystemID ); } IsNotValid: Reception: { return( SystemRulesManager::IsOwnedByLocalSystem( mShooterID ) ); } { return( SystemRulesManager::IsMaster() ); } Perform: Transmission: { return( SystemRulesManager::IsMaster(); } Processing: { return( SenderID() == SystemRulesManager::kMasterSystemID ); } { return( SystemRulesManager::IsOwnedByLocalSystem( mShooterID ) ); } DoNotPerform: Transmission: { return( SystemRulesManager::IsOwnedBySystem( RecipientID(), mShooterID ); } Message Passing Architectures: ReportGunshotHitWorld.m // Report a bullet impact with the world geometry. Message: ReportGunshotHitWorld Descriptors: Combat, Simulation, Action, Report Modes: Action::Action Order: DontCare > Sound > UI Payload: WeaponClass mWeaponClass Vector3 mImpactPoint is WorldCoordinate Vector3 mImpactNormal is NormalizedVector IsValid: Reception: { return( SenderID() == SystemRulesManager::kMasterSystemID ); } Perform: Transmission: { return( SystemRulesManager::IsMaster(); } Processing: { return( SenderID() == SystemRulesManager::kMasterSystemID ); } Message Passing Architectures: Example Uses void Simulation::FireWeapon( const SimHuman& shooter ) { ... [Combat calculations] ... switch( impactType ) { case kImpactNone: Send( ClaimGunshotMsg( shooter.GetID(), shooter.GetActiveWeaponIndex(), impactType ) ); break; case kImpactHuman: case kImpactObject: Send( ClaimGunshotMsg( shooter.GetID(), shooter.GetActiveWeaponIndex(), impactType, impactObject, impactPoint, impactNormal ) ); break; case kImpactWorld: Send( ClaimGunshotMsg( shooter.GetID(), shooter.GetActiveWeaponIndex(), impactType, impactPoint, impactNormal ) ); break; default ASSERT( false && "Invalid impact type returned by CalculateGunshotImpact()" ); } } Message Passing Architectures: Example Uses HANDLER( Simulation, ClaimGunshotMsg ) { // If the shooter isn’t in a position to fire this weapon, nothing will happen anyway if( CanShoot( msg.mShooterID, msg.mWeaponID ) ) { // Ok, they could fire, so let’s report that Send( ReportGunshotMsg( msg.mShooterID, msg.mWeaponID ) ); // And then let’s see if we believe the results, and report those as well if( ValidateGunshot( msg.mShooterID, msg.mWeaponID, msg.mImpactType, msg.mImpactPoint, msg.mImpactNormal ) ) { ... [Results Calculations ... case kImpactWorld: Send( ReportGunshotHitWorldMsg( GetWeaponClass( msg.mShooterID, msg.mShooterWeaponID ), msg.mImpactPoint, msg.mImpactNormal ); break; ... [More Results Processing ... } } } Questions?