From Small Projects to Large-Scale Games: Organizing C++ Game Code Modules

from_small_projects_to_large_scale_games

I. Introduction

I remember for a long time when I first started learning to program, a project would typically have just one main.cpp file. Everything was crammed into it: global variables, window creation, resource loading, logic processing, and rendering output. While such code could run, as the project’s scale grew and the logic became more complex, it quickly devolved into a maintenance nightmare.

Later, I learned to separate declarations and implementations, so a project would have a main.h and a main.cpp. This resolved the issue in C++ where functions had to be declared in a specific order.

But this still wasn’t enough; it didn’t solve the problems caused by an overly large main.cpp file. You, being clever, probably thought, “Just split it up,” and you’d be right. The project code would then be organized like this:

project/ main.cpp player.h player.cpp enemy.h enemy.cpp utils.h utils.cpp

However, as the project size increased, new problems emerged. The most obvious issue was the huge workload involved in navigating between different sections of the code. This led to the idea of organizing code with folders, which looks something like this:

project/ /core/ /game/ /ui/ /assets/ main.cpp

This is essentially how many large-scale projects are structured today, with the main difference being how the code is grouped. On a side note, I have to complain about the default organization in Visual Studio (not Visual Studio Code). By default, all code is added to one folder, with header and source files placed in separate “Header” and “Source” filters. This makes it difficult to navigate between .h and .cpp files in large projects.

This article will combine the characteristics of C++ projects to share a progressive module organization strategy, helping developers transition smoothly from small projects to large-scale projects with clear modules and a stable structure.

II. Typical Organization in Early-Stage Small Projects

An early-stage small project typically has a single main.cpp file. All functionalities are piled together, with global variables and functions callable from anywhere. The main function directly implements most of the logic. There’s no layering or encapsulation. While simple and direct, as more features are added, this structure leads to:

  • Modifications to one feature affecting the entire project
  • Difficulty with code reuse and severe coupling
  • Challenges with unit testing and collaborative development

As the project grows, the code organization gradually evolves.

III. Fundamental Principles of Modularization (in a C++ Context)

1. High Cohesion, Low Coupling

Each module should have a single responsibility and its internal logic should be tightly connected, minimizing dependencies on other modules. Consider the following code:

C++

class Player {
private:
    float x, y;
    std::string texture;
public:
    Player(float startX, float startY, const std::string& tex)
        : x(startX), y(startY), texture(tex) {}
    void UpdateAndRender(float delta) {
        x += delta;
        // Direct rendering details here
        OpenGLDrawCall(x, y, texture);  // Assume this is a global render function
    }
};

All functionality is mixed into one class, which is responsible for both updating its position and rendering. The class has hard-coded dependencies on specific implementations, leading to coupled logic. This results in confused responsibilities and makes the code difficult to reuse. The refactored code is as follows:

C++

// IRenderer.h - Rendering interface
class IRenderer {
public:
    virtual void DrawSprite(float x, float y, const std::string& texture) = 0;
    virtual ~IRenderer() = default;
};

// SpriteRenderer.h - Specific implementation
#include "IRenderer.h"
class SpriteRenderer : public IRenderer {
public:
    void DrawSprite(float x, float y, const std::string& texture) override {
        // Actual rendering code
    }
};

// Player.h - Player class, only responsible for its own behavior
class Player {
private:
    float x, y;
    std::string texture;
public:
    Player(float startX, float startY, const std::string& tex)
        : x(startX), y(startY), texture(tex) {}
    void Update(float delta) {
        x += delta; // Simplified logic
    }
    void Render(IRenderer* renderer) {
        renderer->DrawSprite(x, y, texture);
    }
};

Each class only handles its own responsibilities, and classes are decoupled through interfaces or explicit dependency injection.

2. Separate Interface and Implementation

Use .h and .cpp files to distinguish between the interface and implementation. The interface should be public, stable, and backward-compatible, which facilitates faster compilation and dependency control.

3. Namespace Organization

Use namespace Engine, namespace Game, namespace UI, etc., to prevent symbol pollution and name collisions.

4. Dependency Management and Exposure Control

Avoid mutual inclusion of header files. Use forward declarations or the PIMPL pattern to decouple classes. Modules should only expose the minimum necessary interface. Consider the following code:

C++

// A.h
#include "B.h" // Causes a circular dependency
class A {
    B* b; // A pointer is enough; a full type is not needed
};

// B.h
#include "A.h"
class B {
    A* a;
};

//main.cpp
#include “A.h”
int main(){
    return 0;
}

The Visual Studio 2022 Community Edition compiler will show the following errors in B.h:

error C2143: syntax error: missing ';' before '*' error C4430: missing type specifier - int assumed. Note: C++ does not support default-int error C2238: unexpected token(s) preceding ';'

If you include B.h, the compiler will show the same errors in a.h.

This problem can be solved by using forward declarations, as shown below:

C++

// A.h
class B; // Forward declaration
class A {
    B* b; // OK, because only a pointer/reference is needed
};

// B.h
class A; // Also using a forward declaration
class B {
    A* a;
};

This approach avoids compilation errors and speeds up the build process.

For various reasons, we sometimes need to “hide” implementation details. The PIMPL (Pointer to IMPLementation) pattern can be used for this. PIMPL not only hides details but can also accelerate compilation by reducing dependencies on external header files. Example code:

C++

// GameEngine.h
#pragma once
#include <memory>

class GameEngineImpl; // Forward declaration

class GameEngine {
public:
    GameEngine();
    ~GameEngine();
    void Start();
    void Stop();
private:
    std::unique_ptr<GameEngineImpl> impl; // Holds a pointer to the implementation
};

Users of GameEngine don’t need to be aware of the implementation details. The GameEngine.h header doesn’t expose implementation headers like iostream or GameEngineImpl, so changing the implementation won’t require all dependent files to recompile.

IV. Common Module Division Schemes

UML Class Diagram

1. Core Module

  • Logging system Logger
  • Time system GameClock
  • Configuration loading ConfigLoader
  • Event dispatcher EventBus

2. Foundation Module

  • Math library (Vector2D, Matrix4x4)
  • Utility classes (StringUtil, FileIO, Timer)

3. Asset Module

  • Texture loading TextureLoader
  • Audio management SoundManager
  • Resource cache AssetCache

4. GameLogic Module

  • Character state machine StateMachine
  • Skill system SkillEngine
  • AI behavior tree BehaviorTree

5. Renderer Module

  • Renderer interface IRenderer
  • Specific implementations SDLRenderer, VulkanRenderer
  • Camera system Camera, Frustum

6. UI System

  • UI component tree UIWidget
  • Layout system UILayout
  • Event handling UIEvent

7. Input Module

  • Input capture InputHandler
  • Keyboard, mouse, and gamepad support
  • Input mapping InputActionMap

8. Platform Abstraction

  • Windowing system Window
  • System API abstraction SystemAPI
  • File system FileSystem

9. Network Module (Optional)

  • Communication protocol Packet
  • Client state synchronization NetSync
  • Command dispatcher CommandRouter

V. Practical Module Dependency Management

  • All modules should, as much as possible, depend on interfaces, like Renderer::IRenderer, rather than concrete classes.
  • Use an abstract factory registration mechanism to manage module lifecycles.
  • Example: GameLogic depends on IRenderer*, but is actually injected with SDLRenderer*.
  • Use CMake for module-based compilation in the build system, controlling dependencies with target_link_libraries.

Example Code

C++

// IRenderer.h
class IRenderer {
public:
    virtual void Render() = 0;
    virtual ~IRenderer() {}
};

// SDLRenderer.h
#include "IRenderer.h"
class SDLRenderer : public IRenderer {
public:
    void Render() override;
};

// GameWorld.h
class GameWorld {
private:
    IRenderer* renderer;
public:
    GameWorld(IRenderer* r) : renderer(r) {}
    void Update() {
        // Game logic...
        renderer->Render();
    }
};

VI. Project Structure Evolution Examples

Early Structure:

project/ main.cpp player.cpp enemy.cpp utils.cpp

Intermediate Structure:

project/ /core/ /game/ /ui/ /assets/ main.cpp

Mature Structure:

Engine/ Core/ Math/ Renderer/ Input/ Platform/ Game/ Scenes/ Actors/ GameLogic/ Assets/ Models/ Textures/ Sounds/

VII. Benefits of Modularization

  • Facilitates unit testing and debugging
  • Enhances teamwork, as modules can be developed in parallel
  • Strong extensibility: swapping out a renderer or asset system doesn’t require touching business logic
  • Improved maintainability: adding new features doesn’t impact old logic

VIII. Notes and Recommendations

  • Module organization should be a gradual evolution; it’s not advisable to design a large engine structure from the start.
  • Prioritize solving “coupling pain points” before beginning a modular refactor.
  • Use CMake to build modules, or modern tools like Bazel.

Example CMake Module Organization

CMake

project(MyGameEngine)
add_subdirectory(Engine/Core)
add_subdirectory(Engine/Renderer)
add_subdirectory(Game)

# Engine/Core/CMakeLists.txt
add_library(Core STATIC Logger.cpp Clock.cpp)
target_include_directories(Core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

# Engine/Renderer/CMakeLists.txt
add_library(Renderer STATIC IRenderer.cpp OpenGLRenderer.cpp)
target_link_libraries(Renderer Core)

# Game/CMakeLists.txt
add_executable(Game main.cpp GameWorld.cpp)
target_link_libraries(Game Core Renderer)

IX. Conclusion

The transition from small to large projects is a stage every developer experiences. Mastering the mindset of modularity and practical organization methods will not only help you build stable game projects but also cultivate strong engineering skills.

Modularity is not about “showing off”; it’s about enabling you to iterate on your code consistently, safely, and efficiently. If you are a C++ game developer, I hope this article has provided you with some useful insights.

Leave a Reply