CrackEngine: Custom C++ Engine

CrackEngine is a custom C++ game engine developed over six months by a small TGA student team, built in parallel with several student game projects. It features a full rendering pipeline built on DirectX 11, integrated physics, flexible gameplay systems, and editor support. The engine was iteratively improved project by project, enabling rapid prototyping, reliable reuse of core systems, and efficient iteration on gameplay features.

Dev Time:

6 Months

Group Size:

3-6

Engine:

C++

GameObject–Component System dropdown

The GameObject–Component System is the core of CrackEngine’s architecture, designed and implemented entirely by me. Inspired by Unity's component model, it allows gameplay elements to be composed flexibly: each scene consists of game objects, which can have multiple components and nested child objects. This modular system enables rapid iteration, reusable behaviors, and visual manipulation of objects directly in the editor. Components encapsulate functionality such as rendering, physics, input handling, and AI, and can be reused across different objects and projects.

GameObject–Component Code dropdown

This snippet demonstrates the core of the GameObject–Component System in CrackEngine. Components can be added to a GameObject dynamically using templates, and the system supports querying both single and multiple components of a given type. This design allows gameplay elements to be composed flexibly, enabling modular behaviors such as rendering, physics, AI, and input handling. The template and dynamic_cast usage ensures type safety while keeping the system generic and reusable across projects.

1 // Adds a component of type T to this GameObject
2 template<typename T = Component>
3 void AddComponent(T* aComponent)
4 {
5 myComponents.push_back(aComponent);
6 aComponent->SetOwner(this);
7 }
8
9 // Retrieves the first component of type T
10 template<typename T = Component>
11 T* GetComponent()
12 {
13 for (auto& c : myComponents)
14 {
15 T* val = dynamic_cast<T*>(c);
16 if (val == nullptr) continue;
17 return val;
18 }
19 return nullptr;
20 }
21
22 // Retrieves all components of type T
23 template<typename T = Component>
24 std::vector<T*> GetComponents()
25 {
26 std::vector<T*> components;
27 for (auto& c : myComponents)
28 {
29 T* val = dynamic_cast<T*>(c);
30 if (val == nullptr) continue;
31 components.push_back(val);
32 }
33 return components;
34 }

Engine Editor dropdown

I built the editor backend for CrackEngine to support efficient development and iteration across all projects using the engine. It allows inspection and modification of GameObjects, component composition, transforms, hierarchies, and runtime states. This editor was central to the development workflow, enabling rapid iteration on enemies, player behavior, and interactive objects. The system was designed to integrate tightly with engine math, component management, and scene hierarchy.

Parent-Child & Drag-Drop dropdown

The editor supports robust drag-and-drop workflows, both internally and from the operating system. Assets such as models and textures can be dragged directly from Windows Explorer into the editor, where they are imported and instantiated using the engine’s asset and component systems. The hierarchy view fully supports parent–child relationships. GameObjects can be reparented via drag-and-drop, with local and world transforms recalculated automatically to preserve spatial consistency. This behavior is driven by the engine’s transform and math systems, ensuring correct position, rotation, and scale propagation across complex hierarchies rather than relying on editor-specific overrides.

Component Inspection dropdown

Components can be added, inspected, and modified directly in the editor. The system supports dynamic templates, allowing developers to attach multiple component types to GameObjects, query them individually or in groups, and edit properties at runtime. This made testing and iterating on game mechanics extremely efficient.

Save & Load

The editor integrates with the engine's save/load system to persist scenes, object transforms, and component properties. All data is serialized to JSON, including hierarchical relationships, component types, and values, allowing scenes to be restored exactly as they were edited in the editor.

PhysX Integration dropdown

I implemented the PhysX foundation, scene management, and simulation loop for CrackEngine. This includes fixed-timestep simulation, robust collision filtering, and routing collision events to gameplay components. The system was designed to remain stable under fluctuating frame rates while giving gameplay systems precise control over physics interactions.

Fixed Timestep Simulation

The physics simulation runs with a fixed timestep, capped at 60 updates per second, while still using accumulated frame delta time. This ensures consistent physics behavior even when the game experiences frame drops or stalls. The system also supports skipping a simulation frame when needed for special gameplay events, such as respawning or scene transitions.

PhysX Update Loop dropdown

The PhysX scene is stepped using an accumulator-based fixed timestep. After simulation, collision data is processed and forwarded to gameplay components unless explicitly skipped for special cases.

1 void PhysXManager::Update(float deltaTime)
2 {
3 if (!myPxScene)
4 return;
5
6 myAccumulator += deltaTime;
7
8 if (myAccumulator < myTimeStep)
9 return;
10
11 myAccumulator -= myTimeStep;
12
13 myPxScene->simulate(myTimeStep);
14 myPxScene->fetchResults(true);
15
16 // Can skip giving contact data (used for respawn)
17 if (skipFrame)
18 {
19 std::queue<PairData> empty;
20 std::swap(myPairData, empty);
21 skipFrame = false;
22 return;
23 }
24
25 afterContact();
26 }

Collision Dispatch dropdown

Collision handling is performed after the physics simulation step. Contact pairs collected during simulation are filtered using PhysX filter data to determine which interactions should generate gameplay responses. This allows precise control over collision behavior while keeping gameplay systems decoupled from the physics layer. Filtered collisions are then routed to the appropriate components attached to the involved GameObjects, allowing different component types to respond to physics events without hard dependencies or tight coupling between systems.

1 void PhysXManager::afterContact()
2 {
3 while (!myPairData.empty())
4 {
5 const physx::PxContactPair* pairs = myPairData.front().myPairs;
6 physx::PxU32 nbPairs = myPairData.front().nbPairs;
7
8 for (int i = 0; i < (int)nbPairs; i++)
9 {
10 physx::PxFilterData filterData0 = pairs[i].shapes[0]->getSimulationFilterData();
11 physx::PxFilterData filterData1 = pairs[i].shapes[1]->getSimulationFilterData();
12
13 if (filterData0.word0 & filterData1.word1)
14 {
15 static_cast<PhysicsComponent*>(pairs[i].shapes[1]->userData)
16 ->CallCollisionFunction(pairs[i].shapes[0]);
17 }
18
19 if (filterData1.word0 & filterData0.word1)
20 {
21 static_cast<PhysicsComponent*>(pairs[i].shapes[0]->userData)
22 ->CallCollisionFunction(pairs[i].shapes[1]);
23 }
24 }
25 myPairData.pop();
26 }
27 }

Physics Queries & Debugging dropdown

To support development and verification of the physics system, the engine streams live simulation data to the PhysX Visual Debugger in debug builds. This provided deep visibility into the physics scene, making it possible to inspect simulation state, collision filtering, and actor behavior directly within PhysX’s tooling. In addition to simulation and collision handling, the engine exposes filtered raycasts, sweeps, and overlap queries at the engine level. These are thin wrappers around PhysX functionality and use the same filtering setup as the simulation, ensuring consistent and predictable behavior across all physics interactions.

Debug & Event Systems dropdown

I implemented a global, static logging system that allows any part of the codebase to call `Log(std::string)` with minimal overhead. Log entries can be filtered by type, and in release builds all calls are replaced by empty functions, ensuring zero performance impact.

LineDrawer Rendering dropdown

The engine includes a 3D LineDrawer to visualize debug information such as lines and bounding boxes. All rendering, including vertex buffers and shaders, was implemented from scratch. Lines are drawn at the end of the frame so they appear above other objects. To further improve this system these lines could quite easily be batched but I never did this as it seemed a waste of time for our small engine and it only being used for debugging purposes

1 void LineDrawer::Render(DebugLine& aLine, ID3D11DeviceContext* someContext)
2 {
3 UpdateVertexes(aLine);
4 unsigned int stride = sizeof(SimpleVertex);
5 unsigned int offset = 0;
6 someContext->IASetVertexBuffers(0, 1, myVertexBuffer.GetAddressOf(), &stride, &offset);
7
8 someContext->Draw(2, 0);
9 }
10
11 void LineDrawer::DrawAllLines()
12 {
13 #ifdef _DEBUG
14 if (myActive == false)
15 return;
16
17 auto* instance = GraphicsEngine::GetInstance();
18 auto* context = instance->GetContext();
19 context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_LINELIST);
20 context->IASetInputLayout(myInputLayout.Get());
21
22 context->VSSetShader(myVertexShader.Get(), nullptr, 0);
23 context->PSSetShader(myPixelShader.Get(), nullptr, 0);
24
25 for (int i = 0; i < myLines.size(); ++i)
26 {
27 Render(myLines[i], context);
28 }
29 myLines.clear();
30 #endif
31 }

LineDrawer Showcase dropdown

The video shows the LineDrawer streaming debug lines live from the engine in a debug build.

Event System dropdown

The engine includes a lightweight global event system based on the observer pattern. Any system can subscribe to specific message types and receive events without direct dependencies on the sender. Observers automatically register on creation and unregister on destruction, keeping lifetime management simple and avoiding dangling references. Any number of observers can listen to the same event type, allowing systems to react independently to the same input or state change. A typical use case is input handling. When a key is pressed, a message with Type: InputKeyPressed is sent. Any system currently interested in input receives the event and can inspect the payload to determine whether it should react, for example by checking which key was pressed. Messages carry both a type and a generic payload using std::any. This allows different systems to interpret the same event differently without coupling the event system to specific gameplay logic. The system could be extended with state-based filtering to only dispatch events to observers active in a given game state. Since the event system was used across both engine and gameplay code, this extra complexity was not necessary for the scope of the projects built with it.

1 struct Message
2 {
3 std::any myData;
4 Type myType;
5
6 Message(Type aType, std::any aData = nullptr)
7 : myType(aType), myData(aData) {}
8 };
9
10 EventSystem::EventSystem()
11 {
12 for (size_t i = 0; i < (int)Message::Type::Count; i++)
13 {
14 myObservers.push_back(std::vector<Observer*>());
15 }
16 }
17
18 void EventSystem::SendMsg(const Message& aMessage)
19 {
20 for (int i = 0; i < myObservers[(int)aMessage.myType].size(); i++)
21 {
22 myObservers[(int)aMessage.myType][i]->Receive(aMessage);
23 }
24 }
25
26 void EventSystem::Subscribe(Observer* anObserver, const Message::Type aMessageType)
27 {
28 myObservers[(int)aMessageType].push_back(anObserver);
29 }