Burntbite Engine
Personal Project
A lightweight Entity Component System (ECS) game engine built entirely from scratch in C++. Designed to prioritize data locality, strict decoupling of state and behavior, and data-driven entity instantiation.
Engine Architecture
Type-Indexed Storage
To create a truly decoupled ECS, the core registry must be able to hold any Plain Old Data (POD) struct without requiring hardcoded lists. I implemented a polymorphic hash-map storage system driven by runtime type information.
- Type Erasure pattern: The core
ECSclass utilizesstd::type_indexto map component types to an abstractIStorageinterface, allowing the engine to scale dynamically. - Direct Mapping: Under the hood,
StorageImpl<C>maintains anstd::unordered_map<Entity, C>, ensuring fast lookup times by mapping the unique entity ID directly to its component data.
struct IStorage {
virtual ~IStorage() = default;
virtual void EraseEntity(Entity e) = 0;
};
template<typename C>
struct StorageImpl : IStorage {
std::unordered_map<Entity, C> mMap;
void EraseEntity(Entity e) override { mMap.erase(e); }
};
Data-Driven Instantiation via XML
Hardcoding component data into C++ ruins iteration times. To solve this, I integrated PugiXML to act as a factory, allowing entities to be constructed dynamically from external data files.
- Dynamic Parsing: The
EntityFactoryparses nodes like<transform>or<velocity>and translates them into ECS components dynamically during runtime. - Robust Fallbacks: If an XML attribute or entire node is missing, the parser safely attaches a default-constructed component to the entity ID, ensuring the engine never crashes from bad data.
pugi::xml_node oTransformNode = oNode.child("transform");
if (oTransformNode) {
Transform oTransform;
oTransform.fPosX = oTransformNode.attribute("x").as_float(240.f);
oTransform.fPosY = oTransformNode.attribute("y").as_float(440.f);
_rEcs.AddComponent<Transform>(iEntity, oTransform);
}
Overcoming Global State: The Manager Entity
A persistent architectural challenge in pure ECS is tracking global state such as high scores or universal spawn timers, which do not logically belong to physical actors.
- Headless Architecture: The
GameStatecreates a "Manager Entity" during initialization. This is a headless entity ID that exclusively holdsSpawnTimerandScorecomponents. - Aggressive Memory Sweeping: When the player dies, the engine must reset the board.
ResetForNewGame()aggressively iterates through component maps, systematically erasing any entity ID that doesn't match the Manager ID, cleaning memory while retaining global rules.
auto& rTransforms = _rEcs.Storage<Transform>();
std::vector<Entity> lTransformsToRemove;
// Collect all physical entities except the global manager
for (auto& rKeyValue : rTransforms) {
if (rKeyValue.first != m_iManager) {
lTransformsToRemove.push_back(rKeyValue.first);
}
}
// Aggressively clear memory for the board reset
for (Entity iEntity : lTransformsToRemove) {
rTransforms.erase(iEntity);
}
Polymorphic System Iteration
Behavior in Burntbite is driven entirely by Systems. To maintain a clean game loop in main(), all logic managers inherit from a central ISystem interface.
- Data-Driven Execution: Systems operate only on the entities that possess the specific components they require. The core loop executes their
Update()functions polymorphically. - Tag Querying: The
MovementSystemdirectly applies kinematic calculations frame-by-frame, querying empty structs likeEnemyTagto selectively apply environmental forces like gravity.
void MovementSystem::Update(ECS& _rEcs, float _fDeltaTime) {
auto& rVelocities = _rEcs.Storage<Velocity>();
auto& rTransforms = _rEcs.Storage<Transform>();
for (auto& rKeyValue : rVelocities) {
Entity iEntity = rKeyValue.first;
auto oIterator = rTransforms.find(iEntity);
if (oIterator == rTransforms.end()) continue;
// Apply gravity for enemies via Tag querying
if (_rEcs.HasComponent<EnemyTag>(iEntity)) {
rKeyValue.second.fVelocityY += m_fGravity * _fDeltaTime;
}
oIterator->second.fPosX += rKeyValue.second.fVelocityX * _fDeltaTime;
oIterator->second.fPosY += rKeyValue.second.fVelocityY * _fDeltaTime;
}
}