Fundamentals of Software Engineering for Games
Inspired by: Jason Gregory, Game Engine Architecture (3rd ed., CRC Press), Chapter 3 — rewritten and condensed for teaching, with original code examples and exercises.
Before touching a renderer, a physics solver, or a scripting VM, a professional game programmer needs a shared toolkit: solid object-oriented design instincts, working knowledge of how the C++ language has evolved (and which parts of it are actually worth using), and a disciplined approach to catching bugs before players do. None of this is glamorous — but a AAA engine runs for years across dozens of programmers, and the difference between a codebase that stays maintainable and one that collapses under its own weight almost always traces back to these fundamentals.
C++ remains the dominant language in the game industry, mostly because it gives you both high-level abstraction and the low-level control needed to hit a 16.6ms (or tighter) frame budget. Everything below is framed in C++, but the underlying ideas — encapsulation, composition over inheritance, defensive error handling — transfer to whatever language you end up shipping in.
Object-Oriented Programming, Briefly Revisited
If you've written object-oriented code before, this is a fast refresher tuned toward the parts that matter most in engine code. If any of it is new to you, it's worth pausing here — everything else in engine architecture builds on top of it.
Classes, Objects, and Encapsulation
A class bundles data (attributes) and behavior (methods) into one coherent unit; an object is a specific instance of that class. Enemy is a class; the goblin currently chasing the player is an object.
Encapsulation means a class exposes only what callers need and hides the rest. This isn't about secrecy — it's about reducing the surface area another programmer has to understand, and letting you change the internals later without breaking every caller. A Transform class that hides its internal matrix representation behind GetPosition() / SetPosition() can switch from a 4x4 matrix to a quaternion+vector pair without anyone outside the class noticing.
Inheritance: "Is-A" Relationships
Inheritance lets a derived class extend a base class's data and behavior. Circle deriving from Shape expresses "a circle is a shape." This is the right tool when every derived-class instance is genuinely usable anywhere the base class is expected (the Liskov substitution idea, even if nobody says it out loud on a game team).
Most C++ codebases in games avoid multiple inheritance (a class with more than one direct base) outside of narrow, deliberate cases. The classic failure mode is the diamond problem: if Amphibious and Land both derive from Vehicle, and Duck derives from both, does Duck contain one Vehicle subobject or two? C++ lets you resolve this with virtual inheritance, but it adds real complexity — extra indirection, subtler object layout, and casting that has to shift pointers depending on which base you're viewing the object through. Most studios permit multiple inheritance only for small, state-free mix-in classes (e.g., a Serializable or Debuggable mix-in that adds one capability without participating in a "real" hierarchy).
Polymorphism: One Interface, Many Behaviors
Polymorphism lets code manipulate a collection of different types through one common interface, without needing to know which concrete type it's holding. Contrast a type-tag switch against a virtual function:
// Without polymorphism: every caller needs to know every concrete type.
enum ShapeType { CIRCLE, RECTANGLE, TRIANGLE };
struct Shape {
ShapeType type;
// ... shape-specific data crammed in here somehow
};
void drawShapes(const std::vector<Shape*>& shapes) {
for (Shape* s : shapes) {
switch (s->type) {
case CIRCLE: /* draw circle */ break;
case RECTANGLE: /* draw rectangle */ break;
case TRIANGLE: /* draw triangle */ break;
}
}
}
Every time someone adds a new shape, they have to hunt down every switch statement like this one across the codebase and add a case. Virtual functions push that responsibility onto the new class instead:
struct Shape {
virtual void Draw() const = 0; // pure virtual — no default implementation
virtual ~Shape() = default; // ALWAYS make base-class destructors virtual
};
struct Circle : Shape {
void Draw() const override { /* draw as a circle */ }
};
struct Rectangle : Shape {
void Draw() const override { /* draw as a rectangle */ }
};
void drawShapes(const std::vector<Shape*>& shapes) {
for (Shape* s : shapes) {
s->Draw(); // dispatches to the correct override automatically
}
}
drawShapes never needs to change again. Adding Triangle means writing a new Triangle : Shape class — drawShapes doesn't even need to be recompiled.
If Shape's destructor isn't virtual and you delete a Circle* through a Shape* pointer, only ~Shape() runs — ~Circle() never fires, and any resources Circle owns leak. This is one of the most common real bugs in C++ inheritance hierarchies.
Composition and Aggregation: "Has-A" / "Uses-A"
Composition builds complex objects out of simpler ones — a Spaceship has an Engine, which has a FuelTank. New programmers often reach for inheritance when composition would produce a simpler, more testable design. A Window class that needs rectangle math should generally contain a Rectangle (has-a) rather than derive from one (is-a) — a window isn't a kind of rectangle, it just uses rectangle math internally. Composition keeps classes focused and lets you swap out or mock a component without disturbing the class that owns it.
Check: OOP fundamentals
Classes, inheritance, and polymorphism
1 / 4Why do most game codebases avoid or heavily restrict multiple inheritance?
Pick an answer to see if you are right.
Design Patterns You'll See Constantly in Engines
A handful of general-purpose design patterns show up over and over in engine code:
- Singleton — ensures a class has exactly one instance and provides global access to it. Used (sometimes overused) for engine-wide managers like a
RenderDeviceorAudioSystem. - Iterator — walks a collection's elements without exposing how that collection is actually stored internally.
- Abstract Factory — creates families of related objects without the caller needing to know the concrete types being constructed (useful for swapping a whole rendering backend, for instance).
One pattern deserves special attention because it quietly prevents an entire category of bugs: RAII.
RAII: Resource Acquisition Is Initialization
The idea: tie a resource's acquisition to a constructor and its release to the matching destructor. Construct a local object to acquire the resource; let it fall out of scope to release it — automatically, even if an exception is thrown or an early return fires partway through the function. Some studios call these classes janitors, because they clean up after you whether you remember to or not.
class AllocatorScope {
public:
explicit AllocatorScope(AllocatorId id) : m_id(id) {
PushAllocator(m_id); // acquire: push this allocator as "current"
}
~AllocatorScope() {
PopAllocator(); // release: always pops, no matter how we exit scope
}
private:
AllocatorId m_id;
};
void LoadLevel() {
{
AllocatorScope scope(AllocatorId::kSingleFrame);
// every allocation in this block uses the single-frame allocator
Buffer* temp = AllocateBuffer(4096);
// ... use temp ...
} // scope's destructor pops the allocator here — even if AllocateBuffer() had thrown
}
Without RAII, every early-exit path (an early return, a thrown exception, a break out of a loop) is a place someone can forget to release the resource. With RAII, there's nothing to forget — the compiler calls the destructor for you.
Check: design patterns and RAII
Design patterns and RAII
1 / 2What problem does the RAII pattern solve?
Pick an answer to see if you are right.
Practice: build a janitor
Fill in LogScope so that entering the scope prints "> enter: <name>" and leaving it — no matter how the block exits — prints "< exit: <name>". This is the same shape as the AllocatorScope example above, minus the real allocator calls.
The C++ Standard Keeps Moving
C++ was standardized in 1998 (C++98), refined in C++03, and then began evolving quickly: C++11, C++14, C++17, C++20, and beyond, each adding features aimed at making the language safer, more expressive, or more efficient. A few highlights that show up constantly in modern engine code:
| Standard | Notable additions |
|---|---|
| C++11 | nullptr, auto/decltype, override/final, move semantics & rvalue references, lambdas, constexpr, strongly-typed enum class |
| C++14 | return-type deduction for functions, generic lambdas, relaxed constexpr (loops/branches allowed) |
| C++17 | structured bindings (auto [a, b] = pair;), guaranteed copy elision, if constexpr, [[fallthrough]] |
| C++20 | concepts, ranges, coroutines, modules |
A few of these are worth knowing cold because they directly replace error-prone older patterns:
nullptrreplaces theNULLmacro (which was really just0, and could silently convert to an integer in surprising overload-resolution scenarios).overridedocuments intent and catches typos at compile time — misspell a virtual function's signature in a derived class withoutoverride, and you silently get a new, non-overriding function instead of a compile error.- Move semantics (
T&&, move constructors) let a function return large objects (astd::vector, astd::string) without a full deep copy, by "stealing" the temporary's internal buffer instead.
Which Features Should You Actually Use?
New language features are tempting, but adopting them isn't free. Three practical constraints show up at every studio:
- Compiler/platform support. Console toolchains and older compilers lag behind the latest standard. A feature you use has to actually be supported everywhere your game ships.
- Switching cost. Migrating an existing multi-million-line codebase to a new standard is a real, non-trivial cost — most studios pick a standard for the life of a project rather than chasing the newest one mid-development.
- Readability vs. cleverness. Some features are win-win (
nullptr— just use it). Others trade off:autois convenient, but a file where every variable is declaredautobecomes as hard to skim as a dynamically-typed language, defeating one of the main advantages of a statically-typed language in the first place. Many studios restrictautoto iterators, template code where no other option works, or places where it clearly improves readability — and require explicit types everywhere else.
Heavy template metaprogramming is a similar story: powerful, but it can produce code that's genuinely difficult for another engineer to debug under time pressure — a real cost on a team where anyone might need to jump into unfamiliar code during a crunch or a live outage.
Ask: does this feature make the code easier for the next programmer to read, debug, and modify — not just faster for you to write today? "Because it's new" is not sufficient justification on its own for a shared, long-lived codebase.
Coding Standards That Actually Matter
Coding standards debates can get religious fast, but a few principles are close to universally useful:
- Clean interfaces. Keep header files minimal and well-documented — the interface is what everyone else has to understand; the implementation is yours alone.
- Names that explain intent. A good name saves every future reader a trip to the implementation.
- Don't pollute the global namespace. Use namespaces (without nesting them too deeply) to avoid symbol collisions, especially once you're linking multiple libraries together.
- Consistency over personal preference. Match whatever convention already exists in a file you're editing, even if it's not your favorite.
- Make errors visible. Code that makes a common mistake look wrong at a glance is more valuable than code that merely looks tidy.
Check: C++ evolution and standards
C++ standards and coding conventions
1 / 3What specific bug does the `override` keyword help catch at compile time?
Pick an answer to see if you are right.
Catching and Handling Errors
Every non-trivial program has to deal with things going wrong. The first useful distinction is who caused the problem.
User Errors vs. Programmer Errors
A user error happens when whoever is using the software does something invalid — types a bad input, points at a missing file. A programmer error is a bug: something the code should have handled correctly but didn't, regardless of what the "user" did.
In a game studio, "user" is layered:
- The player, at runtime.
- Other developers on the team — artists, designers, animators — who can produce invalid content (a texture with the wrong format, an animation targeting the wrong skeleton).
- Other programmers, who might call your function with a null pointer or an out-of-range index.
That third layer is genuinely blurry: if programmer A writes f() and programmer B calls it incorrectly, is that B's user error, or a bug in A's function for not validating its inputs and handling the case gracefully? Reasonable engineers disagree here, and the right answer depends on context — but it's worth explicitly deciding, rather than leaving it ambiguous, for anything resembling a public API within your own codebase.
A Different Response for Each Kind of Error
The right response differs sharply by error type:
- Player errors should be handled inside the fiction of the game itself — an empty-clip click sound and animation instead of a crash or a debug message the player will never understand.
- Content/developer errors benefit from a middle ground: make the problem loudly, unmistakably visible (a bright placeholder object, an obvious on-screen warning) but let the rest of the team keep working. Halting the entire build over one bad texture stops every other developer from making progress — multiplied across a whole studio, that's an expensive way to enforce quality. Conversely, silently ignoring bad content risks it shipping. Visible-but-non-blocking is usually the right trade.
- Programmer errors (bugs) should generally not be handled gracefully at all — they should stop the program loudly, as close as possible to where the invalid assumption was first violated, with enough context for a programmer to fix it immediately. This is the job of assertions, covered below.
Check: error philosophy
Error types and handling philosophy
1 / 3Why is the line between "user error" and "programmer error" often blurry in a real codebase?
Pick an answer to see if you are right.
Implementing Error Detection and Handling
C++ gives you three main mechanisms for surfacing errors, each with different tradeoffs.
Error Return Codes
The simplest approach: return a value (a bool, a sentinel, or better, an enum) indicating success or the specific kind of failure.
enum class LoadResult { kSuccess, kFileNotFound, kInvalidFormat };
LoadResult LoadTexture(const char* path, Texture* out);
This is explicit and has zero hidden control flow — but it only works if every caller in the chain actually checks the return value and either handles it or passes it along. A function 40 stack frames deep that detects a problem only the top-level game loop can meaningfully respond to means every one of those 40 functions needs to thread the error code back up manually.
Exceptions
throw lets code that detects an error communicate with code that handles it, without every function in between needing to know or care. The runtime unwinds the stack looking for a matching catch, running destructors along the way (which is exactly why RAII and exceptions pair so well together — a resource acquired via RAII is guaranteed to be released during unwinding, even if you never explicitly catch anything in the functions in between).
That said, many game studios disable exceptions in their runtime engine code entirely. The recurring objections:
- Invisible control flow. A
throwcan jump out of a function that neither throws nor catches anything itself, just because it happens to sit on the call stack between two functions that do — making it hard to reason locally about what a function might do. - Binary size and inlining. The compiler has to generate unwind tables for every function that could be on the stack during an exception, which can bloat code size and discourage inlining.
- All-or-nothing linking. If any linked code uses exceptions, your whole binary effectively needs exception support enabled, even in code paths that never throw.
Studios that want the benefit of a third-party library that does use exceptions internally will often wrap every call into that library behind a thin API that catches everything at the boundary and converts it back into a return code — "sandboxing" the exceptions to one translation unit instead of letting them propagate through the whole engine.
Assertions
An assertion checks a condition the programmer believes must always be true. If it's false, the program halts immediately with debugging information — the file, the line, and the failed expression — rather than limping onward with an already-broken assumption. Standard C/C++ gives you assert() via <cassert>, but engines commonly roll their own so they get finer control over which build configurations keep assertions and which strip them for performance:
#if ASSERTS_ENABLED
void ReportAssertFailure(const char* expr, const char* file, int line);
#define ASSERT(expr) \
if (expr) { } \
else { \
ReportAssertFailure(#expr, __FILE__, __LINE__); \
std::abort(); \
}
#else
#define ASSERT(expr) // compiles to nothing in release builds
#endif
A few details are load-bearing, not accidental:
if (expr) {} else { ... }, not a bareif (!expr) .... This makes the macro safe to drop into anyif/elsechain without accidentally binding to the wrongelse.#exprstringizes the original expression, so the failure message can show exactly what condition failed, not just "assertion failed" with no context.__FILE__/__LINE__are compiler-provided macros that expand to the exact source location — critical for finding the bug fast.- Stripped in release builds (via the
#if/#else) because assertion checks have a real runtime cost you don't want to pay once the code is proven correct and shipping.
Assertions should only ever guard against bugs — never against expected, recoverable user input. If it's fine for the program to continue past a failure, that's a case for an error return code or a graceful in-game message, not an assertion.
Compile-Time Assertions
Some conditions are knowable before the program ever runs — for example, "this struct must be exactly 64 bytes to fit the platform's cache line." Modern C++ (C++11+) gives you static_assert directly:
struct PackedVertex {
float x, y, z;
uint32_t color;
};
static_assert(sizeof(PackedVertex) == 16, "PackedVertex must stay 16 bytes");
If the condition is false, the build itself fails, with your custom message — nobody can accidentally ship a regression in a size-sensitive struct without the compiler stopping them.
Check: error detection mechanisms
Return codes, exceptions, and assertions
1 / 4What is the main practical drawback of relying purely on error return codes for a deeply-nested call stack?
Pick an answer to see if you are right.
Practice: write your own ASSERT macro
Complete the MY_ASSERT macro below so that it prints FAIL: <expr> (<file>:<line>) and exits with a non-zero status when the condition is false, and does nothing when the condition is true. This mirrors the real engine pattern above — reporting plus a hard stop — without needing an actual attached debugger, since there's no debugger to break into here.
Practice: compile-time size guard
Add a static_assert that enforces sizeof(NetPacketHeader) == 8 so a future change to the struct can never silently break the network protocol without the build failing loudly.
Chapter Summary
- Encapsulation, inheritance, and polymorphism remain the backbone of engine design — but reach for composition by default, and reserve inheritance for genuine is-a relationships. Restrict multiple inheritance to small, state-free mix-ins.
- RAII — tying resource cleanup to destructors — eliminates an entire category of "forgot to release it" bugs, and pairs naturally with early returns and exceptions alike.
- The C++ standard keeps evolving, but adopting a new feature is a team decision, not an individual one: weigh compiler/platform support, migration cost, and — most importantly — whether it makes code easier for someone else to read and debug later.
- Not all errors are the same. Player errors belong inside the game's fiction; developer/content errors are best surfaced loudly but non-blockingly; genuine programmer bugs should generally stop execution immediately, at the source, via assertions.
- Return codes, exceptions, and assertions solve different problems: return codes for expected, recoverable failures; exceptions for separating error detection from error handling across a deep call stack (at a real cost many studios choose not to pay); assertions for catching violated programmer assumptions as early and loudly as possible — with
static_assertcatching what's knowable before the program even runs.