Wednesday, October 19, 2011

Did You Get That Thing I Sent Ya?







I knew from the start of the design of the Ir Engine that I wanted to do something that game engines haven't seen much of, or at least the engines I have seen on the market, commercial or otherwise. As a Qt abuser, I love the signals and slots paradigm, and I wanted to bring the concept outside the GUI and apply it to inter-object messaging in games; this has been implemented in various forms in my engine each with their own strengths and weaknesses. I thought I would take the time to reflect on the various methods I tried before I settled on the current one.

Note that I will be referencing Angelscript extensively throughout my post, so I suggest that you read up on its concepts here.

My criteria for a messaging interface was the following:

1) Keep the implementation as "developer simple" as possible.
2) Leave as little room for bugs as possible.
3) Utilize existing functionality as much as possible.
4) Be easy to adapt to networking.

That last parameter is key, and a bear to work around. Being able to send messages over a network seems like the perfect way to implement networking in a game engine while remaining flexible.


As an object oriented language, it would be a sin not to bring up the topic of interfaces in this. Angelscript, like Java, allows for single inheritance through interfaces.

interface IDeathListener
{
    void OnObjectDeath( Object@ obj_that_died );
};
interface ISpawnListener
{
    void OnObjectSpawned( Object@ obj_that_spawned );
};
class Player : Object, ISpawnListener, IDeathListener
{
    void OnObjectDeath( Object@ dead_object )
    {
        Println("I SHAL HAVE MY REVENGE FOUL BEAST!");
    }

    void OnObjectSpawn( Object@ dead_object )
    {
        Println("HAHA! I LIVE AGAIN!!!");
    }

    void SetSpawnListener( ISpawnListener@ sl ) { @m_SpawnListener = sl; }
    void SetDeathListener( IDeathListener@ dl ) { @m_DeathListener = dl; }

    void ReceiveDamage( uint damage ) 
    { 
        m_Health -= damage;
        if( m_Health <= 0 && m_DeathListener !is null)
            m_DeathListener.OnObjectDeath( this );
    }


    void Respawn()
    {
        m_Health = 100;
        m_SpawnListener.OnObjectSpawned( this );
    }

    private uint m_Health;
    private ISpawnListener@ m_SpawnListener;
    private IDeathListener@ m_DeathListener;
};
The idea is then that you have classes that emit these events hold 'handles' (reference counted smart pointers basically) to classes that implement these interfaces. This approach to messaging has the huge advantage that locally it can be implemented in pure Angelscript, with no additional work on the C++ backend required. For a while, this actually worked fairly well. However, when it comes to actually using the paradigm in a real-world scenario, handling all of those interfaces can become extremely unwieldy and inconvenient to maintain. It lacks the conceptual simplicity of other methods. The second iteration of this paradigm came in the form of something similar to Qt's method of signals and slots, minus the meta-object compiler of course. I'll let the code do most of the talking to explain.
class ObjectOne : Object
{
    ObjectOne( Object@ object_to_connect_to )
    {
        @m_ObjectIAmConnectedTo = object_to_connect_to;
        Connect( m_ObjectIAmConnectedTo, "Signal_Test","Slot_Test" );
    }
    private Object@ m_ObjectIAmConnectedTo;
};

class ObjectTwo : Object
{
    void Slot_Test()
    {
        Println( "Slot message received" );
    }
};

void main()
{
    Object@ obj2 = ObjectTwo();
    Object@ obj1 = ObjectOne( obj2 );

    obj1.Emit("Signal_Test");
}
Simple and to the point right? Yeah, I kinda like it to. However, we have a bit of a problem as far as actually implementing this is concerned. The primary problem is that Angelscript is a strongly typed language. Without knowing the types of arguments a slot needs, we are unable to pass any arguments. With Qt, we have the convenient 'emit' (or Q_EMIT, if that's how your roll), and then call a signal like any other function.
emit TheWitchIsDead(time_of_death);
The preprocessing involved in such functionality would require more work than I had time for. Finally, I received some inspiration from a book called "Programming Game AI by Design", my Mat Buckland. It is, as you would guess, a book all about the design of AI systems and applying them to videogames. Being the good little programmer I am, I blatantly copied the concepts and applied them to Angelscript and C++, while throwing in a few extra features of my own. The concept of Mat's messaging interface is a simple void pointer passed along with an integer that is enumerated somewhere else in the application, with each object having an OnMessageReceived event.
// we are in C++ now
enum Message_Types
{
    DEATH,
    SPAWN
};
class Player : public Object
{
    void OnMessageReceived( int message,void* data )
    {
        switch( message )
        {
            case DEATH: printf("I WILL HAVE MY REVENGE FOUL BEAST!"); break;
            case SPAWN: printf("HAHAHA! I LIVE AGAIN!"); break;
        }
    }
};
int main( int ac,char**av)
{
    Object* player = new Player();
    Object* god = new Player();
    // I AM GOD! MUAHAHAHAHAHAH!
    god->Dispatch( player,DEATH,(void*)god );
}
As cruel as this program is, the concept is just simply amazing, and amazingly simple. While Angelscript may be strongly typed, it does contain an extremely versatile "any" type, which acts as a container for any type of object or primitive you might need. Using the any type as a replacement for the void pointer, we are left with the following.
// Back to angelscript
enum Message_Types
{
    DEATH,
    SPAWN
};
class Player : Object
{
    void OnMessageReceived( int message, any@ data )
    {
        switch( message )
        {
            case Message_Types::DEATH: printf("I WILL HAVE MY REVENGE!"); break;
            case Message_Types::SPAWN: printf("HAHAHA! I LIVE AGAIN!"); break;
        }
    }
};
int main()
{
    Object@ player = Player();
    Object@ god = Player();

    // MUAHAHAHAHAHAH!
    god.Dispatch( player,Message_Types::DEATH,any(@god) );
}
This design is so simple that a child could implement it, but it works extremely well. The data can be expanded to entire classes that can hold as much data as needed to get the message across, and even serialized and sent over a network. At the same time, the objects do not need to connect with one another to send messages. Objects can easily send one-off packets of data without worrying about any others. As far as message types are concerned, I have taken that to the Angelscript preprocessor addon. It took only 10 minutes to add to the builder, but greatly simplifies the creating of new message types. Now one simply has to do the following.
// HOLY PREPROCESSOR BATMAN!
#message DEATH
#message SPAWN

class Player : Object
{
    void OnMessageReceived( int message, any@ data )
    {
        switch( message )
        {
            case Messages::DEATH: printf("I WILL HAVE MY REVENGE!"); break;
            case Messages::SPAWN: printf("HAHAHA! I LIVE AGAIN!"); break;
        }
    }
};
int main()
{
    Object@ player = Player();
    Object@ god = Player();

    // MUAHAHAHAHAHAH!
    god.Dispatch( player,Messages::DEATH,any(@god) );
}

What is more is that it all remains type-safe, as can be seen in the retrieval of the values. It even automatically manages the messages that are available since the types are only added to the internal message enumeration as the preprocessor encounters them. Only the messages that you need are ever compiled in.

Well, I am slowly slipping away into a sleep deprived coma, so until next week. What I will post about... I dunno.

No comments:

Post a Comment