Book Excerpt: Game Coding Complete - Smart Design Practices
In this extract from Paraglyph's Game Coding Complete, author and game code veteran Mike McShaffry makes the point: "You can make your work much more efficient by improving how you design your software", and goes on to suggest some basic tips for game professionals.
Isaac Asimov's Foundation series invented an interesting discipline called psycho-history, a social science that could predict societal trends and macro events with great certainty. Each historian in the story was required to contribute new formulas and extend the science. As a programmer, your job is similar. Every new module or class that you create gives you the opportunity to extend the abilities and usefulness of the code base. But to do this effectively, you must learn how to think ahead and design code with the goal of keeping it in use for many projects and many years.
Designing good code in an object-oriented language can be more difficult than in a procedural language like C or PASCAL. Why? The power and flexibility of an object-oriented language like C++, for example, allows you to create extremely complicated systems that look quite simple. This is both good and bad. In other words, it's easy to get yourself into trouble without realizing it. A good example of this is the C++ constructor. Some programmers create code in a constructor that can fail. Maybe they tried to read data from an initialization file and the file doesn't exist. A failed constructor doesn't return any kind of error code, so the badly constructed object still exists and might get used. Another example is the misuse of virtual functions. A naïve programmer might make every method in a class virtual, thinking that future expandability for everything is good. Well, he'd be wrong. A well thought through design is more important than blind application of object-oriented programming constructs.
You can make your work much more efficient by improving how you design your software. With a few keystrokes you can create interesting adaptations of existing systems. There's nothing like having such command and control over a body of code. It makes you more artist than programmer.
A different programmer might view your masterpiece entirely differently, however. For example, intricate relationships inside a class hierarchy could be difficult or impossible to understand without your personal guidance. Documentation, usually written in haste, is almost always inadequate or even misleading.
To help you avoid some of the common design practice pitfalls, I'm going to spend some time in this chapter up-front discussing how you can:
Avoid hidden code that performs nontrivial operations
Keep your class hierarchies as flat as possible
Be aware of the difference between inheritance and containment
Avoid abusing virtual functions
Use interface classes and factories
Use streams in addition to constructors to initialize objects
Avoiding Hidden Code and Nontrivial Operations
Copy constructors, operator overloads, and destructors are all party to the “nasty” hidden code problem which plague game developers. This kind of code can cause you a lot of problems when you least expect them. The best example is a destructor because you never actually call it explicitly; it is called when the memory for an object is being deallocated or the object goes out of scope. If you do something really crazy in a destructor, like attach to a remote computer and download a few megabytes of MP3 files, you're teammates are going to have you drawn and quartered.
My advice is that you should try to avoid copy constructors and operator overloads that perform non-trivial operations. If something looks simple, it should be simple and not something deceptive. For example, most programmers would assume that if they encountered some code that contained a simple equals sign or multiplication symbol that it would not invoke a complicated formula such as a Taylor series. They would assume that the code under the hood would be as straightforward as it looked—a basic assignment or calculation between similar data types such as floats or doubles .
Game programmers love playing with neat technology, and sometimes their sense of elegance drives them to push non-trivial algorithms and calculations into C++ constructs such as copy constructors or overloaded operators. They like it because the high level code performs complicated actions in a few lines of code, and on the surface it seems like the right design choice. Don't be fooled.
Any operation with some meat to it should be called explicitly. This might annoy your sense of cleanliness if you are the kind of programmer who likes to use C++ constructs at each and every opportunity. Of course, there are exceptions. One is when every operation on a particular class is comparatively expensive, such as a 4x4 matrix class. Overloaded operators are perfectly fine for classes like this because the clarity of the resulting code is especially important and useful.
Sometimes you want to go a step further and make copy constructors and assignment operators private. This keeps programmers from assuming an object can be duplicated in the system. A good example of this is an object in your resource cache, such as an ambient sound track that could be tens of megabytes. You clearly want to disable making blind copies of this thing because an unwary programmer might believe all he's doing is copying a tiny sound file.
A recurring theme I'll present throughout this book is that you should always try to avoid surprises. Most programmers don't like surprises because most surprises are bad ones. Don't add to the problem by tucking some crazy piece of code away in a destructor or similar mechanism.
Class Hierarchies: Keep Them Flat
One of the most common mistakes game programmers make is that they either over-design or under-design their classes and class hierarchies. Getting your class structure well designed to your particular needs takes some real practice. Unfortunately, most of my experience came the hard way through trial and error. But you can learn from some of my mistakes and unique techniques that I've picked up along the way.
On the opposite end of the spectrum, a common problem found in C++ programs is the Blob class, as described in the excellent book Antipatterns, by Brown, et. al. This is a class that has a little bit of everything in it, and is a product of the programmer's reluctance to make new, tightly focused classes. In the source code that accompanies my book, the GameCodeApp class is probably the one that comes closest to this, but if you study it a bit, you can find some easy ways to factor it.
When I was working on Ultima VII we actually had a class called KitchenSink and sure enough it had a little bit of everything. I'll admit to creating such a class on one of the Microsoft Casino projects that I worked on that would have made intelligent programmers sick to their stomachs. My class was supposed to encapsulate the data and methods of a screen, but it ended up looking a little like MFC's CWnd class. It was huge, unwieldy, and simply threw everything into one gigantic bucket of semicolons and braces.
Professionally, I like to use a flat class hierarchy. I've also used this approach for the source code for this book. Whenever possible, it begins with an interface class and has at most two or three levels of inheritance. This class design is usually much easier to work with and understand. Any change in the base class propagates to a smaller number of child classes, and the entire architecture is something normal humans can follow.
Try to learn from my mistakes: good class architecture is not like a Swiss Army Knife; it should be more like a well balanced throwing knife.
Inheritance vs. Containment
Game programmers love to debate the topics of inheritance and containment. Inheritance is used when an object is evolved from another object, or when a child object is a version of the parent object. Containment is used when an object is composed of multiple discrete components, or when an aggregate object has a version of the contained object.
A good example of this relationship is found in user interface code. A screen class might have the methods and data to contain multiple controls such as buttons or check boxes. The classes that implement buttons and check boxes probably inherit from a base control class.
When you make a choice about inheritance or containment, your goal is to communicate the right message to other programmers. The resulting assembly code is almost exactly the same, barring the oddities of virtual function tables. This means the CPU doesn't give a damn if you inherit or contain. Your fellow programmers will care, so try to be careful and clear.
Virtual Functions Gone Bad
Virtual functions are powerful creatures that are often abused. Programmers often create virtual functions when they don't need them or they create long chains of overloaded virtual functions that make it difficult to maintain base classes. I did this for a while when I first learned how to program with C++.
Take a look at MFC's class hierarchy. Most of the classes in the hierarchy contain virtual functions which are overloaded by inherited classes, or by new classes created by application programmers. Imagine for a moment the massive effort involved if some assumptions at the top of the hierarchy were changed. This isn't a problem for MFC because it's a stable code base, but your game code isn't a stable code base. Not yet.
An insidious bug is often one that is created innocently by a programmer mucking around in a base class. A seemingly benign change to a virtual function can have unexpected results. Some programmers might count on the oddities of the behavior of the base class that, if they were fixed, will actually break any child classes. Maybe one of these days someone will write an IDE that graphically shows the code that will be affected by any change to a virtual function. Without this aid, any programmer changing a base class must learn (the hard way) for themselves what hell they are about to unleash. One of the best examples of this is changing the parameter list of a virtual function. If you're unlucky enough to change only an inherited class and not the base class, the compiler won't bother to warn you at all; it will simply break the virtual chain and you'll have a brand new virtual function. It won't ever be called by anything, of course.
From one point of view, a programmer overloads a virtual function because the child class has more processing to accomplish in the same “chain of thought.” This concept is incredibly useful and I've used it for nearly ten years. It's funny that I never thought how wrong it can be.
An overloaded virtual function changes the behavior of an object, and gains control over whether to invoke the original behavior. If the new object doesn't invoke the original function at all, the object is essentially different from the original. What makes this problem even worse it that everything about the object screams to programmers that it is just an extension of the original. If you have a different object, make a different object. Consider containing the original class instead of inheriting from it. It's much clearer in the code when you explicitly refer to a method attached to a contained object rather than calling a virtual function.
What happens to code reuse? Yes, have some. I hate duplicating code; I'm a lazy typist and I'm very unlucky when it comes to cutting and pasting code. It also offends me.
Try to look at classes and their relationships as if they are appliances and electrical cords. Always seek to minimize the length of the extension cords, minimize the appliances that plug into one another, and don't make a nasty tangle that you have to figure out every time you want to turn something on. This metaphor is put into practice with a flat class hierarchy—one where you don't have to open twelve source files to see all of the code for a particular class.
Use Interface Classes
Interface classes are those that contain nothing but pure virtual functions. They form the top level in any class hierarchy. Here's an example:
class IAnimation
{
public:
virtual void VAdvance(const int deltaMilliseconds) = 0;
virtual bool const VAtEnd() const = 0;
virtual int const VGetPosition() const = 0;
};
typedef std::list<IAnimation > AnimationList;
This sample interface class defines simple behavior common for a timed animation. We could add other methods such as one to tell how long the animation will run or whether the animation loops; that's purely up to you. The point is that any system that contains a list of objects inheriting and implementing the IAnimation interface can animate them with a few lines of code:
for(AnimationList::iterator itr = animList.begin(); itr != animList.end(); ++itr)
{
(*itr).VAdvance( delta );
}
Interface classes are a great way to enforce design standards. A programmer writing engine code can create systems that expect a certain interface. Any programmer creating objects that inherit from and implement the interface can be confident that object will work with the engine code.
Consider Using Factories
Games tend to build screens and other complex objects constructing groups of objects, such as controls or sprites, and storing them in lists or other collections. A common way to do this is to have the constructor of one object, say a certain implementation of a screen class, “new up” all the sprites and controls. In many cases, many types of screens are used in a game, all having different objects inheriting from the same parents.
In the book, Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma et. al., one of the object creation patterns is called a factory. An abstract factory can define the interface for creating objects. Different implementations of the abstract factory carry out the concrete tasks of constructing objects with multiple parts. Think of it this way: a constructor creates a single object and a factory creates and assembles these objects into a working mechanism of some sort.
Imagine an abstract factory that builds screens. The fictional game engine in this example could define screens as components that have screen elements, a background, and a logic class that accepts control messages. Here's an example:
class SaveGameScreenFactory : public IScreenFactory
{
public:
SaveGameScreenFactory();
virtual IScreenElements * const BuildScreenElements() const;
virtual ScreenBackgroundSprite * const BuildScreenBackgroundSprite() const;
virtual IScreenLogic * const BuildScreenLogic() const;
};
The code that builds screens will call the methods of the IScreenFactory interface, each one returning the different objects that make the screen including screen elements such as buttons and sprites, a background, or the logic that runs the screen. As all interface classes tend to enforce design standards, factories tend to enforce orderly construction of complicated objects. Factories are great for screens, animations, AI, or any nontrivial game object.
What's more, factories can help you construct these mechanisms at the right time. One of the neatest things about the factory design pattern is a delayed instantiation feature. You could create factory objects, push them into a queue, and delay calling the “BuildXYZ” methods until you were ready. In the screen example, you might not have enough memory to instantiate a screen object until the active one is destroyed. The factory object is tiny, perhaps a few tens of bytes, and can easily exist in memory until you are ready to fire it.
Use Streams to Initialize Objects
Any persistent object in your game should implement an overloaded constructor that takes a stream object as a parameter. If the game is loaded from a file, objects can use the stream as a source of parameters. Here's an example to consider:
class AnimationPath
{
public:
//... A better idea! Use a default constructor and an Init method.
AnimationPath(); Initialize (InputStream & stream);
Initialize (std::vector<AnimationPathPoint> const & srcPath);
//Of course, lots more code follows.
};
This class has a default constructor, and two ways to initialize it. The first is through a classic parameter list, in this case a list of AnimationPathPoints. The second initializes the class through a stream object. This is cool because you can initialize objects from disk, a memory stream, or even the network. If you want to load game objects from disk, as you would in a saved game, this is exactly how you do it.
If you read the first edition of this book, perhaps you remember that this section suggested you use input streams in the constructor of an object, like this:
AnimationPath (InputStream & stream);
Boy that was a horrible idea, and I'm not too big to admit it either. The kind “corrections” posted on the web helped me catch this one. The unkind ones I'll happily forget! Here's why it is a bad idea: a bad stream will cause your constructor to fail. You can never trust the content of a stream; it could be coming from a bad disk file, or even from hacked network packets. Ergo, construct objects with a default constructor you can rely on, and create initialization methods for streams.
--
This article is excerpted from Paraglyph Press' Game Coding Complete, (ISBN 1-932111-91-3) by Mike McShaffry.
_____________________________________________________
Read more about:
FeaturesAbout the Author
You May Also Like